chore: sync from fn-registry agent

This commit is contained in:
fn-registry agent
2026-05-06 19:04:45 +02:00
commit 94223e68f7
31 changed files with 6837 additions and 0 deletions
+153
View File
@@ -0,0 +1,153 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Badge,
Group,
Paper,
Popover,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import {
IconClock,
IconEdit,
IconGripVertical,
IconHistory,
IconPalette,
IconTrash,
IconUser,
} from "@tabler/icons-react";
import { memo, useState } from "react";
import type { Card, CardColor } from "../types";
import { CARD_COLORS, colorBg, colorBorder, colorSwatch } from "./colors";
import { formatDuration } from "./format";
interface Props {
card: Card;
now: number;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
isOverlay?: boolean;
}
function KanbanCardImpl({ card, now, onDelete, onEdit, onChangeColor, onShowHistory, isOverlay }: Props) {
const [popOpen, setPopOpen] = useState(false);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id },
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
background: colorBg(card.color),
borderColor: colorBorder(card.color),
};
const enteredAt = card.entered_at ? new Date(card.entered_at).getTime() : now;
const liveMs = Math.max(0, now - enteredAt);
return (
<Paper ref={setNodeRef} style={style} withBorder p="xs" shadow={isOverlay ? "lg" : "xs"} radius="md">
<Stack gap={6}>
<Group justify="space-between" gap={4} wrap="nowrap">
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
{...attributes}
{...listeners}
style={{ cursor: "grab" }}
aria-label="Drag"
>
<IconGripVertical size={14} />
</ActionIcon>
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>
{card.title}
</Text>
</Group>
<Group gap={2} wrap="nowrap">
<Popover opened={popOpen} onChange={setPopOpen} withArrow shadow="md" position="bottom-end">
<Popover.Target>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setPopOpen((v) => !v)}
aria-label="Color"
>
<IconPalette size={14} />
</ActionIcon>
</Popover.Target>
<Popover.Dropdown p="xs">
<Group gap={4} maw={200}>
{CARD_COLORS.map((c) => (
<Tooltip key={c.value} label={c.label} withArrow>
<ActionIcon
variant={card.color === c.value ? "filled" : "default"}
size="md"
radius="xl"
style={{
background: colorSwatch(c.value),
borderColor: colorBorder(c.value),
}}
onClick={() => {
onChangeColor(card.id, c.value);
setPopOpen(false);
}}
aria-label={c.label}
/>
</Tooltip>
))}
</Group>
</Popover.Dropdown>
</Popover>
<ActionIcon variant="subtle" color="gray" size="sm" onClick={() => onEdit(card)} aria-label="Edit">
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => onShowHistory(card)}
aria-label="History"
>
<IconHistory size={14} />
</ActionIcon>
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => onDelete(card.id)} aria-label="Delete">
<IconTrash size={14} />
</ActionIcon>
</Group>
</Group>
{card.requester && (
<Group gap={4}>
<IconUser size={12} />
<Text size="xs" c="dimmed">
{card.requester}
</Text>
</Group>
)}
{card.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{card.description}
</Text>
)}
<Group gap={4}>
<Badge size="xs" variant="light" color="gray" leftSection={<IconClock size={10} />}>
{formatDuration(liveMs)}
</Badge>
</Group>
</Stack>
</Paper>
);
}
// memo: re-render solo cuando cambian props relevantes (card, now). Evita rerenders
// en cascada cuando otra columna cambia durante drag-over.
export const KanbanCard = memo(KanbanCardImpl);