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
}
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 {
writeDomainError(w, err)

View File

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

View File

@@ -25,7 +25,6 @@ type Card struct {
Title string `json:"title"`
Description string `json:"description"`
Position float64 `json:"position"`
Done bool `json:"done"`
CreatedAt string `json:"created_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) {
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)
if err != nil {
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) {
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)
card, err := scanCard(row)
if err == sql.ErrNoRows {
@@ -84,10 +84,7 @@ type scanner interface{ Scan(...any) error }
func scanCard(row scanner) (domain.Card, error) {
var card domain.Card
var done int
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
return card, row.Scan(&card.ID, &card.ListID, &card.Title, &card.Description, &card.Position, &card.CreatedAt, &card.UpdatedAt)
}
func Snapshot(ctx context.Context, q DBTX) (domain.Snapshot, error) {

View File

@@ -1,130 +1,126 @@
# 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
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.
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.
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.
## Technology Stack
- Go module: `havenllo`
- Go version: `1.24`
- Backend dependencies: `modernc.org/sqlite` (pure-Go, CGO-free — keeps `CGO_ENABLED=0`)
- Frontend dependencies: `preact` only (runtime); `@preact/preset-vite`, `typescript`, `vite` (build)
- Frontend DnD: native HTML5 drag-and-drop (no DnD library)
- Static assets: embedded with Go `embed` (`web/embed.go`)
- Runtime port: `8080`
- Environment: `HAVENLLO_LISTEN_ADDR` (default `:8080`), `HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`)
- 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`
- Go module `havenllo`, Go 1.24.
- Backend: `modernc.org/sqlite`, pure Go and CGO-free.
- Frontend runtime: `preact`, `commonmark`, and `dompurify`; build tooling: `@preact/preset-vite`, `@types/commonmark`, `typescript`, and `vite`.
- Card movement: custom Pointer Events interaction; no DnD library.
- Static SPA assets are embedded with Go `embed` from `web/dist`.
- Runtime port: `8080`; `HAVENLLO_LISTEN_ADDR` defaults to `:8080` and `HAVENLLO_DATABASE_PATH` defaults to `/data/havenllo.db`.
- Docker: `node:22-alpine` frontend build, `golang:1.24-alpine` CGO-free build, then `gcr.io/distroless/static-debian12`.
- Deployment: one `Recreate` Deployment replica, `nfs-client` PVC, plain nginx ingress `havenllo.haven` in `default`, no TLS.
## 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.
- `internal/domain/models.go`: core domain types — `List`, `Card`, `Snapshot` — plus sentinel errors `ErrNotFound`, `ErrLastList`, and `ValidationError`. No dependencies beyond `errors`.
- `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/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/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/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/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/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/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.
- `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`: domain `List`, `Card`, `Snapshot`, sentinel errors, and validation error type. Active cards have no completion boolean.
- `internal/database/database.go`: parent-directory setup, WAL/busy timeout/foreign-key connection settings, and migrations.
- `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`: parameterized SQL queries and scans only. Card queries intentionally omit `cards.done`.
- `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 with pointer fields for optional scalar values.
- `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 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/vite.config.ts`: Preact preset; deterministic `web/dist` output.
- `web/package.json`: `havenllo-web`, scripts `dev` / `build` (`tsc --noEmit && vite build`) / `preview`; `preact` dependency only.
- `web/src/main.tsx`: Preact `render(<App/>, document.getElementById("app"))` bootstrap.
- `web/src/types.ts`: TypeScript mirrors of the API resources (`Card`, `List`, `ListWithCards`, `Board`, `CardPatch`, `ListPatch`).
- `web/src/api.ts`: typed fetch client (`APIError` class, `request<T>` helper). All calls are same-origin under `/api`.
- `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/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`: 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 wordmark, "Haven homelab / My board" label, reload button, and the active/inactive Edit toggle for list management.
- `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`: 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`: 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 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 confirmation dialog used before any delete.
- `web/src/components/Toast.tsx`: non-blocking toast notifications (auto-dismiss ~4.4s).
- `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.
- `web/package.json`: frontend scripts and the Preact/CommonMark/sanitization dependencies.
- `web/src/main.tsx`: Preact bootstrap.
- `web/src/types.ts`: API resource and patch types. `Card` and `CardPatch` contain no `done` member.
- `web/src/api.ts`: typed same-origin `/api` fetch client and API error handling.
- `web/src/board-state.ts`: reducer, optimistic board transforms, rollback state, and toasts.
- `web/src/drag.ts`: `movePosition`, which calculates a fractional position from a target index after removing the dragged card.
- `web/src/markdown.ts`: safe CommonMark renderer using `commonmark` plus DOMPurify, and plain-text extraction for card summaries.
- `web/src/App.tsx`: board loading, optimistic mutations, selected-card state, and component wiring.
- `web/src/components/Header.tsx`: brand, board label, refresh, and list-management toggle.
- `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/ListColumn.tsx`: column header/count, drop target and insertion marker, inline card creation, and management-only rename/delete controls.
- `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/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/ConfirmDialog.tsx`: accessible destructive-action confirmation.
- `web/src/components/Toast.tsx`: auto-dismissing non-blocking feedback.
- `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.
### 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
Layered, dependency-light:
```
main.go → api.Handler → app.Service → store (SQL) → database (sqlite.DB)
↑ ↓
domain (types/errors) domain (types/errors)
```text
main.go -> api.Handler -> app.Service -> store -> database (SQLite)
^ |
domain <---------------+
```
- `database` owns connection + migrations (incl. first-run seed).
- `store` owns SQL text and row scanning only.
- `app.Service` owns ordering, validation, last-list protection, and wraps every state change in a transaction.
- `api` owns HTTP routing, JSON (de)serialization, and error shaping. It never exposes SQL errors.
- `domain` is the shared vocabulary (types + sentinel errors) with no internal deps.
- `database` owns connections and migrations.
- `store` owns SQL text and scanning only.
- `app.Service` owns validation, transactions, ordering, and cross-resource behavior.
- `api` owns routing, JSON, and HTTP error shapes.
- `domain` contains shared dependency-free vocabulary.
## 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`.
- `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)`.
- `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 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
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 |
| --- | --- | --- |
| GET | `/api/health` | Liveness; pings SQLite, returns `200 {"status":"ok"}` (K8s probe target) |
| GET | `/api/board` | Full board: `{ "lists": [ {id,name,position,card_count?,cards:[Card] } ] }` |
| GET | `/api/lists` | List collections with `card_count`, no card bodies |
| POST | `/api/lists` | Create list `{"name","position"?}` `201 {list}` |
| GET | `/api/lists/{id}` | One list with its cards |
| PATCH | `/api/lists/{id}` | `{"name"?,"position"?}` `200 {list}` |
| DELETE | `/api/lists/{id}` | `204`; cascades cards; `409` if it is the last list |
| GET | `/api/lists/{id}/cards` | Cards in a list, ordered |
| POST | `/api/lists/{id}/cards` | Create card `{"title","description"?,"position"?}` `201 {card}` |
| GET | `/api/cards/{id}` | One card |
| PATCH | `/api/cards/{id}` | `{"title"?,"description"?,"done"?,"list_id"?,"position"?}` `200 {card}` (move + reorder in one tx) |
| DELETE | `/api/cards/{id}` | `204` |
| GET | `/api/health` | SQLite liveness response: `200 {"status":"ok"}`. |
| GET | `/api/board` | Full board: `{ "lists": [ListWithCards] }`. |
| GET | `/api/lists` | List summaries with `card_count`, without card bodies. |
| POST | `/api/lists` | Create: `{"name","position"?}` -> `201 {list}`. |
| GET | `/api/lists/{id}` | One list and its cards. |
| PATCH | `/api/lists/{id}` | Update: `{"name"?,"position"?}` -> `{list}`. |
| DELETE | `/api/lists/{id}` | `204`; cascades cards, or `409` for the final list. |
| GET | `/api/lists/{id}/cards` | Ordered cards in one list. |
| POST | `/api/lists/{id}/cards` | Create: `{"title","description"?,"position"?}` -> `201 {card}`. |
| GET | `/api/cards/{id}` | One card. |
| PATCH | `/api/cards/{id}` | Update or move: `{"title"?,"description"?,"list_id"?,"position"?}` -> `{card}` atomically. |
| 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
- Loads `/api/board` on mount; no board picker (single fixed board).
- Native HTML5 drag-and-drop reorders cards within and across lists; drop computes a fractional position, updates optimistically, and reconciles/rolls back 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 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.
- 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.
- 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.
- Responsive horizontally-scrolling board; usable on phones.
- The SPA loads `/api/board` on mount; there is no board picker.
- 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.
- 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.
- 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.
- 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.
- The Edit toggle exposes list add/rename/delete controls. Card add, edit, delete, movement, and detail viewing remain available outside list-management mode.
- 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.
- **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`.
- **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`.
- **Manual deploy**: `kubectl apply -f deploy/havenllo.yaml -n default` then rollout restart; ingress is `http://havenllo.haven` (no TLS).
- Local verification: `go test ./...`, `CGO_ENABLED=0 go build ./...`, then `cd web && npm ci && npm run build`.
- `web/dist` and `web/node_modules` are git-ignored. Docker's frontend stage always rebuilds `dist`.
- The image is `git.ivanch.me/ivanch/havenllo:latest`, built multi-arch on `docker-build.haven`.
- `.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.
- 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.
- No auth, no external services beyond the SQLite file.
- Keep dependencies minimal and the image small; no CGO, no ORM, no frontend framework beyond Preact, no DnD/state/icon libraries.
- Single fixed board; lists and cards only.
- SQLite has one writer on an NFS PVC: one replica, `Recreate` strategy, root container user on the NFS mount.
- Same-origin `/api`, embedded SPA, no auth, no CORS, and no external services.
- 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",
"version": "0.1.0",
"dependencies": {
"commonmark": "^0.31.2",
"dompurify": "^3.4.12",
"preact": "^10.26.4"
},
"devDependencies": {
"@preact/preset-vite": "^2.10.1",
"@types/commonmark": "^0.27.10",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
@@ -1327,6 +1330,13 @@
"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": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1334,6 +1344,13 @@
"dev": true,
"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": {
"version": "1.0.2",
"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"
},
"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": {
"version": "2.0.0",
"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"
}
},
"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": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
@@ -1725,6 +1780,21 @@
"@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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

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

View File

@@ -141,11 +141,11 @@ export function App() {
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 card = current?.lists.flatMap((list) => list.cards).find((item) => item.id === cardID);
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 });
};
@@ -167,12 +167,11 @@ export function App() {
) : null}
{state.board ? (
<BoardCanvas
lists={state.board.lists}
lists={state.board?.lists ?? []}
onCreateList={createList}
onEditList={editList}
onDeleteList={deleteList}
onCreateCard={createCard}
onEditCard={editCard}
onMoveCard={moveCard}
onOpenCard={setSelectedCardID}
managingLists={managingLists}
@@ -182,6 +181,7 @@ export function App() {
<CardDetailModal
card={selectedCard}
listName={selectedList.name}
lists={state.board?.lists ?? []}
onClose={() => setSelectedCardID(null)}
onEdit={(patch) => editCard(selectedCard.id, patch)}
onDelete={() => deleteCard(selectedCard.id)}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "preact/hooks";
import type { ListWithCards } from "../types";
import { useEffect, useRef, useState } from "preact/hooks";
import type { Card, ListWithCards } from "../types";
import { ListColumn } from "./ListColumn";
interface BoardCanvasProps {
@@ -8,16 +8,43 @@ interface BoardCanvasProps {
onEditList: (id: number, patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: (id: number) => Promise<void>;
onCreateCard: (listID: number, title: string) => Promise<void>;
onEditCard: (id: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onMoveCard: (cardID: number, targetListID: number, index: number) => Promise<void>;
onOpenCard: (id: number) => void;
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) {
const [adding, setAdding] = useState(false);
const [name, setName] = useState("");
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(() => {
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 (
<section class="board-area" aria-label="Haven task board">
<div class="board-intro">
@@ -53,16 +143,20 @@ export function BoardCanvas(props: BoardCanvasProps) {
</div>
{props.lists.length ? (
<div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
{props.lists.map((list) => (
<div class="board-list-group">
{props.lists.map((list, index) => (
<ListColumn
key={list.id}
list={list}
accent={index % 3}
managingLists={props.managingLists}
draggedCardID={drag?.cardID ?? null}
dropIndex={drag?.targetListID === list.id ? drag.targetIndex : null}
isDropTarget={drag?.targetListID === list.id}
onEditList={(patch) => props.onEditList(list.id, patch)}
onDeleteList={() => props.onDeleteList(list.id)}
onCreateCard={(title) => props.onCreateCard(list.id, title)}
onEditCard={props.onEditCard}
onMoveCard={props.onMoveCard}
onStartCardDrag={startCardDrag}
onOpenCard={props.onOpenCard}
/>
))}
@@ -102,6 +196,7 @@ export function BoardCanvas(props: BoardCanvasProps) {
)}
</section> : null}
</div>
</div>
) : (
<section class="empty-board" aria-live="polite">
<span class="empty-state-mark" aria-hidden="true"></span>

View File

@@ -1,12 +1,14 @@
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";
interface CardDetailModalProps {
card: Card;
listName: string;
lists: List[];
onClose: () => void;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onEdit: (patch: CardPatch) => Promise<void>;
onDelete: () => Promise<void>;
}
@@ -19,12 +21,13 @@ function formatTimestamp(value: string): string {
}).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 [description, setDescription] = useState(card.description);
const [descriptionMode, setDescriptionMode] = useState<"write" | "preview">("write");
const [savingTitle, setSavingTitle] = useState(false);
const [savingDescription, setSavingDescription] = useState(false);
const [savingStatus, setSavingStatus] = useState(false);
const [savingList, setSavingList] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
const dialogRef = useRef<HTMLElement>(null);
const returnFocusRef = useRef<HTMLElement | null>(null);
@@ -38,6 +41,7 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
useEffect(() => {
setTitle(card.title);
setDescription(card.description);
setDescriptionMode("write");
}, [card.id]);
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 nextTitle = title.trim();
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;
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 () => {
setConfirmingDelete(false);
try {
@@ -173,18 +178,21 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
</div>
<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">
<span>Status</span>
<button
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>
<strong class="status-pill">{listName}</strong>
</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>
<section class="detail-description" aria-labelledby="detail-description-heading">
@@ -195,18 +203,48 @@ export function CardDetailModal({ card, listName, onClose, onEdit, onDelete }: C
</div>
{descriptionChanged ? <span class="unsaved-chip">Unsaved changes</span> : null}
</div>
<div class="markdown-tabs" role="tablist" aria-label="Description editor mode">
<button
type="button"
role="tab"
aria-selected={descriptionMode === "write"}
class={descriptionMode === "write" ? "is-active" : ""}
onClick={() => setDescriptionMode("write")}
>
Write
</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"
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">
<span>Changes save independently. Press Ctrl + Enter to save.</span>
<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 { beginCardDrag } from "../drag";
import { markdownToPlainText } from "../markdown";
import type { Card } from "../types";
interface CardItemProps {
card: Card;
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
listName: string;
accent: number;
dragging: boolean;
onOpen: () => void;
onDragStart: (event: PointerEvent) => void;
}
export function CardItem({ card, onEdit, onOpen }: CardItemProps) {
const [saving, setSaving] = useState(false);
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();
};
export function CardItem({ card, listName, accent, dragging, onOpen, onDragStart }: CardItemProps) {
const preview = card.description ? markdownToPlainText(card.description) : "";
return (
<article
class={"card " + (card.done ? "card-done" : "")}
draggable
class={"card " + (dragging ? "is-dragging" : "")}
data-card-id={card.id}
data-accent={accent}
tabIndex={0}
onClick={openIfClicked}
onClick={onOpen}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (event.key === "Enter" || event.key === " ") {
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">
<button
type="button"
class={"done-button " + (card.done ? "is-done" : "")}
aria-label={card.done ? "Mark card active" : "Mark card done"}
title={card.done ? "Mark active" : "Mark done"}
disabled={saving}
onClick={(event) => {
class="card-grip"
aria-label={"Drag \"" + card.title + "\""}
title="Drag card"
onPointerDown={(event) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
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>
<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>
<span class="card-status" data-accent={accent}>{listName}</span>
</div>
{card.description ? <p class="card-preview">{card.description}</p> : null}
{preview ? <p class="card-preview">{preview}</p> : null}
</article>
);
}

View File

@@ -1,58 +1,34 @@
import { useEffect, useState } from "preact/hooks";
import { cardDragType, readCardDrag } from "../drag";
import type { Card, ListWithCards } from "../types";
import { CardItem } from "./CardItem";
import { ConfirmDialog } from "./ConfirmDialog";
interface ListColumnProps {
list: ListWithCards;
accent: number;
managingLists: boolean;
draggedCardID: number | null;
dropIndex: number | null;
isDropTarget: boolean;
onEditList: (patch: { name?: string; position?: number }) => Promise<void>;
onDeleteList: () => Promise<void>;
onCreateCard: (title: string) => Promise<void>;
onEditCard: (cardID: number, patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
onStartCardDrag: (card: Card, event: PointerEvent) => 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({
list,
accent,
onEditList,
onDeleteList,
onCreateCard,
onEditCard,
onMoveCard,
onStartCardDrag,
onOpenCard,
managingLists
managingLists,
draggedCardID,
dropIndex,
isDropTarget
}: ListColumnProps) {
const [editingName, setEditingName] = useState(false);
const [name, setName] = useState(list.name);
@@ -60,6 +36,8 @@ export function ListColumn({
const [cardTitle, setCardTitle] = useState("");
const [confirming, setConfirming] = 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(() => {
if (!managingLists && editingName) {
@@ -112,7 +90,11 @@ export function ListColumn({
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-heading">
{editingName ? (
@@ -149,18 +131,22 @@ export function ListColumn({
</button> : null}
</div>
<div class="cards-stack">
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
{list.cards.map((card: Card, index) => (
{list.cards.map((card) => (
<div class="card-slot" key={card.id}>
{insertionBeforeID === card.id ? <div class="card-insertion-marker" aria-hidden="true" /> : null}
<CardItem
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)}
/>
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
</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>
{addingCard ? (
<form
@@ -197,7 +183,7 @@ export function ListColumn({
</section>
<ConfirmDialog
open={confirming}
title={"Delete " + list.name + "”? "}
title={"Delete \"" + list.name + "\"?"}
detail="All cards in this list will be permanently removed."
confirmLabel="Delete list"
onCancel={() => setConfirming(false)}

View File

@@ -1,45 +1,15 @@
import type { Card, 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;
}
}
import type { ListWithCards } from "./types";
export function movePosition(
lists: ListWithCards[],
cardID: number,
sourceListID: number,
targetListID: number,
dropIndex: number
): number {
const target = lists.find((list) => list.id === targetListID);
if (!target) return 1024;
const sourceIndex = target.cards.findIndex((card) => card.id === cardID);
const cards = target.cards.filter((card) => card.id !== cardID);
let index = dropIndex;
if (sourceListID === targetListID && sourceIndex !== -1 && sourceIndex < dropIndex) index -= 1;
index = Math.max(0, Math.min(index, cards.length));
const index = Math.max(0, Math.min(dropIndex, cards.length));
const previous = cards[index - 1];
const next = cards[index];
if (!previous && !next) return 1024;
@@ -47,4 +17,3 @@ export function movePosition(
if (!next) return previous.position + 1024;
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);
}
button, input, textarea { font: inherit; }
button, input, textarea, select { font: 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-offset: 3px;
}
@@ -179,9 +179,6 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
width: min(100%, 1530px);
min-height: 320px;
margin: 0 auto;
display: flex;
align-items: flex-start;
gap: 16px;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding: 2px 2px 28px;
@@ -190,6 +187,15 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
.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 {
width: min(318px, calc(100vw - 48px));
flex: 0 0 318px;
@@ -200,12 +206,17 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
}
.list-column {
--list-accent: var(--teal);
--list-accent-soft: var(--teal-soft);
padding: 14px;
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.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 {
display: flex;
@@ -247,77 +258,92 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
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:hover:not(:disabled) { color: var(--danger); border-color: rgba(255, 125, 144, 0.35); background: var(--danger-soft); }
.cards-stack { min-height: 48px; }
.card-slot { position: relative; }
.card-slot + .card-slot { margin-top: 8px; }
.drop-zone {
height: 8px;
margin: 1px 0;
border-radius: 7px;
transition: height 140ms ease, background 140ms ease, outline-color 140ms ease;
.card-insertion-marker {
height: 5px;
margin: 5px 3px;
border-radius: 999px;
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 {
padding: 12px;
border: 1px solid rgba(169, 192, 225, 0.15);
border-radius: 13px;
color: var(--ink);
cursor: grab;
cursor: pointer;
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);
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:active { cursor: grabbing; transform: scale(0.987); }
.card:focus-visible { outline: 3px solid rgba(101, 218, 203, 0.7); outline-offset: 3px; }
.card-done { opacity: 0.72; }
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
.card.is-dragging { opacity: 0.42; transform: scale(0.985); }
body.is-card-dragging, body.is-card-dragging * { cursor: grabbing !important; user-select: none; }
.card-topline { align-items: flex-start; gap: 8px; }
.done-button {
.card-grip {
flex: 0 0 auto;
width: 24px;
min-height: 24px;
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;
grid-template-columns: repeat(2, 2px);
place-content: center;
gap: 2px;
margin-top: 7px;
opacity: 0.42;
transition: opacity 140ms ease;
padding: 0;
border: 1px solid transparent;
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-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 {
display: -webkit-box;
margin: 9px 1px 0 34px;
margin: 9px 1px 0 32px;
overflow: hidden;
color: var(--muted);
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; }
input, textarea {
input, textarea, select {
width: 100%;
padding: 10px 11px;
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;
}
input:hover, textarea: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:hover, textarea:hover, select:hover { border-color: rgba(151, 174, 211, 0.45); }
input:focus, textarea:focus, select:focus { border-color: var(--teal); background: rgba(7, 15, 29, 0.88); }
textarea { min-height: 96px; resize: vertical; }
.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-status { display: flex; align-content: initial; align-items: center; justify-content: space-between; gap: 8px; }
.status-pill {
min-height: 32px;
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-status .status-pill { max-width: 190px; color: var(--teal); border-color: rgba(101, 218, 203, 0.42); background: var(--teal-soft); }
.detail-move { grid-column: 1 / -1; }
.detail-move select { min-height: 38px; padding-top: 7px; padding-bottom: 7px; font-size: 0.84rem; }
.detail-description { margin-left: 27px; }
.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); }
.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 .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 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 insertion-pulse { from { opacity: 0.72; } to { opacity: 1; } }
@media (max-width: 700px) {
.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-hint { display: none; }
.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; }
.card-detail { max-height: calc(100vh - 20px); border-radius: 18px; }
.detail-backdrop, .dialog-backdrop { padding: 10px; }

View File

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