ui overhaul
Some checks failed
Build and deploy Havenllo / Verify Go and frontend (push) Failing after 29s
Build and deploy Havenllo / Build and push image (push) Has been skipped
Build and deploy Havenllo / Apply and restart Havenllo (push) Has been skipped

This commit is contained in:
Hermes
2026-07-14 11:09:54 -03:00
parent 240f73b229
commit afd23ca751
19 changed files with 603 additions and 382 deletions

View File

@@ -177,7 +177,7 @@ func (h *Handler) updateCard(w http.ResponseWriter, r *http.Request) {
return return
} }
card, err := h.service.UpdateCard(r.Context(), id, app.CardPatch{ card, err := h.service.UpdateCard(r.Context(), id, app.CardPatch{
Title: request.Title, Description: request.Description, Done: request.Done, ListID: request.ListID, Position: request.Position, Title: request.Title, Description: request.Description, ListID: request.ListID, Position: request.Position,
}) })
if err != nil { if err != nil {
writeDomainError(w, err) writeDomainError(w, err)

View File

@@ -84,3 +84,42 @@ func TestJSONValidationAndLastListConflict(t *testing.T) {
t.Fatalf("last list status = %d", got.Code) t.Fatalf("last list status = %d", got.Code)
} }
} }
func TestCardDoneFieldIsNotPartOfTheAPI(t *testing.T) {
t.Parallel()
handler := newHandler(t)
var board struct {
Lists []struct {
ID int64 `json:"id"`
} `json:"lists"`
}
if err := json.Unmarshal(request(t, handler, http.MethodGet, "/api/board", "", false).Body.Bytes(), &board); err != nil {
t.Fatal(err)
}
if len(board.Lists) == 0 {
t.Fatal("board has no lists")
}
created := request(t, handler, http.MethodPost, "/api/lists/"+strconv.FormatInt(board.Lists[0].ID, 10)+"/cards", `{"title":"status comes from the list"}`, true)
if created.Code != http.StatusCreated {
t.Fatalf("create card status = %d, body %s", created.Code, created.Body)
}
var payload struct {
Card map[string]json.RawMessage `json:"card"`
}
if err := json.Unmarshal(created.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if _, exists := payload.Card["done"]; exists {
t.Fatalf("card response unexpectedly contains done: %s", created.Body)
}
cardID := payload.Card["id"]
var id int64
if err := json.Unmarshal(cardID, &id); err != nil {
t.Fatal(err)
}
if got := request(t, handler, http.MethodPatch, "/api/cards/"+strconv.FormatInt(id, 10), `{"done":true}`, true); got.Code != http.StatusBadRequest {
t.Fatalf("done patch status = %d, body %s", got.Code, got.Body)
}
}

View File

@@ -21,7 +21,6 @@ type createCardRequest struct {
type updateCardRequest struct { type updateCardRequest struct {
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"` Description *string `json:"description,omitempty"`
Done *bool `json:"done,omitempty"`
ListID *int64 `json:"list_id,omitempty"` ListID *int64 `json:"list_id,omitempty"`
Position *float64 `json:"position,omitempty"` Position *float64 `json:"position,omitempty"`
} }

View File

@@ -135,8 +135,8 @@ func (s *Service) CreateCard(ctx context.Context, listID int64, title, descripti
return domain.Card{}, err return domain.Card{}, err
} }
now := timestamp() now := timestamp()
result, err := tx.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at) result, err := tx.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, created_at, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?)`, listID, title, description, pos, now, now) VALUES (?, ?, ?, ?, ?, ?)`, listID, title, description, pos, now, now)
if err != nil { if err != nil {
return domain.Card{}, err return domain.Card{}, err
} }
@@ -154,7 +154,6 @@ func (s *Service) CreateCard(ctx context.Context, listID int64, title, descripti
type CardPatch struct { type CardPatch struct {
Title *string Title *string
Description *string Description *string
Done *bool
ListID *int64 ListID *int64
Position *float64 Position *float64
} }
@@ -189,9 +188,6 @@ func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (do
if patch.Description != nil { if patch.Description != nil {
card.Description = *patch.Description card.Description = *patch.Description
} }
if patch.Done != nil {
card.Done = *patch.Done
}
if patch.ListID != nil { if patch.ListID != nil {
if _, err := store.List(ctx, tx, *patch.ListID); err != nil { if _, err := store.List(ctx, tx, *patch.ListID); err != nil {
return domain.Card{}, err return domain.Card{}, err
@@ -207,12 +203,8 @@ func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (do
} }
card.Position = pos + positionStep card.Position = pos + positionStep
} }
done := 0 if _, err := tx.ExecContext(ctx, `UPDATE cards SET list_id = ?, title = ?, description = ?, position = ?, updated_at = ? WHERE id = ?`,
if card.Done { card.ListID, card.Title, card.Description, card.Position, timestamp(), id); err != nil {
done = 1
}
if _, err := tx.ExecContext(ctx, `UPDATE cards SET list_id = ?, title = ?, description = ?, position = ?, done = ?, updated_at = ? WHERE id = ?`,
card.ListID, card.Title, card.Description, card.Position, done, timestamp(), id); err != nil {
return domain.Card{}, err return domain.Card{}, err
} }
if err := normalizeCards(ctx, tx, sourceListID); err != nil { if err := normalizeCards(ctx, tx, sourceListID); err != nil {

View File

@@ -25,7 +25,6 @@ type Card struct {
Title string `json:"title"` Title string `json:"title"`
Description string `json:"description"` Description string `json:"description"`
Position float64 `json:"position"` Position float64 `json:"position"`
Done bool `json:"done"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }

View File

@@ -53,7 +53,7 @@ func List(ctx context.Context, q DBTX, id int64) (domain.List, error) {
} }
func Cards(ctx context.Context, q DBTX, listID int64) ([]domain.Card, error) { func Cards(ctx context.Context, q DBTX, listID int64) ([]domain.Card, error) {
rows, err := q.QueryContext(ctx, `SELECT id, list_id, title, description, position, done, created_at, updated_at rows, err := q.QueryContext(ctx, `SELECT id, list_id, title, description, position, created_at, updated_at
FROM cards WHERE list_id = ? ORDER BY position, id`, listID) FROM cards WHERE list_id = ? ORDER BY position, id`, listID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,7 +71,7 @@ func Cards(ctx context.Context, q DBTX, listID int64) ([]domain.Card, error) {
} }
func Card(ctx context.Context, q DBTX, id int64) (domain.Card, error) { func Card(ctx context.Context, q DBTX, id int64) (domain.Card, error) {
row := q.QueryRowContext(ctx, `SELECT id, list_id, title, description, position, done, created_at, updated_at row := q.QueryRowContext(ctx, `SELECT id, list_id, title, description, position, created_at, updated_at
FROM cards WHERE id = ?`, id) FROM cards WHERE id = ?`, id)
card, err := scanCard(row) card, err := scanCard(row)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -84,10 +84,7 @@ type scanner interface{ Scan(...any) error }
func scanCard(row scanner) (domain.Card, error) { func scanCard(row scanner) (domain.Card, error) {
var card domain.Card var card domain.Card
var done int return card, row.Scan(&card.ID, &card.ListID, &card.Title, &card.Description, &card.Position, &card.CreatedAt, &card.UpdatedAt)
err := row.Scan(&card.ID, &card.ListID, &card.Title, &card.Description, &card.Position, &done, &card.CreatedAt, &card.UpdatedAt)
card.Done = done != 0
return card, err
} }
func Snapshot(ctx context.Context, q DBTX) (domain.Snapshot, error) { func Snapshot(ctx context.Context, q DBTX) (domain.Snapshot, error) {

View File

@@ -1,130 +1,126 @@
# Project Context # Project Context
This file is the canonical project context for AI agents working on Havenllo. Read it before making changes, and keep it updated whenever project purpose, architecture, APIs, file roles, workflows, constraints, or implementation patterns change. This file is the canonical project context for AI agents working on Havenllo. Read it before making changes and keep it current whenever architecture, APIs, data, dependencies, runtime behavior, or product decisions change.
## Purpose ## Purpose
Havenllo (portmanteau of "Haven" + "Trello") is a lightweight, single-user, no-authentication kanban/to-do web app for tracking tasks inside the Haven homelab. It is a minimal Trello: one fixed board, ordered lists (columns), and ordered cards (tasks). No login, no multi-user, no accounts — open the URL and use it. Havenllo (Haven + Trello) is a lightweight, single-user, no-authentication kanban app for Haven homelab tasks. It has one fixed board with ordered lists and cards. One Go binary serves both its JSON API and embedded Preact SPA; SQLite on a Kubernetes PVC is the only durable state.
The product is intentionally small: one Go binary serves both the JSON API and the embedded Preact SPA. The only durable state is a SQLite database file on a Kubernetes PVC.
## Technology Stack ## Technology Stack
- Go module: `havenllo` - Go module `havenllo`, Go 1.24.
- Go version: `1.24` - Backend: `modernc.org/sqlite`, pure Go and CGO-free.
- Backend dependencies: `modernc.org/sqlite` (pure-Go, CGO-free — keeps `CGO_ENABLED=0`) - Frontend runtime: `preact`, `commonmark`, and `dompurify`; build tooling: `@preact/preset-vite`, `@types/commonmark`, `typescript`, and `vite`.
- Frontend dependencies: `preact` only (runtime); `@preact/preset-vite`, `typescript`, `vite` (build) - Card movement: custom Pointer Events interaction; no DnD library.
- Frontend DnD: native HTML5 drag-and-drop (no DnD library) - Static SPA assets are embedded with Go `embed` from `web/dist`.
- Static assets: embedded with Go `embed` (`web/embed.go`) - Runtime port: `8080`; `HAVENLLO_LISTEN_ADDR` defaults to `:8080` and `HAVENLLO_DATABASE_PATH` defaults to `/data/havenllo.db`.
- Runtime port: `8080` - Docker: `node:22-alpine` frontend build, `golang:1.24-alpine` CGO-free build, then `gcr.io/distroless/static-debian12`.
- Environment: `HAVENLLO_LISTEN_ADDR` (default `:8080`), `HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`) - Deployment: one `Recreate` Deployment replica, `nfs-client` PVC, plain nginx ingress `havenllo.haven` in `default`, no TLS.
- Docker build: `node:22-alpine` (frontend) → `golang:1.24-alpine` (build, `CGO_ENABLED=0`) → `gcr.io/distroless/static-debian12`
- Deployment: single Deployment (1 replica, `Recreate` strategy), PVC on `nfs-client`, nginx ingress `havenllo.haven` (default namespace, no TLS), image `git.ivanch.me/ivanch/havenllo:latest`
## Repository Organization (source code) ## Repository Organization
### Backend (Go) ### Backend
- `cmd/havenllo/main.go`: entrypoint. Reads env (`HAVENLLO_LISTEN_ADDR`, `HAVENLLO_DATABASE_PATH`), opens/migrates the SQLite DB, builds the service + HTTP handler wiring in the embedded SPA, and runs the server with graceful SIGINT/SIGTERM shutdown. - `cmd/havenllo/main.go`: reads environment, opens/migrates SQLite, wires service/API/embedded SPA, and shuts down gracefully on SIGINT/SIGTERM.
- `internal/domain/models.go`: core domain types — `List`, `Card`, `Snapshot` — plus sentinel errors `ErrNotFound`, `ErrLastList`, and `ValidationError`. No dependencies beyond `errors`. - `internal/domain/models.go`: domain `List`, `Card`, `Snapshot`, sentinel errors, and validation error type. Active cards have no completion boolean.
- `internal/database/database.go`: opens the SQLite connection (parent-dir creation, WAL, busy timeout, `foreign_keys=ON`), then runs migrations. Returns the `*sql.DB` used by the whole app. Imports `_ "modernc.org/sqlite"`. - `internal/database/database.go`: parent-directory setup, WAL/busy timeout/foreign-key connection settings, and migrations.
- `internal/database/migrations.go`: versioned, transactional migrations. Migration 1 creates the `lists` and `cards` tables and seeds the three starter lists (`To do`, `In progress`, `Done`) with positions `1024, 2048, 3072`. - `internal/database/migrations.go`: transactional migration 1 creates lists/cards and seeds `To do`, `In progress`, and `Done`. Its historical `cards.done` column remains physically present but is dormant.
- `internal/store/sqlite.go`: small, parameterized SQL query/scan helpers (no ORM). Defines the `DBTX` interface (so tests can pass a `*sql.Tx` or `*sql.DB`), list/card queries, max-position helpers, `CountLists`, and error-wrapping helpers (`Wrap`, `MustRows`). - `internal/store/sqlite.go`: parameterized SQL queries and scans only. Card queries intentionally omit `cards.done`.
- `internal/app/service.go`: business rules and the single "board" aggregate. Owns transactional ordering (float `position` keys, `positionStep = 1024`, renormalization when gaps get too small), last-list protection (`ErrLastList` on deleting the final list), default-list seeding, and validation. Exposes `Health`, `Board`, list/card CRUD, and move/reorder operations — every mutation runs in one transaction. - `internal/app/service.go`: transactional validation, ordering, normalization, last-list protection, list/card CRUD, and atomic move/reorder operations. It neither reads nor writes `done`.
- `internal/api/types.go`: request DTOs. PATCH bodies use pointer fields (`*string`, `*float64`, `*bool`, `*int64`) so an explicit `false`/`0` is distinguishable from an omitted field. - `internal/api/types.go`: request DTOs with pointer fields for optional scalar values.
- `internal/api/http.go`: JSON helpers (`writeJSON`, `writeError` with the `{ "error": { "code", "message" } }` shape), bounded-body decoding with `DisallowUnknownFields`, and typed error mapping (validation → 422, not-found → 404, last-list → 409, etc.). - `internal/api/http.go`: bounded JSON decoding with unknown-field rejection, JSON error responses, and typed status mapping.
- `internal/api/handlers.go`: Go 1.22 `http.ServeMux` routes (`GET /api/health`, `GET /api/board`, lists CRUD, cards CRUD). Registers `/api/` before the embedded static filesystem; unknown API routes return JSON 404, never SPA HTML. All browser traffic is same-origin, so no CORS. - `internal/api/handlers.go`: Go 1.22 ServeMux routes for health, one board, lists, cards, and embedded SPA fallback. `/api/` always returns JSON errors and needs no CORS.
### Frontend (Preact + Vite + TypeScript) ### Frontend
- `web/index.html`: SPA shell — title, `theme-color`, viewport, root `<div id="app">`, and the Vite module script. - `web/package.json`: frontend scripts and the Preact/CommonMark/sanitization dependencies.
- `web/vite.config.ts`: Preact preset; deterministic `web/dist` output. - `web/src/main.tsx`: Preact bootstrap.
- `web/package.json`: `havenllo-web`, scripts `dev` / `build` (`tsc --noEmit && vite build`) / `preview`; `preact` dependency only. - `web/src/types.ts`: API resource and patch types. `Card` and `CardPatch` contain no `done` member.
- `web/src/main.tsx`: Preact `render(<App/>, document.getElementById("app"))` bootstrap. - `web/src/api.ts`: typed same-origin `/api` fetch client and API error handling.
- `web/src/types.ts`: TypeScript mirrors of the API resources (`Card`, `List`, `ListWithCards`, `Board`, `CardPatch`, `ListPatch`). - `web/src/board-state.ts`: reducer, optimistic board transforms, rollback state, and toasts.
- `web/src/api.ts`: typed fetch client (`APIError` class, `request<T>` helper). All calls are same-origin under `/api`. - `web/src/drag.ts`: `movePosition`, which calculates a fractional position from a target index after removing the dragged card.
- `web/src/board-state.ts`: `useReducer` board state — owns the `Board` snapshot, `loading`, `error`, and `toasts`. Pure action creators/reducers for add/update/remove list & card and optimistic transforms, plus rollback support. - `web/src/markdown.ts`: safe CommonMark renderer using `commonmark` plus DOMPurify, and plain-text extraction for card summaries.
- `web/src/drag.ts`: native HTML5 drag-and-drop helpers — `cardDragType` MIME, `beginCardDrag`/`readCardDrag` payloads, and `movePosition` (computes the new fractional position between drop neighbours). - `web/src/App.tsx`: board loading, optimistic mutations, selected-card state, and component wiring.
- `web/src/App.tsx`: top-level component. Loads the board on mount (`GET /api/board`), owns the reducer and selected-card/list-management UI state, wires optimistic mutations with rollback + toasts, and renders `Header`, `BoardCanvas`, the selected card's `CardDetailModal`, and `Toasts`. - `web/src/components/Header.tsx`: brand, board label, refresh, and list-management toggle.
- `web/src/components/Header.tsx`: brand wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management. - `web/src/components/BoardCanvas.tsx`: board intro, centered-or-scrollable rail, list-management add control, and board-level Pointer Events drag lifecycle. It detects whole-column targets, insertion indices, pointer cancellation, and Escape cancellation.
- `web/src/components/BoardCanvas.tsx`: responsive horizontally-scrolling list rail, management-mode-only add-list affordance, and board empty state. Maps lists to `ListColumn`. - `web/src/components/ListColumn.tsx`: column header/count, drop target and insertion marker, inline card creation, and management-only rename/delete controls.
- `web/src/components/ListColumn.tsx`: one column — card count, native drop zones (one before each card + one at the end), inline add-card input, empty-column state, and management-mode-only list rename/delete controls with delete confirmation. - `web/src/components/CardItem.tsx`: focusable card tile with a dedicated drag grip, current-list status chip, and plain-text Markdown excerpt. Clicking outside the grip opens details.
- `web/src/components/CardItem.tsx`: one draggable card tile. A genuine click opens the board-level detail dialog; drag events are suppressed from opening it. It retains the done toggle, title/description preview, and a visible drag grip. - `web/src/components/CardDetailModal.tsx`: accessible card dialog with title editing, current-list status, native move-to-list selector, CommonMark Write/Preview description editor, timestamps, and delete confirmation.
- `web/src/components/CardDetailModal.tsx`: accessible Jira-style card dialog with inline title and description editing, list/status properties, timestamps, delete confirmation, outside/Escape/X close controls, and modal focus restoration. - `web/src/components/ConfirmDialog.tsx`: accessible destructive-action confirmation.
- `web/src/components/ConfirmDialog.tsx`: accessible confirmation dialog used before any delete. - `web/src/components/Toast.tsx`: auto-dismissing non-blocking feedback.
- `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s). - `web/src/styles.css`: dark Haven theme, workflow-specific teal/indigo/gold accents, centered short rails, horizontal mobile scrolling, pointer drop feedback, Markdown typography, focus states, 44px touch targets, and reduced-motion support.
- `web/src/styles.css`: all styling. Elevated dark "Haven" theme (deep slate/navy `#0a1020`, teal/indigo accents), responsive horizontal board rail, polished card/list/button states, edit-mode treatment, detail-dialog layout, empty states, focus rings, 44px mobile touch targets, and `prefers-reduced-motion` support.
### Embedding bridge ### Embedding bridge
- `web/embed.go`: embeds `web/dist` (`//go:embed all:dist`) and re-exports it as `web.Files` (via `fs.Sub`, stripping the `dist/` prefix) for the API handler to serve. `web/dist` is git-ignored; the Docker `frontend` stage rebuilds it before the Go build copies it in. - `web/embed.go`: embeds `web/dist` and exports it as `web.Files` after stripping the `dist/` prefix. `web/dist` is generated and ignored; Docker rebuilds it.
## Backend Architecture ## Backend Architecture
Layered, dependency-light: ```text
main.go -> api.Handler -> app.Service -> store -> database (SQLite)
``` ^ |
main.go → api.Handler → app.Service → store (SQL) → database (sqlite.DB) domain <---------------+
↑ ↓
domain (types/errors) domain (types/errors)
``` ```
- `database` owns connection + migrations (incl. first-run seed). - `database` owns connections and migrations.
- `store` owns SQL text and row scanning only. - `store` owns SQL text and scanning only.
- `app.Service` owns ordering, validation, last-list protection, and wraps every state change in a transaction. - `app.Service` owns validation, transactions, ordering, and cross-resource behavior.
- `api` owns HTTP routing, JSON (de)serialization, and error shaping. It never exposes SQL errors. - `api` owns routing, JSON, and HTTP error shapes.
- `domain` is the shared vocabulary (types + sentinel errors) with no internal deps. - `domain` contains shared dependency-free vocabulary.
## Data Model ## Data Model
Two tables (no `boards` table — there is exactly one fixed board): There is no `boards` table: one fixed board has two tables.
- `lists(id INTEGER PK, name TEXT, position REAL)` columns. Seeded with `To do`, `In progress`, `Done`. - `lists(id INTEGER PK, name TEXT, position REAL)`: ordered columns. First-run seed: `To do`, `In progress`, `Done`.
- `cards(id INTEGER PK, list_id INTEGER FKlists ON DELETE CASCADE, title TEXT, description TEXT DEFAULT '', position REAL, done INTEGER 0/1, created_at TEXT, updated_at TEXT)`. - `cards(id INTEGER PK, list_id INTEGER FK->lists ON DELETE CASCADE, title TEXT, description TEXT DEFAULT '', position REAL, done INTEGER 0/1, created_at TEXT, updated_at TEXT)`: ordered cards. `done` is retained only as unsupported historical storage; active code never reads or writes it. A card's sole active status is its current list name.
`position` is a float sort key ordered by `(position, id)`. New siblings get `max+1024`; drags pick a value between neighbours; the service renormalizes a sibling group to `1024` increments when gaps shrink below threshold. Foreign keys are enabled on every connection; deleting a list cascades its cards; deleting the last list is rejected with `409`. `position` is a float sort key, ordered by `(position, id)`. New siblings use `max + 1024`; pointer drops select a value between adjacent cards. The service renormalizes a sibling group to 1024 increments when gaps become too small. Foreign keys are enabled on every connection; list deletion cascades cards; deletion of the final list returns `409`.
## API Contract ## API Contract
All endpoints are same-origin under `/api`, JSON in/out, integer IDs. Mutation requests require `Content-Type: application/json`; unknown fields are rejected. All endpoints are same-origin under `/api`, exchange JSON, use integer IDs, require `Content-Type: application/json` for mutations, and reject unknown fields.
| Method | Path | Purpose | | Method | Path | Purpose |
| --- | --- | --- | | --- | --- | --- |
| GET | `/api/health` | Liveness; pings SQLite, returns `200 {"status":"ok"}` (K8s probe target) | | GET | `/api/health` | SQLite liveness response: `200 {"status":"ok"}`. |
| GET | `/api/board` | Full board: `{ "lists": [ {id,name,position,card_count?,cards:[Card] } ] }` | | GET | `/api/board` | Full board: `{ "lists": [ListWithCards] }`. |
| GET | `/api/lists` | List collections with `card_count`, no card bodies | | GET | `/api/lists` | List summaries with `card_count`, without card bodies. |
| POST | `/api/lists` | Create list `{"name","position"?}` `201 {list}` | | POST | `/api/lists` | Create: `{"name","position"?}` -> `201 {list}`. |
| GET | `/api/lists/{id}` | One list with its cards | | GET | `/api/lists/{id}` | One list and its cards. |
| PATCH | `/api/lists/{id}` | `{"name"?,"position"?}` `200 {list}` | | PATCH | `/api/lists/{id}` | Update: `{"name"?,"position"?}` -> `{list}`. |
| DELETE | `/api/lists/{id}` | `204`; cascades cards; `409` if it is the last list | | DELETE | `/api/lists/{id}` | `204`; cascades cards, or `409` for the final list. |
| GET | `/api/lists/{id}/cards` | Cards in a list, ordered | | GET | `/api/lists/{id}/cards` | Ordered cards in one list. |
| POST | `/api/lists/{id}/cards` | Create card `{"title","description"?,"position"?}` `201 {card}` | | POST | `/api/lists/{id}/cards` | Create: `{"title","description"?,"position"?}` -> `201 {card}`. |
| GET | `/api/cards/{id}` | One card | | GET | `/api/cards/{id}` | One card. |
| PATCH | `/api/cards/{id}` | `{"title"?,"description"?,"done"?,"list_id"?,"position"?}` `200 {card}` (move + reorder in one tx) | | PATCH | `/api/cards/{id}` | Update or move: `{"title"?,"description"?,"list_id"?,"position"?}` -> `{card}` atomically. |
| DELETE | `/api/cards/{id}` | `204` | | DELETE | `/api/cards/{id}` | `204`. |
Error shape: `{ "error": { "code": "validation_error"|"not_found"|..., "message": "..." } }`. Statuses: `200/201/204` success, `400` malformed, `404` missing, `409` last-list, `415` bad media type, `422` validation, `500` unexpected (non-sensitive). `Card` JSON has `id`, `list_id`, `title`, `description`, `position`, `created_at`, and `updated_at`; it never has `done`. Sending `done` is an unknown field and returns `400`.
Errors use `{ "error": { "code", "message" } }`. Success uses `200`, `201`, or `204`; malformed JSON is `400`, missing resources `404`, final-list deletion `409`, invalid media type `415`, validation errors `422`, and unexpected failures `500`.
## Frontend Behavior ## Frontend Behavior
- Loads `/api/board` on mount; no board picker (single fixed board). - The SPA loads `/api/board` on mount; there is no board picker.
- Native HTML5 drag-and-drop reorders cards within and across lists; drop computes a fractional position, updates optimistically, and reconciles/rolls back on failure. - Start a pointer drag from a card's grip. The entire destination column accepts it; the nearest card midpoint controls the insertion marker and fractional position. Mouse and touch both work. Pointer cancel or Escape aborts without a request. Drops update optimistically and roll back with a toast on failure.
- Clicking a card opens a centered, accessible Jira-style detail dialog. Its title and description patch independently, while list, done status, created/updated timestamps, and delete-with-confirm are visible in the dialog. It closes via X, Escape, or outside click, focuses on open, and restores the prior trigger focus when closed. - The card dialog also has a native Move to list select, which appends the card through the existing `PATCH /api/cards/{id}` behavior and provides a keyboard-friendly alternative.
- The Header's Edit toggle enters list-management mode. Only in that mode are list rename/delete controls and the end-of-rail add-list control visible; card add, done, detail editing, delete, and drag-and-drop remain available in either mode. - Status is derived solely from the card's current list. Tiles show the list-name chip; the dialog shows the same read-only status. `Done` has no special backend meaning.
- Inline add for cards, done toggle, descriptions, and delete-with-confirm. There is no inline card expansion/editor; card editing is centralized in the detail dialog. - Descriptions remain raw strings in the API/database. The dialog writes CommonMark source and previews safe, sanitized CommonMark; card tiles show a compact plain-text rendering.
- Optimistic UI: every mutation updates state immediately, then reconciles with the server response; failures restore the prior snapshot and show an error toast. Only the pending resource's controls disable. - The Edit toggle exposes list add/rename/delete controls. Card add, edit, delete, movement, and detail viewing remain available outside list-management mode.
- Responsive horizontally-scrolling board; usable on phones. - Short rails are horizontally centered on wide screens. Overflowing rails begin at the first column and use responsive horizontal scrolling and mobile scroll snapping.
## Build & Deploy ## Build and Deploy
- **Local verify**: `go test ./...` (backend unit tests) and `cd web && npm ci && npm run build` (frontend type-check + bundle). `web/dist` is produced by the build, not committed. - Local verification: `go test ./...`, `CGO_ENABLED=0 go build ./...`, then `cd web && npm ci && npm run build`.
- **Image**: multi-stage Dockerfile → `git.ivanch.me/ivanch/havenllo:latest`, multi-arch `linux/amd64,linux/arm64`, built on `docker-build.haven` (remote Buildx). Final image is distroless, runs as `USER 0`, serves on `:8080`, mounts the SQLite PVC at `/data`. - `web/dist` and `web/node_modules` are git-ignored. Docker's frontend stage always rebuilds `dist`.
- **CI**: `.gitea/workflows/main.yaml` on `main` push / manual — `verify` (install Go+Node on the Alpine slim runner, `go test`, `npm run build`, offline YAML doc check) → `build` (`pipeline-actions/build-and-push@main`) → `deploy` (`pipeline-actions/kubectl-apply@main` + `pipeline-actions/deploy-restart@main`) using user-level secrets `REGISTRY_PASSWORD`, `SSH_KEY_DOCKERBUILD`, `KUBE_CONFIG`. - The image is `git.ivanch.me/ivanch/havenllo:latest`, built multi-arch on `docker-build.haven`.
- **Manual deploy**: `kubectl apply -f deploy/havenllo.yaml -n default` then rollout restart; ingress is `http://havenllo.haven` (no TLS). - `.gitea/workflows/main.yaml` verifies Go, frontend, and offline deployment YAML before build/deploy pipeline actions. The verify job must remain cluster-free.
- Manual deployment: `kubectl apply -f deploy/havenllo.yaml -n default`, then restart the Deployment. The app is served at `http://havenllo.haven`.
## Constraints & Conventions ## Constraints and Conventions
- Single replica + `Recreate` strategy — SQLite is a single-writer file on the NFS PVC; never run two writable pods against it. - Single fixed board; lists and cards only.
- Container runs as root (`USER 0`) because the NFS `all_squash` export maps to `anonuid=0`; the root filesystem is read-only and only `/data` is writable. - SQLite has one writer on an NFS PVC: one replica, `Recreate` strategy, root container user on the NFS mount.
- No auth, no external services beyond the SQLite file. - Same-origin `/api`, embedded SPA, no auth, no CORS, and no external services.
- Keep dependencies minimal and the image small; no CGO, no ORM, no frontend framework beyond Preact, no DnD/state/icon libraries. - Keep the image and dependency surface small: no CGO, ORM, frontend framework beyond Preact, or DnD/state/icon libraries. `commonmark` and `dompurify` are the intentional runtime exception for safe, full Markdown descriptions.

1
web/dist/.gitkeep vendored
View File

@@ -1 +0,0 @@

70
web/package-lock.json generated
View File

@@ -8,10 +8,13 @@
"name": "havenllo-web", "name": "havenllo-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"commonmark": "^0.31.2",
"dompurify": "^3.4.12",
"preact": "^10.26.4" "preact": "^10.26.4"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.10.1", "@preact/preset-vite": "^2.10.1",
"@types/commonmark": "^0.27.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5"
} }
@@ -1327,6 +1330,13 @@
"win32" "win32"
] ]
}, },
"node_modules/@types/commonmark": {
"version": "0.27.10",
"resolved": "https://registry.npmjs.org/@types/commonmark/-/commonmark-0.27.10.tgz",
"integrity": "sha512-iEZobUnvlM+UX5fXWCmC4eQXwCs01Z8Xa1W0VjiWUF/XsNy4BHtskqJ9MyLZVMHbA0ezhyonCDqz3hMvsCm6Hg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1334,6 +1344,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/babel-plugin-transform-hook-names": { "node_modules/babel-plugin-transform-hook-names": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz",
@@ -1419,6 +1436,35 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/commonmark": {
"version": "0.31.2",
"resolved": "https://registry.npmjs.org/commonmark/-/commonmark-0.31.2.tgz",
"integrity": "sha512-2fRLTyb9r/2835k5cwcAwOj0DEc44FARnMp5veGsJ+mEAZdi52sNopLu07ZyElQUz058H43whzlERDIaaSw4rg==",
"license": "BSD-2-Clause",
"dependencies": {
"entities": "~3.0.1",
"mdurl": "~1.0.1",
"minimist": "~1.2.8"
},
"bin": {
"commonmark": "bin/commonmark"
},
"engines": {
"node": "*"
}
},
"node_modules/commonmark/node_modules/entities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1518,6 +1564,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1" "url": "https://github.com/fb55/domhandler?sponsor=1"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": { "node_modules/domutils": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -1725,6 +1780,21 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==",
"license": "MIT"
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@@ -9,10 +9,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"commonmark": "^0.31.2",
"dompurify": "^3.4.12",
"preact": "^10.26.4" "preact": "^10.26.4"
}, },
"devDependencies": { "devDependencies": {
"@preact/preset-vite": "^2.10.1", "@preact/preset-vite": "^2.10.1",
"@types/commonmark": "^0.27.10",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },

View File

@@ -141,11 +141,11 @@ export function App() {
await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted"); await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted");
}; };
const moveCard = async (cardID: number, sourceListID: number, targetListID: number, dropIndex: number) => { const moveCard = async (cardID: number, targetListID: number, dropIndex: number) => {
const current = boardRef.current; const current = boardRef.current;
const card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID); const card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID);
if (!current || !card) return; if (!current || !card) return;
const position = movePosition(current.lists, cardID, sourceListID, targetListID, dropIndex); const position = movePosition(current.lists, cardID, targetListID, dropIndex);
await editCard(cardID, { list_id: targetListID, position }); await editCard(cardID, { list_id: targetListID, position });
}; };
@@ -167,12 +167,11 @@ export function App() {
) : null} ) : null}
{state.board ? ( {state.board ? (
<BoardCanvas <BoardCanvas
lists={state.board.lists} lists={state.board?.lists ?? []}
onCreateList={createList} onCreateList={createList}
onEditList={editList} onEditList={editList}
onDeleteList={deleteList} onDeleteList={deleteList}
onCreateCard={createCard} onCreateCard={createCard}
onEditCard={editCard}
onMoveCard={moveCard} onMoveCard={moveCard}
onOpenCard={setSelectedCardID} onOpenCard={setSelectedCardID}
managingLists={managingLists} managingLists={managingLists}
@@ -182,6 +181,7 @@ export function App() {
<CardDetailModal <CardDetailModal
card={selectedCard} card={selectedCard}
listName={selectedList.name} listName={selectedList.name}
lists={state.board?.lists ?? []}
onClose={() => setSelectedCardID(null)} onClose={() => setSelectedCardID(null)}
onEdit={(patch) => editCard(selectedCard.id, patch)} onEdit={(patch) => editCard(selectedCard.id, patch)}
onDelete={() => deleteCard(selectedCard.id)} onDelete={() => deleteCard(selectedCard.id)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import type { ListWithCards } from "../types"; import type { Card, ListWithCards } from "../types";
import { ListColumn } from "./ListColumn"; import { ListColumn } from "./ListColumn";
interface BoardCanvasProps { interface BoardCanvasProps {
@@ -8,16 +8,43 @@ interface BoardCanvasProps {
onEditList: (id: number, patch: { name?: string; position?: number }) => Promise<void>; onEditList: (id: number, patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: (id: number) => Promise<void>; onDeleteList: (id: number) => Promise<void>;
onCreateCard: (listID: number, title: string) => Promise<void>; onCreateCard: (listID: number, title: string) => Promise<void>;
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>; onMoveCard: (cardID: number, targetListID: number, index: number) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void; onOpenCard: (id: number) => void;
managingLists: boolean; managingLists: boolean;
} }
interface ActiveCardDrag {
cardID: number;
sourceListID: number;
targetListID: number | null;
targetIndex: number | null;
pointerID: number;
}
function dropTargetAtPoint(cardID: number, clientX: number, clientY: number): Pick<ActiveCardDrag, "targetListID" | "targetIndex"> | null {
const element = document.elementFromPoint(clientX, clientY);
const column = element?.closest<HTMLElement>("[data-list-id]");
const targetListID = Number(column?.dataset.listId);
if (!column || !Number.isInteger(targetListID)) return null;
const cards = Array.from(column.querySelectorAll<HTMLElement>("[data-card-id]"))
.filter((card) => Number(card.dataset.cardId) !== cardID);
const index = cards.findIndex((card) => {
const bounds = card.getBoundingClientRect();
return clientY < bounds.top + bounds.height / 2;
});
return { targetListID, targetIndex: index === -1 ? cards.length : index };
}
export function BoardCanvas(props: BoardCanvasProps) { export function BoardCanvas(props: BoardCanvasProps) {
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [drag, setDrag] = useState<ActiveCardDrag | null>(null);
const dragRef = useRef<ActiveCardDrag | null>(null);
const cleanupDragRef = useRef<() => void>(() => undefined);
useEffect(() => () => cleanupDragRef.current(), []);
useEffect(() => { useEffect(() => {
if (!props.managingLists) { if (!props.managingLists) {
@@ -40,6 +67,69 @@ export function BoardCanvas(props: BoardCanvasProps) {
} }
}; };
const startCardDrag = (card: Card, event: PointerEvent) => {
if (dragRef.current) return;
const initial: ActiveCardDrag = {
cardID: card.id,
sourceListID: card.list_id,
targetListID: null,
targetIndex: null,
pointerID: event.pointerId
};
dragRef.current = initial;
setDrag(initial);
document.body.classList.add("is-card-dragging");
const updateTarget = (moveEvent: PointerEvent) => {
if (moveEvent.pointerId !== initial.pointerID || !dragRef.current) return;
const target = dropTargetAtPoint(initial.cardID, moveEvent.clientX, moveEvent.clientY);
const current = dragRef.current;
if (current.targetListID === target?.targetListID && current.targetIndex === target?.targetIndex) return;
const next = { ...current, targetListID: target?.targetListID ?? null, targetIndex: target?.targetIndex ?? null };
dragRef.current = next;
setDrag(next);
};
const finish = (commit: boolean) => {
const finalDrag = dragRef.current;
cleanupDragRef.current();
cleanupDragRef.current = () => undefined;
dragRef.current = null;
setDrag(null);
document.body.classList.remove("is-card-dragging");
if (!commit || !finalDrag || finalDrag.targetListID === null || finalDrag.targetIndex === null) return;
const source = props.lists.find((list) => list.id === finalDrag.sourceListID);
const sourceIndex = source?.cards.findIndex((item) => item.id === finalDrag.cardID);
if (finalDrag.targetListID === finalDrag.sourceListID && finalDrag.targetIndex === sourceIndex) return;
void props.onMoveCard(finalDrag.cardID, finalDrag.targetListID, finalDrag.targetIndex);
};
const onPointerMove = (moveEvent: PointerEvent) => updateTarget(moveEvent);
const onPointerUp = (upEvent: PointerEvent) => {
if (upEvent.pointerId === initial.pointerID) finish(true);
};
const onPointerCancel = (cancelEvent: PointerEvent) => {
if (cancelEvent.pointerId === initial.pointerID) finish(false);
};
const onKeyDown = (keyEvent: KeyboardEvent) => {
if (keyEvent.key === "Escape") finish(false);
};
const cleanup = () => {
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointercancel", onPointerCancel);
window.removeEventListener("keydown", onKeyDown);
document.body.classList.remove("is-card-dragging");
};
cleanupDragRef.current = cleanup;
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointercancel", onPointerCancel);
window.addEventListener("keydown", onKeyDown);
updateTarget(event);
};
return ( return (
<section class="board-area" aria-label="Haven task board"> <section class="board-area" aria-label="Haven task board">
<div class="board-intro"> <div class="board-intro">
@@ -53,54 +143,59 @@ export function BoardCanvas(props: BoardCanvasProps) {
</div> </div>
{props.lists.length ? ( {props.lists.length ? (
<div class={"board-rail " + (props.managingLists ? "is-managing" : "")}> <div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
{props.lists.map((list) => ( <div class="board-list-group">
<ListColumn {props.lists.map((list, index) => (
key={list.id} <ListColumn
list={list} key={list.id}
managingLists={props.managingLists} list={list}
onEditList={(patch) => props.onEditList(list.id, patch)} accent={index % 3}
onDeleteList={() => props.onDeleteList(list.id)} managingLists={props.managingLists}
onCreateCard={(title) => props.onCreateCard(list.id, title)} draggedCardID={drag?.cardID ?? null}
onEditCard={props.onEditCard} dropIndex={drag?.targetListID === list.id ? drag.targetIndex : null}
onMoveCard={props.onMoveCard} isDropTarget={drag?.targetListID === list.id}
onOpenCard={props.onOpenCard} onEditList={(patch) => props.onEditList(list.id, patch)}
/> onDeleteList={() => props.onDeleteList(list.id)}
))} onCreateCard={(title) => props.onCreateCard(list.id, title)}
{props.managingLists ? <section class="add-list-column"> onStartCardDrag={startCardDrag}
{adding ? ( onOpenCard={props.onOpenCard}
<form
class="quick-add"
onSubmit={(event) => {
event.preventDefault();
void addList();
}}
>
<label class="sr-only" htmlFor="new-list-name">New list name</label>
<input
id="new-list-name"
value={name}
maxlength={120}
autoFocus
placeholder="List name"
onInput={(event) => setName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setAdding(false);
setName("");
}
}}
/> />
<div class="quick-add-actions"> ))}
<button type="submit" class="button button-primary" disabled={saving}>Add list</button> {props.managingLists ? <section class="add-list-column">
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAdding(false)}>Cancel</button> {adding ? (
</div> <form
</form> class="quick-add"
) : ( onSubmit={(event) => {
<button type="button" class="add-list-button" onClick={() => setAdding(true)}> event.preventDefault();
<span aria-hidden="true"></span> Add list void addList();
</button> }}
)} >
</section> : null} <label class="sr-only" htmlFor="new-list-name">New list name</label>
<input
id="new-list-name"
value={name}
maxlength={120}
autoFocus
placeholder="List name"
onInput={(event) => setName(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
setAdding(false);
setName("");
}
}}
/>
<div class="quick-add-actions">
<button type="submit" class="button button-primary" disabled={saving}>Add list</button>
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAdding(false)}>Cancel</button>
</div>
</form>
) : (
<button type="button" class="add-list-button" onClick={() => setAdding(true)}>
<span aria-hidden="true"></span> Add list
</button>
)}
</section> : null}
</div>
</div> </div>
) : ( ) : (
<section class="empty-board" aria-live="polite"> <section class="empty-board" aria-live="polite">

View File

@@ -1,12 +1,14 @@
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";
import type { Card } from "../types"; import { renderMarkdown } from "../markdown";
import type { Card, CardPatch, List } from "../types";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
interface CardDetailModalProps { interface CardDetailModalProps {
card: Card; card: Card;
listName: string; listName: string;
lists: List[];
onClose: () => void; onClose: () => void;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>; onEdit: (patch: CardPatch) => Promise<void>;
onDelete: () => Promise<void>; onDelete: () => Promise<void>;
} }
@@ -19,12 +21,13 @@ function formatTimestamp(value: string): string {
}).format(timestamp); }).format(timestamp);
} }
export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: CardDetailModalProps) { export function CardDetailModal({ card, listName, lists, onClose, onEdit, onDelete }: CardDetailModalProps) {
const [title, setTitle] = useState(card.title); const [title, setTitle] = useState(card.title);
const [description, setDescription] = useState(card.description); const [description, setDescription] = useState(card.description);
const [descriptionMode, setDescriptionMode] = useState<"write" | "preview">("write");
const [savingTitle, setSavingTitle] = useState(false); const [savingTitle, setSavingTitle] = useState(false);
const [savingDescription, setSavingDescription] = useState(false); const [savingDescription, setSavingDescription] = useState(false);
const [savingStatus, setSavingStatus] = useState(false); const [savingList, setSavingList] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false);
const dialogRef = useRef<HTMLElement>(null); const dialogRef = useRef<HTMLElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null); const returnFocusRef = useRef<HTMLElement | null>(null);
@@ -38,6 +41,7 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
useEffect(() => { useEffect(() => {
setTitle(card.title); setTitle(card.title);
setDescription(card.description); setDescription(card.description);
setDescriptionMode("write");
}, [card.id]); }, [card.id]);
useEffect(() => { useEffect(() => {
@@ -86,6 +90,18 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
} }
}; };
const moveToList = async (listID: number) => {
if (listID === card.list_id) return;
setSavingList(true);
try {
await onEdit({ list_id: listID });
} catch {
// The app restores the card and shows the request error as a toast.
} finally {
setSavingList(false);
}
};
const close = () => { const close = () => {
const nextTitle = title.trim(); const nextTitle = title.trim();
if (!discardTitleSaveRef.current && nextTitle && nextTitle !== card.title) void saveTitle(); if (!discardTitleSaveRef.current && nextTitle && nextTitle !== card.title) void saveTitle();
@@ -93,17 +109,6 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
}; };
closeRef.current = close; closeRef.current = close;
const toggleDone = async () => {
setSavingStatus(true);
try {
await onEdit({ done: !card.done });
} catch {
// The app restores the card and shows the request error as a toast.
} finally {
setSavingStatus(false);
}
};
const deleteCard = async () => { const deleteCard = async () => {
setConfirmingDelete(false); setConfirmingDelete(false);
try { try {
@@ -173,18 +178,21 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
</div> </div>
<div class="detail-properties" aria-label="Card properties"> <div class="detail-properties" aria-label="Card properties">
<div class="detail-property"><span>List</span><strong>{listName}</strong></div>
<div class="detail-property detail-status"> <div class="detail-property detail-status">
<span>Status</span> <span>Status</span>
<button <strong class="status-pill">{listName}</strong>
type="button"
class={"status-pill " + (card.done ? "is-done" : "")}
disabled={savingStatus}
onClick={() => void toggleDone()}
>
<span aria-hidden="true">{card.done ? "✓" : "○"}</span> {card.done ? "Done" : "In progress"}
</button>
</div> </div>
<label class="detail-property detail-move">
<span>Move to list</span>
<select
value={String(card.list_id)}
aria-label="Move card to list"
disabled={savingList}
onChange={(event) => void moveToList(Number(event.currentTarget.value))}
>
{lists.map((list) => <option key={list.id} value={String(list.id)}>{list.name}</option>)}
</select>
</label>
</div> </div>
<section class="detail-description" aria-labelledby="detail-description-heading"> <section class="detail-description" aria-labelledby="detail-description-heading">
@@ -195,18 +203,48 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
</div> </div>
{descriptionChanged ? <span class="unsaved-chip">Unsaved changes</span> : null} {descriptionChanged ? <span class="unsaved-chip">Unsaved changes</span> : null}
</div> </div>
<textarea <div class="markdown-tabs" role="tablist" aria-label="Description editor mode">
value={description} <button
maxlength={10000} type="button"
rows={7} role="tab"
placeholder="Add useful context, acceptance criteria, or a helpful note…" aria-selected={descriptionMode === "write"}
aria-label="Card description" class={descriptionMode === "write" ? "is-active" : ""}
disabled={savingDescription} onClick={() => setDescriptionMode("write")}
onInput={(event) => setDescription(event.currentTarget.value)} >
onKeyDown={(event) => { Write
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void saveDescription(); </button>
}} <button
/> type="button"
role="tab"
aria-selected={descriptionMode === "preview"}
class={descriptionMode === "preview" ? "is-active" : ""}
onClick={() => setDescriptionMode("preview")}
>
Preview
</button>
<span>CommonMark supported</span>
</div>
{descriptionMode === "write" ? (
<textarea
value={description}
maxlength={10000}
rows={7}
placeholder="Add useful context, acceptance criteria, or a helpful note…"
aria-label="Card description in Markdown"
disabled={savingDescription}
onInput={(event) => setDescription(event.currentTarget.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void saveDescription();
}}
/>
) : (
<div
class="markdown-preview"
role="tabpanel"
aria-label="Description preview"
dangerouslySetInnerHTML={{ __html: renderMarkdown(description) }}
/>
)}
<div class="detail-save-row"> <div class="detail-save-row">
<span>Changes save independently. Press Ctrl + Enter to save.</span> <span>Changes save independently. Press Ctrl + Enter to save.</span>
<button type="button" class="button button-primary" disabled={!descriptionChanged || savingDescription} onClick={() => void saveDescription()}> <button type="button" class="button button-primary" disabled={!descriptionChanged || savingDescription} onClick={() => void saveDescription()}>

View File

@@ -1,73 +1,53 @@
import { useRef, useState } from "preact/hooks"; import { markdownToPlainText } from "../markdown";
import { beginCardDrag } from "../drag";
import type { Card } from "../types"; import type { Card } from "../types";
interface CardItemProps { interface CardItemProps {
card: Card; card: Card;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>; listName: string;
accent: number;
dragging: boolean;
onOpen: () => void; onOpen: () => void;
onDragStart: (event: PointerEvent) => void;
} }
export function CardItem({ card, onEdit, onOpen }: CardItemProps) { export function CardItem({ card, listName, accent, dragging, onOpen, onDragStart }: CardItemProps) {
const [saving, setSaving] = useState(false); const preview = card.description ? markdownToPlainText(card.description) : "";
const didDrag = useRef(false);
const toggleDone = async () => {
setSaving(true);
try {
await onEdit({ done: !card.done });
} catch {
// The app rolls back the optimistic change and shows an error toast.
} finally {
setSaving(false);
}
};
const openIfClicked = () => {
if (didDrag.current) {
didDrag.current = false;
return;
}
onOpen();
};
return ( return (
<article <article
class={"card " + (card.done ? "card-done" : "")} class={"card " + (dragging ? "is-dragging" : "")}
draggable data-card-id={card.id}
data-accent={accent}
tabIndex={0} tabIndex={0}
onClick={openIfClicked} onClick={onOpen}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault(); event.preventDefault();
openIfClicked(); onOpen();
} }
}} }}
onDragStart={(event) => {
didDrag.current = true;
beginCardDrag(event as unknown as DragEvent, card);
}}
onDragEnd={() => window.setTimeout(() => { didDrag.current = false; }, 0)}
> >
<div class="card-topline"> <div class="card-topline">
<button <button
type="button" type="button"
class={"done-button " + (card.done ? "is-done" : "")} class="card-grip"
aria-label={card.done ? "Mark card active" : "Mark card done"} aria-label={"Drag \"" + card.title + "\""}
title={card.done ? "Mark active" : "Mark done"} title="Drag card"
disabled={saving} onPointerDown={(event) => {
onClick={(event) => { if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
event.stopPropagation(); event.stopPropagation();
void toggleDone(); onDragStart(event as unknown as PointerEvent);
}} }}
onClick={(event) => event.stopPropagation()}
> >
{card.done ? "✓" : ""} <i></i><i></i><i></i><i></i><i></i><i></i>
</button> </button>
<span class="card-grip" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i><i></i></span>
<h3 class="card-title">{card.title}</h3> <h3 class="card-title">{card.title}</h3>
<span class="card-status" data-accent={accent}>{listName}</span>
</div> </div>
{card.description ? <p class="card-preview">{card.description}</p> : null} {preview ? <p class="card-preview">{preview}</p> : null}
</article> </article>
); );
} }

View File

@@ -1,58 +1,34 @@
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { cardDragType, readCardDrag } from "../drag";
import type { Card, ListWithCards } from "../types"; import type { Card, ListWithCards } from "../types";
import { CardItem } from "./CardItem"; import { CardItem } from "./CardItem";
import { ConfirmDialog } from "./ConfirmDialog"; import { ConfirmDialog } from "./ConfirmDialog";
interface ListColumnProps { interface ListColumnProps {
list: ListWithCards; list: ListWithCards;
accent: number;
managingLists: boolean; managingLists: boolean;
draggedCardID: number | null;
dropIndex: number | null;
isDropTarget: boolean;
onEditList: (patch: { name?: string; position?: number }) => Promise<void>; onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: () => Promise<void>; onDeleteList: () => Promise<void>;
onCreateCard: (title: string) => Promise<void>; onCreateCard: (title: string) => Promise<void>;
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>; onStartCardDrag: (card: Card, event: PointerEvent) => void;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void; onOpenCard: (id: number) => void;
} }
interface DropZoneProps {
index: number;
onDropCard: (cardID: number, sourceListID: number, index: number) => Promise<void>;
}
function DropZone({ index, onDropCard }: DropZoneProps) {
const [active, setActive] = useState(false);
return (
<div
class={"drop-zone " + (active ? "is-active" : "")}
aria-label="Drop card here"
onDragOver={(event) => {
if (event.dataTransfer?.types.includes(cardDragType)) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setActive(true);
}
}}
onDragLeave={() => setActive(false)}
onDrop={(event) => {
event.preventDefault();
setActive(false);
const payload = readCardDrag(event as unknown as DragEvent);
if (payload) void onDropCard(payload.cardID, payload.sourceListID, index);
}}
/>
);
}
export function ListColumn({ export function ListColumn({
list, list,
accent,
onEditList, onEditList,
onDeleteList, onDeleteList,
onCreateCard, onCreateCard,
onEditCard, onStartCardDrag,
onMoveCard,
onOpenCard, onOpenCard,
managingLists managingLists,
draggedCardID,
dropIndex,
isDropTarget
}: ListColumnProps) { }: ListColumnProps) {
const [editingName, setEditingName] = useState(false); const [editingName, setEditingName] = useState(false);
const [name, setName] = useState(list.name); const [name, setName] = useState(list.name);
@@ -60,6 +36,8 @@ export function ListColumn({
const [cardTitle, setCardTitle] = useState(""); const [cardTitle, setCardTitle] = useState("");
const [confirming, setConfirming] = useState(false); const [confirming, setConfirming] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const cardsWithoutDragged = list.cards.filter((card) => card.id !== draggedCardID);
const insertionBeforeID = isDropTarget ? cardsWithoutDragged[dropIndex ?? cardsWithoutDragged.length]?.id : undefined;
useEffect(() => { useEffect(() => {
if (!managingLists && editingName) { if (!managingLists && editingName) {
@@ -112,7 +90,11 @@ export function ListColumn({
return ( return (
<> <>
<section class={"list-column " + (managingLists ? "is-managing" : "")}> <section
class={"list-column " + (managingLists ? "is-managing " : "") + (isDropTarget ? "is-drop-target" : "")}
data-list-id={list.id}
data-accent={accent}
>
<div class="list-header"> <div class="list-header">
<div class="list-heading"> <div class="list-heading">
{editingName ? ( {editingName ? (
@@ -149,18 +131,22 @@ export function ListColumn({
</button> : null} </button> : null}
</div> </div>
<div class="cards-stack"> <div class="cards-stack">
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} /> {list.cards.map((card) => (
{list.cards.map((card: Card, index) => (
<div class="card-slot" key={card.id}> <div class="card-slot" key={card.id}>
{insertionBeforeID === card.id ? <div class="card-insertion-marker" aria-hidden="true" /> : null}
<CardItem <CardItem
card={card} card={card}
onEdit={(patch) => onEditCard(card.id, patch)} listName={list.name}
accent={accent}
dragging={card.id === draggedCardID}
onDragStart={(event) => onStartCardDrag(card, event)}
onOpen={() => onOpenCard(card.id)} onOpen={() => onOpenCard(card.id)}
/> />
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
</div> </div>
))} ))}
{!list.cards.length ? <p class="list-empty">No cards yet. Drop one here or add the first task.</p> : null} {isDropTarget && !insertionBeforeID ? <div class="card-insertion-marker" aria-hidden="true" /> : null}
{!list.cards.length && !isDropTarget ? <p class="list-empty">No cards yet. Drop one here or add the first task.</p> : null}
{!list.cards.length && isDropTarget ? <p class="list-empty is-dropping">Release to move the card here.</p> : null}
</div> </div>
{addingCard ? ( {addingCard ? (
<form <form
@@ -197,7 +183,7 @@ export function ListColumn({
</section> </section>
<ConfirmDialog <ConfirmDialog
open={confirming} open={confirming}
title={"Delete " + list.name + "”? "} title={"Delete \"" + list.name + "\"?"}
detail="All cards in this list will be permanently removed." detail="All cards in this list will be permanently removed."
confirmLabel="Delete list" confirmLabel="Delete list"
onCancel={() => setConfirming(false)} onCancel={() => setConfirming(false)}

View File

@@ -1,45 +1,15 @@
import type { Card, ListWithCards } from "./types"; import type { ListWithCards } from "./types";
export const cardDragType = "application/x-havenllo-card";
export interface CardDragPayload {
cardID: number;
sourceListID: number;
}
export function beginCardDrag(event: DragEvent, card: Card): void {
if (!event.dataTransfer) return;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData(cardDragType, JSON.stringify({ cardID: card.id, sourceListID: card.list_id }));
event.dataTransfer.setData("text/plain", String(card.id));
}
export function readCardDrag(event: DragEvent): CardDragPayload | null {
try {
const raw = event.dataTransfer?.getData(cardDragType);
if (!raw) return null;
const payload = JSON.parse(raw) as CardDragPayload;
if (!Number.isInteger(payload.cardID) || !Number.isInteger(payload.sourceListID)) return null;
return payload;
} catch {
return null;
}
}
export function movePosition( export function movePosition(
lists: ListWithCards[], lists: ListWithCards[],
cardID: number, cardID: number,
sourceListID: number,
targetListID: number, targetListID: number,
dropIndex: number dropIndex: number
): number { ): number {
const target = lists.find((list) => list.id === targetListID); const target = lists.find((list) => list.id === targetListID);
if (!target) return 1024; if (!target) return 1024;
const sourceIndex = target.cards.findIndex((card) => card.id === cardID);
const cards = target.cards.filter((card) => card.id !== cardID); const cards = target.cards.filter((card) => card.id !== cardID);
let index = dropIndex; const index = Math.max(0, Math.min(dropIndex, cards.length));
if (sourceListID === targetListID && sourceIndex !== -1 && sourceIndex < dropIndex) index -= 1;
index = Math.max(0, Math.min(index, cards.length));
const previous = cards[index - 1]; const previous = cards[index - 1];
const next = cards[index]; const next = cards[index];
if (!previous && !next) return 1024; if (!previous && !next) return 1024;
@@ -47,4 +17,3 @@ export function movePosition(
if (!next) return previous.position + 1024; if (!next) return previous.position + 1024;
return (previous.position + next.position) / 2; return (previous.position + next.position) / 2;
} }

15
web/src/markdown.ts Normal file
View File

@@ -0,0 +1,15 @@
import * as commonmark from "commonmark";
import DOMPurify from "dompurify";
const parser = new commonmark.Parser();
const renderer = new commonmark.HtmlRenderer({ safe: true });
export function renderMarkdown(source: string): string {
return DOMPurify.sanitize(renderer.render(parser.parse(source)));
}
export function markdownToPlainText(source: string): string {
const container = document.createElement("div");
container.innerHTML = renderMarkdown(source);
return (container.textContent || "").replace(/\s+/g, " ").trim();
}

View File

@@ -36,13 +36,13 @@ body {
var(--canvas); var(--canvas);
} }
button, input, textarea { font: inherit; } button, input, textarea, select { font: inherit; }
button { color: inherit; } button { color: inherit; }
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; } button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled) { touch-action: manipulation; }
button:focus-visible, input:focus-visible, textarea:focus-visible, [tabindex="-1"]:focus-visible { button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, [tabindex="-1"]:focus-visible {
outline: 3px solid rgba(101, 218, 203, 0.7); outline: 3px solid rgba(101, 218, 203, 0.7);
outline-offset: 3px; outline-offset: 3px;
} }
@@ -179,9 +179,6 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
width: min(100%, 1530px); width: min(100%, 1530px);
min-height: 320px; min-height: 320px;
margin: 0 auto; margin: 0 auto;
display: flex;
align-items: flex-start;
gap: 16px;
overflow-x: auto; overflow-x: auto;
overscroll-behavior-inline: contain; overscroll-behavior-inline: contain;
padding: 2px 2px 28px; padding: 2px 2px 28px;
@@ -190,6 +187,15 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.board-rail.is-managing { padding-top: 5px; } .board-rail.is-managing { padding-top: 5px; }
.board-list-group {
width: max-content;
min-width: 100%;
display: flex;
align-items: flex-start;
justify-content: center;
gap: 16px;
}
.list-column, .add-list-column { .list-column, .add-list-column {
width: min(318px, calc(100vw - 48px)); width: min(318px, calc(100vw - 48px));
flex: 0 0 318px; flex: 0 0 318px;
@@ -200,12 +206,17 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
} }
.list-column { .list-column {
--list-accent: var(--teal);
--list-accent-soft: var(--teal-soft);
padding: 14px; padding: 14px;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
} }
.list-column:hover { border-color: rgba(160, 183, 220, 0.26); box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28); } .list-column:hover { border-color: rgba(160, 183, 220, 0.26); box-shadow: 0 20px 44px rgba(0, 0, 0, 0.28); }
.list-column.is-managing { border-color: rgba(101, 218, 203, 0.34); box-shadow: 0 0 0 1px rgba(101, 218, 203, 0.07), var(--shadow); } .list-column.is-managing { border-color: rgba(101, 218, 203, 0.34); box-shadow: 0 0 0 1px rgba(101, 218, 203, 0.07), var(--shadow); }
.list-column.is-drop-target { border-color: var(--list-accent); box-shadow: 0 0 0 2px var(--list-accent-soft), 0 20px 44px rgba(0, 0, 0, 0.28); }
.list-column[data-accent="1"] { --list-accent: var(--indigo); --list-accent-soft: rgba(145, 164, 255, 0.16); }
.list-column[data-accent="2"] { --list-accent: #f1c77a; --list-accent-soft: rgba(241, 199, 122, 0.15); }
.list-header, .list-heading, .card-topline, .quick-add-actions, .dialog-actions, .detail-header, .detail-title-row, .detail-section-heading, .detail-save-row, .detail-footer { .list-header, .list-heading, .card-topline, .quick-add-actions, .dialog-actions, .detail-header, .detail-title-row, .detail-section-heading, .detail-save-row, .detail-footer {
display: flex; display: flex;
@@ -247,77 +258,92 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
background: rgba(140, 164, 201, 0.13); background: rgba(140, 164, 201, 0.13);
} }
.list-column .count-badge { color: var(--list-accent); background: var(--list-accent-soft); }
.list-delete { width: 34px; min-height: 34px; border-color: transparent; color: var(--faint); } .list-delete { width: 34px; min-height: 34px; border-color: transparent; color: var(--faint); }
.list-delete:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); } .list-delete:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); }
.cards-stack { min-height: 48px; } .cards-stack { min-height: 48px; }
.card-slot { position: relative; } .card-slot { position: relative; }
.card-slot + .card-slot { margin-top: 8px; }
.drop-zone { .card-insertion-marker {
height: 8px; height: 5px;
margin: 1px 0; margin: 5px 3px;
border-radius: 7px; border-radius: 999px;
transition: height 140ms ease, background 140ms ease, outline-color 140ms ease; background: var(--list-accent);
box-shadow: 0 0 0 4px var(--list-accent-soft), 0 0 14px var(--list-accent);
animation: insertion-pulse 760ms ease-in-out infinite alternate;
} }
.drop-zone.is-active { height: 28px; background: rgba(101, 218, 203, 0.16); outline: 1px dashed var(--teal); }
.card { .card {
padding: 12px; padding: 12px;
border: 1px solid rgba(169, 192, 225, 0.15); border: 1px solid rgba(169, 192, 225, 0.15);
border-radius: 13px; border-radius: 13px;
color: var(--ink); color: var(--ink);
cursor: grab; cursor: pointer;
background: linear-gradient(145deg, rgba(31, 48, 76, 0.96), rgba(18, 30, 49, 0.97)); background: linear-gradient(145deg, rgba(31, 48, 76, 0.96), rgba(18, 30, 49, 0.97));
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14); box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, opacity 150ms ease; transition: transform 150ms ease, border-color 150ms ease, box-shadow 150ms ease, background 150ms ease, opacity 150ms ease;
} }
.card:hover { border-color: rgba(128, 205, 209, 0.43); background: linear-gradient(145deg, rgba(35, 54, 84, 0.98), rgba(20, 34, 55, 0.98)); box-shadow: var(--shadow-lifted); transform: translateY(-2px); } .card:hover { border-color: rgba(128, 205, 209, 0.43); background: linear-gradient(145deg, rgba(35, 54, 84, 0.98), rgba(20, 34, 55, 0.98)); box-shadow: var(--shadow-lifted); transform: translateY(-2px); }
.card:active { cursor: grabbing; transform: scale(0.987); }
.card:focus-visible { outline: 3px solid rgba(101, 218, 203, 0.7); outline-offset: 3px; } .card:focus-visible { outline: 3px solid rgba(101, 218, 203, 0.7); outline-offset: 3px; }
.card-done { opacity: 0.72; } .card.is-dragging { opacity: 0.42; transform: scale(0.985); }
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
body.is-card-dragging, body.is-card-dragging * { cursor: grabbing !important; user-select: none; }
.card-topline { align-items: flex-start; gap: 8px; } .card-topline { align-items: flex-start; gap: 8px; }
.done-button { .card-grip {
flex: 0 0 auto; flex: 0 0 auto;
width: 24px; width: 24px;
min-height: 24px; min-height: 24px;
margin-top: 1px; margin-top: 1px;
padding: 0;
border: 1.5px solid var(--faint);
border-radius: 8px;
color: #07131e;
cursor: pointer;
font-size: 0.82rem;
font-weight: 900;
background: transparent;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease;
}
.done-button:hover:not(:disabled) { border-color: var(--teal); transform: scale(1.08); }
.done-button.is-done { border-color: var(--teal); background: var(--teal); }
.card-grip {
flex: 0 0 auto;
display: grid; display: grid;
grid-template-columns: repeat(2, 2px); grid-template-columns: repeat(2, 2px);
place-content: center;
gap: 2px; gap: 2px;
margin-top: 7px; padding: 0;
opacity: 0.42; border: 1px solid transparent;
transition: opacity 140ms ease; border-radius: 8px;
cursor: grab;
background: transparent;
opacity: 0.48;
touch-action: none;
transition: transform 140ms ease, border-color 140ms ease, background 140ms ease, opacity 140ms ease;
} }
.card:hover .card-grip { opacity: 0.9; } .card-grip:hover, .card-grip:focus-visible { border-color: var(--teal); background: var(--teal-soft); opacity: 1; transform: scale(1.06); }
.card:hover .card-grip { opacity: 0.92; }
.card-grip i { width: 2px; height: 2px; border-radius: 50%; background: var(--muted); } .card-grip i { width: 2px; height: 2px; border-radius: 50%; background: var(--muted); }
.card-title { flex: 1; min-width: 0; margin: 0; font-size: 0.92rem; font-weight: 720; letter-spacing: -0.012em; line-height: 1.4; } .card-title { flex: 1; min-width: 0; margin: 0; font-size: 0.92rem; font-weight: 720; letter-spacing: -0.012em; line-height: 1.4; }
.card-status, .status-pill {
display: inline-flex;
align-items: center;
min-width: 0;
max-width: 112px;
min-height: 28px;
padding: 5px 9px;
overflow: hidden;
border: 1px solid rgba(145, 164, 255, 0.32);
border-radius: 999px;
color: #cdd5ff;
font-size: 0.72rem;
font-weight: 740;
line-height: 1;
text-overflow: ellipsis;
white-space: nowrap;
background: rgba(145, 164, 255, 0.1);
}
.card-status { max-width: 92px; color: var(--list-accent, var(--indigo)); border-color: color-mix(in srgb, var(--list-accent, var(--indigo)) 48%, transparent); background: var(--list-accent-soft, rgba(145, 164, 255, 0.1)); }
.card-preview { .card-preview {
display: -webkit-box; display: -webkit-box;
margin: 9px 1px 0 34px; margin: 9px 1px 0 32px;
overflow: hidden; overflow: hidden;
color: var(--muted); color: var(--muted);
font-size: 0.78rem; font-size: 0.78rem;
@@ -328,7 +354,7 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.77rem; font-weight: 680; } label { display: grid; gap: 6px; color: var(--muted); font-size: 0.77rem; font-weight: 680; }
input, textarea { input, textarea, select {
width: 100%; width: 100%;
padding: 10px 11px; padding: 10px 11px;
border: 1px solid rgba(151, 174, 211, 0.28); border: 1px solid rgba(151, 174, 211, 0.28);
@@ -339,8 +365,8 @@ input, textarea {
transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease; transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
} }
input:hover, textarea:hover { border-color: rgba(151, 174, 211, 0.45); } input:hover, textarea:hover, select:hover { border-color: rgba(151, 174, 211, 0.45); }
input:focus, textarea:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); } input:focus, textarea:focus, select:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); }
textarea { min-height: 96px; resize: vertical; } textarea { min-height: 96px; resize: vertical; }
.button { .button {
@@ -482,22 +508,9 @@ textarea { min-height: 96px; resize: vertical; }
.detail-property strong { overflow: hidden; color: var(--ink); font-size: 0.87rem; text-overflow: ellipsis; white-space: nowrap; } .detail-property strong { overflow: hidden; color: var(--ink); font-size: 0.87rem; text-overflow: ellipsis; white-space: nowrap; }
.detail-status { display: flex; align-content: initial; align-items: center; justify-content: space-between; gap: 8px; } .detail-status { display: flex; align-content: initial; align-items: center; justify-content: space-between; gap: 8px; }
.detail-status .status-pill { max-width: 190px; color: var(--teal); border-color: rgba(101, 218, 203, 0.42); background: var(--teal-soft); }
.status-pill { .detail-move { grid-column: 1 / -1; }
min-height: 32px; .detail-move select { min-height: 38px; padding-top: 7px; padding-bottom: 7px; font-size: 0.84rem; }
padding: 5px 9px;
border: 1px solid rgba(145, 164, 255, 0.32);
border-radius: 999px;
color: #cdd5ff;
cursor: pointer;
font-size: 0.75rem;
font-weight: 740;
background: rgba(145, 164, 255, 0.1);
transition: transform 140ms ease, border-color 140ms ease, color 140ms ease, background 140ms ease;
}
.status-pill:hover:not(:disabled) { transform: translateY(-1px); border-color: var(--teal); color: var(--teal); }
.status-pill.is-done { border-color: rgba(101, 218, 203, 0.45); color: #07161d; background: var(--teal); }
.detail-description { margin-left: 27px; } .detail-description { margin-left: 27px; }
.detail-section-heading { justify-content: space-between; gap: 12px; margin-bottom: 10px; } .detail-section-heading { justify-content: space-between; gap: 12px; margin-bottom: 10px; }
@@ -506,6 +519,38 @@ textarea { min-height: 96px; resize: vertical; }
.unsaved-chip { padding: 4px 8px; border: 1px solid rgba(145, 164, 255, 0.34); border-radius: 999px; color: #cbd4ff; font-size: 0.69rem; font-weight: 720; background: rgba(145, 164, 255, 0.11); } .unsaved-chip { padding: 4px 8px; border: 1px solid rgba(145, 164, 255, 0.34); border-radius: 999px; color: #cbd4ff; font-size: 0.69rem; font-weight: 720; background: rgba(145, 164, 255, 0.11); }
.detail-description textarea { min-height: 150px; } .detail-description textarea { min-height: 150px; }
.markdown-tabs { display: flex; align-items: center; gap: 4px; margin-bottom: 9px; }
.markdown-tabs button { min-height: 30px; padding: 5px 9px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); font-size: 0.74rem; font-weight: 730; cursor: pointer; background: transparent; }
.markdown-tabs button:hover, .markdown-tabs button.is-active { color: var(--ink); border-color: var(--border); background: rgba(151, 174, 211, 0.1); }
.markdown-tabs button.is-active { color: var(--teal); border-color: rgba(101, 218, 203, 0.35); background: var(--teal-soft); }
.markdown-tabs span { margin-left: auto; color: var(--faint); font-size: 0.71rem; }
.markdown-preview {
min-height: 150px;
padding: 13px;
overflow-wrap: anywhere;
border: 1px solid rgba(151, 174, 211, 0.28);
border-radius: 10px;
color: var(--ink);
line-height: 1.58;
background: rgba(6, 13, 27, 0.43);
}
.markdown-preview > :first-child { margin-top: 0; }
.markdown-preview > :last-child { margin-bottom: 0; }
.markdown-preview h1, .markdown-preview h2, .markdown-preview h3, .markdown-preview h4 { margin: 1.1em 0 0.5em; color: var(--ink); line-height: 1.22; }
.markdown-preview h1 { font-size: 1.5rem; }
.markdown-preview h2 { font-size: 1.25rem; }
.markdown-preview h3, .markdown-preview h4 { font-size: 1.04rem; }
.markdown-preview p, .markdown-preview ul, .markdown-preview ol, .markdown-preview blockquote, .markdown-preview pre { margin: 0 0 0.9em; }
.markdown-preview ul, .markdown-preview ol { padding-left: 1.35rem; }
.markdown-preview blockquote { padding-left: 12px; border-left: 3px solid var(--teal); color: var(--muted); }
.markdown-preview code { padding: 0.13em 0.34em; border-radius: 5px; color: #c9f7ef; background: rgba(101, 218, 203, 0.12); }
.markdown-preview pre { overflow-x: auto; padding: 12px; border-radius: 9px; background: rgba(4, 10, 22, 0.7); }
.markdown-preview pre code { padding: 0; background: transparent; }
.markdown-preview a { color: var(--teal); }
.markdown-preview hr { border: 0; border-top: 1px solid var(--border); }
.detail-save-row { justify-content: space-between; gap: 16px; margin-top: 9px; color: var(--faint); font-size: 0.74rem; line-height: 1.4; } .detail-save-row { justify-content: space-between; gap: 16px; margin-top: 9px; color: var(--faint); font-size: 0.74rem; line-height: 1.4; }
.detail-save-row .button { flex: 0 0 auto; } .detail-save-row .button { flex: 0 0 auto; }
@@ -556,6 +601,7 @@ textarea { min-height: 96px; resize: vertical; }
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } } @keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
@keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(9px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } } @keyframes modal-in { from { opacity: 0; transform: translateY(9px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes insertion-pulse { from { opacity: 0.72; } to { opacity: 1; } }
@media (max-width: 700px) { @media (max-width: 700px) {
.topbar { min-height: 66px; padding: 10px 16px; gap: 12px; } .topbar { min-height: 66px; padding: 10px 16px; gap: 12px; }
@@ -565,6 +611,7 @@ textarea { min-height: 96px; resize: vertical; }
.board-intro { align-items: flex-start; margin-bottom: 19px; } .board-intro { align-items: flex-start; margin-bottom: 19px; }
.board-hint { display: none; } .board-hint { display: none; }
.board-rail { width: calc(100% + 16px); margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; } .board-rail { width: calc(100% + 16px); margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
.board-list-group { justify-content: flex-start; }
.list-column, .add-list-column { scroll-snap-align: start; } .list-column, .add-list-column { scroll-snap-align: start; }
.card-detail { max-height: calc(100vh - 20px); border-radius: 18px; } .card-detail { max-height: calc(100vh - 20px); border-radius: 18px; }
.detail-backdrop, .dialog-backdrop { padding: 10px; } .detail-backdrop, .dialog-backdrop { padding: 10px; }

View File

@@ -4,7 +4,6 @@ export interface Card {
title: string; title: string;
description: string; description: string;
position: number; position: number;
done: boolean;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -39,8 +38,6 @@ export interface ListPatch {
export interface CardPatch { export interface CardPatch {
title?: string; title?: string;
description?: string; description?: string;
done?: boolean;
list_id?: number; list_id?: number;
position?: number; position?: number;
} }