Complete architecture for a ClickUp/Notion/Miro-class project management app: - Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM) - Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards) - NextAuth v5 authentication with credentials + OAuth providers - tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search - Three-panel UI: collapsible sidebar, center content area, push-in right panel - Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS - Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe - TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block - Real-time collaboration via Yjs + Hocuspocus with presence/cursors - tldraw whiteboard with custom shape cards (task, document, project) - MCP server exposing all app data/tools for AI agents - AI chat panel, editor AI slash commands, Cmd+K command palette - Template system with built-in templates (Bug Report, Meeting Notes, Sprint) - Full-text search with result highlighting - Docker Compose for full-stack deployment (web + collab + postgres + redis) Made-with: Cursor
116 lines
3.3 KiB
TypeScript
116 lines
3.3 KiB
TypeScript
import type { TLStore } from "tldraw";
|
|
import * as Y from "yjs";
|
|
|
|
/** Y.Text name used under the shared Y.Doc for whole-document snapshots. */
|
|
export const TLDRAW_Y_TEXT_KEY = "tldraw-document-snapshot";
|
|
|
|
export type SyncTldrawWithYjsOptions = {
|
|
/** Debounce pushing local store changes to Yjs (ms). Default 120. */
|
|
debounceMs?: number;
|
|
/** Called when applying a remote snapshot from Yjs fails (e.g. migration). */
|
|
onError?: (error: unknown) => void;
|
|
};
|
|
|
|
/**
|
|
* Bidirectional sync between a tldraw `TLStore` (document scope) and a Yjs `Y.Text` field.
|
|
*
|
|
* Strategy: serialize the full document snapshot as JSON in Y.Text. Yjs/Hocuspocus replicate
|
|
* the string; last-writer wins per character merge is **not** ideal for concurrent edits — this
|
|
* is a practical stub that demonstrates the wiring. Production multiplayer usually syncs
|
|
* granular records or uses tldraw's sync packages.
|
|
*
|
|
* @returns Unsubscribe/cleanup function.
|
|
*/
|
|
export function syncTldrawStoreWithYjs(
|
|
store: TLStore,
|
|
yDoc: Y.Doc,
|
|
options?: SyncTldrawWithYjsOptions,
|
|
): () => void {
|
|
const debounceMs = options?.debounceMs ?? 120;
|
|
const yText = yDoc.getText(TLDRAW_Y_TEXT_KEY);
|
|
|
|
let applyingRemote = false;
|
|
let lastSent = "";
|
|
|
|
const pushToYjs = () => {
|
|
if (applyingRemote) return;
|
|
try {
|
|
const snapshot = store.getStoreSnapshot("document");
|
|
const json = JSON.stringify(snapshot);
|
|
if (json === lastSent) return;
|
|
lastSent = json;
|
|
yDoc.transact(() => {
|
|
const len = yText.length;
|
|
if (len > 0) yText.delete(0, len);
|
|
if (json.length > 0) yText.insert(0, json);
|
|
}, "tldraw");
|
|
} catch (e) {
|
|
options?.onError?.(e);
|
|
}
|
|
};
|
|
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
const schedulePush = () => {
|
|
if (applyingRemote) return;
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = null;
|
|
pushToYjs();
|
|
}, debounceMs);
|
|
};
|
|
|
|
const unsubStore = store.listen(
|
|
() => {
|
|
schedulePush();
|
|
},
|
|
{ source: "user", scope: "document" },
|
|
);
|
|
|
|
const onYjsText = (_: Y.YTextEvent, transaction: Y.Transaction) => {
|
|
if (transaction.local) return;
|
|
|
|
const raw = yText.toString();
|
|
if (!raw) return;
|
|
|
|
applyingRemote = true;
|
|
try {
|
|
const parsed = JSON.parse(raw) as Parameters<TLStore["loadStoreSnapshot"]>[0];
|
|
store.mergeRemoteChanges(() => {
|
|
store.loadStoreSnapshot(parsed);
|
|
});
|
|
lastSent = raw;
|
|
} catch (e) {
|
|
options?.onError?.(e);
|
|
} finally {
|
|
applyingRemote = false;
|
|
}
|
|
};
|
|
|
|
yText.observe(onYjsText);
|
|
|
|
// Initial hydrate if Yjs already has server state
|
|
if (yText.length > 0) {
|
|
try {
|
|
const raw = yText.toString();
|
|
const parsed = JSON.parse(raw) as Parameters<TLStore["loadStoreSnapshot"]>[0];
|
|
applyingRemote = true;
|
|
store.mergeRemoteChanges(() => {
|
|
store.loadStoreSnapshot(parsed);
|
|
});
|
|
lastSent = raw;
|
|
} catch (e) {
|
|
options?.onError?.(e);
|
|
} finally {
|
|
applyingRemote = false;
|
|
}
|
|
} else {
|
|
// Seed Yjs from the empty default store so peers share the same baseline
|
|
pushToYjs();
|
|
}
|
|
|
|
return () => {
|
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
yText.unobserve(onYjsText);
|
|
unsubStore();
|
|
};
|
|
}
|