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
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user