Files
kanban/frontend/src/components/CardFilesPanel.tsx
T
egutierrez ac5f016e7e feat(frontend): UI archivos en cards (issue 0128)
- CardFilesPanel: tab Archivos con grid thumbs + boton subir/borrar
- CardForm: drag&drop en descripcion, inserta ref markdown en cursor
- CardChatPanel: drag&drop + boton paperclip, sube y envia ref como mensaje
- MessageBody: renderer markdown minimo (img inline + link chip)
- api.ts: listCardFiles, uploadCardFile (multipart), deleteCardFile
- types.ts: CardFile
2026-05-27 10:52:01 +02:00

224 lines
6.7 KiB
TypeScript

import {
ActionIcon,
Anchor,
Badge,
Box,
Button,
FileButton,
Group,
Image,
Loader,
Paper,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { notifications } from "@mantine/notifications";
import {
IconDownload,
IconFile,
IconFileSpreadsheet,
IconFileText,
IconFileTypePdf,
IconPhoto,
IconTrash,
IconUpload,
} from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import * as api from "../api";
import type { CardFile } from "../types";
interface Props {
cardId: string;
refreshKey?: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function isImage(mime: string): boolean {
return mime.startsWith("image/");
}
function fileIcon(mime: string, size = 18) {
const m = mime.toLowerCase();
if (m.startsWith("image/")) return <IconPhoto size={size} />;
if (m === "application/pdf") return <IconFileTypePdf size={size} />;
if (
m.includes("spreadsheet") ||
m.includes("excel") ||
m === "text/csv" ||
m === "application/vnd.ms-excel"
) {
return <IconFileSpreadsheet size={size} />;
}
if (m.startsWith("text/")) return <IconFileText size={size} />;
return <IconFile size={size} />;
}
function sourceBadge(s: CardFile["source"]) {
if (s === "description") return { color: "blue", label: "descripcion" };
if (s === "chat") return { color: "teal", label: "chat" };
return { color: "gray", label: "subido" };
}
export function CardFilesPanel({ cardId, refreshKey }: Props) {
const [files, setFiles] = useState<CardFile[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const reload = useCallback(async () => {
try {
const list = await api.listCardFiles(cardId);
setFiles(list);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId]);
useEffect(() => {
reload();
}, [reload, refreshKey]);
const onUpload = async (file: File | null) => {
if (!file) return;
setUploading(true);
try {
const cf = await api.uploadCardFile(cardId, file, "upload");
setFiles((prev) => [...prev, cf]);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setUploading(false);
}
};
const onDelete = async (id: string) => {
if (!window.confirm("¿Borrar este archivo?")) return;
try {
await api.deleteCardFile(id);
setFiles((prev) => prev.filter((f) => f.id !== id));
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
return (
<Stack gap="xs" p={4}>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{files.length} archivo{files.length === 1 ? "" : "s"}
</Text>
<FileButton onChange={onUpload} disabled={uploading}>
{(props) => (
<Button
size="xs"
variant="light"
leftSection={<IconUpload size={14} />}
loading={uploading}
{...props}
>
Subir
</Button>
)}
</FileButton>
</Group>
{loading ? (
<Group justify="center" p="md">
<Loader size="sm" />
</Group>
) : files.length === 0 ? (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 160 }}>
<Text size="sm" c="dimmed">
Sin archivos
</Text>
<Text size="xs" c="dimmed" ta="center">
Sube archivos con el boton, arrastra al chat o a la descripcion.
</Text>
</Stack>
) : (
<Stack gap={6}>
{files.map((f) => {
const badge = sourceBadge(f.source);
return (
<Paper key={f.id} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" align="flex-start">
{isImage(f.mime) ? (
<Anchor href={f.url} target="_blank" rel="noopener noreferrer">
<Image
src={f.url}
alt={f.filename}
w={64}
h={64}
fit="cover"
radius="sm"
/>
</Anchor>
) : (
<Box
style={{
width: 64,
height: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-gray-1)",
borderRadius: 4,
}}
>
{fileIcon(f.mime, 28)}
</Box>
)}
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={f.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
{f.filename}
</Anchor>
<Group gap={6} mt={4}>
<Badge size="xs" variant="light" color={badge.color}>
{badge.label}
</Badge>
<Text size="xs" c="dimmed">{formatSize(f.size)}</Text>
<Text size="xs" c="dimmed">{f.mime || "?"}</Text>
</Group>
</Box>
<Group gap={4} wrap="nowrap">
<Tooltip label="Descargar" withArrow>
<ActionIcon
component="a"
href={f.url}
download={f.filename}
variant="subtle"
size="sm"
aria-label="Descargar"
>
<IconDownload size={14} />
</ActionIcon>
</Tooltip>
<Tooltip label="Borrar" withArrow>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => onDelete(f.id)}
aria-label="Borrar"
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
);
})}
</Stack>
)}
</Stack>
);
}