ubiquitous-invention/apps/web/components/views/board/board-card.tsx

210 lines
6.2 KiB
TypeScript
Raw Permalink Normal View History

"use client";
import * as React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Calendar, GripVertical } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import type { ViewObject } from "@/lib/hooks/use-view-data";
import { usePanelStore } from "@/lib/stores/panel-store";
import { cn } from "@/lib/utils";
function getPriorityValue(object: ViewObject): string {
const v = object.propertyValues?.find(
(p) => p.propertyDefinition.name === "Priority",
)?.value;
return typeof v === "string" ? v : "—";
}
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",
});
} catch {
return iso;
}
}
function priorityTone(p: string): string {
switch (p) {
case "High":
return "border-destructive/40 bg-destructive/10 text-destructive";
case "Medium":
return "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-400";
case "Low":
return "border-muted-foreground/30 bg-muted/80 text-muted-foreground";
default:
return "border-border bg-muted/50 text-muted-foreground";
}
}
function initials(name: string | null): string {
return (name ?? "?")
.split(/\s+/)
.map((p) => p[0])
.join("")
.slice(0, 2)
.toUpperCase();
}
function CardMeta({ object }: { object: ViewObject }) {
const openDetail = usePanelStore((s) => s.open);
const priority = getPriorityValue(object);
const dueRaw = getDueDateValue(object);
const due = formatDueDisplay(dueRaw);
const assignees = object.assignees ?? [];
const shown = assignees.slice(0, 3);
const extra = assignees.length - shown.length;
return (
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<button
type="button"
onClick={() => openDetail("object-detail", object.id)}
className="min-w-0 text-left"
>
<span className="line-clamp-2 text-sm font-medium leading-snug text-foreground">
{object.title}
</span>
</button>
<div className="flex flex-wrap items-center gap-2 pl-1">
<Badge
variant="outline"
className={cn(
"h-5 max-w-[7rem] shrink-0 truncate px-1.5 py-0 text-[10px] font-semibold uppercase tracking-wide",
priorityTone(priority),
)}
>
{priority}
</Badge>
<div className="ml-auto flex min-w-0 items-center gap-2">
{due && (
<span className="flex items-center gap-0.5 text-[11px] tabular-nums text-muted-foreground">
<Calendar className="h-3 w-3 shrink-0 opacity-70" />
{due}
</span>
)}
<div className="flex -space-x-1.5">
{shown.map((a) => (
<Avatar
key={a.user.id}
className="h-6 w-6 border-2 border-card text-[9px]"
>
<AvatarImage src={a.user.avatarUrl ?? undefined} alt="" />
<AvatarFallback>{initials(a.user.name)}</AvatarFallback>
</Avatar>
))}
{extra > 0 && (
<span className="flex h-6 min-w-[1.5rem] items-center justify-center rounded-full border-2 border-card bg-muted px-1 text-[10px] font-medium text-muted-foreground">
+{extra}
</span>
)}
</div>
</div>
</div>
</div>
);
}
export interface BoardCardPreviewProps {
object: ViewObject;
}
/** Used inside DragOverlay — no sortable hooks; matches card visuals while dragging. */
export function BoardCardPreview({ object }: BoardCardPreviewProps) {
return (
<div
className={cn(
"flex min-h-[80px] max-h-[100px] rounded-lg border border-border/80 bg-card p-2.5 shadow-xl ring-2 ring-primary/25",
"rotate-2",
)}
>
<div className="flex min-h-0 flex-1 gap-2">
<span
className="mt-0.5 shrink-0 cursor-grabbing rounded p-0.5 text-muted-foreground"
aria-hidden
>
<GripVertical className="h-4 w-4" />
</span>
<CardMeta object={object} />
</div>
</div>
);
}
export interface BoardCardProps {
object: ViewObject;
}
export function BoardCard({ object }: BoardCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isOver,
} = useSortable({
id: object.id,
data: { type: "card", object },
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<div className="relative">
{!isDragging && isOver && (
<div
className="absolute -top-1 left-0 right-0 z-10 h-0.5 rounded-full bg-primary shadow-[0_0_8px_hsl(var(--primary))]"
aria-hidden
/>
)}
<div
ref={setNodeRef}
style={style}
className={cn(
"group/card flex min-h-[80px] max-h-[100px] rounded-lg border border-border/80 bg-card p-2.5 shadow-sm transition-[box-shadow,transform,opacity]",
"hover:shadow-md",
isDragging && "opacity-40",
)}
>
<div className="flex min-h-0 flex-1 gap-2">
<button
type="button"
className={cn(
"mt-0.5 shrink-0 cursor-grab touch-none rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-foreground",
"group-hover/card:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label="Drag to reorder or move"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
<CardMeta object={object} />
</div>
</div>
</div>
);
}