From 4d5de1ba88351d8ff65d7767b5fbbe4dfae8e53b Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Thu, 4 Sep 2025 15:09:27 -0300 Subject: [PATCH] feat: add DeleteSpaceDialog and EditSpaceDialog components for space management --- .../src/components/delete-space-dialog.tsx | 58 +++ apps/web/src/components/edit-space-dialog.tsx | 93 ++++ apps/web/src/components/mode-toggle.tsx | 2 +- apps/web/src/components/space-actions.tsx | 162 ++++++ apps/web/src/components/spaces-grid.tsx | 18 +- apps/web/src/components/ui/dropdown-menu.tsx | 463 +++++++++--------- apps/web/src/components/user-menu.tsx | 31 +- apps/web/src/index.css | 5 +- apps/web/src/lib/appwrite-db.ts | 71 +++ 9 files changed, 655 insertions(+), 248 deletions(-) create mode 100644 apps/web/src/components/delete-space-dialog.tsx create mode 100644 apps/web/src/components/edit-space-dialog.tsx create mode 100644 apps/web/src/components/space-actions.tsx diff --git a/apps/web/src/components/delete-space-dialog.tsx b/apps/web/src/components/delete-space-dialog.tsx new file mode 100644 index 0000000..0c150a1 --- /dev/null +++ b/apps/web/src/components/delete-space-dialog.tsx @@ -0,0 +1,58 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +export type DeleteSpaceDialogProps = { + open: boolean; + onOpenChange: (v: boolean) => void; + isDeleting: boolean; + onConfirm: () => void; + spaceName: string; +}; + +export function DeleteSpaceDialog({ + open, + onOpenChange, + isDeleting, + onConfirm, + spaceName, +}: DeleteSpaceDialogProps) { + return ( + + e.stopPropagation()} + style={{ zIndex: 201 }} + > + + Delete Space + + This action cannot be undone. This will permanently delete the space + "{spaceName}" for your account. + + + + + + + + + + + ); +} diff --git a/apps/web/src/components/edit-space-dialog.tsx b/apps/web/src/components/edit-space-dialog.tsx new file mode 100644 index 0000000..f1700ed --- /dev/null +++ b/apps/web/src/components/edit-space-dialog.tsx @@ -0,0 +1,93 @@ +import { Button } from "@/components/ui/button"; +import { ColorPicker, type ColorValue } from "@/components/ui/color-picker"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; + +export type EditSpaceDialogProps = { + open: boolean; + onOpenChange: (v: boolean) => void; + title: string; + color: ColorValue; + isSaving: boolean; + onChangeTitle: (v: string) => void; + onChangeColor: (v: ColorValue) => void; + onSave: () => void; +}; + +export function EditSpaceDialog({ + open, + onOpenChange, + title, + color, + isSaving, + onChangeTitle, + onChangeColor, + onSave, +}: EditSpaceDialogProps) { + return ( + + e.stopPropagation()} + style={{ zIndex: 201 }} + > + + Edit Space + + Update the title and color of your space. + + + +
+
+ + onChangeTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && title.trim()) { + onSave(); + } + }} + placeholder="Enter space title..." + value={title} + /> +
+ +
+ Color + +
+
+ + + + + + + +
+
+ ); +} diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx index c5ddea2..24119b8 100644 --- a/apps/web/src/components/mode-toggle.tsx +++ b/apps/web/src/components/mode-toggle.tsx @@ -13,7 +13,7 @@ export function ModeToggle() { return ( - + + + + { + e.preventDefault(); + e.stopPropagation(); + setOpenEdit(true); + }} + > + Edit + + { + e.preventDefault(); + e.stopPropagation(); + setOpenDelete(true); + }} + variant="destructive" + > + Delete + + + + + + + + ); +} + +// Color helpers constrained to the allowed palette from ColorPicker +const allowedColors = new Set([ + "#3b82f6", + "#22c55e", + "#a855f7", + "#f59e0b", + "#ec4899", + "#06b6d4", + "#ef4444", + "#10b981", +]); + +function normalizeColor(value?: string): ColorValue { + if (value && (allowedColors as Set).has(value)) { + return value as ColorValue; + } + return "#3b82f6"; +} diff --git a/apps/web/src/components/spaces-grid.tsx b/apps/web/src/components/spaces-grid.tsx index 70d5df8..b8b0932 100644 --- a/apps/web/src/components/spaces-grid.tsx +++ b/apps/web/src/components/spaces-grid.tsx @@ -1,8 +1,9 @@ import { useQuery } from "@tanstack/react-query"; -import { MoreHorizontal, Plus, Search } from "lucide-react"; +import { Plus, Search } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Tldraw } from "tldraw"; import { NewSpaceDialog } from "@/components/new-space-dialog"; +import { SpaceActions } from "@/components/space-actions"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -170,13 +171,12 @@ export function SpacesGrid() { {space.name} - + spacesQuery.refetch()} + onUpdated={() => spacesQuery.refetch()} + space={space} + userId={session?.$id} + /> @@ -257,3 +257,5 @@ export function SpacesGrid() { ); } + +// Inlined action/dialog components moved to dedicated files for reuse diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 301922d..7b535b9 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -1,257 +1,278 @@ -"use client"; +import * as React from "react" +import { Menu as BaseMenu } from "@base-ui-components/react/menu" +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" -import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" function DropdownMenu({ - ...props -}: React.ComponentProps) { - return ; + ...props +}: React.ComponentProps) { + return } function DropdownMenuPortal({ - ...props -}: React.ComponentProps) { - return ( - - ); + ...props +}: React.ComponentProps) { + return } function DropdownMenuTrigger({ - ...props -}: React.ComponentProps) { - return ( - - ); + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPositioner({ + ...props +}: React.ComponentProps) { + return } function DropdownMenuContent({ - className, - sideOffset = 4, - ...props -}: React.ComponentProps) { - return ( - - - - ); + className, + sideOffset = 4, + align = "center", + ...props +}: React.ComponentProps & { + align?: BaseMenu.Positioner.Props["align"] + sideOffset?: BaseMenu.Positioner.Props["sideOffset"] +}) { + return ( + + + + + + ) } function DropdownMenuGroup({ - ...props -}: React.ComponentProps) { - return ( - - ); + ...props +}: React.ComponentProps) { + return } function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: React.ComponentProps & { - inset?: boolean; - variant?: "default" | "destructive"; + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" }) { - return ( - - ); -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ); -} - -function DropdownMenuRadioGroup({ - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function DropdownMenuRadioItem({ - className, - children, - ...props -}: React.ComponentProps) { - return ( - - - - - - - {children} - - ); -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: React.ComponentProps & { - inset?: boolean; -}) { - return ( - - ); -} - -function DropdownMenuSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); + return ( + + ) } function DropdownMenuShortcut({ - className, - ...props + className, + ...props }: React.ComponentProps<"span">) { - return ( - - ); + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) } function DropdownMenuSub({ - ...props -}: React.ComponentProps) { - return ; + ...props +}: React.ComponentProps) { + return ( + + ) } function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: React.ComponentProps & { - inset?: boolean; + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean }) { - return ( - - {children} - - - ); + return ( + + {children} + + + ) } function DropdownMenuSubContent({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); + className, + sideOffset = 0, + align = "start", + ...props +}: React.ComponentProps & { + align?: BaseMenu.Positioner.Props["align"] + sideOffset?: BaseMenu.Positioner.Props["sideOffset"] +}) { + return ( + + + + + + ) } export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -}; + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/apps/web/src/components/user-menu.tsx b/apps/web/src/components/user-menu.tsx index 6492d91..815d2fb 100644 --- a/apps/web/src/components/user-menu.tsx +++ b/apps/web/src/components/user-menu.tsx @@ -31,29 +31,26 @@ export default function UserMenu() { return ( - + My Account {session.email} - - + { + await authClient.signOut(); + // Immediately reflect logout in UI + queryClient.setQueryData(["session", "me"], null); + await queryClient.invalidateQueries({ + queryKey: ["session", "me"], + }); + navigate({ to: "/" }); + }} + variant="destructive" + > + Sign Out diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c2d5e4e..f675ee7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -122,10 +122,13 @@ body { --color-sidebar-ring: var(--sidebar-ring); } -button { +button, +[role="menuitem"] { @apply transition-colors disabled:opacity-50 disabled:pointer-events-none cursor-pointer; } +/* role="menuitem" selector */ + @layer base { * { @apply border-border outline-ring/50; diff --git a/apps/web/src/lib/appwrite-db.ts b/apps/web/src/lib/appwrite-db.ts index 60b1c74..12b5c49 100644 --- a/apps/web/src/lib/appwrite-db.ts +++ b/apps/web/src/lib/appwrite-db.ts @@ -196,3 +196,74 @@ export async function listUserSpaceSnapshots( return { row, snapshot: parsed }; }); } + +/** + * Update a space's metadata (title/color) identified by spaceId for the current (or provided) user. + */ +export async function updateSpace(args: { + spaceId: string; + title?: string; + color?: string; + userId?: string; +}): Promise { + ensureEnv(); + const uid = args.userId ?? (await getCurrentUserId()); + if (!uid) { + throw new Error("Not authenticated"); + } + + const res = (await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ + Query.equal("spaceId", args.spaceId), + Query.equal("userId", uid), + ])) as Models.DocumentList; + + const existing = res.documents?.[0] as Models.Document | undefined; + if (!existing) { + throw new Error("Space not found"); + } + + const patch: Record = {}; + if (typeof args.title === "string") { + patch.title = args.title; + } + if (typeof args.color === "string") { + patch.color = args.color; + } + + if (Object.keys(patch).length === 0) { + return; // nothing to update + } + + await databases.updateDocument( + DATABASE_ID, + COLLECTION_ID, + existing.$id, + patch + ); +} + +/** + * Delete a space (document) identified by spaceId for the current (or provided) user. + */ +export async function deleteSpace(args: { + spaceId: string; + userId?: string; +}): Promise { + ensureEnv(); + const uid = args.userId ?? (await getCurrentUserId()); + if (!uid) { + throw new Error("Not authenticated"); + } + + const res = (await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ + Query.equal("spaceId", args.spaceId), + Query.equal("userId", uid), + ])) as Models.DocumentList; + + const existing = res.documents?.[0] as Models.Document | undefined; + if (!existing) { + throw new Error("Space not found"); + } + + await databases.deleteDocument(DATABASE_ID, COLLECTION_ID, existing.$id); +}