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

322 lines
8.9 KiB
TypeScript

"use client";
import * as React from "react";
import {
ExternalLink,
FileIcon,
Link2,
User,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
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";
export type PropertyFieldType =
| "text"
| "richtext"
| "number"
| "date"
| "select"
| "multiselect"
| "person"
| "relation"
| "url"
| "file"
| "checkbox";
export interface PropertyDefinitionInput {
name: string;
fieldType: PropertyFieldType;
config?: {
options?: { label: string; value: string }[];
[key: string]: unknown;
} | null;
}
export interface PropertyEditorProps {
definition: PropertyDefinitionInput;
value: unknown;
onChange: (value: unknown) => void;
className?: string;
}
function initials(name: string) {
return name
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
export function PropertyEditor({
definition,
value,
onChange,
className,
}: PropertyEditorProps) {
const { name, fieldType, config } = definition;
const options = config?.options ?? [];
const row = (control: React.ReactNode) => (
<div
className={cn(
"grid grid-cols-[minmax(0,1fr)_minmax(0,1.4fr)] items-center gap-2 py-1.5",
className,
)}
>
<label className="truncate text-xs font-medium text-muted-foreground">
{name}
</label>
<div className="min-w-0">{control}</div>
</div>
);
switch (fieldType) {
case "text":
return row(
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-xs"
/>,
);
case "richtext":
return row(
<textarea
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
rows={3}
className="flex min-h-[72px] w-full resize-y rounded-md border border-input bg-background px-2 py-1.5 text-xs ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
placeholder="Rich text (TipTap later)"
/>,
);
case "number": {
const n =
value === null || value === undefined || value === ""
? ""
: Number(value);
return row(
<Input
type="number"
value={Number.isFinite(n as number) ? String(n) : ""}
onChange={(e) => {
const v = e.target.value;
onChange(v === "" ? null : Number(v));
}}
className="h-8 text-xs"
/>,
);
}
case "date":
return row(
<Input
type="date"
value={String(value ?? "").slice(0, 10)}
onChange={(e) => onChange(e.target.value || null)}
className="h-8 text-xs"
/>,
);
case "select": {
const current = String(value ?? "");
const label =
options.find((o) => o.value === current)?.label ??
(current || "—");
return row(
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-full justify-between px-2 text-xs font-normal"
>
<span className="truncate">{label}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[var(--radix-dropdown-menu-trigger-width)]">
{options.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="text-xs"
onClick={() => onChange(opt.value)}
>
{opt.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>,
);
}
case "multiselect": {
const arr = Array.isArray(value)
? (value as string[])
: value
? [String(value)]
: [];
const toggle = (v: string) => {
if (arr.includes(v)) onChange(arr.filter((x) => x !== v));
else onChange([...arr, v]);
};
return row(
<div className="flex flex-wrap gap-1">
{arr.map((v) => (
<Badge
key={v}
variant="secondary"
className="gap-0.5 pr-1 text-[10px] font-normal"
>
{options.find((o) => o.value === v)?.label ?? v}
<button
type="button"
className="ml-0.5 rounded p-0.5 hover:bg-muted"
onClick={() => toggle(v)}
aria-label={`Remove ${v}`}
>
<X className="size-3" />
</button>
</Badge>
))}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]">
+ Add
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="max-h-48 overflow-y-auto">
{options.map((opt) => (
<DropdownMenuItem
key={opt.value}
className="text-xs"
onClick={() => toggle(opt.value)}
>
{opt.label}
{arr.includes(opt.value) ? " ✓" : ""}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>,
);
}
case "person": {
const raw = value as
| { user?: { name?: string; avatarUrl?: string | null } }
| { name?: string; avatarUrl?: string | null }
| null
| undefined;
const person =
raw && typeof raw === "object" && "user" in raw && raw.user
? raw.user
: (raw as { name?: string; avatarUrl?: string | null } | undefined);
const displayName = person?.name ?? "Unassigned";
return row(
<div className="flex items-center gap-2">
<Avatar className="size-7 border border-border">
<AvatarImage src={person?.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[10px] font-medium">
{displayName === "Unassigned" ? (
<User className="size-3.5 opacity-70" />
) : (
initials(displayName)
)}
</AvatarFallback>
</Avatar>
<span className="truncate text-xs">{displayName}</span>
</div>,
);
}
case "relation": {
const rel = value as
| { id?: string; title?: string }
| string
| null
| undefined;
const title =
typeof rel === "object" && rel && "title" in rel
? String(rel.title)
: typeof rel === "string"
? rel
: "—";
return row(
<Button
variant="link"
className="h-auto min-h-0 justify-start p-0 text-xs text-primary"
type="button"
>
<Link2 className="mr-1 size-3.5 shrink-0" />
<span className="truncate">{title}</span>
</Button>,
);
}
case "url":
return row(
<div className="relative">
<ExternalLink className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="url"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
className="h-8 pl-8 text-xs"
placeholder="https://"
/>
</div>,
);
case "file":
return row(
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon className="size-3.5 shrink-0" />
<span className="truncate">{String(value ?? "No file")}</span>
</div>,
);
case "checkbox":
return row(
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={Boolean(value)}
onChange={(e) => onChange(e.target.checked)}
className={cn(
"size-4 rounded border border-input bg-background",
"text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
/>
<span className="text-xs text-muted-foreground">
{Boolean(value) ? "Yes" : "No"}
</span>
</label>,
);
default:
return row(
<Input
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
className="h-8 text-xs"
/>,
);
}
}