chore: sync from fn-registry agent
This commit is contained in:
@@ -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);
|
||||
Reference in New Issue
Block a user