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:
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+1176
File diff suppressed because one or more lines are too long
-1151
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Kanban</title>
|
||||
<script type="module" crossorigin src="/assets/index-CPqSy0gZ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BETde3Km.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-nR9uJgze.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,20 +97,26 @@ export function LoginPage() {
|
||||
<Button type="submit" loading={submitting} fullWidth>
|
||||
{mode === "login" ? "Entrar" : "Registrar"}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
{registrationEnabled ? (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
{mode === "login" ? "No tienes cuenta?" : "Ya tienes cuenta?"}{" "}
|
||||
<Anchor
|
||||
component="button"
|
||||
type="button"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "Registrate" : "Inicia sesion"}
|
||||
</Anchor>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" ta="center">
|
||||
Registro de nuevos usuarios deshabilitado.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user