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.fixAll.biome": "explicit",
|
||||||
"source.organizeImports.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 { createFileRoute } from "@tanstack/react-router";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Tldraw } from "tldraw";
|
import {
|
||||||
|
createTLStore,
|
||||||
|
getSnapshot,
|
||||||
|
loadSnapshot,
|
||||||
|
type TLStoreWithStatus,
|
||||||
|
Tldraw,
|
||||||
|
} from "tldraw";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import "tldraw/tldraw.css";
|
import "tldraw/tldraw.css";
|
||||||
|
import {
|
||||||
|
getLatestSpaceSnapshot,
|
||||||
|
type RemoteSnapshot,
|
||||||
|
upsertSpaceSnapshot,
|
||||||
|
} from "@/lib/appwrite-db";
|
||||||
import { authClient } from "@/lib/auth-client";
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
export const Route = createFileRoute("/space")({
|
export const Route = createFileRoute("/space")({
|
||||||
@@ -25,16 +36,91 @@ function SpaceRoute() {
|
|||||||
}
|
}
|
||||||
}, [session, isPending, navigate, id]);
|
}, [session, isPending, navigate, id]);
|
||||||
|
|
||||||
// Use a stable persistence key so tabs with same id share state locally
|
// Create a stable store instance once
|
||||||
const persistenceKey = useMemo(() => `space-${id ?? "default"}`, [id]);
|
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 (
|
return (
|
||||||
<div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}>
|
<div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}>
|
||||||
<Tldraw
|
<Tldraw
|
||||||
onMount={(editor) => {
|
onMount={(editor) => {
|
||||||
editor.user.updateUserPreferences({ colorScheme: "dark" });
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user