Merge pull request 'feat(kanban): adjuntos de archivos por card (issue 0128)' (#1) from issue/0128-files-attachments into master
This commit was merged in pull request #1.
This commit is contained in:
@@ -16,6 +16,9 @@ frontend/tsconfig.tsbuildinfo
|
|||||||
# Local files
|
# Local files
|
||||||
local_files/
|
local_files/
|
||||||
|
|
||||||
|
# Card file attachments (issue 0128) — binarios en disco; metadata en card_files
|
||||||
|
uploads/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
frontend/test-results/
|
frontend/test-results/
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
name: kanban
|
name: kanban
|
||||||
lang: go
|
lang: go
|
||||||
domain: tools
|
domain: tools
|
||||||
version: 0.1.0
|
version: 0.2.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."
|
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]
|
tags: [service, kanban, web, dnd-kit, mantine, sqlite, time-tracking]
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- random_hex_id_go_core
|
- random_hex_id_go_core
|
||||||
@@ -81,6 +81,10 @@ e2e_checks:
|
|||||||
cmd: "go test -tags fts5 -count=1 ./..."
|
cmd: "go test -tags fts5 -count=1 ./..."
|
||||||
timeout_s: 120
|
timeout_s: 120
|
||||||
expect_exit: 0
|
expect_exit: 0
|
||||||
|
- id: smoke_files
|
||||||
|
cmd: "bash e2e/files_smoke.sh"
|
||||||
|
timeout_s: 30
|
||||||
|
expect_exit: 0
|
||||||
---
|
---
|
||||||
|
|
||||||
## Arquitectura
|
## 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"`).
|
- **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)".
|
- **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.
|
- **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
|
### Build
|
||||||
|
|
||||||
@@ -182,3 +191,4 @@ Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
|||||||
- `patch`: bugfix sin cambio observable.
|
- `patch`: bugfix sin cambio observable.
|
||||||
|
|
||||||
- v0.1.0 (2026-05-18) — baseline.
|
- 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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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/metrics", Handler: handleMetrics(db)},
|
||||||
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
{Method: "GET", Path: "/api/tags", Handler: handleListTags(db)},
|
||||||
{Method: "GET", Path: "/api/requesters", Handler: handleListRequesters(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 {
|
import type {
|
||||||
Board,
|
Board,
|
||||||
Card,
|
Card,
|
||||||
|
CardFile,
|
||||||
CardHistoryResponse,
|
CardHistoryResponse,
|
||||||
CardMessage,
|
CardMessage,
|
||||||
Column,
|
Column,
|
||||||
@@ -380,6 +381,42 @@ export function listRequesters(): Promise<string[]> {
|
|||||||
return fetchJSON("/requesters");
|
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> {
|
export function getMetrics(f: MetricsFilter): Promise<Metrics> {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (f.from) qs.set("from", f.from);
|
if (f.from) qs.set("from", f.from);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ActionIcon,
|
ActionIcon,
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
Box,
|
||||||
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -11,26 +12,35 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconSend, IconTrash } from "@tabler/icons-react";
|
import { IconPaperclip, IconSend, IconTrash } from "@tabler/icons-react";
|
||||||
import { notifications } from "@mantine/notifications";
|
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 * as api from "../api";
|
||||||
import type { CardMessage, User } from "../types";
|
import type { CardMessage, User } from "../types";
|
||||||
import { tagColor } from "./colors";
|
import { tagColor } from "./colors";
|
||||||
import { formatDateTimeShort } from "./format";
|
import { formatDateTimeShort } from "./format";
|
||||||
|
import { MessageBody } from "./MessageBody";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
onMessagesChange?: (messages: CardMessage[]) => void;
|
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 [messages, setMessages] = useState<CardMessage[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const usersById = new Map(users.map((u) => [u.id, u]));
|
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 (
|
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
|
<ScrollArea
|
||||||
viewportRef={viewportRef}
|
viewportRef={viewportRef}
|
||||||
style={{ flex: 1, minHeight: 200 }}
|
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>
|
<Group justify="center" p="md"><Loader size="sm" /></Group>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<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>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={6} p={4}>
|
<Stack gap={6} p={4}>
|
||||||
@@ -138,9 +202,9 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
<Stack gap={4}>
|
||||||
{m.body}
|
<MessageBody text={m.body} />
|
||||||
</Text>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -154,13 +218,29 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
value={body}
|
value={body}
|
||||||
onChange={(e) => setBody(e.currentTarget.value)}
|
onChange={(e) => setBody(e.currentTarget.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Escribe un mensaje (Enter = enviar, Shift+Enter = salto)"
|
placeholder="Escribe un mensaje. Arrastra archivos o usa el clip."
|
||||||
autosize
|
autosize
|
||||||
minRows={1}
|
minRows={1}
|
||||||
maxRows={6}
|
maxRows={6}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={sending}
|
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>
|
<Tooltip label="Enviar" withArrow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -174,6 +254,24 @@ export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</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>
|
</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 { IconLink, IconMessage, IconPaperclip } from "@tabler/icons-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { Card, CardMessage, User } from "../types";
|
import type { Card, CardMessage, User } from "../types";
|
||||||
import { CardChatPanel } from "./CardChatPanel";
|
import { CardChatPanel } from "./CardChatPanel";
|
||||||
|
import { CardFilesPanel } from "./CardFilesPanel";
|
||||||
import { CardLinksPanel } from "./CardLinksPanel";
|
import { CardLinksPanel } from "./CardLinksPanel";
|
||||||
import { CardForm, CardFormValues } from "./CardForm";
|
import { CardForm, CardFormValues } from "./CardForm";
|
||||||
|
|
||||||
@@ -27,12 +28,15 @@ export function CardEditPanel({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const [messages, setMessages] = useState<CardMessage[]>([]);
|
const [messages, setMessages] = useState<CardMessage[]>([]);
|
||||||
const [liveCard, setLiveCard] = useState(card);
|
const [liveCard, setLiveCard] = useState(card);
|
||||||
|
const [filesRefreshKey, setFilesRefreshKey] = useState(0);
|
||||||
|
|
||||||
const wrappedSubmit = async (v: CardFormValues) => {
|
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 }));
|
setLiveCard((c) => ({ ...c, title: v.title, description: v.description, requester: v.requester, tags: v.tags, assignee_id: v.assignee_id }));
|
||||||
await onSubmit(v);
|
await onSubmit(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const bumpFiles = () => setFilesRefreshKey((k) => k + 1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
|
||||||
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
|
||||||
@@ -48,6 +52,8 @@ export function CardEditPanel({
|
|||||||
tags: liveCard.tags || [],
|
tags: liveCard.tags || [],
|
||||||
}}
|
}}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
|
cardId={liveCard.id}
|
||||||
|
onFileUploaded={bumpFiles}
|
||||||
onSubmit={wrappedSubmit}
|
onSubmit={wrappedSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
@@ -58,7 +64,7 @@ export function CardEditPanel({
|
|||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="chat" leftSection={<IconMessage size={14} />}>Chat</Tabs.Tab>
|
<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="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>
|
</Tabs.List>
|
||||||
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
|
||||||
@@ -68,6 +74,7 @@ export function CardEditPanel({
|
|||||||
users={users}
|
users={users}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
onMessagesChange={setMessages}
|
onMessagesChange={setMessages}
|
||||||
|
onFileUploaded={bumpFiles}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
@@ -75,9 +82,7 @@ export function CardEditPanel({
|
|||||||
<CardLinksPanel card={liveCard} messages={messages} />
|
<CardLinksPanel card={liveCard} messages={messages} />
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
<Tabs.Panel value="files">
|
<Tabs.Panel value="files">
|
||||||
<Text size="sm" c="dimmed" ta="center" p="md">
|
<CardFilesPanel cardId={liveCard.id} refreshKey={filesRefreshKey} />
|
||||||
Proximamente: adjuntos de archivos.
|
|
||||||
</Text>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Box>
|
</Box>
|
||||||
</Tabs>
|
</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 { Autocomplete, Box, Button, Group, Select, Stack, TagsInput, Text, Textarea } from "@mantine/core";
|
||||||
import { FormEvent, KeyboardEvent, useState } from "react";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { DragEvent, FormEvent, KeyboardEvent, useRef, useState } from "react";
|
||||||
|
import * as api from "../api";
|
||||||
import type { User } from "../types";
|
import type { User } from "../types";
|
||||||
|
|
||||||
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
// CardForm: Solicitante (Autocomplete) intencionadamente NO hace submit en Enter.
|
||||||
@@ -21,16 +23,25 @@ interface Props {
|
|||||||
users?: User[];
|
users?: User[];
|
||||||
requesterOptions?: string[];
|
requesterOptions?: string[];
|
||||||
tagOptions?: string[];
|
tagOptions?: string[];
|
||||||
|
cardId?: string;
|
||||||
|
onFileUploaded?: () => void;
|
||||||
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
onSubmit: (v: CardFormValues) => Promise<void> | void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markdownRef(filename: string, url: string, isImage: boolean): string {
|
||||||
|
const safeName = filename.replace(/]/g, "");
|
||||||
|
return isImage ? `` : `[${safeName}](${url})`;
|
||||||
|
}
|
||||||
|
|
||||||
export function CardForm({
|
export function CardForm({
|
||||||
initial,
|
initial,
|
||||||
submitLabel = "Guardar",
|
submitLabel = "Guardar",
|
||||||
users = [],
|
users = [],
|
||||||
requesterOptions = [],
|
requesterOptions = [],
|
||||||
tagOptions = [],
|
tagOptions = [],
|
||||||
|
cardId,
|
||||||
|
onFileUploaded,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -39,6 +50,9 @@ export function CardForm({
|
|||||||
const [description, setDescription] = useState(initial?.description ?? "");
|
const [description, setDescription] = useState(initial?.description ?? "");
|
||||||
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
const [assigneeId, setAssigneeId] = useState<string | null>(initial?.assignee_id ?? null);
|
||||||
const [tags, setTags] = useState<string[]>(initial?.tags ?? []);
|
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) => {
|
const submit = async (e?: FormEvent) => {
|
||||||
e?.preventDefault();
|
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 (
|
return (
|
||||||
<form onSubmit={submit}>
|
<form onSubmit={submit}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
@@ -95,17 +169,48 @@ export function CardForm({
|
|||||||
if (e.key === "Enter") e.preventDefault();
|
if (e.key === "Enter") e.preventDefault();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Box
|
||||||
label="Descripcion"
|
onDrop={onDrop}
|
||||||
value={description}
|
onDragOver={onDragOver}
|
||||||
onChange={(e) => setDescription(e.currentTarget.value)}
|
onDragLeave={onDragLeave}
|
||||||
tabIndex={3}
|
style={{
|
||||||
autosize
|
position: "relative",
|
||||||
minRows={3}
|
outline: dragOver ? "2px dashed var(--mantine-color-blue-5)" : undefined,
|
||||||
maxRows={8}
|
outlineOffset: dragOver ? 2 : undefined,
|
||||||
onKeyDown={textareaEnter}
|
borderRadius: 4,
|
||||||
description="Ctrl+Enter para guardar"
|
}}
|
||||||
/>
|
>
|
||||||
|
<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
|
<Select
|
||||||
label="Asignar a"
|
label="Asignar a"
|
||||||
placeholder="Sin asignar"
|
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;
|
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 {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user