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
171 lines
5.5 KiB
TypeScript
171 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import {
|
|
FileText,
|
|
LayoutList,
|
|
Search,
|
|
Share2,
|
|
Users,
|
|
Wifi,
|
|
WifiOff,
|
|
} from "lucide-react";
|
|
import { useEditor, toRichText } from "tldraw";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type WhiteboardToolbarProps = {
|
|
className?: string;
|
|
readOnly?: boolean;
|
|
/** When set, shows live collaboration status */
|
|
collab?: {
|
|
isConnected: boolean;
|
|
isSynced: boolean;
|
|
};
|
|
};
|
|
|
|
export function WhiteboardToolbar({ className, collab, readOnly = false }: WhiteboardToolbarProps) {
|
|
const editor = useEditor();
|
|
const [projectPickerOpen, setProjectPickerOpen] = React.useState(false);
|
|
|
|
const dropAtViewportCenter = React.useCallback(
|
|
(kind: "task" | "document") => {
|
|
const b = editor.getViewportPageBounds();
|
|
const cx = b.x + b.w / 2 - 120;
|
|
const cy = b.y + b.h / 2 - 80;
|
|
|
|
if (kind === "task") {
|
|
editor.createShapes([
|
|
{
|
|
type: "note",
|
|
x: cx,
|
|
y: cy,
|
|
props: {
|
|
color: "violet",
|
|
size: "m",
|
|
richText: toRichText("Task (placeholder)"),
|
|
},
|
|
meta: { whiteboardCard: "task" },
|
|
},
|
|
]);
|
|
} else {
|
|
editor.createShapes([
|
|
{
|
|
type: "geo",
|
|
x: cx,
|
|
y: cy,
|
|
props: {
|
|
w: 280,
|
|
h: 160,
|
|
geo: "rectangle",
|
|
color: "light-blue",
|
|
fill: "solid",
|
|
richText: toRichText("Document (placeholder)"),
|
|
},
|
|
meta: { whiteboardCard: "document" },
|
|
},
|
|
]);
|
|
}
|
|
},
|
|
[editor],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className={cn(
|
|
"pointer-events-auto absolute left-1/2 top-3 z-[300] flex -translate-x-1/2 items-center gap-1.5 rounded-xl border px-2 py-1.5 shadow-lg backdrop-blur-md",
|
|
"border-[hsl(var(--border))] bg-[hsl(var(--card)/0.92)] text-[hsl(var(--foreground))]",
|
|
className,
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition hover:bg-[hsl(var(--accent))] disabled:pointer-events-none disabled:opacity-40"
|
|
onClick={() => dropAtViewportCenter("task")}
|
|
>
|
|
<LayoutList className="size-3.5 text-[hsl(var(--primary))]" />
|
|
Add Task
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition hover:bg-[hsl(var(--accent))] disabled:pointer-events-none disabled:opacity-40"
|
|
onClick={() => dropAtViewportCenter("document")}
|
|
>
|
|
<FileText className="size-3.5 text-[hsl(var(--secondary-foreground))]" />
|
|
Add Document
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={readOnly}
|
|
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition hover:bg-[hsl(var(--accent))] disabled:pointer-events-none disabled:opacity-40"
|
|
onClick={() => setProjectPickerOpen(true)}
|
|
>
|
|
<Search className="size-3.5 opacity-80" />
|
|
Add from project…
|
|
</button>
|
|
|
|
<div className="mx-1 h-5 w-px bg-[hsl(var(--border))]" />
|
|
|
|
<div
|
|
className="flex items-center gap-1.5 px-1 text-xs text-muted-foreground"
|
|
title={
|
|
collab
|
|
? collab.isConnected
|
|
? collab.isSynced
|
|
? "Synced with collaborators"
|
|
: "Connecting…"
|
|
: "Offline — reconnect to collaborate"
|
|
: "Local whiteboard"
|
|
}
|
|
>
|
|
<Share2 className="size-3.5 text-[hsl(var(--primary))]" />
|
|
{collab ? (
|
|
<>
|
|
{collab.isConnected ? (
|
|
<Wifi className="size-3.5 text-[hsl(var(--chart-2))]" />
|
|
) : (
|
|
<WifiOff className="size-3.5 text-amber-500" />
|
|
)}
|
|
<Users className="size-3.5 opacity-80" />
|
|
<span className="max-w-[7rem] truncate font-medium text-foreground">
|
|
{collab.isConnected ? (collab.isSynced ? "Live" : "Syncing") : "Offline"}
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="font-medium text-foreground">Local</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{projectPickerOpen ? (
|
|
<div className="pointer-events-auto absolute inset-0 z-[400] flex items-center justify-center bg-black/40 p-4">
|
|
<div
|
|
className="w-full max-w-md rounded-xl border border-[hsl(var(--border))] bg-[hsl(var(--card))] p-4 shadow-xl"
|
|
role="dialog"
|
|
aria-modal
|
|
aria-labelledby="wb-project-picker-title"
|
|
>
|
|
<h2 id="wb-project-picker-title" className="text-sm font-semibold">
|
|
Add from project
|
|
</h2>
|
|
<p className="mt-2 text-xs text-muted-foreground">
|
|
Project search and picker will plug in here. This is a stub for now.
|
|
</p>
|
|
<div className="mt-4 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-lg border border-[hsl(var(--border))] px-3 py-1.5 text-xs font-medium hover:bg-[hsl(var(--accent))]"
|
|
onClick={() => setProjectPickerOpen(false)}
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|