ubiquitous-invention/apps/web/components/whiteboard/toolbar.tsx
Randall Stillwell a508ece6e7 feat: Full project management application scaffold
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
2026-03-26 22:39:16 -05:00

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}
</>
);
}