mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: add NewSpaceDialog component for creating spaces and integrate it into SpacesGrid
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"desktop:build": "tauri build"
|
"desktop:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui-components/react": "1.0.0-beta.3",
|
||||||
"@hookform/resolvers": "^5.1.1",
|
"@hookform/resolvers": "^5.1.1",
|
||||||
"@tailwindcss/vite": "^4.0.15",
|
"@tailwindcss/vite": "^4.0.15",
|
||||||
"@tanstack/react-form": "^1.12.3",
|
"@tanstack/react-form": "^1.12.3",
|
||||||
|
|||||||
130
apps/web/src/components/new-space-dialog.tsx
Normal file
130
apps/web/src/components/new-space-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { MoreHorizontal, Plus, Search } from "lucide-react";
|
import { MoreHorizontal, Plus, Search } from "lucide-react";
|
||||||
import { Tldraw } from "tldraw";
|
import { Tldraw } from "tldraw";
|
||||||
|
import { NewSpaceDialog } from "@/components/new-space-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -32,7 +33,7 @@ export function SpacesGrid() {
|
|||||||
return [] as SpaceCard[];
|
return [] as SpaceCard[];
|
||||||
}
|
}
|
||||||
const rows = await listUserSpaceSnapshots(session.$id);
|
const rows = await listUserSpaceSnapshots(session.$id);
|
||||||
return rows.map(({ row, snapshot }, idx) => {
|
return rows.map(({ row, snapshot }) => {
|
||||||
const snapshotText = snapshot
|
const snapshotText = snapshot
|
||||||
? JSON.stringify(snapshot)
|
? JSON.stringify(snapshot)
|
||||||
: (row.snapshot ?? "");
|
: (row.snapshot ?? "");
|
||||||
@@ -50,24 +51,16 @@ export function SpacesGrid() {
|
|||||||
return 0;
|
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 {
|
return {
|
||||||
id: row.spaceId,
|
id: row.spaceId,
|
||||||
name: row.spaceId,
|
name: row.title || row.spaceId,
|
||||||
lastEdited: row.$updatedAt
|
lastEdited: row.$updatedAt
|
||||||
? new Date(row.$updatedAt).toLocaleString()
|
? new Date(row.$updatedAt).toLocaleString()
|
||||||
: "",
|
: "",
|
||||||
snapshotText,
|
snapshotText,
|
||||||
itemCount,
|
itemCount,
|
||||||
// keep some color variety
|
// Use actual color from DB or fallback to color variety
|
||||||
color: COLORS[idx % COLORS.length],
|
color: row.color || "#3b82f6", // Default to blue if no color
|
||||||
} satisfies SpaceCard;
|
} satisfies SpaceCard;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -78,6 +71,11 @@ export function SpacesGrid() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-6 p-6">
|
||||||
|
<style>
|
||||||
|
{`.tl-watermark_SEE-LICENSE {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}`}
|
||||||
|
</style>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -86,10 +84,7 @@ export function SpacesGrid() {
|
|||||||
Organize your thoughts and ideas in visual workspaces
|
Organize your thoughts and ideas in visual workspaces
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button className="gap-2">
|
<NewSpaceDialog onSpaceCreated={() => spacesQuery.refetch()} />
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
New Space
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters */}
|
{/* Search and Filters */}
|
||||||
@@ -116,7 +111,10 @@ export function SpacesGrid() {
|
|||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<h3 className="font-semibold text-foreground transition-colors group-hover:text-primary">
|
||||||
{space.name}
|
{space.name}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -160,7 +158,11 @@ export function SpacesGrid() {
|
|||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
editor.updateInstanceState({ isReadonly: true });
|
editor.updateInstanceState({ isReadonly: true });
|
||||||
}}
|
}}
|
||||||
snapshot={JSON.parse(space.snapshotText).document}
|
snapshot={
|
||||||
|
space.snapshotText
|
||||||
|
? JSON.parse(space.snapshotText).document
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -186,10 +188,15 @@ export function SpacesGrid() {
|
|||||||
Create your first space to start organizing your thoughts and ideas
|
Create your first space to start organizing your thoughts and ideas
|
||||||
visually.
|
visually.
|
||||||
</p>
|
</p>
|
||||||
<Button>
|
<NewSpaceDialog
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
onSpaceCreated={() => spacesQuery.refetch()}
|
||||||
Create Your First Space
|
triggerButton={
|
||||||
</Button>
|
<Button>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Create Your First Space
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
79
apps/web/src/components/ui/color-picker.tsx
Normal file
79
apps/web/src/components/ui/color-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/web/src/components/ui/dialog.tsx
Normal file
143
apps/web/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
@@ -122,6 +122,10 @@ body {
|
|||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
@apply transition-colors disabled:opacity-50 disabled:pointer-events-none cursor-pointer;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export type SpaceSnapshotRow = {
|
|||||||
spaceId: string;
|
spaceId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
snapshot: string; // JSON string containing { schema, document }
|
snapshot: string; // JSON string containing { schema, document }
|
||||||
|
// Optional metadata (create attributes in Appwrite collection)
|
||||||
|
title?: string;
|
||||||
|
color?: string; // hex color like #7c3aed
|
||||||
$createdAt?: string;
|
$createdAt?: string;
|
||||||
$updatedAt?: 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.
|
* List all space snapshots for a user.
|
||||||
*/
|
*/
|
||||||
|
|||||||
59
pnpm-lock.yaml
generated
59
pnpm-lock.yaml
generated
@@ -72,6 +72,9 @@ importers:
|
|||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
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':
|
'@hookform/resolvers':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 5.2.1(react-hook-form@7.62.0(react@19.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==}
|
resolution: {integrity: sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
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':
|
'@biomejs/biome@2.2.2':
|
||||||
resolution: {integrity: sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w==}
|
resolution: {integrity: sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w==}
|
||||||
engines: {node: '>=14.21.3'}
|
engines: {node: '>=14.21.3'}
|
||||||
@@ -4217,6 +4241,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
reselect@5.1.1:
|
||||||
|
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||||
|
|
||||||
@@ -4454,6 +4481,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
tabbable@6.2.0:
|
||||||
|
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
|
||||||
|
|
||||||
tailwind-merge@3.3.1:
|
tailwind-merge@3.3.1:
|
||||||
resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==}
|
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-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 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':
|
'@biomejs/biome@2.2.2':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@biomejs/cli-darwin-arm64': 2.2.2
|
'@biomejs/cli-darwin-arm64': 2.2.2
|
||||||
@@ -9025,6 +9080,8 @@ snapshots:
|
|||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
|
|
||||||
|
reselect@5.1.1: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
||||||
resolve@1.22.10:
|
resolve@1.22.10:
|
||||||
@@ -9357,6 +9414,8 @@ snapshots:
|
|||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
|
tabbable@6.2.0: {}
|
||||||
|
|
||||||
tailwind-merge@3.3.1: {}
|
tailwind-merge@3.3.1: {}
|
||||||
|
|
||||||
tailwindcss@4.1.12: {}
|
tailwindcss@4.1.12: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user