ui overhaul
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 FK→lists 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
1
web/dist/.gitkeep
vendored
@@ -1 +0,0 @@
|
||||
|
||||
70
web/package-lock.json
generated
70
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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,54 +143,59 @@ export function BoardCanvas(props: BoardCanvasProps) {
|
||||
</div>
|
||||
{props.lists.length ? (
|
||||
<div class={"board-rail " + (props.managingLists ? "is-managing" : "")}>
|
||||
{props.lists.map((list) => (
|
||||
<ListColumn
|
||||
key={list.id}
|
||||
list={list}
|
||||
managingLists={props.managingLists}
|
||||
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}
|
||||
onOpenCard={props.onOpenCard}
|
||||
/>
|
||||
))}
|
||||
{props.managingLists ? <section class="add-list-column">
|
||||
{adding ? (
|
||||
<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="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)}
|
||||
onStartCardDrag={startCardDrag}
|
||||
onOpenCard={props.onOpenCard}
|
||||
/>
|
||||
<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}
|
||||
))}
|
||||
{props.managingLists ? <section class="add-list-column">
|
||||
{adding ? (
|
||||
<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>
|
||||
<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>
|
||||
) : (
|
||||
<section class="empty-board" aria-live="polite">
|
||||
|
||||
@@ -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>
|
||||
<textarea
|
||||
value={description}
|
||||
maxlength={10000}
|
||||
rows={7}
|
||||
placeholder="Add useful context, acceptance criteria, or a helpful note…"
|
||||
aria-label="Card description"
|
||||
disabled={savingDescription}
|
||||
onInput={(event) => setDescription(event.currentTarget.value)}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") void saveDescription();
|
||||
}}
|
||||
/>
|
||||
<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 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()}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
15
web/src/markdown.ts
Normal 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();
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user