From ac5f016e7ebf7dc7d8e965b75ac13a7988622a03 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Wed, 27 May 2026 10:52:01 +0200 Subject: [PATCH] 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 --- frontend/src/api.ts | 37 ++++ frontend/src/components/CardChatPanel.tsx | 116 ++++++++++- frontend/src/components/CardEditPanel.tsx | 15 +- frontend/src/components/CardFilesPanel.tsx | 223 +++++++++++++++++++++ frontend/src/components/CardForm.tsx | 131 ++++++++++-- frontend/src/components/MessageBody.tsx | 98 +++++++++ frontend/src/types.ts | 12 ++ 7 files changed, 605 insertions(+), 27 deletions(-) create mode 100644 frontend/src/components/CardFilesPanel.tsx create mode 100644 frontend/src/components/MessageBody.tsx diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ecaabc1..59c3649 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,6 +1,7 @@ import type { Board, Card, + CardFile, CardHistoryResponse, CardMessage, Column, @@ -380,6 +381,42 @@ export function listRequesters(): Promise { return fetchJSON("/requesters"); } +// --- Files (issue 0128) ----------------------------------------------------- + +export function listCardFiles(cardId: string): Promise { + return fetchJSON(`/cards/${cardId}/files`); +} + +export async function uploadCardFile( + cardId: string, + file: File, + source: "upload" | "description" | "chat" = "upload" +): Promise { + 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 { + return fetchJSON(`/files/${fileId}`, { method: "DELETE" }); +} + export function getMetrics(f: MetricsFilter): Promise { const qs = new URLSearchParams(); if (f.from) qs.set("from", f.from); diff --git a/frontend/src/components/CardChatPanel.tsx b/frontend/src/components/CardChatPanel.tsx index 45b7360..00284dd 100644 --- a/frontend/src/components/CardChatPanel.tsx +++ b/frontend/src/components/CardChatPanel.tsx @@ -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([]); 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(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) => { + e.preventDefault(); + setDragOver(false); + if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return; + handleFiles(e.dataTransfer.files); + }; + + const onDragOver = (e: DragEvent) => { + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + setDragOver(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + }; + return ( - + ) : messages.length === 0 ? ( - Sin mensajes aun. Escribe el primero. + Sin mensajes aun. Escribe el primero o arrastra un archivo. ) : ( @@ -138,9 +202,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange } )} - - {m.body} - + + + @@ -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} /> + file && handleFiles([file])} disabled={uploading}> + {(props) => ( + + + + + + )} + + {(dragOver || uploading) && ( + + + {uploading ? "Subiendo..." : "Suelta para adjuntar"} + + + )} ); } diff --git a/frontend/src/components/CardEditPanel.tsx b/frontend/src/components/CardEditPanel.tsx index 843b9c9..c9eb7a6 100644 --- a/frontend/src/components/CardEditPanel.tsx +++ b/frontend/src/components/CardEditPanel.tsx @@ -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([]); 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 ( @@ -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({ }>Chat }>Enlaces - } disabled>Archivos + }>Archivos @@ -68,6 +74,7 @@ export function CardEditPanel({ users={users} currentUserId={currentUserId} onMessagesChange={setMessages} + onFileUploaded={bumpFiles} /> @@ -75,9 +82,7 @@ export function CardEditPanel({ - - Proximamente: adjuntos de archivos. - + diff --git a/frontend/src/components/CardFilesPanel.tsx b/frontend/src/components/CardFilesPanel.tsx new file mode 100644 index 0000000..127d673 --- /dev/null +++ b/frontend/src/components/CardFilesPanel.tsx @@ -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 ; + if (m === "application/pdf") return ; + if ( + m.includes("spreadsheet") || + m.includes("excel") || + m === "text/csv" || + m === "application/vnd.ms-excel" + ) { + return ; + } + if (m.startsWith("text/")) return ; + return ; +} + +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([]); + 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 ( + + + + {files.length} archivo{files.length === 1 ? "" : "s"} + + + {(props) => ( + + )} + + + + {loading ? ( + + + + ) : files.length === 0 ? ( + + + Sin archivos + + + Sube archivos con el boton, arrastra al chat o a la descripcion. + + + ) : ( + + {files.map((f) => { + const badge = sourceBadge(f.source); + return ( + + + {isImage(f.mime) ? ( + + {f.filename} + + ) : ( + + {fileIcon(f.mime, 28)} + + )} + + + {f.filename} + + + + {badge.label} + + {formatSize(f.size)} + {f.mime || "?"} + + + + + + + + + + onDelete(f.id)} + aria-label="Borrar" + > + + + + + + + ); + })} + + )} + + ); +} diff --git a/frontend/src/components/CardForm.tsx b/frontend/src/components/CardForm.tsx index 6bc9933..274a14e 100644 --- a/frontend/src/components/CardForm.tsx +++ b/frontend/src/components/CardForm.tsx @@ -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; 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(initial?.assignee_id ?? null); const [tags, setTags] = useState(initial?.tags ?? []); + const [dragOver, setDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const textareaRef = useRef(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) => { + e.preventDefault(); + setDragOver(false); + if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return; + handleFiles(e.dataTransfer.files); + }; + + const onDragOver = (e: DragEvent) => { + if (!cardId) return; + if (!e.dataTransfer.types.includes("Files")) return; + e.preventDefault(); + setDragOver(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + }; + return (
@@ -95,17 +169,48 @@ export function CardForm({ if (e.key === "Enter") e.preventDefault(); }} /> -