mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: integrate Appwrite for snapshot management and enhance Tldraw component with remote loading and saving
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -32,5 +32,5 @@
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
},
|
||||
"cSpell.words": ["Reflecto", "Tldraw"]
|
||||
"cSpell.words": ["appwrite", "Reflecto", "Tldraw"]
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
124
apps/web/src/lib/appwrite-db.ts
Normal file
124
apps/web/src/lib/appwrite-db.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user