ubiquitous-invention/apps/web/components/panels/object-detail.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

706 lines
24 KiB
TypeScript

"use client";
import * as React from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Check,
CheckSquare,
ChevronDown,
ExternalLink,
FileText,
Folder,
Link2,
Plus,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { api } from "@/lib/trpc";
import {
usePanelStore,
type PanelDetailTab,
} from "@/lib/stores/panel-store";
import { AssigneePicker, WORKSPACE_USERS } from "@/components/panels/assignee-picker";
import {
PropertyEditor,
type PropertyFieldType,
} from "@/components/panels/property-editor";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
const OBJECT_DETAIL_KEY = "object-detail";
type StatusValue = "open" | "in_progress" | "done" | "closed";
const STATUS_OPTIONS: {
value: StatusValue;
label: string;
dot: string;
}[] = [
{ value: "open", label: "Open", dot: "bg-slate-400" },
{ value: "in_progress", label: "In progress", dot: "bg-amber-500" },
{ value: "done", label: "Done", dot: "bg-emerald-500" },
{ value: "closed", label: "Closed", dot: "bg-zinc-400" },
];
const MOCK_OBJECT: ObjectDetailData = {
id: "demo",
title: "Design Landing Page",
type: "task",
status: "in_progress",
description: "Create wireframes and align with brand guidelines before handoff.",
assignees: [
{ user: { id: "u1", name: "Alice", avatarUrl: null } },
],
propertyValues: [
{
id: "pv1",
propertyDefinition: {
name: "Priority",
fieldType: "select",
config: {
options: [
{ label: "High", value: "High" },
{ label: "Medium", value: "Medium" },
{ label: "Low", value: "Low" },
],
},
},
value: "High",
},
{
id: "pv2",
propertyDefinition: {
name: "Due Date",
fieldType: "date",
config: {},
},
value: "2025-04-01",
},
],
children: [
{ id: "child-1", title: "Review copy", type: "task" },
],
relations: [
{ id: "rel-1", title: "Brand guidelines PDF", type: "document" },
{ id: "rel-2", title: "Q2 marketing plan", type: "task" },
],
};
export interface ObjectDetailData {
id: string;
title: string;
type: string;
status: string;
description: string | null;
assignees: {
user: { id?: string; name: string; avatarUrl?: string | null };
}[];
propertyValues: {
id?: string;
propertyDefinition: {
name: string;
fieldType: string;
config?: Record<string, unknown> | null;
};
value: unknown;
}[];
children?: { id: string; title: string; type: string }[];
relations?: { id: string; title: string; type: string }[];
}
function normalizeFieldType(raw: string): PropertyFieldType {
const t = raw.toLowerCase().replace(/[\s-]+/g, "_") as PropertyFieldType;
const allowed: PropertyFieldType[] = [
"text",
"richtext",
"number",
"date",
"select",
"multiselect",
"person",
"relation",
"url",
"file",
"checkbox",
];
return allowed.includes(t) ? t : "text";
}
function typeLabel(type: string) {
const t = type.toLowerCase();
if (t === "task") return "Task";
if (t === "document") return "Document";
return type.charAt(0).toUpperCase() + type.slice(1);
}
function TypeIcon({ type }: { type: string }) {
const t = type.toLowerCase();
if (t === "document") return <FileText className="size-4 text-teal-600 dark:text-teal-400" />;
if (t === "task") return <CheckSquare className="size-4 text-violet-600 dark:text-violet-400" />;
return <Folder className="size-4 text-muted-foreground" />;
}
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function useObjectDetailQuery(objectId: string | null) {
const utils = api.useUtils();
return useQuery({
queryKey: [OBJECT_DETAIL_KEY, objectId],
queryFn: async (): Promise<ObjectDetailData | null> => {
if (!objectId) return null;
const fetcher = (
utils as unknown as {
objects?: { getById?: { fetch: (args: { id: string }) => Promise<ObjectDetailData> } };
}
).objects?.getById?.fetch;
if (typeof fetcher === "function") {
return fetcher({ id: objectId });
}
await new Promise((r) => setTimeout(r, 220));
return { ...MOCK_OBJECT, id: objectId };
},
enabled: Boolean(objectId),
});
}
function DetailSkeleton() {
return (
<div className="flex animate-pulse flex-col gap-3 p-3">
<div className="flex gap-2">
<div className="size-8 rounded-md bg-muted" />
<div className="flex flex-1 flex-col gap-2">
<div className="h-3 w-20 rounded bg-muted" />
<div className="h-6 w-full rounded bg-muted" />
</div>
</div>
<div className="h-9 w-full rounded-md bg-muted" />
<div className="h-24 w-full rounded-md bg-muted" />
<div className="h-32 w-full rounded-md bg-muted" />
</div>
);
}
export function ObjectDetail() {
const objectId = usePanelStore((s) => s.objectId);
const activeTab = usePanelStore((s) => s.activeTab);
const setActiveTab = usePanelStore((s) => s.setActiveTab);
const closePanel = usePanelStore((s) => s.close);
const openPanel = usePanelStore((s) => s.open);
const queryClient = useQueryClient();
const utils = api.useUtils();
const { data, isPending, isError, error } = useObjectDetailQuery(objectId);
const [titleDraft, setTitleDraft] = React.useState("");
const [editingTitle, setEditingTitle] = React.useState(false);
const [descriptionDraft, setDescriptionDraft] = React.useState("");
const [assigneeOpen, setAssigneeOpen] = React.useState(false);
React.useEffect(() => {
if (data?.title != null) setTitleDraft(data.title);
}, [data?.title]);
React.useEffect(() => {
if (data?.description != null) setDescriptionDraft(data.description ?? "");
}, [data?.description]);
const mergeObjectCache = React.useCallback(
(id: string, patch: Partial<ObjectDetailData>) => {
queryClient.setQueryData<ObjectDetailData | null>(
[OBJECT_DETAIL_KEY, id],
(old) => (old ? { ...old, ...patch } : old),
);
},
[queryClient],
);
const updateObject = useMutation({
mutationFn: async (patch: Partial<ObjectDetailData> & { id: string }) => {
const u = utils as unknown as {
objects?: { update?: { mutate: (args: unknown) => Promise<unknown> } };
};
if (typeof u.objects?.update?.mutate === "function") {
return u.objects.update.mutate(patch);
}
},
onMutate: async (patch) => {
mergeObjectCache(patch.id, patch);
},
});
const setPropertyValue = useMutation({
mutationFn: async (args: {
objectId: string;
propertyValueId?: string;
propertyDefinitionId?: string;
value: unknown;
}) => {
const u = utils as unknown as {
properties?: { setValue?: { mutate: (a: unknown) => Promise<unknown> } };
};
if (typeof u.properties?.setValue?.mutate === "function") {
return u.properties.setValue.mutate(args);
}
},
});
const handlePropertyChange = (
index: number,
next: unknown,
row: ObjectDetailData["propertyValues"][number],
) => {
if (!data) return;
const nextRows = [...data.propertyValues];
nextRows[index] = { ...row, value: next };
mergeObjectCache(data.id, { propertyValues: nextRows });
setPropertyValue.mutate({
objectId: data.id,
propertyValueId: row.id,
value: next,
});
};
const assignedIds = React.useMemo(() => {
if (!data) return [];
return data.assignees
.map((a) => a.user.id)
.filter((id): id is string => Boolean(id));
}, [data]);
const toggleAssignee = (userId: string) => {
if (!data) return;
const user = WORKSPACE_USERS.find((u) => u.id === userId);
if (!user) return;
const has = assignedIds.includes(userId);
let nextAssignees: ObjectDetailData["assignees"];
if (has) {
nextAssignees = data.assignees.filter((a) => a.user.id !== userId);
} else {
nextAssignees = [
...data.assignees,
{ user: { id: user.id, name: user.name, avatarUrl: user.avatarUrl } },
];
}
mergeObjectCache(data.id, { assignees: nextAssignees });
const u = utils as unknown as {
objects?: { assign?: { mutate: (a: unknown) => Promise<unknown> } };
};
if (typeof u.objects?.assign?.mutate === "function") {
u.objects.assign.mutate({
objectId: data.id,
userId,
assign: !has,
});
}
};
const commitTitle = () => {
if (!data || titleDraft.trim() === data.title) {
setEditingTitle(false);
return;
}
updateObject.mutate({ id: data.id, title: titleDraft.trim() });
setEditingTitle(false);
};
const commitDescription = () => {
if (!data) return;
if (descriptionDraft === (data.description ?? "")) return;
updateObject.mutate({ id: data.id, description: descriptionDraft });
};
const setStatus = (status: StatusValue) => {
if (!data) return;
updateObject.mutate({ id: data.id, status });
};
const onTabChange = (v: string) => {
setActiveTab(v as PanelDetailTab);
};
if (!objectId) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-6 text-center text-xs text-muted-foreground">
<p>No object selected.</p>
<Button variant="outline" size="sm" onClick={() => closePanel()}>
Close panel
</Button>
</div>
);
}
if (isPending) {
return <DetailSkeleton />;
}
if (isError || !data) {
return (
<div className="p-4 text-xs text-destructive">
{isError
? error instanceof Error
? error.message
: "Failed to load object."
: "Nothing to display."}
</div>
);
}
const status =
STATUS_OPTIONS.find((s) => s.value === data.status)?.value ??
(data.status as StatusValue);
const statusMeta =
STATUS_OPTIONS.find((s) => s.value === status) ?? STATUS_OPTIONS[0];
return (
<div className="flex h-full min-h-0 flex-1 flex-col bg-card">
<header className="shrink-0 border-b border-border px-3 pb-2 pt-3">
<div className="flex items-start gap-2">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md border border-border bg-muted/40">
<TypeIcon type={data.type} />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<Badge
variant="secondary"
className="h-5 px-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground"
>
{typeLabel(data.type)}
</Badge>
</div>
{editingTitle ? (
<Input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitle}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitTitle();
}
if (e.key === "Escape") {
setTitleDraft(data.title);
setEditingTitle(false);
}
}}
className="h-8 text-sm font-semibold"
/>
) : (
<button
type="button"
onClick={() => setEditingTitle(true)}
className="w-full rounded px-0.5 text-left text-sm font-semibold leading-snug text-foreground hover:bg-muted/60"
>
{data.title}
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0 text-muted-foreground hover:text-foreground"
onClick={() => closePanel()}
aria-label="Close panel"
>
<X className="size-4" />
</Button>
</div>
<div className="mt-2 flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 gap-1.5 border-border px-2 text-xs font-normal"
>
<span
className={cn("size-2 shrink-0 rounded-full", statusMeta.dot)}
/>
{statusMeta.label}
<ChevronDown className="size-3.5 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-44">
{STATUS_OPTIONS.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="gap-2 text-xs"
onClick={() => setStatus(opt.value)}
>
<span className={cn("size-2 rounded-full", opt.dot)} />
{opt.label}
{opt.value === status ? (
<Check className="ml-auto size-3.5 text-primary" />
) : null}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<Tabs
value={activeTab}
onValueChange={onTabChange}
className="flex min-h-0 flex-1 flex-col"
>
<TabsList className="flex shrink-0 gap-0 border-b border-border px-1">
{(
[
["details", "Details"],
["activity", "Activity"],
["comments", "Comments"],
] as const
).map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className={cn(
"relative flex-1 px-2 py-2 text-center text-[11px] font-semibold uppercase tracking-wide text-muted-foreground transition-colors",
"hover:text-foreground",
"data-[state=active]:text-foreground",
"data-[state=active]:after:absolute data-[state=active]:after:inset-x-2 data-[state=active]:after:bottom-0 data-[state=active]:after:h-0.5 data-[state=active]:after:rounded-full data-[state=active]:after:bg-primary",
)}
>
{label}
</TabsTrigger>
))}
</TabsList>
<TabsContent
value="details"
className="flex min-h-0 flex-1 flex-col overflow-hidden animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0 px-3 pb-6 pt-2">
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Assignees
</h3>
<div className="flex flex-wrap items-center gap-1.5">
{data.assignees.map((a, i) => (
<Avatar
key={`${a.user.id ?? a.user.name}-${i}`}
className="size-7 border border-border"
title={a.user.name}
>
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px]">
{initials(a.user.name)}
</AvatarFallback>
</Avatar>
))}
<AssigneePicker
open={assigneeOpen}
onOpenChange={setAssigneeOpen}
assignedIds={assignedIds}
onToggle={toggleAssignee}
>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-[11px] font-normal"
>
<Plus className="size-3.5" />
Add assignee
</Button>
</AssigneePicker>
</div>
</section>
<Separator className="bg-border/80" />
<section className="py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Properties
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] font-medium text-primary"
type="button"
>
<Plus className="mr-1 size-3" />
Add property
</Button>
</div>
<div className="rounded-md border border-border/80 bg-muted/20">
{data.propertyValues.length === 0 ? (
<p className="px-3 py-4 text-center text-[11px] text-muted-foreground">
No custom properties yet.
</p>
) : (
data.propertyValues.map((row, index) => (
<div
key={row.id ?? `${row.propertyDefinition.name}-${index}`}
className="border-b border-border/60 px-2 last:border-b-0"
>
<PropertyEditor
definition={{
name: row.propertyDefinition.name,
fieldType: normalizeFieldType(
row.propertyDefinition.fieldType,
),
config: row.propertyDefinition.config as
| { options?: { label: string; value: string }[] }
| undefined,
}}
value={row.value}
onChange={(v) => handlePropertyChange(index, v, row)}
/>
</div>
))
)}
</div>
</section>
<Separator className="bg-border/80" />
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Description
</h3>
<textarea
value={descriptionDraft}
onChange={(e) => setDescriptionDraft(e.target.value)}
onBlur={commitDescription}
rows={4}
placeholder="Add a description…"
className="w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs leading-relaxed ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
/>
</section>
{data.children && data.children.length > 0 ? (
<>
<Separator className="bg-border/80" />
<section className="py-2">
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Subtasks
</h3>
<ul className="space-y-1">
{data.children.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => openPanel("object-detail", c.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
>
<CheckSquare className="size-3.5 shrink-0 text-violet-500" />
<span className="min-w-0 flex-1 truncate font-medium">
{c.title}
</span>
<Badge
variant="outline"
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
>
{typeLabel(c.type)}
</Badge>
</button>
</li>
))}
</ul>
</section>
</>
) : null}
<Separator className="bg-border/80" />
<section className="py-2">
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Relations
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] font-medium text-primary"
type="button"
>
<Link2 className="mr-1 size-3" />
Add relation
</Button>
</div>
{data.relations && data.relations.length > 0 ? (
<ul className="space-y-1">
{data.relations.map((r) => (
<li key={r.id}>
<button
type="button"
onClick={() => openPanel("object-detail", r.id)}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/70"
>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">
{r.title}
</span>
<Badge
variant="secondary"
className="h-5 shrink-0 px-1.5 text-[9px] font-normal uppercase"
>
{typeLabel(r.type)}
</Badge>
</button>
</li>
))}
</ul>
) : (
<p className="py-2 text-[11px] text-muted-foreground">
No relations yet.
</p>
)}
</section>
</div>
</ScrollArea>
</TabsContent>
<TabsContent
value="activity"
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<div className="p-4 text-xs text-muted-foreground">
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
Activity feed will appear here (audit log, updates, mentions).
</p>
</div>
</TabsContent>
<TabsContent
value="comments"
className="min-h-0 flex-1 animate-in fade-in-0 duration-200 ease-out data-[state=inactive]:hidden"
>
<div className="p-4 text-xs text-muted-foreground">
<p className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-6 text-center">
Comments thread coming soon.
</p>
</div>
</TabsContent>
</Tabs>
</div>
);
}