feat: add NewSpaceDialog component for creating spaces and integrate it into SpacesGrid

This commit is contained in:
2025-09-04 13:37:47 -03:00
parent 3972771148
commit e8b178b0c9
8 changed files with 490 additions and 22 deletions

View File

@@ -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",

View File

@@ -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<ColorValue>("#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 = (
<Button className="gap-2">
<Plus className="h-4 w-4" />
New Space
</Button>
);
return (
<Dialog>
<DialogTrigger>{triggerButton || defaultTrigger}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Create New Space</DialogTitle>
<DialogDescription>
Create a new visual workspace to organize your thoughts and ideas.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Title Input */}
<div className="space-y-2">
<label
className="font-medium text-foreground text-sm"
htmlFor="space-title"
>
Title
</label>
<Input
id="space-title"
onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && title.trim()) {
handleCreate();
}
}}
placeholder="Enter space title..."
value={title}
/>
</div>
{/* Color Picker */}
<div className="flex flex-col space-y-1">
<span className="font-medium text-foreground text-sm">Color</span>
<ColorPicker name="space-color" onChange={setColor} value={color} />
</div>
</div>
<DialogFooter className="gap-2">
<DialogClose>
<Button
disabled={isCreating}
onClick={() => setOpen(false)}
variant="outline"
>
Cancel
</Button>
</DialogClose>
<DialogClose>
<Button
disabled={!title.trim() || isCreating}
onClick={handleCreate}
>
{isCreating ? "Creating..." : "Create Space"}
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<div className="space-y-6 p-6">
<style>
{`.tl-watermark_SEE-LICENSE {
z-index: 1 !important;
}`}
</style>
{/* Header */}
<div className="flex items-center justify-between">
<div>
@@ -86,10 +84,7 @@ export function SpacesGrid() {
Organize your thoughts and ideas in visual workspaces
</p>
</div>
<Button className="gap-2">
<Plus className="h-4 w-4" />
New Space
</Button>
<NewSpaceDialog onSpaceCreated={() => spacesQuery.refetch()} />
</div>
{/* Search and Filters */}
@@ -116,7 +111,10 @@ export function SpacesGrid() {
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`h-3 w-3 rounded-full ${space.color}`} />
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: space.color }}
/>
<h3 className="font-semibold text-foreground transition-colors group-hover:text-primary">
{space.name}
</h3>
@@ -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
}
/>
</div>
</CardContent>
@@ -186,10 +188,15 @@ export function SpacesGrid() {
Create your first space to start organizing your thoughts and ideas
visually.
</p>
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Your First Space
</Button>
<NewSpaceDialog
onSpaceCreated={() => spacesQuery.refetch()}
triggerButton={
<Button>
<Plus className="mr-2 h-4 w-4" />
Create Your First Space
</Button>
}
/>
</div>
)}
</div>

View File

@@ -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<string | null>(
value ?? colors[0] ?? null
);
const active = value ?? internal;
return (
<div className={cn("flex flex-wrap gap-2", className)}>
{colors.map((c) => {
const isActive = c.toLowerCase() === (active ?? "").toLowerCase();
const id = `${groupId}-${c}`;
return (
<label
className="group inline-flex cursor-pointer items-center justify-center"
htmlFor={id}
key={c}
>
<input
checked={isActive}
className="sr-only"
id={id}
name={name ?? groupId}
onChange={() => {
setInternal(c);
onChange?.(c as ColorValue);
}}
type="radio"
value={c}
/>
<span
aria-hidden
className={cn(
"inline-flex rounded-full border border-border/40 ring-offset-background transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive ? "ring-2 ring-ring ring-offset-2" : ""
)}
style={{
width: size,
height: size,
backgroundColor: c,
}}
/>
<span className="sr-only">{c}</span>
</label>
);
})}
</div>
);
}

View File

@@ -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<typeof BaseDialog.Root>) {
return <BaseDialog.Root data-slot="dialog" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof BaseDialog.Portal>) {
return <BaseDialog.Portal data-slot="dialog-portal" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof BaseDialog.Trigger>) {
return <BaseDialog.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof BaseDialog.Close>) {
return <BaseDialog.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof BaseDialog.Backdrop>) {
return (
<BaseDialog.Backdrop
className={cn(
"fixed inset-0 bg-black/50 transition-all duration-200 [&[data-ending-style]]:opacity-0 [&[data-starting-style]]:opacity-0",
className
)}
data-slot="dialog-overlay"
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof BaseDialog.Popup> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<BaseDialog.Popup
className={cn(
"fixed z-50 grid w-full bg-popover text-popover-foreground sm:max-w-[calc(100%-2rem)]",
"gap-4 rounded-lg border p-6 shadow-lg outline-none duration-200 sm:max-w-lg sm:scale-[calc(1-0.1*var(--nested-dialogs))]",
"fixed bottom-0 w-full sm:top-[50%] sm:bottom-auto sm:left-[50%] sm:translate-x-[-50%] sm:translate-y-[-50%]",
"duration-200",
"data-[starting-style]:translate-y-full data-[starting-style]:opacity-0",
"data-[ending-style]:translate-y-full data-[ending-style]:opacity-0",
"data-[starting-style]:sm:translate-y-[-50%] data-[starting-style]:sm:scale-95",
"data-[ending-style]:sm:translate-y-[-50%] data-[ending-style]:sm:scale-95",
className
)}
data-slot="dialog-content"
{...props}
>
{children}
{showCloseButton && (
<DialogClose className="absolute top-4 right-4 rounded-xs text-muted-foreground opacity-50 ring-offset-popover transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-[3px] focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0">
<XIcon />
<span className="sr-only">Close</span>
</DialogClose>
)}
</BaseDialog.Popup>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
data-slot="dialog-header"
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
data-slot="dialog-footer"
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof BaseDialog.Title>) {
return (
<BaseDialog.Title
className={cn("font-semibold text-lg leading-none", className)}
data-slot="dialog-title"
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof BaseDialog.Description>) {
return (
<BaseDialog.Description
className={cn("text-muted-foreground text-sm", className)}
data-slot="dialog-description"
{...props}
/>
);
}
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -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;

View File

@@ -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<string> {
ensureEnv();
const uid = args.userId ?? (await getCurrentUserId());
if (!uid) {
throw new Error("Not authenticated");
}
const data: Record<string, unknown> = {
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.
*/

59
pnpm-lock.yaml generated
View File

@@ -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: {}