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:
2026-05-27 10:52:01 +02:00
parent 2401eb5abc
commit ac5f016e7e
7 changed files with 605 additions and 27 deletions
+37
View File
@@ -1,6 +1,7 @@
import type {
Board,
Card,
CardFile,
CardHistoryResponse,
CardMessage,
Column,
@@ -380,6 +381,42 @@ export function listRequesters(): Promise<string[]> {
return fetchJSON("/requesters");
}
// --- Files (issue 0128) -----------------------------------------------------
export function listCardFiles(cardId: string): Promise<CardFile[]> {
return fetchJSON(`/cards/${cardId}/files`);
}
export async function uploadCardFile(
cardId: string,
file: File,
source: "upload" | "description" | "chat" = "upload"
): Promise<CardFile> {
const fd = new FormData();
fd.append("file", file);
fd.append("source", source);
const res = await fetch(`${BASE}/cards/${cardId}/files`, {
method: "POST",
credentials: "same-origin",
body: fd,
});
if (!res.ok) {
let msg = `upload failed: ${res.status}`;
try {
const body = (await res.json()) as { Message?: string; message?: string };
if (body.Message || body.message) msg = body.Message || body.message || msg;
} catch {
/* ignore */
}
throw new HTTPError(res.status, msg);
}
return (await res.json()) as CardFile;
}
export function deleteCardFile(fileId: string): Promise<void> {
return fetchJSON(`/files/${fileId}`, { method: "DELETE" });
}
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
const qs = new URLSearchParams();
if (f.from) qs.set("from", f.from);
+107 -9
View File
@@ -2,6 +2,7 @@ import {
ActionIcon,
Avatar,
Box,
FileButton,
Group,
Loader,
Paper,
@@ -11,26 +12,35 @@ import {
Textarea,
Tooltip,
} from "@mantine/core";
import { IconSend, IconTrash } from "@tabler/icons-react";
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
import { DragEvent, KeyboardEvent, useCallback, useEffect, useRef, useState } from "react";
import * as api from "../api";
import type { CardMessage, User } from "../types";
import { tagColor } from "./colors";
import { formatDateTimeShort } from "./format";
import { MessageBody } from "./MessageBody";
interface Props {
cardId: string;
users: User[];
currentUserId?: string;
onMessagesChange?: (messages: CardMessage[]) => void;
onFileUploaded?: () => void;
}
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }: Props) {
function refForFile(filename: string, url: string, mime: string): string {
const safe = filename.replace(/]/g, "");
return mime.startsWith("image/") ? `![${safe}](${url})` : `[${safe}](${url})`;
}
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange, onFileUploaded }: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [loading, setLoading] = useState(true);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [dragOver, setDragOver] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const usersById = new Map(users.map((u) => [u.id, u]));
@@ -92,8 +102,62 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
}
};
const handleFiles = async (files: FileList | File[]) => {
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "chat");
const ref = refForFile(cf.filename, cf.url, cf.mime);
const m = await api.createCardMessage(cardId, ref);
setMessages((prev) => {
const next = [...prev, m];
onMessagesChange?.(next);
return next;
});
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
return (
<Stack gap="xs" style={{ height: "100%", minHeight: 0 }}>
<Stack
gap="xs"
style={{
height: "100%",
minHeight: 0,
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? -2 : undefined,
borderRadius: 4,
}}
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
>
<ScrollArea
viewportRef={viewportRef}
style={{ flex: 1, minHeight: 200 }}
@@ -104,7 +168,7 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
<Group justify="center" p="md"><Loader size="sm" /></Group>
) : messages.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" p="md">
Sin mensajes aun. Escribe el primero.
Sin mensajes aun. Escribe el primero o arrastra un archivo.
</Text>
) : (
<Stack gap={6} p={4}>
@@ -138,9 +202,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
</Tooltip>
)}
</Group>
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{m.body}
</Text>
<Stack gap={4}>
<MessageBody text={m.body} />
</Stack>
</Box>
</Group>
</Paper>
@@ -154,13 +218,29 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
value={body}
onChange={(e) => setBody(e.currentTarget.value)}
onKeyDown={onKeyDown}
placeholder="Escribe un mensaje (Enter = enviar, Shift+Enter = salto)"
placeholder="Escribe un mensaje. Arrastra archivos o usa el clip."
autosize
minRows={1}
maxRows={6}
style={{ flex: 1 }}
disabled={sending}
/>
<FileButton onChange={(file) => file && handleFiles([file])} disabled={uploading}>
{(props) => (
<Tooltip label="Adjuntar archivo" withArrow>
<ActionIcon
size="lg"
variant="subtle"
color="gray"
aria-label="Adjuntar"
loading={uploading}
{...props}
>
<IconPaperclip size={16} />
</ActionIcon>
</Tooltip>
)}
</FileButton>
<Tooltip label="Enviar" withArrow>
<ActionIcon
size="lg"
@@ -174,6 +254,24 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
</ActionIcon>
</Tooltip>
</Group>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Stack>
);
}
+10 -5
View File
@@ -1,8 +1,9 @@
import { Box, Divider, Group, Tabs, Text } from "@mantine/core";
import { Box, Divider, Group, Tabs } from "@mantine/core";
import { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
import { useState } from "react";
import type { Card, CardMessage, User } from "../types";
import { CardChatPanel } from "./CardChatPanel";
import { CardFilesPanel } from "./CardFilesPanel";
import { CardLinksPanel } from "./CardLinksPanel";
import { CardForm, CardFormValues } from "./CardForm";
@@ -27,12 +28,15 @@ export function CardEditPanel({
}: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [liveCard, setLiveCard] = useState(card);
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
const wrappedSubmit = async (v: CardFormValues) => {
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
await onSubmit(v);
};
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
return (
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
@@ -48,6 +52,8 @@ export function CardEditPanel({
tags: liveCard.tags || [],
}}
submitLabel="Guardar"
cardId={liveCard.id}
onFileUploaded={bumpFiles}
onSubmit={wrappedSubmit}
onCancel={onCancel}
/>
@@ -58,7 +64,7 @@ export function CardEditPanel({
<Tabs.List>
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
<Tabs.Tab value="links" leftSection={<IconLink size={14} />}>Enlaces</Tabs.Tab>
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />} disabled>Archivos</Tabs.Tab>
<Tabs.Tab value="files" leftSection={<IconPaperclip size={14} />}>Archivos</Tabs.Tab>
</Tabs.List>
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
@@ -68,6 +74,7 @@ export function CardEditPanel({
users={users}
currentUserId={currentUserId}
onMessagesChange={setMessages}
onFileUploaded={bumpFiles}
/>
</Box>
</Tabs.Panel>
@@ -75,9 +82,7 @@ export function CardEditPanel({
<CardLinksPanel card={liveCard} messages={messages} />
</Tabs.Panel>
<Tabs.Panel value="files">
<Text size="sm" c="dimmed" ta="center" p="md">
Proximamente: adjuntos de archivos.
</Text>
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
</Tabs.Panel>
</Box>
</Tabs>
+223
View File
@@ -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>
);
}
+118 -13
View File
@@ -1,5 +1,7 @@
import { Autocomplete, Button, Group, Select, Stack, TagsInput, Textarea } from "@mantine/core";
import { FormEvent, KeyboardEvent, useState } from "react";
import { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
import * as api from "../api";
import type { User } from "../types";
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
@@ -21,16 +23,25 @@ interface Props {
users?: User[];
requesterOptions?: string[];
tagOptions?: string[];
cardId?: string;
onFileUploaded?: () => void;
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
function markdownRef(filename: string, url: string, isImage: boolean): string {
const safeName = filename.replace(/]/g, "");
return isImage ? `![${safeName}](${url})` : `[${safeName}](${url})`;
}
export function CardForm({
initial,
submitLabel = "Guardar",
users = [],
requesterOptions = [],
tagOptions = [],
cardId,
onFileUploaded,
onSubmit,
onCancel,
}: Props) {
@@ -39,6 +50,9 @@ export function CardForm({
const [description, setDescription] = useState(initial?.description ?? "");
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
const [dragOver, setDragOver] = useState(false);
const [uploading, setUploading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const submit = async (e?: FormEvent) => {
e?.preventDefault();
@@ -60,6 +74,66 @@ export function CardForm({
}
};
const insertAtCursor = (snippet: string) => {
const ta = textareaRef.current;
if (!ta) {
setDescription((d) => (d ? d + "\n" + snippet : snippet));
return;
}
const start = ta.selectionStart ?? description.length;
const end = ta.selectionEnd ?? description.length;
const before = description.slice(0, start);
const after = description.slice(end);
const sep = before && !before.endsWith("\n") ? "\n" : "";
const next = before + sep + snippet + after;
setDescription(next);
queueMicrotask(() => {
ta.focus();
const pos = (before + sep + snippet).length;
ta.setSelectionRange(pos, pos);
});
};
const handleFiles = async (files: FileList | File[]) => {
if (!cardId) {
notifications.show({ color: "yellow", message: "Guarda la tarjeta antes de subir archivos." });
return;
}
setUploading(true);
try {
for (const file of Array.from(files)) {
try {
const cf = await api.uploadCardFile(cardId, file, "description");
insertAtCursor(markdownRef(cf.filename, cf.url, cf.mime.startsWith("image/")));
onFileUploaded?.();
} catch (e) {
notifications.show({ color: "red", message: `${file.name}: ${(e as Error).message}` });
}
}
} finally {
setUploading(false);
}
};
const onDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
handleFiles(e.dataTransfer.files);
};
const onDragOver = (e: DragEvent<HTMLDivElement>) => {
if (!cardId) return;
if (!e.dataTransfer.types.includes("Files")) return;
e.preventDefault();
setDragOver(true);
};
const onDragLeave = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setDragOver(false);
};
return (
<form onSubmit={submit}>
<Stack gap="sm">
@@ -95,17 +169,48 @@ export function CardForm({
if (e.key === "Enter") e.preventDefault();
}}
/>
<Textarea
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description="Ctrl+Enter para guardar"
/>
<Box
onDrop={onDrop}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
style={{
position: "relative",
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
outlineOffset: dragOver ? 2 : undefined,
borderRadius: 4,
}}
>
<Textarea
ref={textareaRef}
label="Descripcion"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
tabIndex={3}
autosize
minRows={3}
maxRows={8}
onKeyDown={textareaEnter}
description={cardId ? "Ctrl+Enter para guardar. Arrastra archivos para adjuntar." : "Ctrl+Enter para guardar"}
/>
{(dragOver || uploading) && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(34,139,230,0.08)",
display: "flex",
alignItems: "center",
justifyContent: "center",
pointerEvents: "none",
borderRadius: 4,
}}
>
<Text size="sm" fw={500} c="blue">
{uploading ? "Subiendo..." : "Suelta para adjuntar"}
</Text>
</Box>
)}
</Box>
<Select
label="Asignar a"
placeholder="Sin asignar"
+98
View File
@@ -0,0 +1,98 @@
import { Anchor, Image, Text } from "@mantine/core";
import { Fragment, ReactNode } from "react";
// Minimal markdown renderer for chat/desc embeds (issue 0128).
// Recognises:
// ![alt](url) -> <Image>
// [name](url) -> <Anchor>name</Anchor>
// Everything else is plain text with preserved whitespace.
interface ImgToken { kind: "img"; alt: string; url: string }
interface LinkToken { kind: "link"; label: string; url: string }
interface TextToken { kind: "text"; value: string }
type Token = ImgToken | LinkToken | TextToken;
const TOKEN_RE = /(!\[([^\]\n]*)\]\(([^)\s]+)\))|(\[([^\]\n]+)\]\(([^)\s]+)\))/g;
function tokenize(input: string): Token[] {
const out: Token[] = [];
let last = 0;
let m: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
while ((m = TOKEN_RE.exec(input)) !== null) {
if (m.index > last) {
out.push({ kind: "text", value: input.slice(last, m.index) });
}
if (m[1]) {
out.push({ kind: "img", alt: m[2] || "", url: m[3] });
} else if (m[4]) {
out.push({ kind: "link", label: m[5], url: m[6] });
}
last = TOKEN_RE.lastIndex;
}
if (last < input.length) {
out.push({ kind: "text", value: input.slice(last) });
}
return out;
}
interface Props {
text: string;
size?: string;
}
export function MessageBody({ text, size = "sm" }: Props): ReactNode {
const tokens = tokenize(text);
if (tokens.length === 0) {
return null;
}
const inlineNodes: ReactNode[] = [];
const blocks: ReactNode[] = [];
let key = 0;
const flushInline = () => {
if (inlineNodes.length === 0) return;
blocks.push(
<Text
key={`t-${key++}`}
size={size}
style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
>
{inlineNodes.map((n, i) => (
<Fragment key={i}>{n}</Fragment>
))}
</Text>
);
inlineNodes.length = 0;
};
for (const t of tokens) {
if (t.kind === "img") {
flushInline();
blocks.push(
<Anchor key={`i-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
<Image
src={t.url}
alt={t.alt}
maw={220}
mah={220}
fit="contain"
radius="sm"
/>
</Anchor>
);
} else if (t.kind === "link") {
inlineNodes.push(
<Anchor key={`l-${key++}`} href={t.url} target="_blank" rel="noopener noreferrer">
{t.label}
</Anchor>
);
} else {
inlineNodes.push(t.value);
}
}
flushInline();
return <>{blocks}</>;
}
+12
View File
@@ -46,6 +46,18 @@ export interface Card {
total_locked_ms: number;
}
export interface CardFile {
id: string;
card_id: string;
uploader_id: string;
filename: string;
mime: string;
size: number;
source: "upload" | "description" | "chat";
url: string;
created_at: string;
}
export interface User {
id: string;
username: string;