210 lines
6.2 KiB
TypeScript
210 lines
6.2 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
}
|