diff options
| author | Dhravya Shah <[email protected]> | 2025-02-14 15:56:55 -0800 |
|---|---|---|
| committer | Dhravya Shah <[email protected]> | 2025-02-14 15:56:55 -0800 |
| commit | 3e1b4bb788c8d1ee1d64c62cc53088bc4002a3c8 (patch) | |
| tree | ea3517c30ec5430e2010691621b8975bc307d502 | |
| parent | intuitive memory movement, avoid duplicates in home (diff) | |
| download | supermemory-3e1b4bb788c8d1ee1d64c62cc53088bc4002a3c8.tar.xz supermemory-3e1b4bb788c8d1ee1d64c62cc53088bc4002a3c8.zip | |
edit space names
| -rw-r--r-- | apps/backend/src/routes/spaces.ts | 52 | ||||
| -rw-r--r-- | apps/web/app/routes/space.$spaceId.tsx | 97 |
2 files changed, 142 insertions, 7 deletions
diff --git a/apps/backend/src/routes/spaces.ts b/apps/backend/src/routes/spaces.ts index bede366c..cb0cf417 100644 --- a/apps/backend/src/routes/spaces.ts +++ b/apps/backend/src/routes/spaces.ts @@ -99,7 +99,6 @@ const spacesRoute = new Hono<{ Variables: Variables; Bindings: Env }>() const spaceId = c.req.param("spaceId"); const db = database(c.env.HYPERDRIVE.connectionString); - const space = await db .select() .from(spaces) @@ -261,7 +260,7 @@ const spacesRoute = new Hono<{ Variables: Variables; Bindings: Env }>() error instanceof Error && error.message.includes("saved_spaces_user_space_idx") ) { - // Space is already favorited + // Space is already favorited return c.json({ message: "Space already favorited" }); } throw error; @@ -634,7 +633,54 @@ const spacesRoute = new Hono<{ Variables: Variables; Bindings: Env }>() return c.json({ success: true }); } - ).delete("/:spaceId", async (c) => { + ) + .patch( + "/:spaceId", + zValidator( + "json", + z.object({ + name: z.string().min(1, "Space name cannot be empty").max(100), + }) + ), + async (c) => { + const user = c.get("user"); + if (!user) { + return c.json({ error: "Unauthorized" }, 401); + } + + const { spaceId } = c.req.param(); + const { name } = c.req.valid("json"); + const db = database(c.env.HYPERDRIVE.connectionString); + + // Get space and verify ownership + const space = await db + .select() + .from(spaces) + .where(eq(spaces.uuid, spaceId)) + .limit(1); + + if (space.length === 0) { + return c.json({ error: "Space not found" }, 404); + } + + if (space[0].ownerId !== user.id) { + return c.json({ error: "Only space owner can edit space name" }, 403); + } + + if (name.trim() === "<HOME>") { + return c.json({ error: "Cannot use reserved name <HOME>" }, 400); + } + + // Update space name + await db + .update(spaces) + .set({ name: name.trim() }) + .where(eq(spaces.uuid, spaceId)); + + return c.json({ success: true, name: name.trim() }); + } + ) + .delete("/:spaceId", async (c) => { const user = c.get("user"); if (!user) { return c.json({ error: "Unauthorized" }, 401); diff --git a/apps/web/app/routes/space.$spaceId.tsx b/apps/web/app/routes/space.$spaceId.tsx index 88f817c9..a2a88caa 100644 --- a/apps/web/app/routes/space.$spaceId.tsx +++ b/apps/web/app/routes/space.$spaceId.tsx @@ -4,7 +4,7 @@ import { LoaderFunctionArgs, json } from "@remix-run/cloudflare"; import { useLoaderData, useNavigate } from "@remix-run/react"; import { getSessionFromRequest } from "@supermemory/authkit-remix-cloudflare/src/session"; -import { Clipboard, DeleteIcon, Share, Star, Trash, UserPlus } from "lucide-react"; +import { Clipboard, DeleteIcon, Pencil, Share, Star, Trash, UserPlus } from "lucide-react"; import { proxy } from "server/proxy"; import { toast } from "sonner"; import Navbar from "~/components/Navbar"; @@ -76,6 +76,9 @@ export default function SpacePage() { const [accessType, setAccessType] = useState<"read" | "edit">("read"); const [isInviting, setIsInviting] = useState(false); const [isFavorited, setIsFavorited] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editedName, setEditedName] = useState(space.name); + const [isUpdating, setIsUpdating] = useState(false); useEffect(() => { // Only update if we're on exactly /space/spaceid and not already in the correct format @@ -95,6 +98,40 @@ export default function SpacePage() { } }, [space, navigate]); + const handleEditName = async () => { + if (!editedName.trim() || editedName.trim() === space.name || isUpdating) return; + + setIsUpdating(true); + try { + const response = await fetch(`/backend/v1/spaces/${space.uuid}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: editedName.trim() }), + credentials: "include", + }); + + if (!response.ok) { + const data = (await response.json()) as { error: string }; + throw new Error(data.error || "Failed to update space name"); + } + + toast.success("Space name updated successfully"); + // Update URL with new name + const urlFriendlyName = editedName + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + navigate(`/space/${space.uuid}---${urlFriendlyName}`); + } catch (error) { + toast.error(error instanceof Error ? error.message : "Failed to update space name"); + setEditedName(space.name); // Reset to original name on error + } finally { + setIsUpdating(false); + setIsEditing(false); + } + }; + const handleInvite = async () => { if (!email) return; @@ -189,9 +226,61 @@ export default function SpacePage() { <div className="flex flex-col gap-2"> <div className="flex flex-col md:flex-row items-start md:items-center gap-3 md:justify-between"> <div className="flex flex-col md:flex-row items-start md:items-center gap-3"> - <h1 className="font-geist text-3xl font-semibold dark:text-neutral-100 text-neutral-700 tracking-[-0.020em]"> - {space.name} - </h1> + {isEditing && space.permissions.isOwner ? ( + <div className="flex items-center gap-2"> + <Input + value={editedName} + onChange={(e) => setEditedName(e.target.value)} + className="text-3xl font-semibold h-auto py-1" + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter") { + handleEditName(); + } else if (e.key === "Escape") { + setIsEditing(false); + setEditedName(space.name); + } + }} + /> + <Button + variant="ghost" + size="sm" + onClick={() => { + setIsEditing(false); + setEditedName(space.name); + }} + className="text-muted-foreground" + > + Cancel + </Button> + <Button + variant="default" + size="sm" + onClick={handleEditName} + disabled={ + isUpdating || !editedName.trim() || editedName.trim() === space.name + } + > + {isUpdating ? "Saving..." : "Save"} + </Button> + </div> + ) : ( + <div className="flex items-center gap-2"> + <h1 className="font-geist text-3xl font-semibold dark:text-neutral-100 text-neutral-700 tracking-[-0.020em]"> + {space.name} + </h1> + {space.permissions.isOwner && ( + <Button + variant="ghost" + size="sm" + onClick={() => setIsEditing(true)} + className="text-muted-foreground" + > + <Pencil className="h-4 w-4" /> + </Button> + )} + </div> + )} {space.permissions.canEdit && ( <span className="px-2 py-0.5 text-sm bg-emerald-100 dark:bg-emerald-900 text-emerald-700 dark:text-emerald-300 rounded-md"> Can Edit |