ubiquitous-invention/apps/web/components/templates/template-picker.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

337 lines
12 KiB
TypeScript

"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { FileStack, Search, X } from "lucide-react";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
export type TemplateSchemaJson = {
properties?: {
name: string;
fieldType: string;
defaultValue?: unknown;
}[];
defaultContent?: string;
};
export type PickerTemplate = {
id: string;
workspaceId: string;
name: string;
targetType: string;
schema: TemplateSchemaJson | null;
isBuiltin?: boolean;
};
/** Preset templates for UI / offline use; merge with API `templates.list` when available. */
export const BUILTIN_TEMPLATES: PickerTemplate[] = [
{
id: "00000000-0000-4000-8000-000000000001",
workspaceId: "",
name: "Bug Report",
targetType: "task",
isBuiltin: true,
schema: {
properties: [
{ name: "Priority", fieldType: "select", defaultValue: "medium" },
{ name: "Severity", fieldType: "select", defaultValue: "major" },
{
name: "Steps to Reproduce",
fieldType: "textarea",
defaultValue: "",
},
{
name: "Expected Behavior",
fieldType: "textarea",
defaultValue: "",
},
],
defaultContent:
"## Summary\n\n## Steps to reproduce\n\n1. \n\n## Expected behavior\n\n",
},
},
{
id: "00000000-0000-4000-8000-000000000002",
workspaceId: "",
name: "Meeting Notes",
targetType: "document",
isBuiltin: true,
schema: {
properties: [
{ name: "Date", fieldType: "date", defaultValue: "" },
{ name: "Attendees", fieldType: "text", defaultValue: "" },
{ name: "Agenda", fieldType: "textarea", defaultValue: "" },
{ name: "Action Items", fieldType: "textarea", defaultValue: "" },
],
defaultContent: "# Meeting\n\n## Agenda\n\n## Notes\n\n## Action items\n\n",
},
},
{
id: "00000000-0000-4000-8000-000000000003",
workspaceId: "",
name: "Sprint",
targetType: "project",
isBuiltin: true,
schema: {
properties: [
{ name: "Sprint Goal", fieldType: "text", defaultValue: "" },
{ name: "Start Date", fieldType: "date", defaultValue: "" },
{ name: "End Date", fieldType: "date", defaultValue: "" },
{ name: "Velocity", fieldType: "number", defaultValue: null },
],
defaultContent: "## Sprint goal\n\n## Commitments\n\n",
},
},
];
const CREATE_SENTINEL = "__create__";
function schemaPreview(schema: TemplateSchemaJson | null | undefined): string {
const props = schema?.properties ?? [];
if (props.length === 0) {
return "No custom properties";
}
const names = props.slice(0, 6).map((p) => p.name);
const extra = props.length > 6 ? ` +${props.length - 6} more` : "";
return `${names.join(" · ")}${extra}`;
}
function propertyCount(schema: TemplateSchemaJson | null | undefined): number {
return schema?.properties?.length ?? 0;
}
export type TemplatePickerProps = {
objectId: string;
objectType: string;
onSelect: (templateId: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function TemplatePicker({
objectId,
objectType,
onSelect,
open,
onOpenChange,
}: TemplatePickerProps) {
const [search, setSearch] = React.useState("");
const [showAllTypes, setShowAllTypes] = React.useState(false);
const objectQuery = api.objects.getById.useQuery(
{ id: objectId },
{ enabled: open && Boolean(objectId) },
);
const objWorkspace = (objectQuery.data as unknown as { workspaceId?: string | null } | undefined)
?.workspaceId;
const workspaceId =
typeof objWorkspace === "string" && objWorkspace.length > 0 ? objWorkspace : undefined;
const listQuery = api.templates.list.useQuery(
{ workspaceId: workspaceId!, targetType: showAllTypes ? undefined : objectType },
{ enabled: open && Boolean(workspaceId) },
);
const merged = React.useMemo(() => {
const fromApi = listQuery.data?.templates ?? [];
const builtinFiltered = BUILTIN_TEMPLATES.filter(
(b) => showAllTypes || b.targetType === objectType,
);
const seen = new Set<string>();
const out: PickerTemplate[] = [];
for (const t of [...builtinFiltered, ...fromApi]) {
if (seen.has(t.id)) continue;
seen.add(t.id);
out.push({
id: t.id,
workspaceId: t.workspaceId,
name: t.name,
targetType: t.targetType,
schema: (t.schema as TemplateSchemaJson | null) ?? null,
isBuiltin: "isBuiltin" in t ? t.isBuiltin : false,
});
}
return out;
}, [listQuery.data?.templates, objectType, showAllTypes]);
const filtered = React.useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return merged;
return merged.filter((t) => t.name.toLowerCase().includes(q));
}, [merged, search]);
const grouped = React.useMemo(() => {
if (!showAllTypes) {
return new Map([[objectType, filtered]]);
}
const m = new Map<string, PickerTemplate[]>();
const order = ["project", "task", "document", "whiteboard", "group", "workspace"];
for (const t of filtered) {
const k = t.targetType;
if (!m.has(k)) m.set(k, []);
m.get(k)!.push(t);
}
const keys = [...m.keys()].sort(
(a, b) => order.indexOf(a) - order.indexOf(b) || a.localeCompare(b),
);
return new Map(keys.map((k) => [k, m.get(k)!]));
}, [filtered, objectType, showAllTypes]);
React.useEffect(() => {
if (!open) {
setSearch("");
setShowAllTypes(false);
}
}, [open]);
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
)}
/>
<DialogPrimitive.Content
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-0 rounded-lg border bg-background p-0 shadow-lg duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
)}
>
<div className="flex items-start justify-between gap-3 border-b px-4 py-3">
<div className="min-w-0 space-y-1">
<DialogPrimitive.Title className="text-lg font-semibold leading-none tracking-tight">
Use a template
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-sm text-muted-foreground">
Apply structure and default fields to this {objectType}.
</DialogPrimitive.Description>
</div>
<DialogPrimitive.Close asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 shrink-0" aria-label="Close">
<X className="h-4 w-4" />
</Button>
</DialogPrimitive.Close>
</div>
<div className="space-y-3 px-4 py-3">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search templates…"
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
className="rounded border-input"
checked={showAllTypes}
onChange={(e) => setShowAllTypes(e.target.checked)}
/>
Show all types
</label>
</div>
<ScrollArea className="max-h-[min(420px,55vh)] px-4">
<div className="space-y-4 pb-3 pr-3">
{listQuery.isPending && workspaceId ? (
<p className="text-sm text-muted-foreground">Loading templates</p>
) : null}
{!workspaceId && objectQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading object</p>
) : null}
{!workspaceId && objectQuery.isError ? (
<p className="text-sm text-destructive">Could not load workspace.</p>
) : null}
{Array.from(grouped.entries()).map(([typeKey, items], gi) => (
<React.Fragment key={typeKey}>
{gi > 0 && showAllTypes ? <Separator className="my-2" /> : null}
<div className="space-y-2">
{showAllTypes ? (
<div className="flex items-center gap-2 pt-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{typeKey}
</span>
<Separator className="flex-1" />
</div>
) : null}
{items.length === 0 ? (
<p className="text-sm text-muted-foreground">
No templates match this filter.
</p>
) : (
items.map((t) => (
<div
key={t.id}
className="rounded-lg border bg-card p-3 shadow-sm transition-colors hover:bg-accent/40"
>
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium leading-tight">{t.name}</span>
<Badge variant="secondary" className="text-[10px] uppercase">
{t.targetType}
</Badge>
{t.isBuiltin ? (
<Badge variant="outline" className="text-[10px]">
Built-in
</Badge>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{propertyCount(t.schema)} properties ·{" "}
<span className="line-clamp-2">{schemaPreview(t.schema)}</span>
</p>
</div>
<Button
size="sm"
className="shrink-0"
onClick={() => {
onSelect(t.id);
onOpenChange(false);
}}
>
Use Template
</Button>
</div>
</div>
))
)}
</div>
</React.Fragment>
))}
</div>
</ScrollArea>
<div className="border-t px-4 py-3">
<Button
variant="outline"
className="w-full gap-2"
onClick={() => {
onSelect(CREATE_SENTINEL);
onOpenChange(false);
}}
>
<FileStack className="h-4 w-4" />
Create New Template
</Button>
</div>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}
export { CREATE_SENTINEL as TEMPLATE_PICKER_CREATE_SENTINEL };