ac5f016e7e
- 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
224 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|