feat(kanban): adjuntos de archivos por card (issue 0128) #1
@@ -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/
|
||||
|
||||
@@ -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 `` (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 `` -> `<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.
|
||||
|
||||
+1283
File diff suppressed because one or more lines are too long
-1250
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Executable
+82
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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})`;
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
@@ -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:
|
||||
//  -> <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}</>;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user