261 lines
7.7 KiB
TypeScript
261 lines
7.7 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|