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
262 lines
6.9 KiB
TypeScript
262 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import * as Y from "yjs";
|
|
import {
|
|
HocuspocusProvider,
|
|
WebSocketStatus,
|
|
type onStatusParameters,
|
|
type onSyncedParameters,
|
|
} from "@hocuspocus/provider";
|
|
|
|
/** Minimal surface used for presence (avoids y-protocols direct import). */
|
|
export type AwarenessApi = {
|
|
getStates(): Map<number, { user?: Record<string, unknown> }>;
|
|
on(event: "change", fn: () => void): void;
|
|
off(event: "change", fn: () => void): void;
|
|
};
|
|
|
|
export type UseCollaborationOptions = {
|
|
/** Auth token or resolver (session); placeholder used when unset */
|
|
token?: string | (() => string | Promise<string | null>) | null;
|
|
};
|
|
|
|
type CacheEntry = {
|
|
doc: Y.Doc;
|
|
provider: HocuspocusProvider;
|
|
refCount: number;
|
|
};
|
|
|
|
const collabCache = new Map<string, CacheEntry>();
|
|
|
|
function resolveToken(
|
|
token: UseCollaborationOptions["token"],
|
|
): string | (() => string) | (() => Promise<string>) | null {
|
|
if (token === undefined || token === null) {
|
|
return "dev-placeholder-token";
|
|
}
|
|
if (typeof token === "string") {
|
|
return token;
|
|
}
|
|
return async () => {
|
|
const raw = await Promise.resolve(token());
|
|
return typeof raw === "string" ? raw : "";
|
|
};
|
|
}
|
|
|
|
function acquireEntry(
|
|
documentName: string,
|
|
options: UseCollaborationOptions | undefined,
|
|
): CacheEntry {
|
|
let entry = collabCache.get(documentName);
|
|
if (!entry) {
|
|
const doc = new Y.Doc();
|
|
const provider = new HocuspocusProvider({
|
|
name: documentName,
|
|
document: doc,
|
|
url: getCollabUrl(),
|
|
token: resolveToken(options?.token),
|
|
});
|
|
entry = { doc, provider, refCount: 0 };
|
|
collabCache.set(documentName, entry);
|
|
}
|
|
entry.refCount += 1;
|
|
return entry;
|
|
}
|
|
|
|
function releaseEntry(documentName: string) {
|
|
const entry = collabCache.get(documentName);
|
|
if (!entry) return;
|
|
entry.refCount -= 1;
|
|
if (entry.refCount <= 0) {
|
|
try {
|
|
entry.provider.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
try {
|
|
entry.doc.destroy();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
collabCache.delete(documentName);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* WebSocket URL for the collaboration server.
|
|
* Uses `NEXT_PUBLIC_COLLAB_URL`, then `COLLAB_SERVER_URL`, then ws://localhost:1234.
|
|
*/
|
|
/**
|
|
* Ask the cached provider for this document to open the WebSocket again (e.g. user clicked Retry).
|
|
*/
|
|
export function reconnectCollaboration(documentName: string) {
|
|
const entry = collabCache.get(documentName);
|
|
const ws = entry?.provider?.configuration?.websocketProvider;
|
|
if (ws && typeof (ws as { connect?: () => void }).connect === "function") {
|
|
void (ws as { connect: () => Promise<unknown> }).connect();
|
|
}
|
|
}
|
|
|
|
export function getCollabUrl(): string {
|
|
if (typeof process !== "undefined" && process.env.NEXT_PUBLIC_COLLAB_URL) {
|
|
return process.env.NEXT_PUBLIC_COLLAB_URL;
|
|
}
|
|
if (typeof process !== "undefined" && process.env.COLLAB_SERVER_URL) {
|
|
return process.env.COLLAB_SERVER_URL;
|
|
}
|
|
return "ws://localhost:1234";
|
|
}
|
|
|
|
export type CollaborationConnection = {
|
|
doc: Y.Doc | null;
|
|
provider: HocuspocusProvider | null;
|
|
awareness: AwarenessApi | null;
|
|
isConnected: boolean;
|
|
isSynced: boolean;
|
|
wsStatus: WebSocketStatus;
|
|
error: Error | null;
|
|
};
|
|
|
|
/**
|
|
* Yjs document + Hocuspocus provider for a named document.
|
|
* Providers are cached per `documentName` and shared across hook instances (ref-counted).
|
|
*/
|
|
export function useCollaboration(
|
|
documentName: string,
|
|
options?: UseCollaborationOptions,
|
|
): CollaborationConnection {
|
|
const [doc, setDoc] = React.useState<Y.Doc | null>(null);
|
|
const [provider, setProvider] = React.useState<HocuspocusProvider | null>(null);
|
|
const [isConnected, setIsConnected] = React.useState(false);
|
|
const [isSynced, setIsSynced] = React.useState(false);
|
|
const [wsStatus, setWsStatus] = React.useState<WebSocketStatus>(
|
|
WebSocketStatus.Disconnected,
|
|
);
|
|
const [error, setError] = React.useState<Error | null>(null);
|
|
|
|
const optionsRef = React.useRef(options);
|
|
optionsRef.current = options;
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
setError(null);
|
|
|
|
let entry: CacheEntry;
|
|
try {
|
|
entry = acquireEntry(documentName, optionsRef.current);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e : new Error(String(e)));
|
|
setDoc(null);
|
|
setProvider(null);
|
|
return;
|
|
}
|
|
|
|
const { doc: yDoc, provider: hp } = entry;
|
|
|
|
setDoc(yDoc);
|
|
setProvider(hp);
|
|
setIsConnected(hp.configuration.websocketProvider.status === WebSocketStatus.Connected);
|
|
setWsStatus(hp.configuration.websocketProvider.status);
|
|
setIsSynced(hp.synced);
|
|
|
|
const onStatus = ({ status }: onStatusParameters) => {
|
|
setWsStatus(status);
|
|
setIsConnected(status === WebSocketStatus.Connected);
|
|
};
|
|
|
|
const onSynced = ({ state }: onSyncedParameters) => {
|
|
setIsSynced(state);
|
|
};
|
|
|
|
hp.on("status", onStatus);
|
|
hp.on("synced", onSynced);
|
|
|
|
return () => {
|
|
hp.off("status", onStatus);
|
|
hp.off("synced", onSynced);
|
|
releaseEntry(documentName);
|
|
setDoc(null);
|
|
setProvider(null);
|
|
setIsConnected(false);
|
|
setIsSynced(false);
|
|
setWsStatus(WebSocketStatus.Disconnected);
|
|
};
|
|
}, [documentName]);
|
|
|
|
return {
|
|
doc,
|
|
provider,
|
|
awareness: (provider?.awareness as AwarenessApi | null) ?? null,
|
|
isConnected,
|
|
isSynced,
|
|
wsStatus,
|
|
error,
|
|
};
|
|
}
|
|
|
|
export type AwarenessPeer = {
|
|
clientId: number;
|
|
name: string;
|
|
color: string;
|
|
imageUrl?: string;
|
|
};
|
|
|
|
/** Deterministic HSL color from a display name (stable cursor / avatar border). */
|
|
export function colorFromUserName(name: string): string {
|
|
let hash = 0;
|
|
for (let i = 0; i < name.length; i += 1) {
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const hue = Math.abs(hash) % 360;
|
|
return `hsl(${hue} 65% 45%)`;
|
|
}
|
|
|
|
/**
|
|
* Remote peers from Yjs awareness (excludes local client).
|
|
*/
|
|
export function useAwarenessPeers(
|
|
awareness: AwarenessApi | null,
|
|
localClientId: number | null,
|
|
): AwarenessPeer[] {
|
|
const [peers, setPeers] = React.useState<AwarenessPeer[]>([]);
|
|
|
|
React.useEffect(() => {
|
|
if (!awareness || localClientId === null) {
|
|
setPeers([]);
|
|
return;
|
|
}
|
|
|
|
const read = () => {
|
|
const states = awareness.getStates() as Map<
|
|
number,
|
|
{ user?: { name?: string; color?: string; image?: string } }
|
|
>;
|
|
const next: AwarenessPeer[] = [];
|
|
states.forEach((state, clientId) => {
|
|
if (clientId === localClientId) return;
|
|
const u = state.user;
|
|
if (!u?.name) return;
|
|
const name = u.name;
|
|
const color = u.color ?? colorFromUserName(name);
|
|
next.push({
|
|
clientId,
|
|
name,
|
|
color,
|
|
imageUrl: typeof u.image === "string" ? u.image : undefined,
|
|
});
|
|
});
|
|
next.sort((a, b) => a.name.localeCompare(b.name));
|
|
setPeers(next);
|
|
};
|
|
|
|
read();
|
|
awareness.on("change", read);
|
|
return () => {
|
|
awareness.off("change", read);
|
|
};
|
|
}, [awareness, localClientId]);
|
|
|
|
return peers;
|
|
}
|