chore: auto-commit (23 archivos)

- app.md
- backend/auth.go
- backend/db.go
- backend/dist/assets/index-CPqSy0gZ.js
- backend/dist/index.html
- backend/handlers.go
- backend/main.go
- frontend/src/App.tsx
- frontend/src/api.ts
- frontend/src/components/KanbanCard.tsx
- ...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 18:40:22 +02:00
parent f1ee116d3b
commit a34a8142cc
23 changed files with 2034 additions and 1184 deletions
+23 -1
View File
@@ -79,7 +79,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
./kanban --port 8095 --db kanban.db
```
### Schema SQLite (`migrations/001_init.sql`)
### Schema SQLite (`migrations/001_init.sql` … `010_card_messages.sql`)
- **columns** — id, name, position, created_at
- **cards** — id, title, description, column_id (FK), position, created_at, updated_at
@@ -87,6 +87,7 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- Una entrada con `exited_at IS NULL` = posicion actual
- Al mover una tarjeta a otra columna: cierra la entrada activa (`exited_at = now`) e inserta una nueva
- El borrado de tarjeta hace CASCADE sobre el historial
- **card_messages** (migration 010) — id, card_id (FK CASCADE), author_id (nullable), body, created_at. Comentarios humano-a-humano por card; distintos de `card_events` (sistema) y `/api/chat` (LLM global).
### API REST
@@ -101,7 +102,21 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
| PATCH | `/api/cards/{id}` | `{title?, description?}` |
| DELETE | `/api/cards/{id}` | — |
| POST | `/api/cards/{id}/move` | `{column_id, ordered_ids: [...]}` |
| POST | `/api/cards/{id}/duplicate` | — (clona la card en la misma columna al final; copia titulo+" (copia)", descripcion, color, requester, assignee, tags, stickers, deadline; NO copia historial ni mensajes) |
| GET | `/api/cards/{id}/messages` | — (lista de comentarios humano-a-humano de la card) |
| POST | `/api/cards/{id}/messages` | `{body}` (crea comentario; author = usuario de la sesion) |
| DELETE | `/api/cards/{cid}/messages/{mid}` | — (solo el autor puede borrar su mensaje) |
| GET | `/api/cards/{id}/history` | — (timeline con duraciones por columna) |
| GET | `/api/flags` | — (retorna `{ <name>: bool }` con los feature flags efectivos en esta instancia) |
| POST | `/api/auth/register` | `{username, password, display_name?}` (devuelve 403 `registration_disabled` si el flag `registration-enabled` esta en `false`) |
### Feature flags
`dev/feature_flags.json` (lado del repo) define los flags por instancia. Se cargan al arrancar (override con `--flags <path>`); fichero ausente equivale a "todos los flags en `false`". El endpoint `GET /api/flags` expone el estado actual para que el frontend oculte UI condicional (ej. el toggle de "Registrate" en `LoginPage` solo aparece cuando `registration-enabled` es `true`).
| Flag | Default | Efecto cuando esta en `true` |
|---|---|---|
| `registration-enabled` | `false` | Permite crear cuentas nuevas via `POST /api/auth/register` y muestra el toggle "Registrate" en la pantalla de login. |
### Frontend
@@ -110,6 +125,13 @@ Single-binary: backend Go con frontend Vite embebido. SQLite local con tres tabl
- **Modales** con `@mantine/modals` (confirmacion borrado, history timeline).
- Time-in-column live: `time_in_column_ms` del backend + tick local cada segundo para que el badge se actualice sin reload.
- DnD con `closestCorners` + `DragOverlay` para feedback visual al arrastrar.
- **Auto-refresh:** el board se recarga cada 30s (`api.getBoard`) sin interaccion del usuario; equivalente a pulsar el boton de refresco. El tick de 1s del time-in-column es independiente y no toca red.
- **Modal de card en dos columnas** (`CardEditPanel`): izquierda mantiene `CardForm` (titulo, solicitante, descripcion, asignacion, tags); derecha es un `Tabs` con `Chat` (por defecto) | `Enlaces` | `Archivos` (proximamente). Tamaño del modal: 85% del viewport.
- **Chat per-card** (`CardChatPanel`): lista de comentarios humano-a-humano persistidos en `card_messages`. Enter envia, Shift+Enter salto de linea. Solo el autor puede borrar su propio mensaje.
- **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.
### Build
+5 -1
View File
@@ -30,8 +30,12 @@ func tokenFromRequest(r *http.Request) string {
}
// POST /api/auth/register {username, password, display_name?}
func handleRegister(db *DB) http.HandlerFunc {
func handleRegister(db *DB, flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !flags.Enabled("registration-enabled") {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusForbidden, Code: "registration_disabled", Message: "user registration is disabled on this instance"})
return
}
var body struct {
Username string `json:"username"`
Password string `json:"password"`
+168
View File
@@ -1031,3 +1031,171 @@ func (db *DB) CardHistory(cardID string) (*CardHistoryResponse, error) {
CurrentlyLock: currently,
}, nil
}
type CardMessage struct {
ID string `json:"id"`
CardID string `json:"card_id"`
AuthorID *string `json:"author_id"`
Body string `json:"body"`
CreatedAt string `json:"created_at"`
}
func (db *DB) ListCardMessages(cardID string) ([]CardMessage, error) {
rows, err := db.conn.Query(
`SELECT id, card_id, author_id, body, created_at FROM card_messages WHERE card_id=? ORDER BY created_at`,
cardID,
)
if err != nil {
return nil, err
}
defer rows.Close()
out := []CardMessage{}
for rows.Next() {
var m CardMessage
var author sql.NullString
if err := rows.Scan(&m.ID, &m.CardID, &author, &m.Body, &m.CreatedAt); err != nil {
return nil, err
}
if author.Valid && author.String != "" {
s := author.String
m.AuthorID = &s
}
out = append(out, m)
}
return out, rows.Err()
}
func (db *DB) CreateCardMessage(cardID, authorID, body string) (*CardMessage, error) {
body = strings.TrimSpace(body)
if body == "" {
return nil, fmt.Errorf("body required")
}
if authorID == "" {
return nil, fmt.Errorf("author required")
}
var exists int
if err := db.conn.QueryRow(`SELECT 1 FROM cards WHERE id=?`, cardID).Scan(&exists); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
s := authorID
m := &CardMessage{ID: newID(), CardID: cardID, AuthorID: &s, Body: body, CreatedAt: nowRFC3339()}
if _, err := db.conn.Exec(
`INSERT INTO card_messages (id, card_id, author_id, body, created_at) VALUES (?, ?, ?, ?, ?)`,
m.ID, m.CardID, authorID, m.Body, m.CreatedAt,
); err != nil {
return nil, err
}
return m, nil
}
func (db *DB) DeleteCardMessage(id, requesterID string) error {
if requesterID == "" {
return fmt.Errorf("session required")
}
res, err := db.conn.Exec(`DELETE FROM card_messages WHERE id=? AND author_id=?`, id, requesterID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("not found or not author")
}
return nil
}
// DuplicateCard clones a card into the same column at the end of the list.
// Copies title, description, color, requester, assignee, tags, deadline, stickers.
// Does NOT copy card_column_history, card_lock_history, card_events, card_messages.
// Title gets " (copia)" suffix.
func (db *DB) DuplicateCard(srcID, actorID string) (*Card, error) {
tx, err := db.conn.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var src Card
var assignee sql.NullString
var deadline sql.NullString
var tagsJSON, stickersJSON string
if err := tx.QueryRow(
`SELECT requester, title, description, color, column_id, assignee_id, tags, stickers, deadline
FROM cards WHERE id=? AND deleted_at IS NULL`, srcID,
).Scan(&src.Requester, &src.Title, &src.Description, &src.Color, &src.ColumnID, &assignee, &tagsJSON, &stickersJSON, &deadline); err != nil {
return nil, fmt.Errorf("card not found: %w", err)
}
if assignee.Valid && assignee.String != "" {
s := assignee.String
src.AssigneeID = &s
}
if deadline.Valid && deadline.String != "" {
s := deadline.String
src.Deadline = &s
}
src.Tags = parseTags(tagsJSON)
src.Stickers = parseStickers(stickersJSON)
var maxPos sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(position) FROM cards WHERE column_id=?`, src.ColumnID).Scan(&maxPos); err != nil {
return nil, err
}
pos := 0
if maxPos.Valid {
pos = int(maxPos.Int64) + 1
}
var maxSeq sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(seq_num) FROM cards`).Scan(&maxSeq); err != nil {
return nil, err
}
seqNum := 1
if maxSeq.Valid {
seqNum = int(maxSeq.Int64) + 1
}
now := nowRFC3339()
newTitle := src.Title + " (copia)"
c := Card{
ID: newID(), SeqNum: seqNum, Requester: src.Requester, Title: newTitle,
Description: src.Description, Color: src.Color, ColumnID: src.ColumnID, Position: pos,
AssigneeID: src.AssigneeID, Tags: src.Tags, Stickers: src.Stickers, Deadline: src.Deadline,
CreatedAt: now, UpdatedAt: now, EnteredAt: now,
}
var assigneeVal any
if c.AssigneeID != nil && *c.AssigneeID != "" {
assigneeVal = *c.AssigneeID
}
var deadlineVal any
if c.Deadline != nil && *c.Deadline != "" {
deadlineVal = *c.Deadline
}
if _, err := tx.Exec(
`INSERT INTO cards (id, seq_num, requester, title, description, color, column_id, position, assignee_id, tags, stickers, deadline, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
c.ID, c.SeqNum, c.Requester, c.Title, c.Description, c.Color, c.ColumnID, c.Position,
assigneeVal, encodeTags(c.Tags), encodeStickers(c.Stickers), deadlineVal, c.CreatedAt, c.UpdatedAt,
); err != nil {
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO card_column_history (id, card_id, column_id, entered_at, actor_id) VALUES (?, ?, ?, ?, ?)`,
newID(), c.ID, c.ColumnID, now, nullableActor(actorID),
); err != nil {
return nil, err
}
var destDone int
if err := tx.QueryRow(`SELECT is_done FROM columns WHERE id=?`, c.ColumnID).Scan(&destDone); err != nil {
return nil, err
}
if destDone == 1 {
if _, err := tx.Exec(`UPDATE cards SET completed_at=? WHERE id=?`, now, c.ID); err != nil {
return nil, err
}
c.CompletedAt = &now
}
if err := insertCardEvent(tx, c.ID, "created", actorID, map[string]any{"title": newTitle, "column_id": c.ColumnID, "duplicated_from": srcID}); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &c, nil
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-CPqSy0gZ.js"></script>
<script type="module" crossorigin src="/assets/index-BETde3Km.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
</head>
<body>
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"encoding/json"
"net/http"
"os"
"fn-registry/functions/infra"
)
type FeatureFlag struct {
Enabled bool `json:"enabled"`
Issue string `json:"issue,omitempty"`
Description string `json:"description"`
Added string `json:"added,omitempty"`
EnabledAt string `json:"enabled_at,omitempty"`
}
type FeatureFlags struct {
Flags map[string]FeatureFlag `json:"flags"`
}
func (f FeatureFlags) Enabled(name string) bool {
flag, ok := f.Flags[name]
return ok && flag.Enabled
}
func loadFeatureFlags(path string) (FeatureFlags, error) {
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return FeatureFlags{Flags: map[string]FeatureFlag{}}, nil
}
return FeatureFlags{}, err
}
var f FeatureFlags
if err := json.Unmarshal(b, &f); err != nil {
return FeatureFlags{}, err
}
if f.Flags == nil {
f.Flags = map[string]FeatureFlag{}
}
return f, nil
}
// GET /api/flags → { "<name>": true/false, ... }
func handleListFlags(flags *FeatureFlags) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := make(map[string]bool, len(flags.Flags))
for name, fl := range flags.Flags {
out[name] = fl.Enabled
}
infra.HTTPJSONResponse(w, http.StatusOK, out)
}
}
+92 -2
View File
@@ -280,6 +280,91 @@ func handleMoveCard(db *DB) http.HandlerFunc {
}
}
// GET /api/cards/{id}/messages → [CardMessage, ...]
func handleListCardMessages(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
msgs, err := db.ListCardMessages(id)
if err != nil {
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusOK, msgs)
}
}
// POST /api/cards/{id}/messages { body }
func handleCreateCardMessage(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var body struct {
Body string `json:"body"`
}
if err := infra.HTTPParseBody(r, &body, maxBodyBytes); err != nil {
badRequest(w, err.Error())
return
}
if strings.TrimSpace(body.Body) == "" {
badRequest(w, "body required")
return
}
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
m, err := db.CreateCardMessage(id, actor, body.Body)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, m)
}
}
// DELETE /api/cards/{cid}/messages/{mid}
func handleDeleteCardMessage(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
mid := r.PathValue("mid")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
if actor == "" {
infra.HTTPErrorResponse(w, infra.HTTPError{Status: http.StatusUnauthorized, Code: "unauthorized", Message: "session required"})
return
}
if err := db.DeleteCardMessage(mid, actor); err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, err.Error())
return
}
serverError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// POST /api/cards/{id}/duplicate
func handleDuplicateCard(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
actor, _ := infra.UserIDFromContext(r.Context(), userCtxKey)
c, err := db.DuplicateCard(id, actor)
if err != nil {
if strings.Contains(err.Error(), "not found") {
notFound(w, "card not found")
return
}
serverError(w, err)
return
}
infra.HTTPJSONResponse(w, http.StatusCreated, c)
}
}
// GET /api/cards/{id}/history → [HistoryEntry, ...]
func handleCardHistory(db *DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -330,9 +415,10 @@ func handlePurgeCard(db *DB) http.HandlerFunc {
}
}
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string) []infra.Route {
func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken string, flags *FeatureFlags) []infra.Route {
return []infra.Route{
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db)},
{Method: "GET", Path: "/api/flags", Handler: handleListFlags(flags)},
{Method: "POST", Path: "/api/auth/register", Handler: handleRegister(db, flags)},
{Method: "POST", Path: "/api/auth/login", Handler: handleLogin(db)},
{Method: "POST", Path: "/api/auth/logout", Handler: handleLogout(db)},
{Method: "GET", Path: "/api/me", Handler: handleMe(db)},
@@ -348,6 +434,10 @@ func apiRoutes(db *DB, chatWorkdir string, logger *ChatLogger, internalToken str
{Method: "PUT", Path: "/api/cards/{id}/stickers", Handler: handleUpdateCardStickers(db)},
{Method: "DELETE", Path: "/api/cards/{id}", Handler: handleDeleteCard(db)},
{Method: "POST", Path: "/api/cards/{id}/move", Handler: handleMoveCard(db)},
{Method: "POST", Path: "/api/cards/{id}/duplicate", Handler: handleDuplicateCard(db)},
{Method: "GET", Path: "/api/cards/{id}/messages", Handler: handleListCardMessages(db)},
{Method: "POST", Path: "/api/cards/{id}/messages", Handler: handleCreateCardMessage(db)},
{Method: "DELETE", Path: "/api/cards/{id}/messages/{mid}", Handler: handleDeleteCardMessage(db)},
{Method: "GET", Path: "/api/cards/{id}/history", Handler: handleCardHistory(db)},
{Method: "GET", Path: "/api/trash", Handler: handleListTrash(db)},
{Method: "POST", Path: "/api/cards/{id}/restore", Handler: handleRestoreCard(db)},
+11 -2
View File
@@ -35,8 +35,17 @@ func main() {
port := flags.Int("port", 8095, "HTTP port")
dbPath := flags.String("db", "operations.db", "SQLite database path")
initialAdmin := flags.String("initial-admin", os.Getenv("KANBAN_INITIAL_ADMIN"), "Bootstrap admin in user:pass form (only if no users yet)")
flagsPath := flags.String("flags", "dev/feature_flags.json", "Feature flags JSON path (missing file → all disabled)")
flags.Parse(os.Args[1:])
featureFlags, err := loadFeatureFlags(*flagsPath)
if err != nil {
log.Fatalf("load feature flags: %v", err)
}
for name, fl := range featureFlags.Flags {
log.Printf("feature flag %q enabled=%v", name, fl.Enabled)
}
db, err := openDB(*dbPath)
if err != nil {
log.Fatalf("open db: %v", err)
@@ -54,7 +63,7 @@ func main() {
wd := chatWorkdir(*dbPath)
logger := newChatLogger(filepath.Join(wd, "chat.log"))
log.Printf("chat tool log: %s", logger.path)
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken))
mux := infra.HTTPRouter(apiRoutes(db, wd, logger, internalToken, &featureFlags))
feHandler := frontendHandler()
if feHandler != nil {
@@ -67,7 +76,7 @@ func main() {
authMW := infra.HTTPSessionCookieMiddleware(infra.SessionCookieConfig{
DB: db.conn,
CookieName: cookieName,
SkipPaths: []string{"/api/auth/", "/api/tool/", "/health", "/assets/", "/index.html"},
SkipPaths: []string{"/api/auth/", "/api/tool/", "/api/flags", "/health", "/assets/", "/index.html"},
UserCtxKey: userCtxKey,
})
+14
View File
@@ -0,0 +1,14 @@
-- Per-card chat messages (human-to-human comments).
-- Distinct from card_events (which records system events like title_changed)
-- and from /api/chat (which is the board-level LLM chat).
CREATE TABLE IF NOT EXISTS card_messages (
id TEXT PRIMARY KEY,
card_id TEXT NOT NULL,
author_id TEXT,
body TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (card_id) REFERENCES cards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_card_messages_card ON card_messages(card_id, created_at);
+11
View File
@@ -0,0 +1,11 @@
{
"flags": {
"registration-enabled": {
"enabled": false,
"issue": null,
"description": "Allows new users to register via POST /api/auth/register and the LoginPage register toggle.",
"added": "2026-05-12",
"enabled_at": null
}
}
}
+25 -11
View File
@@ -68,6 +68,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as api from "./api";
import { useAuth } from "./auth";
import { CardForm } from "./components/CardForm";
import { CardEditPanel } from "./components/CardEditPanel";
import { ChatPanel } from "./components/ChatPanel";
import { CalendarView } from "./components/CalendarView";
import { Dashboard } from "./components/Dashboard";
@@ -238,6 +239,13 @@ export function App() {
return () => clearInterval(t);
}, []);
useEffect(() => {
const t = setInterval(() => {
reload();
}, 30000);
return () => clearInterval(t);
}, [reload]);
useEffect(() => {
if (!activeSticker) return;
const onKey = (e: KeyboardEvent) => {
@@ -566,20 +574,14 @@ export function App() {
const openEditCard = useCallback((card: Card) => {
const id = modals.open({
title: "Editar tarjeta",
size: "md",
size: "85%",
children: (
<CardForm
<CardEditPanel
card={card}
users={users}
currentUserId={auth.user?.id}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: card.requester,
title: card.title,
description: card.description,
assignee_id: card.assignee_id,
tags: card.tags || [],
}}
submitLabel="Guardar"
onCancel={() => modals.close(id)}
onSubmit={async (v) => {
try {
@@ -601,7 +603,17 @@ export function App() {
/>
),
});
}, [reload, users, requesterOptions, tagOptions]);
}, [reload, users, auth.user, requesterOptions, tagOptions]);
const handleDuplicateCard = useCallback(async (cardId: string) => {
try {
const dup = await api.duplicateCard(cardId);
await reload();
notifications.show({ color: "teal", message: `Duplicada: ${dup.title}` });
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
}, [reload]);
const handleSetRequester = useCallback(async (id: string, requester: string) => {
setBoard((prev) => {
@@ -986,6 +998,7 @@ export function App() {
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onDuplicateCard={handleDuplicateCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
@@ -1253,6 +1266,7 @@ export function App() {
onToggleDone={handleToggleDone}
onEditCard={openEditCard}
onDeleteCard={handleDeleteCard}
onDuplicateCard={handleDuplicateCard}
onChangeCardColor={handleChangeCardColor}
onShowHistory={handleShowHistory}
onToggleCardLock={handleToggleCardLock}
+24
View File
@@ -2,6 +2,7 @@ import type {
Board,
Card,
CardHistoryResponse,
CardMessage,
Column,
Metrics,
MetricsFilter,
@@ -22,6 +23,10 @@ export function getBoard(): Promise<Board> {
return fetchJSON("/board");
}
export function getFlags(): Promise<Record<string, boolean>> {
return fetchJSON("/flags");
}
export function createColumn(name: string): Promise<Column> {
return fetchJSON("/columns", { method: "POST", body: JSON.stringify({ name }) });
}
@@ -112,6 +117,25 @@ export function cardHistory(id: string): Promise<CardHistoryResponse> {
return fetchJSON(`/cards/${id}/history`);
}
export function listCardMessages(id: string): Promise<CardMessage[]> {
return fetchJSON(`/cards/${id}/messages`);
}
export function createCardMessage(id: string, body: string): Promise<CardMessage> {
return fetchJSON(`/cards/${id}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
}
export function deleteCardMessage(cardId: string, messageId: string): Promise<void> {
return fetchJSON(`/cards/${cardId}/messages/${messageId}`, { method: "DELETE" });
}
export function duplicateCard(id: string): Promise<Card> {
return fetchJSON(`/cards/${id}/duplicate`, { method: "POST" });
}
export interface ChatMessage {
role: "user" | "assistant";
content: string;
+179
View File
@@ -0,0 +1,179 @@
import {
ActionIcon,
Avatar,
Box,
Group,
Loader,
Paper,
ScrollArea,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import { IconSend, IconTrash } from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import { 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";
interface Props {
cardId: string;
users: User[];
currentUserId?: string;
onMessagesChange?: (messages: CardMessage[]) => void;
}
export function CardChatPanel({ cardId, users, currentUserId, onMessagesChange }: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [loading, setLoading] = useState(true);
const [body, setBody] = useState("");
const [sending, setSending] = useState(false);
const viewportRef = useRef<HTMLDivElement | null>(null);
const usersById = new Map(users.map((u) => [u.id, u]));
const reload = useCallback(async () => {
try {
const ms = await api.listCardMessages(cardId);
setMessages(ms);
onMessagesChange?.(ms);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setLoading(false);
}
}, [cardId, onMessagesChange]);
useEffect(() => {
reload();
}, [reload]);
useEffect(() => {
if (viewportRef.current) {
viewportRef.current.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}
}, [messages.length]);
const send = async () => {
const text = body.trim();
if (!text || sending) return;
setSending(true);
try {
const m = await api.createCardMessage(cardId, text);
const next = [...messages, m];
setMessages(next);
onMessagesChange?.(next);
setBody("");
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
} finally {
setSending(false);
}
};
const remove = async (mid: string) => {
try {
await api.deleteCardMessage(cardId, mid);
const next = messages.filter((m) => m.id !== mid);
setMessages(next);
onMessagesChange?.(next);
} catch (e) {
notifications.show({ color: "red", message: (e as Error).message });
}
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
};
return (
<Stack gap="xs" style={{ height: "100%", minHeight: 0 }}>
<ScrollArea
viewportRef={viewportRef}
style={{ flex: 1, minHeight: 200 }}
type="auto"
offsetScrollbars
>
{loading ? (
<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.
</Text>
) : (
<Stack gap={6} p={4}>
{messages.map((m) => {
const author = m.author_id ? usersById.get(m.author_id) : null;
const isMe = m.author_id && m.author_id === currentUserId;
const label = author ? author.display_name || author.username : "Anonimo";
return (
<Paper
key={m.id}
withBorder
p="xs"
radius="sm"
bg={isMe ? "var(--mantine-color-blue-light)" : undefined}
>
<Group gap={6} wrap="nowrap" align="flex-start">
<Avatar size={22} radius="xl" color={author?.color || tagColor(label)}>
{label.slice(0, 2).toUpperCase()}
</Avatar>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600}>{label}</Text>
<Text size="xs" c="dimmed">{formatDateTimeShort(m.created_at)}</Text>
</Group>
{isMe && (
<Tooltip label="Borrar" withArrow>
<ActionIcon size="xs" variant="subtle" color="red" onClick={() => remove(m.id)}>
<IconTrash size={12} />
</ActionIcon>
</Tooltip>
)}
</Group>
<Text size="sm" style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{m.body}
</Text>
</Box>
</Group>
</Paper>
);
})}
</Stack>
)}
</ScrollArea>
<Group gap="xs" align="flex-end">
<Textarea
value={body}
onChange={(e) => setBody(e.currentTarget.value)}
onKeyDown={onKeyDown}
placeholder="Escribe un mensaje (Enter = enviar, Shift+Enter = salto)"
autosize
minRows={1}
maxRows={6}
style={{ flex: 1 }}
disabled={sending}
/>
<Tooltip label="Enviar" withArrow>
<ActionIcon
size="lg"
variant="filled"
color="blue"
onClick={send}
disabled={!body.trim() || sending}
aria-label="Enviar"
>
<IconSend size={16} />
</ActionIcon>
</Tooltip>
</Group>
</Stack>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { Box, Divider, Group, Tabs, Text } 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 { CardLinksPanel } from "./CardLinksPanel";
import { CardForm, CardFormValues } from "./CardForm";
interface Props {
card: Card;
users: User[];
currentUserId?: string;
requesterOptions: string[];
tagOptions: string[];
onSubmit: (v: CardFormValues) => Promise<void> | void;
onCancel: () => void;
}
export function CardEditPanel({
card,
users,
currentUserId,
requesterOptions,
tagOptions,
onSubmit,
onCancel,
}: Props) {
const [messages, setMessages] = useState<CardMessage[]>([]);
const [liveCard, setLiveCard] = useState(card);
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);
};
return (
<Group align="stretch" gap="md" wrap="nowrap" style={{ minHeight: 460 }}>
<Box style={{ flex: "1 1 0", minWidth: 320 }}>
<CardForm
users={users}
requesterOptions={requesterOptions}
tagOptions={tagOptions}
initial={{
requester: liveCard.requester,
title: liveCard.title,
description: liveCard.description,
assignee_id: liveCard.assignee_id,
tags: liveCard.tags || [],
}}
submitLabel="Guardar"
onSubmit={wrappedSubmit}
onCancel={onCancel}
/>
</Box>
<Divider orientation="vertical" />
<Box style={{ flex: "1 1 0", minWidth: 320, display: "flex", flexDirection: "column" }}>
<Tabs defaultValue="chat" keepMounted={false} style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
<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.List>
<Box pt="xs" style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
<Tabs.Panel value="chat" style={{ flex: 1, minHeight: 0, display: "flex" }}>
<Box style={{ flex: 1, minHeight: 0, display: "flex", flexDirection: "column", width: "100%" }}>
<CardChatPanel
cardId={liveCard.id}
users={users}
currentUserId={currentUserId}
onMessagesChange={setMessages}
/>
</Box>
</Tabs.Panel>
<Tabs.Panel value="links">
<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>
</Tabs.Panel>
</Box>
</Tabs>
</Box>
</Group>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { Anchor, Badge, Box, Group, Paper, Stack, Text } from "@mantine/core";
import { IconExternalLink } from "@tabler/icons-react";
import { useMemo } from "react";
import type { Card, CardMessage } from "../types";
interface ExtractedLink {
url: string;
source: "title" | "description" | "chat";
context: string;
}
const URL_RE = /(https?:\/\/[^\s<>()"']+)/gi;
function extract(source: ExtractedLink["source"], text: string): ExtractedLink[] {
if (!text) return [];
const out: ExtractedLink[] = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(text)) !== null) {
let url = m[1];
// Strip common trailing punctuation that isn't part of a URL.
url = url.replace(/[.,;:!?)\]}>]+$/, "");
if (seen.has(url)) continue;
seen.add(url);
out.push({ url, source, context: text });
}
return out;
}
function hostname(u: string): string {
try {
return new URL(u).hostname;
} catch {
return u;
}
}
interface Props {
card: Card;
messages: CardMessage[];
}
export function CardLinksPanel({ card, messages }: Props) {
const links = useMemo<ExtractedLink[]>(() => {
const all: ExtractedLink[] = [
...extract("title", card.title),
...extract("description", card.description),
...messages.flatMap((m) => extract("chat", m.body)),
];
const seen = new Set<string>();
return all.filter((l) => {
if (seen.has(l.url)) return false;
seen.add(l.url);
return true;
});
}, [card.title, card.description, messages]);
if (links.length === 0) {
return (
<Stack gap="xs" p="md" align="center" justify="center" style={{ minHeight: 200 }}>
<Text size="sm" c="dimmed">Sin enlaces detectados</Text>
<Text size="xs" c="dimmed" ta="center">
Pega URLs en el titulo, descripcion o chat y apareceran aqui.
</Text>
</Stack>
);
}
const badgeColor = (s: ExtractedLink["source"]): string => {
if (s === "title") return "grape";
if (s === "description") return "blue";
return "teal";
};
const badgeLabel = (s: ExtractedLink["source"]): string => {
if (s === "title") return "titulo";
if (s === "description") return "descripcion";
return "chat";
};
return (
<Stack gap={6} p={4}>
{links.map((l) => (
<Paper key={l.url} withBorder p="xs" radius="sm">
<Group gap="xs" wrap="nowrap" justify="space-between" align="flex-start">
<Box style={{ flex: 1, minWidth: 0 }}>
<Anchor href={l.url} target="_blank" rel="noopener noreferrer" size="sm" style={{ wordBreak: "break-all" }}>
<Group gap={4} wrap="nowrap" align="center">
<IconExternalLink size={12} />
<span>{hostname(l.url)}</span>
</Group>
</Anchor>
<Text size="xs" c="dimmed" style={{ wordBreak: "break-all" }}>{l.url}</Text>
</Box>
<Badge size="xs" variant="light" color={badgeColor(l.source)}>
{badgeLabel(l.source)}
</Badge>
</Group>
</Paper>
))}
</Stack>
);
}
+14
View File
@@ -18,6 +18,7 @@ import {
IconCalendarDue,
IconCheck,
IconClock,
IconCopy,
IconDotsVertical,
IconEdit,
IconGripVertical,
@@ -42,6 +43,7 @@ interface Props {
now: number;
onDelete: (id: string) => void;
onEdit: (card: Card) => void;
onDuplicate?: (id: string) => void;
onChangeColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleLock: (id: string, locked: boolean) => void;
@@ -67,6 +69,7 @@ function KanbanCardImpl({
now,
onDelete,
onEdit,
onDuplicate,
onChangeColor,
onShowHistory,
onToggleLock,
@@ -206,6 +209,17 @@ function KanbanCardImpl({
>
Editar
</Menu.Item>
{onDuplicate && (
<Menu.Item
leftSection={<IconCopy size={14} />}
onClick={() => {
setMenuOpen(false);
onDuplicate(card.id);
}}
>
Duplicar
</Menu.Item>
)}
<Popover
opened={colorPopOpen}
onChange={setColorPopOpen}
+3
View File
@@ -50,6 +50,7 @@ interface Props {
onToggleDone: (id: string, is_done: boolean) => void;
onEditCard: (card: Card) => void;
onDeleteCard: (id: string) => void;
onDuplicateCard: (id: string) => void;
onChangeCardColor: (id: string, color: CardColor) => void;
onShowHistory: (card: Card) => void;
onToggleCardLock: (id: string, locked: boolean) => void;
@@ -82,6 +83,7 @@ function KanbanColumnImpl({
onToggleDone,
onEditCard,
onDeleteCard,
onDuplicateCard,
onChangeCardColor,
onShowHistory,
onToggleCardLock,
@@ -421,6 +423,7 @@ function KanbanColumnImpl({
now={now}
onDelete={onDeleteCard}
onEdit={onEditCard}
onDuplicate={onDuplicateCard}
onChangeColor={onChangeCardColor}
onShowHistory={onShowHistory}
onToggleLock={onToggleCardLock}
+20 -1
View File
@@ -10,8 +10,9 @@ import {
Title,
} from "@mantine/core";
import { IconLayoutKanban } from "@tabler/icons-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAuth } from "../auth";
import * as api from "../api";
type Mode = "login" | "register";
@@ -23,6 +24,18 @@ export function LoginPage() {
const [displayName, setDisplayName] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [registrationEnabled, setRegistrationEnabled] = useState(false);
useEffect(() => {
api
.getFlags()
.then((f) => setRegistrationEnabled(!!f["registration-enabled"]))
.catch(() => setRegistrationEnabled(false));
}, []);
useEffect(() => {
if (!registrationEnabled && mode === "register") setMode("login");
}, [registrationEnabled, mode]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -84,6 +97,7 @@ export function LoginPage() {
<Button type="submit" loading={submitting} fullWidth>
{mode === "login" ? "Entrar" : "Registrar"}
</Button>
{registrationEnabled ? (
<Text size="xs" c="dimmed" ta="center">
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
<Anchor
@@ -98,6 +112,11 @@ export function LoginPage() {
{mode === "login" ? "Registrate" : "Inicia sesion"}
</Anchor>
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
Registro de nuevos usuarios deshabilitado.
</Text>
)}
</Stack>
</form>
</Paper>
+8
View File
@@ -188,3 +188,11 @@ export interface CardHistoryResponse {
total_locked_ms: number;
currently_locked: boolean;
}
export interface CardMessage {
id: string;
card_id: string;
author_id: string | null;
body: string;
created_at: string;
}
Binary file not shown.
Binary file not shown.
Binary file not shown.