ubiquitous-invention/apps/web/components/views/list/list-item.tsx

261 lines
7.7 KiB
TypeScript
Raw Normal View History

"use client";
import * as React from "react";
import {
Archive,
MoreHorizontal,
Pencil,
} from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ViewObject } from "@/lib/hooks/use-view-data";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
/** Shared grid for list header and rows — dense ClickUp-style columns */
export const LIST_ROW_GRID =
"grid grid-cols-[28px_32px_minmax(0,1fr)_minmax(5rem,7.5rem)_5.5rem_6rem_5rem] gap-2 items-center px-3";
const STATUS_CYCLE = ["open", "in_progress", "done", "closed"] as const;
function statusColorClass(status: string | null): string {
switch (status) {
case "in_progress":
return "bg-blue-500 shadow-[0_0_0_1px_hsl(var(--background))]";
case "done":
return "bg-green-500 shadow-[0_0_0_1px_hsl(var(--background))]";
case "closed":
return "bg-slate-500 shadow-[0_0_0_1px_hsl(var(--background))]";
case "open":
default:
return "bg-muted-foreground/60 shadow-[0_0_0_1px_hsl(var(--background))]";
}
}
export function getPriorityValue(object: ViewObject): string {
const v = object.propertyValues?.find(
(p) => p.propertyDefinition.name === "Priority",
)?.value;
return typeof v === "string" ? v : "—";
}
export function getDueDateValue(object: ViewObject): string {
const v = object.propertyValues?.find(
(p) => p.propertyDefinition.name === "Due Date",
)?.value;
return typeof v === "string" ? v : "—";
}
function formatDueDisplay(iso: string): string {
if (iso === "—") return "—";
try {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return iso;
}
}
export interface ListItemProps {
object: ViewObject;
onSelect: (id: string) => void;
selected?: boolean;
className?: string;
rowIndex?: number;
}
export function ListItem({
object,
onSelect,
selected = false,
className,
rowIndex = 0,
}: ListItemProps) {
const openDetail = usePanelStore((s) => s.open);
const [status, setStatus] = React.useState(object.status);
React.useEffect(() => {
setStatus(object.status);
}, [object.status]);
const priority = getPriorityValue(object);
const dueRaw = getDueDateValue(object);
const cycleStatus = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const current = status ?? "open";
const idx = STATUS_CYCLE.indexOf(
current as (typeof STATUS_CYCLE)[number],
);
const next =
STATUS_CYCLE[(idx === -1 ? 0 : idx + 1) % STATUS_CYCLE.length];
setStatus(next);
};
const initials = (name: string | null) =>
(name ?? "?")
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
const zebra = rowIndex % 2 === 1;
return (
<div
className={cn(
"group/row",
LIST_ROW_GRID,
"h-9 min-h-[36px] max-h-10 border-b border-border/40 transition-colors",
zebra && "bg-muted/20",
selected && "bg-primary/10 ring-1 ring-inset ring-primary/25",
"hover:bg-accent/50",
className,
)}
>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={selected}
onChange={() => onSelect(object.id)}
className={cn(
"h-3.5 w-3.5 shrink-0 cursor-pointer rounded border border-input bg-background",
"text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1",
)}
aria-label={`Select ${object.title}`}
/>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={cycleStatus}
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded-full outline-none transition-transform hover:scale-110 focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={`Status: ${status ?? "none"}. Click to change.`}
>
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
statusColorClass(status),
)}
/>
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="capitalize">{String(status ?? "open").replace("_", " ")}</p>
</TooltipContent>
</Tooltip>
<button
type="button"
onClick={() => openDetail("object-detail", object.id)}
className="min-w-0 truncate text-left text-sm font-medium text-foreground hover:text-primary"
>
{object.title}
</button>
<div className="flex -space-x-1.5 overflow-hidden">
{(object.assignees ?? []).slice(0, 3).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>
))}
{(object.assignees?.length ?? 0) === 0 && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<div className="min-w-0">
<Badge
variant="muted"
className="max-w-full truncate px-1.5 py-0 text-[11px] font-medium"
>
{priority}
</Badge>
</div>
<div className="text-xs tabular-nums text-muted-foreground">
{formatDueDisplay(dueRaw)}
</div>
<div className="flex items-center justify-end gap-0.5 opacity-0 transition-opacity group-hover/row:opacity-100 focus-within:opacity-100">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Edit"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="Archive"
>
<Archive className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Archive</TooltipContent>
</Tooltip>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7"
aria-label="More actions"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Move to</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-destructive focus:text-destructive">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}