From 2401eb5abcaf7a6da95dbeb6ecc2efe34bb77e25 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Wed, 27 May 2026 10:51:52 +0200 Subject: [PATCH 1/6] feat(backend): card file attachments (issue 0128) - migration 014_card_files: tabla con soft-delete + index activo - handlers POST/GET/DELETE en backend/files.go - routes /api/cards/{id}/files, /api/files/{id} - limite 10MB, storage en uploads//__ --- backend/files.go | 351 ++++++++++++++++++++++++++ backend/handlers.go | 5 + backend/migrations/014_card_files.sql | 16 ++ 3 files changed, 372 insertions(+) create mode 100644 backend/files.go create mode 100644 backend/migrations/014_card_files.sql diff --git a/backend/files.go b/backend/files.go new file mode 100644 index 0000000..015adef --- /dev/null +++ b/backend/files.go @@ -0,0 +1,351 @@ +package main + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "fn-registry/functions/infra" +) + +// Issue 0128: adjuntos de archivos por card. + +const ( + maxUploadBytes = 10 << 20 // 10 MiB + uploadsSubdir = "uploads" +) + +type CardFile struct { + ID string `json:"id"` + CardID string `json:"card_id"` + UploaderID string `json:"uploader_id"` + Filename string `json:"filename"` + MIME string `json:"mime"` + Size int64 `json:"size"` + Source string `json:"source"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` +} + +func (db *DB) CreateCardFile(cardID, uploaderID, filename, mimeType, storedPath, source string, size int64) (*CardFile, error) { + id := newID() + now := nowRFC3339() + if source == "" { + source = "upload" + } + _, err := db.conn.Exec( + `INSERT INTO card_files + (id, card_id, uploader_id, filename, mime, size, stored_path, source, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, cardID, uploaderID, filename, mimeType, size, storedPath, source, now, + ) + if err != nil { + return nil, err + } + return &CardFile{ + ID: id, + CardID: cardID, + UploaderID: uploaderID, + Filename: filename, + MIME: mimeType, + Size: size, + Source: source, + URL: "/api/files/" + id, + CreatedAt: now, + }, nil +} + +func (db *DB) ListCardFiles(cardID string) ([]CardFile, error) { + rows, err := db.conn.Query( + `SELECT id, card_id, uploader_id, filename, mime, size, source, created_at + FROM card_files + WHERE card_id = ? AND deleted_at IS NULL + ORDER BY created_at ASC`, + cardID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + out := []CardFile{} + for rows.Next() { + var f CardFile + if err := rows.Scan(&f.ID, &f.CardID, &f.UploaderID, &f.Filename, &f.MIME, &f.Size, &f.Source, &f.CreatedAt); err != nil { + return nil, err + } + f.URL = "/api/files/" + f.ID + out = append(out, f) + } + return out, rows.Err() +} + +type storedCardFile struct { + ID string + CardID string + Filename string + MIME string + Size int64 + StoredPath string +} + +func (db *DB) GetCardFile(id string) (*storedCardFile, error) { + var f storedCardFile + err := db.conn.QueryRow( + `SELECT id, card_id, filename, mime, size, stored_path + FROM card_files + WHERE id = ? AND deleted_at IS NULL`, + id, + ).Scan(&f.ID, &f.CardID, &f.Filename, &f.MIME, &f.Size, &f.StoredPath) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &f, nil +} + +func (db *DB) SoftDeleteCardFile(id string) (int64, error) { + res, err := db.conn.Exec( + `UPDATE card_files SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL`, + nowRFC3339(), id, + ) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func uploadsDir(workdir string) string { + return filepath.Join(workdir, uploadsSubdir) +} + +func safeFilename(name string) string { + name = filepath.Base(name) + name = strings.ReplaceAll(name, string(os.PathSeparator), "_") + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + return "file" + } + return name +} + +func randomFilePrefix() string { + b := make([]byte, 8) + if _, err := rand.Read(b); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// POST /api/cards/{id}/files (multipart, field "file", optional "source") +func handleUploadCardFile(db *DB, workdir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cardID := r.PathValue("id") + if cardID == "" { + badRequest(w, "card id required") + return + } + + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes+1<<20) + if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + badRequest(w, "multipart parse: "+err.Error()) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + badRequest(w, "missing 'file' field: "+err.Error()) + return + } + defer file.Close() + + if header.Size > maxUploadBytes { + infra.HTTPErrorResponse(w, infra.HTTPError{ + Status: http.StatusRequestEntityTooLarge, + Code: "file_too_large", + Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes), + }) + return + } + + source := r.FormValue("source") + switch source { + case "", "upload": + source = "upload" + case "description", "chat": + // keep + default: + source = "upload" + } + + dir := filepath.Join(uploadsDir(workdir), cardID) + if err := os.MkdirAll(dir, 0o755); err != nil { + serverError(w, fmt.Errorf("mkdir uploads: %w", err)) + return + } + + fname := safeFilename(header.Filename) + fileID := newID() + storedPath := filepath.Join(dir, randomFilePrefix()+"__"+fname) + + out, err := os.Create(storedPath) + if err != nil { + serverError(w, fmt.Errorf("create file: %w", err)) + return + } + written, copyErr := io.Copy(out, file) + closeErr := out.Close() + if copyErr != nil { + os.Remove(storedPath) + serverError(w, fmt.Errorf("write file: %w", copyErr)) + return + } + if closeErr != nil { + os.Remove(storedPath) + serverError(w, fmt.Errorf("close file: %w", closeErr)) + return + } + if written > maxUploadBytes { + os.Remove(storedPath) + infra.HTTPErrorResponse(w, infra.HTTPError{ + Status: http.StatusRequestEntityTooLarge, + Code: "file_too_large", + Message: fmt.Sprintf("file exceeds %d bytes", maxUploadBytes), + }) + return + } + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = mime.TypeByExtension(filepath.Ext(fname)) + } + if mimeType == "" { + mimeType = "application/octet-stream" + } + + actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey) + + // Use the random-prefixed path on disk but a stable file id in the DB. + cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written) + if err != nil { + os.Remove(storedPath) + serverError(w, err) + return + } + // We generated newID() in CreateCardFile; align the on-disk filename with that id + // is not required since stored_path is what we serve from. + _ = fileID + + infra.HTTPJSONResponse(w, http.StatusCreated, cf) + } +} + +// GET /api/cards/{id}/files +func handleListCardFiles(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cardID := r.PathValue("id") + if cardID == "" { + badRequest(w, "card id required") + return + } + files, err := db.ListCardFiles(cardID) + if err != nil { + serverError(w, err) + return + } + infra.HTTPJSONResponse(w, http.StatusOK, files) + } +} + +// GET /api/files/{id} +func handleServeFile(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + badRequest(w, "file id required") + return + } + f, err := db.GetCardFile(id) + if err != nil { + serverError(w, err) + return + } + if f == nil { + notFound(w, "file not found") + return + } + fh, err := os.Open(f.StoredPath) + if err != nil { + notFound(w, "file missing on disk") + return + } + defer fh.Close() + if f.MIME != "" { + w.Header().Set("Content-Type", f.MIME) + } + w.Header().Set("Content-Length", fmt.Sprintf("%d", f.Size)) + disposition := "inline" + if !isInlineMIME(f.MIME) { + disposition = "attachment" + } + w.Header().Set("Content-Disposition", fmt.Sprintf(`%s; filename="%s"`, disposition, sanitizeHeaderFilename(f.Filename))) + w.Header().Set("Cache-Control", "private, max-age=3600") + _, _ = io.Copy(w, fh) + } +} + +// DELETE /api/files/{id} +func handleDeleteCardFile(db *DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + badRequest(w, "file id required") + return + } + n, err := db.SoftDeleteCardFile(id) + if err != nil { + serverError(w, err) + return + } + if n == 0 { + notFound(w, "file not found") + return + } + w.WriteHeader(http.StatusNoContent) + } +} + +func isInlineMIME(m string) bool { + if m == "" { + return false + } + m = strings.ToLower(m) + switch { + case strings.HasPrefix(m, "image/"): + return true + case m == "application/pdf": + return true + case strings.HasPrefix(m, "text/"): + return true + } + return false +} + +func sanitizeHeaderFilename(name string) string { + name = strings.ReplaceAll(name, `"`, "") + name = strings.ReplaceAll(name, "\n", "") + name = strings.ReplaceAll(name, "\r", "") + return name +} diff --git a/backend/handlers.go b/backend/handlers.go index 13d74ca..34db7f8 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -629,6 +629,11 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str {Method: "GET", Path: "/api/metrics", Handler: handleMetrics(db)}, {Method: "GET", Path: "/api/tags", Handler: handleListTags(db)}, {Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(db)}, + // Issue 0128: adjuntos de archivos. + {Method: "POST", Path: "/api/cards/{id}/files", Handler: handleUploadCardFile(db, chatWorkdir)}, + {Method: "GET", Path: "/api/cards/{id}/files", Handler: handleListCardFiles(db)}, + {Method: "GET", Path: "/api/files/{id}", Handler: handleServeFile(db)}, + {Method: "DELETE", Path: "/api/files/{id}", Handler: handleDeleteCardFile(db)}, } } diff --git a/backend/migrations/014_card_files.sql b/backend/migrations/014_card_files.sql new file mode 100644 index 0000000..111ad61 --- /dev/null +++ b/backend/migrations/014_card_files.sql @@ -0,0 +1,16 @@ +-- Issue 0128: adjuntos de archivos por card. +CREATE TABLE IF NOT EXISTS card_files ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL, + uploader_id TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL, + mime TEXT NOT NULL DEFAULT '', + size INTEGER NOT NULL DEFAULT 0, + stored_path TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'upload', + created_at TEXT NOT NULL, + deleted_at TEXT +); + +CREATE INDEX IF NOT EXISTS idx_card_files_card_active + ON card_files(card_id, deleted_at); From ac5f016e7ebf7dc7d8e965b75ac13a7988622a03 Mon Sep 17 00:00:00 2001 From: egutierrez Date: Wed, 27 May 2026 10:52:01 +0200 Subject: [PATCH 2/6] 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(); }} /> -