ubiquitous-invention/apps/web/components/views/table/table-cell.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
9.3 KiB
TypeScript

"use client";
import * as React from "react";
import { ChevronDown } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
export type TableFieldType =
| "title"
| "text"
| "status"
| "priority"
| "date"
| "select"
| "assignees"
| "created";
export interface TableCellProps {
value: unknown;
fieldType: TableFieldType;
onChange?: (value: unknown) => void;
readOnly?: boolean;
className?: string;
/** Options for status, priority, or custom select columns */
selectOptions?: string[];
/** Optional: move focus to next/previous cell (Tab / Shift+Tab) */
onNavigate?: (dir: "next" | "prev") => void;
}
function formatDateDisplay(raw: string): string {
if (!raw || raw === "—") return "—";
try {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return raw;
}
}
function formatCreatedDisplay(raw: string): string {
if (!raw) return "—";
try {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return String(raw);
}
}
function statusLabel(status: string | null): string {
if (!status) return "—";
return String(status).replace(/_/g, " ");
}
function initials(name: string | null): string {
return (name ?? "?")
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
type AssigneeLike = { user: { id: string; name: string | null; avatarUrl: string | null } };
function isAssigneeArray(v: unknown): v is AssigneeLike[] {
return Array.isArray(v) && v.every((x) => x && typeof x === "object" && "user" in x);
}
export function TableCell({
value,
fieldType,
onChange,
readOnly = false,
className,
selectOptions = [],
onNavigate,
}: TableCellProps) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const stringValue = React.useMemo(() => {
if (value === null || value === undefined) return "";
if (typeof value === "string") return value;
return String(value);
}, [value]);
React.useEffect(() => {
if (!editing) return;
if (fieldType === "date") {
const raw = stringValue;
if (raw && raw !== "—") {
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
setDraft(d.toISOString().slice(0, 10));
return;
}
}
setDraft("");
return;
}
setDraft(stringValue);
}, [editing, fieldType, stringValue]);
React.useEffect(() => {
if (editing && fieldType !== "assignees" && fieldType !== "created") {
const id = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(id);
}
}, [editing, fieldType]);
const commit = React.useCallback(() => {
if (!onChange) {
setEditing(false);
return;
}
if (fieldType === "date") {
onChange(draft || null);
} else if (fieldType === "title" || fieldType === "text" || fieldType === "select") {
onChange(draft);
} else if (fieldType === "status" || fieldType === "priority") {
onChange(draft);
}
setEditing(false);
}, [draft, fieldType, onChange]);
const cancel = React.useCallback(() => {
setEditing(false);
}, []);
const startEdit = React.useCallback(() => {
if (readOnly || fieldType === "assignees") return;
if (fieldType === "created") return;
if (fieldType === "status" || fieldType === "priority" || fieldType === "select") return;
setEditing(true);
}, [readOnly, fieldType]);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Tab" && onNavigate) {
e.preventDefault();
commit();
onNavigate(e.shiftKey ? "prev" : "next");
}
};
const displayNode = (() => {
if (fieldType === "assignees" && isAssigneeArray(value)) {
const list = value;
if (list.length === 0) {
return <span className="text-muted-foreground"></span>;
}
return (
<div className="flex min-w-0 -space-x-1.5 overflow-hidden">
{list.slice(0, 4).map((a) => (
<Avatar
key={a.user.id}
className="h-6 w-6 border-2 border-background text-[10px]"
>
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
<AvatarFallback className="text-[9px]">{initials(a.user.name)}</AvatarFallback>
</Avatar>
))}
</div>
);
}
if (fieldType === "status") {
const s = typeof value === "string" ? value : value === null ? null : String(value);
return (
<Badge variant="muted" className="max-w-full truncate px-1.5 py-0 text-[11px] font-medium capitalize">
{statusLabel(s)}
</Badge>
);
}
if (fieldType === "priority") {
return (
<Badge variant="muted" className="max-w-full truncate px-1.5 py-0 text-[11px] font-medium">
{stringValue || "—"}
</Badge>
);
}
if (fieldType === "date") {
return (
<span className="tabular-nums text-muted-foreground">{formatDateDisplay(stringValue || "—")}</span>
);
}
if (fieldType === "created") {
return (
<span className="tabular-nums text-muted-foreground">
{formatCreatedDisplay(stringValue)}
</span>
);
}
return (
<span className="min-w-0 truncate text-sm text-foreground">{stringValue || "—"}</span>
);
})();
if (readOnly || fieldType === "assignees" || fieldType === "created") {
return (
<div
className={cn(
"flex h-9 min-h-9 max-h-9 items-center px-2 text-sm",
className,
)}
>
{displayNode}
</div>
);
}
if (fieldType === "status" || fieldType === "priority" || fieldType === "select") {
const opts =
fieldType === "status"
? ["open", "in_progress", "done", "closed"]
: fieldType === "priority"
? ["High", "Medium", "Low"]
: selectOptions;
return (
<div className={cn("flex h-9 min-h-9 max-h-9 items-stretch px-1", className)}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-9 min-h-9 w-full min-w-0 items-center justify-between gap-1 rounded-sm px-2 text-left text-sm outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="min-w-0 flex-1">{displayNode}</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-40" aria-hidden />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[8rem]" align="start">
{opts.map((opt) => (
<DropdownMenuItem
key={opt}
className="text-xs"
onSelect={() => onChange?.(opt)}
>
{fieldType === "status" ? statusLabel(opt) : opt}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
if (fieldType === "date") {
if (editing) {
return (
<div className={cn("flex h-9 items-center px-1", className)}>
<Input
ref={inputRef}
type="date"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={onKeyDown}
className="h-7 border-0 bg-transparent px-2 py-0 text-xs shadow-none focus-visible:ring-1"
/>
</div>
);
}
return (
<button
type="button"
onClick={startEdit}
className={cn(
"flex h-9 w-full min-w-0 items-center rounded-sm px-2 text-left outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{displayNode}
</button>
);
}
if (editing) {
return (
<div className={cn("flex h-9 items-center px-1", className)}>
<Input
ref={inputRef}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={onKeyDown}
className="h-7 border-0 bg-transparent px-2 py-0 text-sm shadow-none focus-visible:ring-1"
/>
</div>
);
}
return (
<button
type="button"
onClick={startEdit}
className={cn(
"flex h-9 w-full min-w-0 items-center rounded-sm px-2 text-left outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
{displayNode}
</button>
);
}