feat: integrate Appwrite for snapshot management and enhance Tldraw component with remote loading and saving

This commit is contained in:
2025-09-04 10:38:17 -03:00
parent 78962e7ad6
commit eb76be59a7
4 changed files with 220 additions and 7 deletions

View File

@@ -32,5 +32,5 @@
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"cSpell.words": ["Reflecto", "Tldraw"]
"cSpell.words": ["appwrite", "Reflecto", "Tldraw"]
}

View File

@@ -1 +1,4 @@
VITE_SERVER_URL=
VITE_APPWRITE_ENDPOINT=https://<REGION>.cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=<PROJECT_ID>
VITE_APPWRITE_DB_ID=<DATABASE_ID>
VITE_APPWRITE_COLLECTION_ID=<COLLECTION_ID>

View File

@@ -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<TLEditorSnapshot>;
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<string | null> {
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<RemoteSnapshot | null> {
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<Models.Document>;
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<void> {
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<Models.Document>;
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
);
}
}

View File

@@ -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<TLStoreWithStatus>({
status: "not-synced",
store,
});
// Track latest saved version hash to suppress redundant saves
const lastSavedRef = useRef<string>("");
// 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 (
<div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}>
<Tldraw
onMount={(editor) => {
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}
/>
</div>
);