diff --git a/apps/web/package.json b/apps/web/package.json index c24e570..0ea2293 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "desktop:build": "tauri build" }, "dependencies": { + "@base-ui-components/react": "1.0.0-beta.3", "@hookform/resolvers": "^5.1.1", "@tailwindcss/vite": "^4.0.15", "@tanstack/react-form": "^1.12.3", diff --git a/apps/web/src/components/new-space-dialog.tsx b/apps/web/src/components/new-space-dialog.tsx new file mode 100644 index 0000000..92900f8 --- /dev/null +++ b/apps/web/src/components/new-space-dialog.tsx @@ -0,0 +1,130 @@ +import { Plus } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { ColorPicker, type ColorValue } from "@/components/ui/color-picker"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { createSpace } from "@/lib/appwrite-db"; +import { authClient } from "@/lib/auth-client"; + +type NewSpaceDialogProps = { + onSpaceCreated?: () => void; + triggerButton?: React.ReactNode; +}; + +export function NewSpaceDialog({ + onSpaceCreated, + triggerButton, +}: NewSpaceDialogProps) { + const [_open, setOpen] = useState(false); + const [title, setTitle] = useState(""); + const [color, setColor] = useState("#3b82f6"); + const [isCreating, setIsCreating] = useState(false); + const { data: session } = authClient.useSession(); + + const handleCreate = async () => { + if (!(session?.$id && title.trim())) { + return; + } + + setIsCreating(true); + try { + await createSpace({ + title: title.trim(), + color, + userId: session.$id, + }); + + // Reset form + setTitle(""); + setColor("#3b82f6"); + setOpen(false); + + // Notify parent to refresh + onSpaceCreated?.(); + } catch { + // TODO: Add proper error handling/toast notification + } finally { + setIsCreating(false); + } + }; + + const defaultTrigger = ( + + ); + + return ( + + {triggerButton || defaultTrigger} + + + Create New Space + + Create a new visual workspace to organize your thoughts and ideas. + + + +
+ {/* Title Input */} +
+ + setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && title.trim()) { + handleCreate(); + } + }} + placeholder="Enter space title..." + value={title} + /> +
+ + {/* Color Picker */} +
+ Color + +
+
+ + + + + + + + + +
+
+ ); +} diff --git a/apps/web/src/components/spaces-grid.tsx b/apps/web/src/components/spaces-grid.tsx index 021bd2b..784939c 100644 --- a/apps/web/src/components/spaces-grid.tsx +++ b/apps/web/src/components/spaces-grid.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { MoreHorizontal, Plus, Search } from "lucide-react"; import { Tldraw } from "tldraw"; +import { NewSpaceDialog } from "@/components/new-space-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -32,7 +33,7 @@ export function SpacesGrid() { return [] as SpaceCard[]; } const rows = await listUserSpaceSnapshots(session.$id); - return rows.map(({ row, snapshot }, idx) => { + return rows.map(({ row, snapshot }) => { const snapshotText = snapshot ? JSON.stringify(snapshot) : (row.snapshot ?? ""); @@ -50,24 +51,16 @@ export function SpacesGrid() { return 0; } })(); - const COLORS = [ - "bg-blue-500", - "bg-green-500", - "bg-purple-500", - "bg-orange-500", - "bg-pink-500", - "bg-cyan-500", - ] as const; return { id: row.spaceId, - name: row.spaceId, + name: row.title || row.spaceId, lastEdited: row.$updatedAt ? new Date(row.$updatedAt).toLocaleString() : "", snapshotText, itemCount, - // keep some color variety - color: COLORS[idx % COLORS.length], + // Use actual color from DB or fallback to color variety + color: row.color || "#3b82f6", // Default to blue if no color } satisfies SpaceCard; }); }, @@ -78,6 +71,11 @@ export function SpacesGrid() { return (
+ {/* Header */}
@@ -86,10 +84,7 @@ export function SpacesGrid() { Organize your thoughts and ideas in visual workspaces

- + spacesQuery.refetch()} />
{/* Search and Filters */} @@ -116,7 +111,10 @@ export function SpacesGrid() {
-
+

{space.name}

@@ -160,7 +158,11 @@ export function SpacesGrid() { onMount={(editor) => { editor.updateInstanceState({ isReadonly: true }); }} - snapshot={JSON.parse(space.snapshotText).document} + snapshot={ + space.snapshotText + ? JSON.parse(space.snapshotText).document + : undefined + } />
@@ -186,10 +188,15 @@ export function SpacesGrid() { Create your first space to start organizing your thoughts and ideas visually.

- + spacesQuery.refetch()} + triggerButton={ + + } + />
)}
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx new file mode 100644 index 0000000..f29e72d --- /dev/null +++ b/apps/web/src/components/ui/color-picker.tsx @@ -0,0 +1,79 @@ +import { useId, useState } from "react"; +import { cn } from "@/lib/utils"; + +const PRESET_COLORS = [ + "#3b82f6", // blue-500 + "#22c55e", // green-500 + "#a855f7", // purple-500 + "#f59e0b", // amber-500 + "#ec4899", // pink-500 + "#06b6d4", // cyan-500 + "#ef4444", // red-500 + "#10b981", // emerald-500 +] as const; + +export type ColorValue = (typeof PRESET_COLORS)[number]; + +export function ColorPicker({ + value, + onChange, + className, + colors = PRESET_COLORS, + size = 24, + name, +}: { + value?: string | null; + onChange?: (v: ColorValue) => void; + className?: string; + colors?: readonly string[]; + size?: number; + name?: string; +}) { + const groupId = useId(); + const [internal, setInternal] = useState( + value ?? colors[0] ?? null + ); + const active = value ?? internal; + + return ( +
+ {colors.map((c) => { + const isActive = c.toLowerCase() === (active ?? "").toLowerCase(); + const id = `${groupId}-${c}`; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3416d85 --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,143 @@ +import { Dialog as BaseDialog } from "@base-ui-components/react"; +import { XIcon } from "lucide-react"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ ...props }: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/apps/web/src/index.css b/apps/web/src/index.css index abe10b3..c2d5e4e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -122,6 +122,10 @@ body { --color-sidebar-ring: var(--sidebar-ring); } +button { + @apply transition-colors disabled:opacity-50 disabled:pointer-events-none cursor-pointer; +} + @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 89b8d8e..9920db0 100644 --- a/apps/web/src/lib/appwrite-db.ts +++ b/apps/web/src/lib/appwrite-db.ts @@ -17,6 +17,9 @@ export type SpaceSnapshotRow = { spaceId: string; userId: string; snapshot: string; // JSON string containing { schema, document } + // Optional metadata (create attributes in Appwrite collection) + title?: string; + color?: string; // hex color like #7c3aed $createdAt?: string; $updatedAt?: string; }; @@ -123,6 +126,48 @@ export async function upsertSpaceSnapshot( } } +/** + * Create a new space with optional initial snapshot and metadata (title, color). + * Returns the new spaceId. + */ +export async function createSpace(args: { + title: string; + color: string; // hex + snapshot?: RemoteSnapshot; + userId: string; +}): Promise { + ensureEnv(); + const uid = args.userId ?? (await getCurrentUserId()); + if (!uid) { + throw new Error("Not authenticated"); + } + + const data: Record = { + spaceId: crypto.randomUUID(), + userId: uid, + snapshot: args.snapshot ? JSON.stringify(args.snapshot) : undefined, + title: args.title, + color: args.color, + }; + + // Limit access to the owner by default + const permissions = [ + Permission.read(Role.user(uid)), + Permission.update(Role.user(uid)), + Permission.delete(Role.user(uid)), + ]; + + const doc = await databases.createDocument( + DATABASE_ID, + COLLECTION_ID, + ID.unique(), + data, + permissions + ); + + return (doc as Models.Document).spaceId as string; +} + /** * List all space snapshots for a user. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fadd651..7bf859b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: apps/web: dependencies: + '@base-ui-components/react': + specifier: 1.0.0-beta.3 + version: 1.0.0-beta.3(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@hookform/resolvers': specifier: ^5.1.1 version: 5.2.1(react-hook-form@7.62.0(react@19.1.1)) @@ -725,6 +728,27 @@ packages: resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==} engines: {node: '>=6.9.0'} + '@base-ui-components/react@1.0.0-beta.3': + resolution: {integrity: sha512-4sAq6zmDA9ixV2HRjjeM1+tSEw5R6nvGjXUQmFoQnC3DZLEUdwO94gWDmUDdpoDuChn27jdbaJs9F0Ih4w2UAA==} + engines: {node: '>=14.0.0'} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + + '@base-ui-components/utils@0.1.1': + resolution: {integrity: sha512-HWXZA8upEKgrdL1rQqxWu1H+2tB2cXzY2jCxvgnpUv3eoWN2jldhXxMZnXIjZF7jahGxSWXfSIM/qskiTWFFxA==} + peerDependencies: + '@types/react': ^17 || ^18 || ^19 + react: ^17 || ^18 || ^19 + react-dom: ^17 || ^18 || ^19 + peerDependenciesMeta: + '@types/react': + optional: true + '@biomejs/biome@2.2.2': resolution: {integrity: sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w==} engines: {node: '>=14.21.3'} @@ -4217,6 +4241,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -4454,6 +4481,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -5640,6 +5670,31 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@base-ui-components/react@1.0.0-beta.3(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.3 + '@base-ui-components/utils': 0.1.1(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@floating-ui/utils': 0.2.10 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + reselect: 5.1.1 + tabbable: 6.2.0 + use-sync-external-store: 1.5.0(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + + '@base-ui-components/utils@0.1.1(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@babel/runtime': 7.28.3 + '@floating-ui/utils': 0.2.10 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + reselect: 5.1.1 + use-sync-external-store: 1.5.0(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.12 + '@biomejs/biome@2.2.2': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.2.2 @@ -9025,6 +9080,8 @@ snapshots: require-from-string@2.0.2: {} + reselect@5.1.1: {} + resolve-pkg-maps@1.0.0: {} resolve@1.22.10: @@ -9357,6 +9414,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + tabbable@6.2.0: {} + tailwind-merge@3.3.1: {} tailwindcss@4.1.12: {}