diff --git a/.vscode/settings.json b/.vscode/settings.json index ac1ff81..64256ff 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -32,5 +32,5 @@ "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" }, - "cSpell.words": ["Reflecto", "Tldraw"] + "cSpell.words": ["appwrite", "Reflecto", "Tldraw"] } diff --git a/apps/web/.env.example b/apps/web/.env.example index e63fe1a..cd3d3ca 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1 +1,4 @@ -VITE_SERVER_URL= \ No newline at end of file +VITE_APPWRITE_ENDPOINT=https://.cloud.appwrite.io/v1 +VITE_APPWRITE_PROJECT_ID= +VITE_APPWRITE_DB_ID= +VITE_APPWRITE_COLLECTION_ID= \ No newline at end of file diff --git a/apps/web/src/lib/appwrite-db.ts b/apps/web/src/lib/appwrite-db.ts new file mode 100644 index 0000000..504b1a3 --- /dev/null +++ b/apps/web/src/lib/appwrite-db.ts @@ -0,0 +1,124 @@ +import type { Models } from "appwrite"; +import { Databases, ID, Permission, Query, Role } from "appwrite"; +import type { TLEditorSnapshot } from "tldraw"; +import { account, appwriteClient } from "@/lib/auth-client"; + +// Environment-configured IDs for Appwrite +const DATABASE_ID = import.meta.env.VITE_APPWRITE_DB_ID as string; +const COLLECTION_ID = import.meta.env.VITE_APPWRITE_COLLECTION_ID as string; + +// Initialize TablesDB once per app +const databases = new Databases(appwriteClient); + +export type SpaceSnapshotRow = { + $id: string; + $databaseId: string; + $tableId: string; + spaceId: string; + userId: string; + snapshot: string; // JSON string containing { schema, document } + $createdAt?: string; + $updatedAt?: string; +}; + +export type RemoteSnapshot = Partial; + +function ensureEnv() { + if (!(DATABASE_ID && COLLECTION_ID)) { + throw new Error( + "Missing Appwrite DB config. Please set VITE_APPWRITE_DB_ID and VITE_APPWRITE_COLLECTION_ID." + ); + } +} + +export async function getCurrentUserId(): Promise { + try { + const me = await account.get(); + return me.$id ?? null; + } catch { + return null; + } +} + +/** + * Fetch latest snapshot for a given space (scoped to the current user). + */ +export async function getLatestSpaceSnapshot( + spaceId: string, + userId?: string +): Promise { + ensureEnv(); + const uid = userId ?? (await getCurrentUserId()); + if (!uid) { + return null; + } + + const res = (await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ + Query.equal("spaceId", spaceId), + Query.equal("userId", uid), + ])) as Models.DocumentList; + + const row = res.documents?.[0] as unknown as SpaceSnapshotRow | undefined; + if (!row) { + return null; + } + + try { + const parsed = JSON.parse(row.snapshot) as RemoteSnapshot; + return parsed; + } catch { + return null; + } +} + +/** + * Create or update a snapshot row for the space (per user). + */ +export async function upsertSpaceSnapshot( + spaceId: string, + payload: RemoteSnapshot, + userId?: string +): Promise { + ensureEnv(); + const uid = userId ?? (await getCurrentUserId()); + if (!uid) { + throw new Error("Not authenticated"); + } + + const res = (await databases.listDocuments(DATABASE_ID, COLLECTION_ID, [ + Query.equal("spaceId", spaceId), + Query.equal("userId", uid), + ])) as Models.DocumentList; + const existing = res.documents?.[0] as unknown as + | SpaceSnapshotRow + | undefined; + + const data = { + spaceId, + userId: uid, + snapshot: JSON.stringify(payload), + } as const; + + if (existing) { + await databases.updateDocument( + DATABASE_ID, + COLLECTION_ID, + existing.$id, + data + ); + } else { + // Limit access to the owner by default + const permissions = [ + Permission.read(Role.user(uid)), + Permission.update(Role.user(uid)), + Permission.delete(Role.user(uid)), + ]; + await databases.createDocument( + DATABASE_ID, + COLLECTION_ID, + ID.unique(), + data, + permissions + ); + } +} diff --git a/apps/web/src/routes/space.tsx b/apps/web/src/routes/space.tsx index d990e43..a8b1f81 100644 --- a/apps/web/src/routes/space.tsx +++ b/apps/web/src/routes/space.tsx @@ -1,8 +1,19 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect, useMemo } from "react"; -import { Tldraw } from "tldraw"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + createTLStore, + getSnapshot, + loadSnapshot, + type TLStoreWithStatus, + Tldraw, +} from "tldraw"; import { z } from "zod"; import "tldraw/tldraw.css"; +import { + getLatestSpaceSnapshot, + type RemoteSnapshot, + upsertSpaceSnapshot, +} from "@/lib/appwrite-db"; import { authClient } from "@/lib/auth-client"; export const Route = createFileRoute("/space")({ @@ -25,16 +36,91 @@ function SpaceRoute() { } }, [session, isPending, navigate, id]); - // Use a stable persistence key so tabs with same id share state locally - const persistenceKey = useMemo(() => `space-${id ?? "default"}`, [id]); + // Create a stable store instance once + const store = useMemo(() => createTLStore(), []); + + // TL Store with status for remote-loaded snapshots + const [storeWithStatus, setStoreWithStatus] = useState({ + status: "not-synced", + store, + }); + + // Track latest saved version hash to suppress redundant saves + const lastSavedRef = useRef(""); + + // Load initial snapshot from Appwrite + useEffect(() => { + let cancelled = false; + async function load(_id: string | undefined) { + if (!(_id && session)) { + return; + } + const remote = await getLatestSpaceSnapshot(_id, session.$id); + if (remote) { + try { + loadSnapshot(store, remote); + } catch { + // ignore and start fresh + } + } + if (!cancelled) { + setStoreWithStatus({ + store, + status: "synced-remote", + connectionStatus: "online", + }); + } + } + setStoreWithStatus({ status: "not-synced", store }); + load(id); + return () => { + cancelled = true; + }; + }, [id, session, store]); return (
{ editor.user.updateUserPreferences({ colorScheme: "dark" }); + + // Debounced save on document changes (user-originated) + const debounceMs = 1200; + let timeout: number | undefined; + const unlisten = editor.store.listen( + () => { + if (!(id && session)) { + return; + } + window.clearTimeout(timeout); + timeout = window.setTimeout(async () => { + try { + const payload: RemoteSnapshot = getSnapshot(editor.store); + const hash = JSON.stringify(payload.document); + if (hash === lastSavedRef.current) { + return; + } + await upsertSpaceSnapshot( + id, + { document: payload.document }, + session.$id + ); + lastSavedRef.current = hash; + } catch { + // ignore save errors for now + } + }, debounceMs); + }, + { scope: "document", source: "user" } + ); + + // Cleanup listener on unmount + return () => { + unlisten(); + window.clearTimeout(timeout); + }; }} - persistenceKey={persistenceKey} + store={storeWithStatus} />
);