feat(kanban): adjuntos de archivos por card (issue 0128) #1

Merged
dataforge merged 6 commits from issue/0128-files-attachments into master 2026-05-27 09:04:38 +00:00
16 changed files with 2382 additions and 1281 deletions
+3
View File
@@ -16,6 +16,9 @@ frontend/tsconfig.tsbuildinfo
# Local files
local_files/
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
uploads/
# Logs
*.log
frontend/test-results/
+13 -3
View File
@@ -2,8 +2,8 @@
name: kanban
lang: go
domain: tools
version: 0.1.0
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit) y tracking del tiempo que cada tarjeta pasa en cada columna. Frontend Vite + React + Mantine v9 embebido en el binario Go."
version: 0.2.0
description: "Kanban board con persistencia SQLite, drag-and-drop entre columnas (dnd-kit), tracking del tiempo por columna y adjuntos de archivos por card (drag&drop en descripcion y chat). Frontend Vite + React + Mantine v9 embebido en el binario Go."
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
uses_functions:
- random_hex_id_go_core
@@ -81,6 +81,10 @@ e2e_checks:
cmd: "go test -tags fts5 -count=1 ./..."
timeout_s: 120
expect_exit: 0
- id: smoke_files
cmd: "bash e2e/files_smoke.sh"
timeout_s: 30
expect_exit: 0
---
## Arquitectura
@@ -143,7 +147,12 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- **Enlaces** (`CardLinksPanel`): extrae URLs (`https?://...`) de titulo, descripcion y cuerpo de cada mensaje del chat. Deduplica, muestra hostname + URL completa + badge de origen. Click abre en pestaña nueva (`target="_blank"`).
- **Duplicar card:** click derecho sobre la card abre el menu contextual (mismo que el boton `⋮`), donde aparece el item "Duplicar". Al pulsarlo invoca `POST /api/cards/{id}/duplicate`. La copia se inserta al final de la misma columna con titulo + " (copia)".
- **Sesion obligatoria para chat:** `POST/DELETE /api/cards/{id}/messages` exige sesion activa (401 si falta). `author_id` siempre poblado; no hay comentarios anonimos.
- **Archivos (proximamente):** blobs persistidos en SQLite (`card_attachments` con `BLOB`), no en filesystem.
- **Archivos** (`CardFilesPanel`): adjuntos por card almacenados en `apps/kanban/uploads/<card_id>/<random>__<safe_filename>` (filesystem, gitignored). Tabla `card_files` con soft-delete. Limite 10 MB por archivo. Tres vias de upload:
1. Drag&drop en el editor de descripcion (`CardForm`) → inserta `![name](url)` (imagen) o `[name](url)` (resto) en la posicion del cursor.
2. Drag&drop o boton paperclip en el chat (`CardChatPanel`) → crea un mensaje cuyo cuerpo es la ref markdown.
3. Boton "Subir" en el tab Archivos → sube sin embed.
- El renderer de mensajes (`MessageBody`) reconoce `![alt](url)` -> `<Image>` thumb 220px y `[name](url)` -> `<Anchor>`. Texto plano se renderiza con `whiteSpace: pre-wrap`.
- Endpoints: `POST /api/cards/{id}/files` (multipart, 10 MB max), `GET /api/cards/{id}/files`, `GET /api/files/{id}` (sirve binario con `inline` o `attachment` segun MIME), `DELETE /api/files/{id}` (soft delete).
### Build
@@ -182,3 +191,4 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
- `patch`: bugfix sin cambio observable.
- v0.1.0 (2026-05-18) — baseline.
- v0.2.0 (2026-05-27) — adjuntos de archivos por card (issue 0128): tabla `card_files` con soft-delete, endpoints REST (`POST/GET/DELETE /api/cards/{id}/files`, `GET/DELETE /api/files/{id}`), tres vias de upload (drag&drop en descripcion y chat, boton en tab Archivos), render inline de imagenes via `MessageBody`. Limite 10 MB.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-D_Kep7Fb.js"></script>
<script type="module" crossorigin src="/assets/index-DT3pghXY.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+346
View File
@@ -0,0 +1,346 @@
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)
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)
cf, err := db.CreateCardFile(cardID, actor, fname, mimeType, storedPath, source, written)
if err != nil {
os.Remove(storedPath)
serverError(w, err)
return
}
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
}
+5
View File
@@ -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)},
}
}
+16
View File
@@ -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);
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Issue 0128 — smoke test of card file attachments.
# Builds kanban (assumes ./kanban present or builds it), boots on ephemeral port,
# exercises POST upload, GET list, GET serve, DELETE, GET list-after-delete.
# Exits 0 on success, non-zero on any failure.
set -euo pipefail
PORT=${PORT:-18095}
BASE="http://127.0.0.1:${PORT}"
DB=$(mktemp /tmp/kanban_files_smoke.XXXXXX.db)
COOKIE=$(mktemp /tmp/kanban_files_smoke.cookie.XXXXXX)
UPLOAD_DIR=$(dirname "$DB")/uploads
PNG=$(mktemp /tmp/kanban_files_smoke.XXXXXX.png)
PID_FILE=$(mktemp /tmp/kanban_files_smoke.pid.XXXXXX)
cleanup() {
if [ -s "$PID_FILE" ]; then
kill "$(cat "$PID_FILE")" 2>/dev/null || true
fi
rm -f "$DB" "$DB-shm" "$DB-wal" "$COOKIE" "$PNG" "$PID_FILE"
rm -rf "$UPLOAD_DIR"
}
trap cleanup EXIT
# Build if missing.
if [ ! -x ./kanban ]; then
echo "[smoke] building kanban binary..."
(cd backend && CGO_ENABLED=1 go build -tags fts5 -o ../kanban .)
fi
# Boot.
./kanban --port "$PORT" --db "$DB" --initial-admin admin:adminpw \
> /tmp/kanban_files_smoke.log 2>&1 &
echo $! > "$PID_FILE"
# Wait for /api/board.
for _ in $(seq 1 30); do
if curl -sf -o /dev/null "$BASE/api/board"; then break; fi
sleep 0.2
done
# Login.
curl -sf -c "$COOKIE" -X POST "$BASE/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"adminpw"}' > /dev/null
# Column + card.
COL=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/columns" \
-H 'Content-Type: application/json' \
-d '{"name":"To Do"}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
CARD=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards" \
-H 'Content-Type: application/json' \
-d "{\"column_id\":\"$COL\",\"title\":\"smoke\",\"requester\":\"r\"}" \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
# Minimal PNG.
printf '\x89PNG\r\n\x1a\n' > "$PNG"
# Upload.
UP=$(curl -sf -b "$COOKIE" -X POST "$BASE/api/cards/$CARD/files" \
-F "file=@$PNG;type=image/png")
FID=$(echo "$UP" | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
[ -n "$FID" ] || { echo "[smoke] upload missing id"; exit 1; }
# List active.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "1" ] || { echo "[smoke] expected 1 file, got $N"; exit 1; }
# Serve.
CT=$(curl -sf -b "$COOKIE" -I "$BASE/api/files/$FID" | awk '/^[Cc]ontent-[Tt]ype/ {print $2}' | tr -d '\r\n')
echo "$CT" | grep -q image/png || { echo "[smoke] wrong content-type: $CT"; exit 1; }
# Delete.
HTTP=$(curl -sb "$COOKIE" -X DELETE -o /dev/null -w "%{http_code}" "$BASE/api/files/$FID")
[ "$HTTP" = "204" ] || { echo "[smoke] expected 204 on delete, got $HTTP"; exit 1; }
# List after delete.
N=$(curl -sf -b "$COOKIE" "$BASE/api/cards/$CARD/files" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')
[ "$N" = "0" ] || { echo "[smoke] expected 0 after delete, got $N"; exit 1; }
echo "[smoke] OK"
+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"
+126
View File
@@ -0,0 +1,126 @@
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;
// Allow only safe URL schemes. Reject javascript:, data:text/html, vbscript:, etc.
// Accepts: absolute http(s), protocol-relative //, and same-origin paths (/...).
function safeURL(url: string): string | null {
const u = url.trim();
if (u.startsWith("/")) return u;
if (/^https?:\/\//i.test(u)) return u;
return null;
}
// data: scheme is allowed only when the MIME prefix is image/.
function safeImageURL(url: string): string | null {
const safe = safeURL(url);
if (safe) return safe;
const u = url.trim();
if (/^data:image\/[a-z0-9.+-]+(;[a-z0-9-]+=[^,]+)*;base64,/i.test(u)) return u;
return null;
}
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]) {
const url = safeImageURL(m[3]);
if (url) {
out.push({ kind: "img", alt: m[2] || "", url });
} else {
out.push({ kind: "text", value: m[0] });
}
} else if (m[4]) {
const url = safeURL(m[6]);
if (url) {
out.push({ kind: "link", label: m[5], url });
} else {
out.push({ kind: "text", value: m[0] });
}
}
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;