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
+296
View File
@@ -0,0 +1,296 @@
import { useSortable } from "@dnd-kit/sortable";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
ActionIcon,
Badge,
Box,
Button,
Group,
Paper,
ScrollArea,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
IconArchive,
IconArchiveOff,
IconCheck,
IconGripVertical,
IconPencil,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import type { Card, CardColor, Column } from "../types";
import { KanbanCard } from "./KanbanCard";
interface Props {
column: Column;
cards: Card[];
now: number;
collapsed?: boolean;
onAddCard: (columnId: string) => void;
onRenameColumn: (id: string, name: string) => void;
onResizeColumn: (id: string, width: number) => void;
onMoveColumnLocation: (id: string, location: "board" | "sidebar") => void;
onDeleteColumn: (id: string) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
}
function KanbanColumnImpl({
column,
cards,
now,
collapsed,
onAddCard,
onRenameColumn,
onResizeColumn,
onMoveColumnLocation,
onDeleteColumn,
onEditCard,
onDeleteCard,
onChangeCardColor,
onShowHistory,
}: Props) {
const [renaming, setRenaming] = useState(false);
const [name, setName] = useState(column.name);
const [localWidth, setLocalWidth] = useState<number | null>(null);
// sync local width when column.width changes from outside (other clients).
useEffect(() => {
setLocalWidth(null);
}, [column.width]);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `column-${column.id}`,
data: { type: "column", columnId: column.id, location: column.location },
});
const effectiveWidth = collapsed ? 220 : localWidth ?? column.width;
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
width: effectiveWidth,
minWidth: effectiveWidth,
maxWidth: effectiveWidth,
display: "flex",
flexDirection: "column",
height: "100%",
position: "relative",
};
const cardIds = cards.map((c) => c.id);
const submitRename = () => {
const trimmed = name.trim();
if (trimmed && trimmed !== column.name) onRenameColumn(column.id, trimmed);
setRenaming(false);
};
// --- resize handle ---
const resizingRef = useRef<{ startX: number; startWidth: number } | null>(null);
const onResizeMouseDown = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
resizingRef.current = { startX: e.clientX, startWidth: column.width };
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (ev: globalThis.MouseEvent) => {
if (!resizingRef.current) return;
const dx = ev.clientX - resizingRef.current.startX;
const next = Math.min(800, Math.max(200, resizingRef.current.startWidth + dx));
setLocalWidth(next);
};
const onUp = () => {
if (resizingRef.current && localWidthRef.current !== null) {
onResizeColumn(column.id, localWidthRef.current);
}
resizingRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
};
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
};
// mirror localWidth into a ref so the mouseup handler always sees the latest value.
const localWidthRef = useRef<number | null>(null);
useEffect(() => {
localWidthRef.current = localWidth;
}, [localWidth]);
const isInSidebar = column.location === "sidebar";
const archiveLabel = isInSidebar ? "Restaurar al board" : "Mover al sidebar";
const ArchiveIcon = isInSidebar ? IconArchiveOff : IconArchive;
return (
<Paper ref={setNodeRef} style={style} withBorder radius="md" p="sm" bg="dark.7">
<Group justify="space-between" mb="xs" 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 column"
>
<IconGripVertical size={14} />
</ActionIcon>
{renaming ? (
<TextInput
size="xs"
value={name}
onChange={(e) => setName(e.currentTarget.value)}
autoFocus
onBlur={submitRename}
onKeyDown={(e) => {
if (e.key === "Enter") submitRename();
if (e.key === "Escape") {
setName(column.name);
setRenaming(false);
}
}}
style={{ flex: 1 }}
/>
) : (
<Text
fw={600}
size="sm"
truncate
onDoubleClick={() => {
setName(column.name);
setRenaming(true);
}}
style={{ flex: 1, cursor: "text" }}
title="Doble click para renombrar"
>
{column.name}
</Text>
)}
<Badge size="xs" variant="light" color="gray">
{cards.length}
</Badge>
</Group>
<Group gap={2} wrap="nowrap">
{renaming ? (
<>
<ActionIcon variant="subtle" color="green" size="sm" onClick={submitRename} aria-label="Save">
<IconCheck size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setName(column.name);
setRenaming(false);
}}
aria-label="Cancel"
>
<IconX size={14} />
</ActionIcon>
</>
) : (
<>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => {
setName(column.name);
setRenaming(true);
}}
aria-label="Rename"
>
<IconPencil size={14} />
</ActionIcon>
<Tooltip label={archiveLabel} withArrow>
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => onMoveColumnLocation(column.id, isInSidebar ? "board" : "sidebar")}
aria-label={archiveLabel}
>
<ArchiveIcon size={14} />
</ActionIcon>
</Tooltip>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDeleteColumn(column.id)}
aria-label="Delete column"
>
<IconTrash size={14} />
</ActionIcon>
</>
)}
</Group>
</Group>
<ScrollArea style={{ flex: 1 }} type="auto">
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
<Stack gap="xs" pb="xs" style={{ minHeight: 40 }}>
{cards.map((c) => (
<KanbanCard
key={c.id}
card={c}
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
/>
))}
</Stack>
</SortableContext>
</ScrollArea>
<Button
variant="subtle"
color="gray"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={() => onAddCard(column.id)}
mt="xs"
fullWidth
>
Anadir tarjeta
</Button>
{/* Resize handle (only on board, not sidebar) */}
{!isInSidebar && (
<Box
onMouseDown={onResizeMouseDown}
style={{
position: "absolute",
top: 0,
right: -3,
width: 6,
height: "100%",
cursor: "col-resize",
zIndex: 5,
}}
aria-label="Resize column"
/>
)}
</Paper>
);
}
export const KanbanColumn = memo(KanbanColumnImpl);