feat: implement Havenllo single-board kanban (Go+SQLite backend, Preact/Vite frontend)
- Single fixed 'Haven' board: lists (To do/In progress/Done) + cards, no multi-board - Go 1.24 backend, CGO-free modernc.org/sqlite, embedded SPA, REST /api - Preact + Vite + TS frontend, native HTML5 drag-and-drop, optimistic UI, dark Haven theme - PVC (nfs-client) for SQLite, root container on NFS, simple nginx ingress havenllo.haven - Dockerfile multi-arch build, Gitea CI via pipeline-actions@main
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.codex-*
|
||||||
|
.code-prompt.md
|
||||||
|
PLAN.md
|
||||||
|
README.md
|
||||||
|
havenllo
|
||||||
|
*.exe
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
coverage.out
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
|
|
||||||
5
.gitattributes
vendored
Normal file
5
.gitattributes
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Dockerfile text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
59
.gitea/workflows/main.yaml
Normal file
59
.gitea/workflows/main.yaml
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build and deploy Havenllo
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
name: Verify Go and frontend
|
||||||
|
runs-on: runner-slim-amd64
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Test Go
|
||||||
|
run: go test ./...
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: web
|
||||||
|
run: npm ci
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: web
|
||||||
|
run: npm run build
|
||||||
|
- name: Validate Kubernetes manifest
|
||||||
|
run: |
|
||||||
|
if command -v kubectl >/dev/null 2>&1; then
|
||||||
|
kubectl apply --dry-run=client -f deploy/havenllo.yaml
|
||||||
|
fi
|
||||||
|
build:
|
||||||
|
name: Build and push image
|
||||||
|
needs: verify
|
||||||
|
runs-on: runner-slim-amd64
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Build and push multi-architecture image
|
||||||
|
uses: https://git.ivanch.me/ivanch/pipeline-actions/build-and-push@main
|
||||||
|
with:
|
||||||
|
image: git.ivanch.me/ivanch/havenllo
|
||||||
|
image_tag: latest
|
||||||
|
registry_password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
ssh_key: ${{ secrets.SSH_KEY_DOCKERBUILD }}
|
||||||
|
deploy:
|
||||||
|
name: Apply and restart Havenllo
|
||||||
|
needs: build
|
||||||
|
runs-on: runner-slim-amd64
|
||||||
|
steps:
|
||||||
|
- name: Check out source
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Apply Kubernetes manifest
|
||||||
|
uses: https://git.ivanch.me/ivanch/pipeline-actions/kubectl-apply@main
|
||||||
|
with:
|
||||||
|
manifest: deploy/havenllo.yaml
|
||||||
|
kube_config: ${{ secrets.KUBE_CONFIG }}
|
||||||
|
- name: Restart deployment
|
||||||
|
uses: https://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@main
|
||||||
|
with:
|
||||||
|
deployment_name: havenllo
|
||||||
|
namespace: default
|
||||||
|
kube_config: ${{ secrets.KUBE_CONFIG }}
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/havenllo
|
||||||
|
/havenllo.exe
|
||||||
|
*.exe
|
||||||
|
*-test.exe
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
coverage.out
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/*
|
||||||
|
!web/dist/.gitkeep
|
||||||
|
.codex-*
|
||||||
|
.code-prompt.md
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
|
||||||
|
WORKDIR /src/web
|
||||||
|
COPY web/package.json web/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY web/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS build
|
||||||
|
ARG TARGETOS
|
||||||
|
ARG TARGETARCH
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
COPY --from=frontend /src/web/dist ./web/dist
|
||||||
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /out/havenllo ./cmd/havenllo
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
USER 0
|
||||||
|
COPY --from=build /out/havenllo /havenllo
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/havenllo"]
|
||||||
|
|
||||||
540
PLAN.md
Normal file
540
PLAN.md
Normal file
@@ -0,0 +1,540 @@
|
|||||||
|
# Havenllo implementation plan
|
||||||
|
|
||||||
|
## 1. Overview and goals
|
||||||
|
|
||||||
|
Havenllo will be a single-user kanban application for the Haven homelab. It will
|
||||||
|
provide multiple boards, ordered lists, and ordered task cards without accounts,
|
||||||
|
authentication, or external services. One Go HTTP server will expose a
|
||||||
|
same-origin JSON API and serve the compiled Preact SPA from one container. Its
|
||||||
|
only durable state will be a SQLite database stored on a 1Gi PVC.
|
||||||
|
|
||||||
|
The implementation will favour a polished day-to-day board: fast board loading,
|
||||||
|
native card drag-and-drop, useful empty/loading states, keyboard-friendly inline
|
||||||
|
editing, and a responsive horizontally scrolling kanban layout. It will not add
|
||||||
|
an ORM, WebSockets, a separate frontend server, or a separate database service.
|
||||||
|
|
||||||
|
## 2. Proposed repository tree
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── .dockerignore # Excludes Git data, node_modules, local DBs, and build artefacts from Docker context.
|
||||||
|
├── .gitattributes # Enforces LF for Docker/YAML/workflow files edited from Windows.
|
||||||
|
├── .gitea/
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── main.yaml # Gitea Actions: verify, multi-arch build/push, apply, and restart.
|
||||||
|
├── .gitignore # Ignores database, binaries, coverage, node_modules, and web/dist output.
|
||||||
|
├── Dockerfile # Three-stage Node + Go + distroless build for the one production image.
|
||||||
|
├── README.md # Local development, test, build, and Kubernetes deployment instructions.
|
||||||
|
├── go.mod # Module definition; direct dependency only on modernc.org/sqlite.
|
||||||
|
├── go.sum # Checksums generated by go mod tidy and committed.
|
||||||
|
├── cmd/
|
||||||
|
│ └── havenllo/
|
||||||
|
│ └── main.go # Configuration, database setup, and HTTP server lifecycle.
|
||||||
|
├── internal/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── handlers.go # Go 1.22 ServeMux routes and endpoint handlers.
|
||||||
|
│ │ ├── http.go # JSON, errors, ID parsing, and validation helpers.
|
||||||
|
│ │ ├── types.go # API DTOs and domain-to-response conversion.
|
||||||
|
│ │ └── handlers_test.go # HTTP contract tests against temporary SQLite.
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── service.go # Transactional validation, ordering, moves, and board seeding.
|
||||||
|
│ │ └── service_test.go # Ordering, move, delete, and default-board tests.
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── database.go # SQLite options, migrations, and initial seed data.
|
||||||
|
│ │ ├── migrations.go # Versioned schema migrations.
|
||||||
|
│ │ └── database_test.go # Migration/idempotency and foreign-key tests.
|
||||||
|
│ ├── domain/
|
||||||
|
│ │ └── models.go # Board, List, Card, snapshot, and domain errors.
|
||||||
|
│ └── store/
|
||||||
|
│ ├── sqlite.go # Parameterized SQL repository methods; no ORM.
|
||||||
|
│ └── sqlite_test.go # Persistence and ordering-query tests.
|
||||||
|
├── web/
|
||||||
|
│ ├── embed.go # Embeds dist/ into the Go binary and exposes an fs.FS.
|
||||||
|
│ ├── index.html # Vite HTML shell, title, theme-color, and root element.
|
||||||
|
│ ├── package.json # Preact runtime; Vite/TS/Preact preset development tools.
|
||||||
|
│ ├── package-lock.json # Generated locked npm dependency graph, committed.
|
||||||
|
│ ├── tsconfig.json # Strict TypeScript configuration.
|
||||||
|
│ ├── vite.config.ts # Preact plugin and deterministic web/dist output.
|
||||||
|
│ ├── dist/
|
||||||
|
│ │ └── .gitkeep # Placeholder for pre-build Go embedding; replaced in image builds.
|
||||||
|
│ └── src/
|
||||||
|
│ ├── main.tsx # Preact bootstrap and global error boundary.
|
||||||
|
│ ├── App.tsx # Board selection, fetch, optimistic state hook, and shell.
|
||||||
|
│ ├── api.ts # Typed fetch client, endpoint methods, and API error parsing.
|
||||||
|
│ ├── types.ts # TypeScript representations of resources and errors.
|
||||||
|
│ ├── board-state.ts # Reducer/selectors, pending mutations, rollback, and toast state.
|
||||||
|
│ ├── drag.ts # Native drag payload, drop-index, and position helpers.
|
||||||
|
│ ├── styles.css # Design tokens and responsive component styling.
|
||||||
|
│ └── components/
|
||||||
|
│ ├── Header.tsx # Wordmark, board picker, create board, and status indicator.
|
||||||
|
│ ├── BoardCanvas.tsx # List rail, empty state, and add-list affordance.
|
||||||
|
│ ├── ListColumn.tsx # List editing, count, drop zones, and inline card creation.
|
||||||
|
│ ├── CardItem.tsx # Draggable card, inline title edit, done control, actions.
|
||||||
|
│ ├── CardEditor.tsx # Description editor and destructive card action.
|
||||||
|
│ ├── ConfirmDialog.tsx # Accessible confirmation dialog for deletes.
|
||||||
|
│ └── Toast.tsx # Non-blocking mutation feedback.
|
||||||
|
└── deploy/
|
||||||
|
└── havenllo.yaml # PVC, Deployment, Service, and Ingress multi-document manifest.
|
||||||
|
```
|
||||||
|
|
||||||
|
No application source files will be created during this planning step. The
|
||||||
|
`web/dist` directory is ignored except for its tracked placeholder; the Docker
|
||||||
|
build replaces it with Vite output before Go evaluates the embed directive.
|
||||||
|
|
||||||
|
## 3. Data model and SQLite schema
|
||||||
|
|
||||||
|
IDs are SQLite `INTEGER PRIMARY KEY` values. `position` is a floating-point
|
||||||
|
sort key; records are ordered by `(position, id)`. A new sibling receives the
|
||||||
|
current maximum plus `1024`; a drag chooses a value between its new neighbours.
|
||||||
|
The service renumbers a sibling group to `1024` increments inside the same
|
||||||
|
transaction whenever positions become too close or invalid.
|
||||||
|
|
||||||
|
Timestamps are UTC RFC 3339 strings written by Go. SQLite foreign keys are
|
||||||
|
enabled for every connection, and migrations run transactionally before the
|
||||||
|
server accepts traffic.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS boards (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
position REAL NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lists (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
board_id INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
|
||||||
|
position REAL NOT NULL,
|
||||||
|
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
list_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 240),
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
position REAL NOT NULL,
|
||||||
|
done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_boards_position ON boards(position, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lists_board_position ON lists(board_id, position, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_list_position ON cards(list_id, position, id);
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration 1 atomically seeds a `Haven` board with `To do`, `In progress`, and
|
||||||
|
`Done` lists. Creating later boards seeds the same three lists. Deleting a board
|
||||||
|
cascades its lists and cards, but the API rejects deletion of the final board
|
||||||
|
with `409 Conflict` so the app remains usable.
|
||||||
|
|
||||||
|
The database comes from `HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`).
|
||||||
|
Connection setup uses WAL, a 5-second busy timeout, `foreign_keys=ON`, and one
|
||||||
|
open connection. This single-replica deployment intentionally keeps SQLite a
|
||||||
|
safe, simple single-writer database on the NFS-backed PVC.
|
||||||
|
|
||||||
|
## 4. REST API contract
|
||||||
|
|
||||||
|
All endpoints are same-origin under `/api`, accept and return JSON, and use
|
||||||
|
integer resource IDs. Mutation requests require `Content-Type: application/json`.
|
||||||
|
Unknown fields are rejected; names and titles are trimmed; descriptions allow up
|
||||||
|
to 10,000 characters. PATCH DTOs use pointer fields so `done: false` is distinct
|
||||||
|
from an omitted field.
|
||||||
|
|
||||||
|
Every error has this shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "title is required"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Success statuses are `200`, `201`, and `204`. The API returns `400` for malformed
|
||||||
|
JSON/IDs, `404` for absent records, `409` for final-board deletion, `415` for an
|
||||||
|
incorrect media type, and `422` for business validation. Unexpected database
|
||||||
|
errors are logged server-side and produce a non-sensitive `500` response.
|
||||||
|
|
||||||
|
### Resource shapes
|
||||||
|
|
||||||
|
A board is `{ "id": 1, "name": "Haven", "position": 1024,
|
||||||
|
"created_at": "2026-07-14T12:00:00Z" }`.
|
||||||
|
|
||||||
|
A list is `{ "id": 10, "board_id": 1, "name": "To do", "position": 1024,
|
||||||
|
"card_count": 2 }`. `card_count` is included only in list collection responses.
|
||||||
|
|
||||||
|
A card is:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"list_id": 10,
|
||||||
|
"title": "Back up the router config",
|
||||||
|
"description": "Export it after the firmware update.",
|
||||||
|
"position": 1024,
|
||||||
|
"done": false,
|
||||||
|
"created_at": "2026-07-14T12:00:00Z",
|
||||||
|
"updated_at": "2026-07-14T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The primary board-load response is a snapshot:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"board": { "id": 1, "name": "Haven", "position": 1024, "created_at": "2026-07-14T12:00:00Z" },
|
||||||
|
"lists": [
|
||||||
|
{ "id": 10, "board_id": 1, "name": "To do", "position": 1024, "cards": [] }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health
|
||||||
|
|
||||||
|
| Method | Path | Behaviour and response |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/health` | Pings SQLite and returns `200 {"status":"ok"}`; used by Kubernetes probes. |
|
||||||
|
|
||||||
|
### Boards
|
||||||
|
|
||||||
|
| Method | Path | Request body | Success response |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `GET` | `/api/boards` | — | `200 {"boards":[Board...]}`, ordered by position. |
|
||||||
|
| `POST` | `/api/boards` | `{"name":"Homelab"}` | `201` BoardSnapshot with three starter lists. |
|
||||||
|
| `GET` | `/api/boards/{boardID}` | — | `200` BoardSnapshot with lists and cards in render order. |
|
||||||
|
| `PATCH` | `/api/boards/{boardID}` | `{"name":"…","position":2048}`; either field optional | `200 {"board":Board}`. |
|
||||||
|
| `DELETE` | `/api/boards/{boardID}` | — | `204`, unless it is the final board (`409`). |
|
||||||
|
|
||||||
|
### Lists
|
||||||
|
|
||||||
|
| Method | Path | Request body | Success response |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `GET` | `/api/boards/{boardID}/lists` | — | `200 {"lists":[List...]}`, with card counts but not card bodies. |
|
||||||
|
| `POST` | `/api/boards/{boardID}/lists` | `{"name":"Waiting","position":4096}`; position optional | `201 {"list":List}`. |
|
||||||
|
| `GET` | `/api/lists/{listID}` | — | `200 {"list":List,"cards":[Card...]}`. |
|
||||||
|
| `PATCH` | `/api/lists/{listID}` | `{"name":"…","board_id":2,"position":1024}`; all optional | `200 {"list":List}`. |
|
||||||
|
| `DELETE` | `/api/lists/{listID}` | — | `204`; its cards are cascade-deleted after UI confirmation. |
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
| Method | Path | Request body | Success response |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `GET` | `/api/lists/{listID}/cards` | — | `200 {"cards":[Card...]}`, ordered by position. |
|
||||||
|
| `POST` | `/api/lists/{listID}/cards` | `{"title":"Replace UPS battery","description":"…","position":4096}`; description/position optional | `201 {"card":Card}`. |
|
||||||
|
| `GET` | `/api/cards/{cardID}` | — | `200 {"card":Card}`. |
|
||||||
|
| `PATCH` | `/api/cards/{cardID}` | `{"title":"…","description":"…","done":true,"list_id":11,"position":1536}`; all optional | `200 {"card":Card}`. |
|
||||||
|
| `DELETE` | `/api/cards/{cardID}` | — | `204`. |
|
||||||
|
|
||||||
|
The browser calculates target position from adjacent cards at the drop location.
|
||||||
|
The service independently validates it, normalizes sibling positions when
|
||||||
|
needed, and returns the authoritative resource. A card list move and position
|
||||||
|
update happen in one transaction.
|
||||||
|
|
||||||
|
## 5. Frontend architecture and interaction design
|
||||||
|
|
||||||
|
The frontend uses Preact, TypeScript, and Vite with only Preact and the Preact
|
||||||
|
Vite preset as dependencies. It uses no component, state, icon, or drag library.
|
||||||
|
`web/embed.go` exposes the completed Vite output as an embedded `fs.FS`, so the
|
||||||
|
SPA and API are always same-origin and need no CORS configuration.
|
||||||
|
|
||||||
|
`App` loads boards, restores the selected board from `localStorage` when it is
|
||||||
|
still valid (otherwise picks the first), and fetches one BoardSnapshot. Its
|
||||||
|
`useReducer`-based board state owns the snapshot, selection, pending mutation
|
||||||
|
IDs, rollback snapshots, and toast/error state. Every create/edit/move action
|
||||||
|
first performs an immutable optimistic update, calls `api.ts`, reconciles with
|
||||||
|
the canonical response, and restores state with an error toast if it fails.
|
||||||
|
Controls disable only for their pending resource, rather than freezing the board.
|
||||||
|
|
||||||
|
`Header` contains the Havenllo wordmark, board picker, create-board action, and
|
||||||
|
small connection/status feedback. `BoardCanvas` maps lists to columns and ends
|
||||||
|
with an inline Add list affordance. `ListColumn` provides list-name inline edit,
|
||||||
|
card count, card drop zones, and inline add-card input. `CardItem` provides
|
||||||
|
click-to-edit title, a done control, and a small actions control; `CardEditor`
|
||||||
|
edits descriptions and hosts destructive actions. `ConfirmDialog` is required
|
||||||
|
before deleting a card, list, or board, and `Toast` gives non-blocking feedback.
|
||||||
|
|
||||||
|
Cards use native HTML5 drag-and-drop. The drag payload holds card and source-list
|
||||||
|
IDs. Drop zones before every card plus one at the end provide an unambiguous
|
||||||
|
target index. Drag-over uses an accent outline and local preview; drop calculates
|
||||||
|
the new fractional position, updates optimistically, and sends one PATCH. A
|
||||||
|
failed call restores the pre-drag snapshot. The usual add/edit/done controls stay
|
||||||
|
fully usable on phones, while the board uses a horizontally scrolling rail,
|
||||||
|
280px minimum columns, and 44px touch targets; this remains useful on mobile
|
||||||
|
browsers with limited native DnD support.
|
||||||
|
|
||||||
|
Plain CSS in `styles.css` will use variables and deliberately named component
|
||||||
|
classes. The visual system is deep slate/navy with restrained teal/indigo accents,
|
||||||
|
12–16px rounded surfaces, subtle borders, layered soft shadows, system font
|
||||||
|
stacks, clear focus rings, and reduced-motion support. Done cards, skeletons,
|
||||||
|
empty states, inline save/cancel affordances, and error states will feel designed
|
||||||
|
rather than like bare CRUD.
|
||||||
|
|
||||||
|
## 6. Backend architecture
|
||||||
|
|
||||||
|
`cmd/havenllo/main.go` reads `HAVENLLO_LISTEN_ADDR` (default `:8080`) and
|
||||||
|
`HAVENLLO_DATABASE_PATH` (default `/data/havenllo.db`), configures standard
|
||||||
|
library logging, opens/migrates SQLite, constructs the service and HTTP handler,
|
||||||
|
and gracefully shuts down on SIGINT/SIGTERM.
|
||||||
|
|
||||||
|
`database` owns parent-directory creation, SQLite connection options, and ordered
|
||||||
|
migrations. `store` contains parameterized SQL and record scanning, with no ORM.
|
||||||
|
`app.Service` owns cross-record validation, default list creation, ordering and
|
||||||
|
renumbering, and final-board rules. This keeps every state-changing operation
|
||||||
|
transactional and independently testable.
|
||||||
|
|
||||||
|
`api` uses Go 1.22 `http.ServeMux` method/path patterns and `PathValue`. It
|
||||||
|
decodes bounded JSON bodies with `DisallowUnknownFields`, maps typed domain errors
|
||||||
|
to the documented JSON shape, and never exposes SQL errors. It registers `/api/`
|
||||||
|
before static files: hashed Vite assets are served directly, and embedded
|
||||||
|
`index.html` is a fallback only for non-API SPA routes. An unknown API route
|
||||||
|
always produces JSON 404, never the SPA HTML. CORS headers are intentionally not
|
||||||
|
set because all browser traffic is same-origin.
|
||||||
|
|
||||||
|
Tests cover migration/seed behaviour, cascade effects, position normalization,
|
||||||
|
invalid moves, final-board protection, JSON validation, and essential status/
|
||||||
|
response shapes. Temporary SQLite files and an injectable static filesystem keep
|
||||||
|
tests independent of the PVC and production frontend bundle.
|
||||||
|
|
||||||
|
## 7. Dockerfile strategy
|
||||||
|
|
||||||
|
The Dockerfile has three stages:
|
||||||
|
|
||||||
|
1. A `node:22-alpine` frontend stage copies package manifests first, runs
|
||||||
|
`npm ci`, then builds `web/` with `npm run build` into `web/dist`.
|
||||||
|
2. A `golang:1.24-alpine` stage copies `go.mod`/`go.sum`, runs `go mod download`,
|
||||||
|
copies source and the frontend stage's `web/dist`, then runs
|
||||||
|
`CGO_ENABLED=0 go build`. Buildx arguments `TARGETOS` and `TARGETARCH` are
|
||||||
|
passed to the build so the pure-Go `modernc.org/sqlite` binary targets each
|
||||||
|
requested architecture without CGO.
|
||||||
|
3. `gcr.io/distroless/static-debian12:nonroot` receives only the statically
|
||||||
|
linked `havenllo` binary, exposes 8080, and runs as its non-root user.
|
||||||
|
|
||||||
|
The Dockerfile declares `BUILDPLATFORM`, `TARGETOS`, and `TARGETARCH` for clean
|
||||||
|
`linux/amd64,linux/arm64` remote Buildx builds. `.dockerignore` excludes local
|
||||||
|
`web/node_modules`, existing `web/dist`, Git metadata, test output, and `*.db`.
|
||||||
|
The stage-to-stage copy supplies the actual bundle, so ignoring any local dist
|
||||||
|
cannot break embedding. The final image contains no Node runtime, Go toolchain,
|
||||||
|
shell, npm cache, source tree, or database.
|
||||||
|
|
||||||
|
## 8. Exact Kubernetes manifest
|
||||||
|
|
||||||
|
`deploy/havenllo.yaml` will contain the following four documents. Each resource
|
||||||
|
explicitly targets the existing `default` namespace. One replica and a `Recreate`
|
||||||
|
strategy prevent concurrent SQLite writers or a conflicting RWO attachment during
|
||||||
|
an update. `fsGroup` matches the non-root distroless user so the provisioned NFS
|
||||||
|
volume remains writable.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: havenllo-data
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
nfs.io/storage-path: havenllo-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: nfs-client
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: havenllo
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
fsGroup: 65532
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
containers:
|
||||||
|
- name: havenllo
|
||||||
|
image: git.ivanch.me/ivanch/havenllo:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: HAVENLLO_DATABASE_PATH
|
||||||
|
value: /data/havenllo.db
|
||||||
|
- name: HAVENLLO_LISTEN_ADDR
|
||||||
|
value: :8080
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: havenllo-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: havenllo
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: http
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: havenllo
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
cert-manager.io/cluster-issuer: internal-ca
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- havenllo.haven
|
||||||
|
secretName: havenllo-tls
|
||||||
|
rules:
|
||||||
|
- host: havenllo.haven
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: havenllo
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
|
```
|
||||||
|
|
||||||
|
There is no ConfigMap or Secret: the only environment values are non-sensitive,
|
||||||
|
and all durable state is the mounted SQLite file. The image needs no pull secret
|
||||||
|
when the cluster is already configured for `git.ivanch.me`; if that is not true,
|
||||||
|
the implementation will reference an existing pull-secret by name rather than
|
||||||
|
commit registry credentials.
|
||||||
|
|
||||||
|
## 9. Build and deploy flow
|
||||||
|
|
||||||
|
`.gitea/workflows/main.yaml` will run on pushes to `main` and manual dispatch.
|
||||||
|
It will keep YAML files LF-only via `.gitattributes` and use descriptive `name:`
|
||||||
|
values for every Gitea job.
|
||||||
|
|
||||||
|
1. A `verify` job on `runner-slim-amd64` checks out the repository, runs Go
|
||||||
|
tests, runs `npm ci` and the TypeScript/Vite production build, and performs a
|
||||||
|
client-side `kubectl apply --dry-run=client` validation of the manifest when
|
||||||
|
kubectl is available.
|
||||||
|
2. A `build` job uses
|
||||||
|
`https://git.ivanch.me/ivanch/pipeline-actions/build-and-push@main` with
|
||||||
|
`image: git.ivanch.me/ivanch/havenllo`, `image_tag: latest`, registry-password
|
||||||
|
and remote-builder SSH-key secrets. It retains the action's default
|
||||||
|
`linux/amd64,linux/arm64` platforms, builds on `docker-build.haven`, and pushes
|
||||||
|
`git.ivanch.me/ivanch/havenllo:latest`.
|
||||||
|
3. A dependent `deploy` job uses
|
||||||
|
`https://git.ivanch.me/ivanch/pipeline-actions/kubectl-apply@main` with
|
||||||
|
`manifest: deploy/havenllo.yaml` and `${{ secrets.KUBE_CONFIG }}`. It then uses
|
||||||
|
`https://git.ivanch.me/ivanch/pipeline-actions/deploy-restart@main` with
|
||||||
|
`deployment_name: havenllo`, `namespace: default`, and the same kubeconfig.
|
||||||
|
Applying before restart reconciles PVC/Service/Ingress changes before the
|
||||||
|
`latest` image is restarted.
|
||||||
|
4. Deployment verification is `kubectl -n default rollout status deployment/havenllo`,
|
||||||
|
followed by `kubectl -n default get certificate,ingress havenllo` and opening
|
||||||
|
`https://havenllo.haven`. cert-manager creates `havenllo-tls` through the
|
||||||
|
existing `internal-ca` ClusterIssuer.
|
||||||
|
|
||||||
|
The Gitea repository requires `REGISTRY_PASSWORD`, `SSH_KEY_DOCKERBUILD`, and
|
||||||
|
`KUBE_CONFIG` secrets. No secret values are committed.
|
||||||
|
|
||||||
|
## 10. Ordered implementation checklist
|
||||||
|
|
||||||
|
1. Add `.gitignore`, `.dockerignore`, `.gitattributes`, and README. Confirm no
|
||||||
|
local database or generated frontend build is tracked and workflow YAML will
|
||||||
|
remain LF-only on Windows.
|
||||||
|
2. Add `go.mod` with only `modernc.org/sqlite`, run `go mod tidy`, and define the
|
||||||
|
domain models and errors.
|
||||||
|
3. Implement SQLite connection options, migration tracking, the schema above,
|
||||||
|
and one-time `Haven`/starter-list seeding. Add migration and foreign-key tests.
|
||||||
|
4. Implement parameterized SQLite store methods and the transactional service:
|
||||||
|
validation, float ordering/normalization, list/card moves, board starter lists,
|
||||||
|
cascade-aware deletion, and final-board protection. Add store/service tests.
|
||||||
|
5. Implement the standard-library API router, JSON/error helpers, health check,
|
||||||
|
all documented handlers, and graceful process entry point. Add HTTP contract
|
||||||
|
tests for normal, invalid, missing, and conflict cases.
|
||||||
|
6. Scaffold Vite/Preact, the typed client, reducer-based optimistic board state,
|
||||||
|
board selector persistence, and loading/error states.
|
||||||
|
7. Implement the visual components and CSS system: header, list rail, inline
|
||||||
|
creation/editing, count badges, done state, descriptions, confirmations,
|
||||||
|
toasts, empty states, and responsive interaction styling.
|
||||||
|
8. Add native card drag/drop previews and drop zones for same-list and cross-list
|
||||||
|
moves. Manually test error rollback, keyboard controls, and mobile-sized views.
|
||||||
|
9. Add `web/embed.go` and Go static-file/SPA fallback routing. Verify a production
|
||||||
|
frontend build is embedded and API misses return JSON rather than HTML.
|
||||||
|
10. Add the multi-stage Dockerfile; build locally for the host platform and verify
|
||||||
|
the container creates/uses `/data/havenllo.db`, serves `/`, and passes
|
||||||
|
`/api/health` with a read-only root filesystem.
|
||||||
|
11. Add the exact deployment manifest and Gitea workflow using the documented
|
||||||
|
pipeline-actions URLs. Validate YAML, a client-side apply, and LF-only files.
|
||||||
|
12. Push `main`; allow the multi-architecture image to build and deploy; verify
|
||||||
|
rollout, certificate, and ingress; then smoke-test create, edit, complete,
|
||||||
|
drag, refresh persistence, and deletion confirmation at `havenllo.haven`.
|
||||||
52
README.md
Normal file
52
README.md
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Havenllo
|
||||||
|
|
||||||
|
Havenllo is a small, single-board kanban app for the Haven homelab. It serves a
|
||||||
|
Preact SPA and a same-origin Go/SQLite API from one binary. On first start it
|
||||||
|
creates the fixed **To do**, **In progress**, and **Done** columns.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Use a writable database path outside the default container location:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:HAVENLLO_DATABASE_PATH = "$PWD\havenllo.db"
|
||||||
|
go run ./cmd/havenllo
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, run the frontend development server:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Set-Location web
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
For the embedded production bundle, run `npm run build` in `web/` before
|
||||||
|
`go build ./...`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
go test ./...
|
||||||
|
Set-Location web; npm ci; npm run build
|
||||||
|
Set-Location ..; go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Container and deployment
|
||||||
|
|
||||||
|
Build and push the multi-architecture image on `docker-build.haven`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker buildx build --platform linux/amd64,linux/arm64 -t git.ivanch.me/ivanch/havenllo:latest --push .
|
||||||
|
```
|
||||||
|
|
||||||
|
Or push `main` / dispatch the Gitea workflow. Apply the manifest and restart
|
||||||
|
the deployment:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl apply -f deploy/havenllo.yaml -n default
|
||||||
|
kubectl -n default rollout restart deployment/havenllo
|
||||||
|
```
|
||||||
|
|
||||||
|
The app is available internally at `http://havenllo.haven`.
|
||||||
|
|
||||||
57
cmd/havenllo/main.go
Normal file
57
cmd/havenllo/main.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"havenllo/internal/api"
|
||||||
|
"havenllo/internal/app"
|
||||||
|
"havenllo/internal/database"
|
||||||
|
"havenllo/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
listenAddr := env("HAVENLLO_LISTEN_ADDR", ":8080")
|
||||||
|
databasePath := env("HAVENLLO_DATABASE_PATH", "/data/havenllo.db")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := database.Open(ctx, databasePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("database setup failed: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: listenAddr,
|
||||||
|
Handler: api.NewHandler(app.New(db), web.Files),
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
log.Printf("Havenllo listening on %s", listenAddr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Fatalf("server failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
stop := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-stop
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("graceful shutdown failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func env(key, fallback string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
120
deploy/havenllo.yaml
Normal file
120
deploy/havenllo.yaml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: havenllo-data
|
||||||
|
namespace: default
|
||||||
|
annotations:
|
||||||
|
nfs.io/storage-path: havenllo-data
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
storageClassName: nfs-client
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: havenllo
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
strategy:
|
||||||
|
type: Recreate
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: havenllo
|
||||||
|
image: git.ivanch.me/ivanch/havenllo:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
env:
|
||||||
|
- name: HAVENLLO_DATABASE_PATH
|
||||||
|
value: /data/havenllo.db
|
||||||
|
- name: HAVENLLO_LISTEN_ADDR
|
||||||
|
value: :8080
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 2
|
||||||
|
periodSeconds: 5
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: http
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
timeoutSeconds: 2
|
||||||
|
failureThreshold: 3
|
||||||
|
securityContext:
|
||||||
|
runAsUser: 0
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: havenllo-data
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: havenllo
|
||||||
|
namespace: default
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
selector:
|
||||||
|
app.kubernetes.io/name: havenllo
|
||||||
|
ports:
|
||||||
|
- name: http
|
||||||
|
port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: havenllo
|
||||||
|
namespace: default
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: havenllo.haven
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: havenllo
|
||||||
|
port:
|
||||||
|
number: 8080
|
||||||
18
go.mod
Normal file
18
go.mod
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
module havenllo
|
||||||
|
|
||||||
|
go 1.24
|
||||||
|
|
||||||
|
require modernc.org/sqlite v1.38.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.34.0 // indirect
|
||||||
|
modernc.org/libc v1.66.3 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
49
go.sum
Normal file
49
go.sum
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||||
|
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||||
|
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||||
|
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||||
|
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
|
||||||
|
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM=
|
||||||
|
modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
|
||||||
|
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
224
internal/api/handlers.go
Normal file
224
internal/api/handlers.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
// Package api exposes Havenllo's same-origin JSON API and embedded SPA handler.
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"havenllo/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
service *app.Service
|
||||||
|
static fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandler(service *app.Service, static fs.FS) http.Handler {
|
||||||
|
h := &Handler{service: service, static: static}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /api/health", h.health)
|
||||||
|
mux.HandleFunc("GET /api/board", h.board)
|
||||||
|
mux.HandleFunc("GET /api/lists", h.lists)
|
||||||
|
mux.HandleFunc("POST /api/lists", h.createList)
|
||||||
|
mux.HandleFunc("GET /api/lists/{id}", h.list)
|
||||||
|
mux.HandleFunc("PATCH /api/lists/{id}", h.updateList)
|
||||||
|
mux.HandleFunc("DELETE /api/lists/{id}", h.deleteList)
|
||||||
|
mux.HandleFunc("GET /api/lists/{id}/cards", h.cards)
|
||||||
|
mux.HandleFunc("POST /api/lists/{id}/cards", h.createCard)
|
||||||
|
mux.HandleFunc("GET /api/cards/{id}", h.card)
|
||||||
|
mux.HandleFunc("PATCH /api/cards/{id}", h.updateCard)
|
||||||
|
mux.HandleFunc("DELETE /api/cards/{id}", h.deleteCard)
|
||||||
|
mux.HandleFunc("/api/", h.apiNotFound)
|
||||||
|
mux.HandleFunc("/", h.serveApp)
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) health(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.service.Health(r.Context()); err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "unavailable", "database is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) board(w http.ResponseWriter, r *http.Request) {
|
||||||
|
snapshot, err := h.service.Board(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) lists(w http.ResponseWriter, r *http.Request) {
|
||||||
|
lists, err := h.service.Lists(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, listsResponse{Lists: listSummaries(lists)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !requireJSON(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request createListRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.service.CreateList(r.Context(), request.Name, request.Position)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, listResponse{List: list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.service.List(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cards, err := h.service.Cards(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, listDetailsResponse{List: list, Cards: cards})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r, "id")
|
||||||
|
if !ok || !requireJSON(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request updateListRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list, err := h.service.UpdateList(r.Context(), id, request.Name, request.Position)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, listResponse{List: list})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.DeleteList(r.Context(), id); err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cards(w http.ResponseWriter, r *http.Request) {
|
||||||
|
listID, ok := pathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cards, err := h.service.Cards(r.Context(), listID)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cardsResponse{Cards: cards})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
listID, ok := pathID(w, r, "id")
|
||||||
|
if !ok || !requireJSON(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request createCardRequest
|
||||||
|
if !decodeJSON(w, r, &request) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
card, err := h.service.CreateCard(r.Context(), listID, request.Title, request.Description, request.Position)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, cardResponse{Card: card})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) card(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
card, err := h.service.Card(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cardResponse{Card: card})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r, "id")
|
||||||
|
if !ok || !requireJSON(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request updateCardRequest
|
||||||
|
if !decodeJSON(w, r, &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,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cardResponse{Card: card})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteCard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, ok := pathID(w, r, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.service.DeleteCard(r.Context(), id); err != nil {
|
||||||
|
writeDomainError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) apiNotFound(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "API endpoint not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveApp(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.static == nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "application bundle not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requested := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||||
|
if requested != "" {
|
||||||
|
if _, err := fs.Stat(h.static, requested); err == nil {
|
||||||
|
http.FileServer(http.FS(h.static)).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index, err := fs.ReadFile(h.static, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "application bundle not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_, _ = w.Write(index)
|
||||||
|
}
|
||||||
86
internal/api/handlers_test.go
Normal file
86
internal/api/handlers_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
"havenllo/internal/api"
|
||||||
|
"havenllo/internal/app"
|
||||||
|
"havenllo/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newHandler(t *testing.T) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
return api.NewHandler(app.New(db), fstest.MapFS{"index.html": {Data: []byte("<html></html>")}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func request(t *testing.T, handler http.Handler, method, path, body string, jsonBody bool) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
||||||
|
if jsonBody {
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBoardAndHealth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
handler := newHandler(t)
|
||||||
|
for _, path := range []string{"/api/health", "/api/board"} {
|
||||||
|
response := request(t, handler, http.MethodGet, path, "", false)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("%s status = %d, body %s", path, response.Code, response.Body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var snapshot struct {
|
||||||
|
Lists []json.RawMessage `json:"lists"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(request(t, handler, http.MethodGet, "/api/board", "", false).Body.Bytes(), &snapshot); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Lists) != 3 {
|
||||||
|
t.Fatalf("snapshot lists = %d, want 3", len(snapshot.Lists))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJSONValidationAndLastListConflict(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
handler := newHandler(t)
|
||||||
|
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later","unknown":true}`, true); got.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("unknown field status = %d", got.Code)
|
||||||
|
}
|
||||||
|
if got := request(t, handler, http.MethodPost, "/api/lists", `{"name":"later"}`, false); got.Code != http.StatusUnsupportedMediaType {
|
||||||
|
t.Fatalf("content type status = %d", got.Code)
|
||||||
|
}
|
||||||
|
response := request(t, handler, http.MethodGet, "/api/lists", "", false)
|
||||||
|
var lists struct {
|
||||||
|
Lists []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
} `json:"lists"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &lists); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, list := range lists.Lists[:2] {
|
||||||
|
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(list.ID, 10), "", false); got.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete list status = %d", got.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := request(t, handler, http.MethodDelete, "/api/lists/"+strconv.FormatInt(lists.Lists[2].ID, 10), "", false); got.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("last list status = %d", got.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
95
internal/api/http.go
Normal file
95
internal/api/http.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"havenllo/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxJSONBody = 1 << 20
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
writeJSON(w, status, errorResponse{Error: apiError{Code: code, Message: message}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDomainError(w http.ResponseWriter, err error) {
|
||||||
|
var validation domain.ValidationError
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, "not_found", "resource not found")
|
||||||
|
case errors.Is(err, domain.ErrLastList):
|
||||||
|
writeError(w, http.StatusConflict, "last_list", err.Error())
|
||||||
|
case errors.As(err, &validation):
|
||||||
|
writeError(w, http.StatusUnprocessableEntity, "validation_error", validation.Message)
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal_error", "an unexpected error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireJSON(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
contentType := r.Header.Get("Content-Type")
|
||||||
|
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil || mediaType != "application/json" {
|
||||||
|
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media_type", "Content-Type must be application/json")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, maxJSONBody)
|
||||||
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
writeDecodeError(w, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body must contain one JSON value")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeDecodeError(w http.ResponseWriter, err error) {
|
||||||
|
var syntaxError *json.SyntaxError
|
||||||
|
var typeError *json.UnmarshalTypeError
|
||||||
|
switch {
|
||||||
|
case errors.As(err, &syntaxError):
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
|
||||||
|
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains malformed JSON")
|
||||||
|
case errors.As(err, &typeError):
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", fmt.Sprintf("invalid value for %s", typeError.Field))
|
||||||
|
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body contains an unknown field")
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body must not be empty")
|
||||||
|
case strings.Contains(err.Error(), "http: request body too large"):
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body is too large")
|
||||||
|
default:
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_json", "request body is invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathID(w http.ResponseWriter, r *http.Request, key string) (int64, bool) {
|
||||||
|
id, err := strconv.ParseInt(r.PathValue(key), 10, 64)
|
||||||
|
if err != nil || id < 1 {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid_id", "resource ID must be a positive integer")
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
72
internal/api/types.go
Normal file
72
internal/api/types.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "havenllo/internal/domain"
|
||||||
|
|
||||||
|
type createListRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position *float64 `json:"position,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateListRequest struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Position *float64 `json:"position,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createCardRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Position *float64 `json:"position,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorResponse struct {
|
||||||
|
Error apiError `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listResponse struct {
|
||||||
|
List domain.List `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listDetailsResponse struct {
|
||||||
|
List domain.List `json:"list"`
|
||||||
|
Cards []domain.Card `json:"cards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listSummary struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
CardCount int `json:"card_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type listsResponse struct {
|
||||||
|
Lists []listSummary `json:"lists"`
|
||||||
|
}
|
||||||
|
type cardResponse struct {
|
||||||
|
Card domain.Card `json:"card"`
|
||||||
|
}
|
||||||
|
type cardsResponse struct {
|
||||||
|
Cards []domain.Card `json:"cards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func listSummaries(lists []domain.List) []listSummary {
|
||||||
|
summaries := make([]listSummary, 0, len(lists))
|
||||||
|
for _, list := range lists {
|
||||||
|
summaries = append(summaries, listSummary{
|
||||||
|
ID: list.ID, Name: list.Name, Position: list.Position, CardCount: list.CardCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return summaries
|
||||||
|
}
|
||||||
351
internal/app/service.go
Normal file
351
internal/app/service.go
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
// Package app contains the transactional business rules for the fixed Haven board.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"havenllo/internal/domain"
|
||||||
|
"havenllo/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
const positionStep = 1024.0
|
||||||
|
const minimumPositionGap = 0.0001
|
||||||
|
|
||||||
|
type Service struct{ db *sql.DB }
|
||||||
|
|
||||||
|
func New(db *sql.DB) *Service { return &Service{db: db} }
|
||||||
|
|
||||||
|
func (s *Service) Health(ctx context.Context) error { return s.db.PingContext(ctx) }
|
||||||
|
|
||||||
|
func (s *Service) Board(ctx context.Context) (domain.Snapshot, error) {
|
||||||
|
return store.Snapshot(ctx, s.db)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Lists(ctx context.Context) ([]domain.List, error) {
|
||||||
|
return store.Lists(ctx, s.db, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, id int64) (domain.List, error) {
|
||||||
|
return store.List(ctx, s.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Cards(ctx context.Context, listID int64) ([]domain.Card, error) {
|
||||||
|
if _, err := store.List(ctx, s.db, listID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return store.Cards(ctx, s.db, listID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Card(ctx context.Context, id int64) (domain.Card, error) {
|
||||||
|
return store.Card(ctx, s.db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateList(ctx context.Context, name string, position *float64) (domain.List, error) {
|
||||||
|
name, err := validName(name)
|
||||||
|
if err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.List, error) {
|
||||||
|
pos, err := positionForNewList(ctx, tx, position)
|
||||||
|
if err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
result, err := tx.ExecContext(ctx, "INSERT INTO lists (name, position) VALUES (?, ?)", name, pos)
|
||||||
|
if err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
if err := normalizeLists(ctx, tx); err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
return store.List(ctx, tx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateList(ctx context.Context, id int64, name *string, position *float64) (domain.List, error) {
|
||||||
|
if name != nil {
|
||||||
|
var err error
|
||||||
|
if *name, err = validName(*name); err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if position != nil && !validPosition(*position) {
|
||||||
|
return domain.List{}, domain.ValidationError{Message: "position must be a finite number"}
|
||||||
|
}
|
||||||
|
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.List, error) {
|
||||||
|
list, err := store.List(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
if name != nil {
|
||||||
|
list.Name = *name
|
||||||
|
}
|
||||||
|
if position != nil {
|
||||||
|
list.Position = *position
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, "UPDATE lists SET name = ?, position = ? WHERE id = ?", list.Name, list.Position, id); err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
if err := normalizeLists(ctx, tx); err != nil {
|
||||||
|
return domain.List{}, err
|
||||||
|
}
|
||||||
|
return store.List(ctx, tx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteList(ctx context.Context, id int64) error {
|
||||||
|
return withTxVoid(ctx, s.db, func(tx *sql.Tx) error {
|
||||||
|
if _, err := store.List(ctx, tx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
count, err := store.CountLists(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count <= 1 {
|
||||||
|
return domain.ErrLastList
|
||||||
|
}
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM lists WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) CreateCard(ctx context.Context, listID int64, title, description string, position *float64) (domain.Card, error) {
|
||||||
|
title, err := validTitle(title)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
if err := validDescription(description); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.Card, error) {
|
||||||
|
if _, err := store.List(ctx, tx, listID); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
pos, err := positionForNewCard(ctx, tx, listID, position)
|
||||||
|
if err != nil {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
if err := normalizeCards(ctx, tx, listID); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
return store.Card(ctx, tx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CardPatch struct {
|
||||||
|
Title *string
|
||||||
|
Description *string
|
||||||
|
Done *bool
|
||||||
|
ListID *int64
|
||||||
|
Position *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateCard(ctx context.Context, id int64, patch CardPatch) (domain.Card, error) {
|
||||||
|
if patch.Title != nil {
|
||||||
|
var err error
|
||||||
|
if *patch.Title, err = validTitle(*patch.Title); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.Description != nil {
|
||||||
|
if err := validDescription(*patch.Description); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch.Position != nil && !validPosition(*patch.Position) {
|
||||||
|
return domain.Card{}, domain.ValidationError{Message: "position must be a finite number"}
|
||||||
|
}
|
||||||
|
if patch.ListID != nil && *patch.ListID < 1 {
|
||||||
|
return domain.Card{}, domain.ValidationError{Message: "list_id must be a positive integer"}
|
||||||
|
}
|
||||||
|
return withTx(ctx, s.db, func(tx *sql.Tx) (domain.Card, error) {
|
||||||
|
card, err := store.Card(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
sourceListID := card.ListID
|
||||||
|
if patch.Title != nil {
|
||||||
|
card.Title = *patch.Title
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
card.ListID = *patch.ListID
|
||||||
|
}
|
||||||
|
if patch.Position != nil {
|
||||||
|
card.Position = *patch.Position
|
||||||
|
} else if card.ListID != sourceListID {
|
||||||
|
pos, err := store.MaxCardPosition(ctx, tx, card.ListID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
if err := normalizeCards(ctx, tx, sourceListID); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
if card.ListID != sourceListID {
|
||||||
|
if err := normalizeCards(ctx, tx, card.ListID); err != nil {
|
||||||
|
return domain.Card{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return store.Card(ctx, tx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DeleteCard(ctx context.Context, id int64) error {
|
||||||
|
result, err := s.db.ExecContext(ctx, "DELETE FROM cards WHERE id = ?", id)
|
||||||
|
return store.MustRows(result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionForNewList(ctx context.Context, q store.DBTX, requested *float64) (float64, error) {
|
||||||
|
if requested != nil {
|
||||||
|
if !validPosition(*requested) {
|
||||||
|
return 0, domain.ValidationError{Message: "position must be a finite number"}
|
||||||
|
}
|
||||||
|
return *requested, nil
|
||||||
|
}
|
||||||
|
max, err := store.MaxListPosition(ctx, q)
|
||||||
|
return max + positionStep, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionForNewCard(ctx context.Context, q store.DBTX, listID int64, requested *float64) (float64, error) {
|
||||||
|
if requested != nil {
|
||||||
|
if !validPosition(*requested) {
|
||||||
|
return 0, domain.ValidationError{Message: "position must be a finite number"}
|
||||||
|
}
|
||||||
|
return *requested, nil
|
||||||
|
}
|
||||||
|
max, err := store.MaxCardPosition(ctx, q, listID)
|
||||||
|
return max + positionStep, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLists(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
lists, err := store.Lists(ctx, tx, false)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !needsNormalizationList(lists) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i, list := range lists {
|
||||||
|
if _, err := tx.ExecContext(ctx, "UPDATE lists SET position = ? WHERE id = ?", float64(i+1)*positionStep, list.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCards(ctx context.Context, tx *sql.Tx, listID int64) error {
|
||||||
|
cards, err := store.Cards(ctx, tx, listID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !needsNormalizationCards(cards) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i, card := range cards {
|
||||||
|
if _, err := tx.ExecContext(ctx, "UPDATE cards SET position = ? WHERE id = ?", float64(i+1)*positionStep, card.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsNormalizationList(lists []domain.List) bool {
|
||||||
|
for i, list := range lists {
|
||||||
|
if !validPosition(list.Position) || (i > 0 && list.Position-lists[i-1].Position < minimumPositionGap) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func needsNormalizationCards(cards []domain.Card) bool {
|
||||||
|
for i, card := range cards {
|
||||||
|
if !validPosition(card.Position) || (i > 0 && card.Position-cards[i-1].Position < minimumPositionGap) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func validName(name string) (string, error) { return validTrimmed(name, 120, "name") }
|
||||||
|
func validTitle(title string) (string, error) { return validTrimmed(title, 240, "title") }
|
||||||
|
|
||||||
|
func validTrimmed(value string, max int, field string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return "", domain.ValidationError{Message: field + " is required"}
|
||||||
|
}
|
||||||
|
if len([]rune(value)) > max {
|
||||||
|
return "", domain.ValidationError{Message: fmt.Sprintf("%s must be at most %d characters", field, max)}
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDescription(description string) error {
|
||||||
|
if len([]rune(description)) > 10000 {
|
||||||
|
return domain.ValidationError{Message: "description must be at most 10000 characters"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validPosition(position float64) bool { return !math.IsNaN(position) && !math.IsInf(position, 0) }
|
||||||
|
func timestamp() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||||
|
|
||||||
|
func withTx[T any](ctx context.Context, db *sql.DB, run func(*sql.Tx) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
value, err := run(tx)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func withTxVoid(ctx context.Context, db *sql.DB, run func(*sql.Tx) error) error {
|
||||||
|
_, err := withTx(ctx, db, func(tx *sql.Tx) (struct{}, error) { return struct{}{}, run(tx) })
|
||||||
|
return err
|
||||||
|
}
|
||||||
97
internal/app/service_test.go
Normal file
97
internal/app/service_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"havenllo/internal/app"
|
||||||
|
"havenllo/internal/database"
|
||||||
|
"havenllo/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newService(t *testing.T) (*app.Service, func()) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return app.New(db), func() { _ = db.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLastListProtection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
service, closeDB := newService(t)
|
||||||
|
defer closeDB()
|
||||||
|
ctx := context.Background()
|
||||||
|
lists, err := service.Lists(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, list := range lists[:2] {
|
||||||
|
if err := service.DeleteList(ctx, list.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := service.DeleteList(ctx, lists[2].ID); !errors.Is(err, domain.ErrLastList) {
|
||||||
|
t.Fatalf("DeleteList error = %v, want ErrLastList", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardMoveNormalizesCrowdedPositions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
service, closeDB := newService(t)
|
||||||
|
defer closeDB()
|
||||||
|
ctx := context.Background()
|
||||||
|
lists, err := service.Lists(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := lists[0]
|
||||||
|
a, err := service.CreateCard(ctx, first.ID, "a", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := service.CreateCard(ctx, first.ID, "b", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
position := a.Position + 0.00001
|
||||||
|
if _, err := service.UpdateCard(ctx, b.ID, app.CardPatch{Position: &position}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cards, err := service.Cards(ctx, first.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(cards) != 2 || cards[1].Position-cards[0].Position < 0.0001 {
|
||||||
|
t.Fatalf("cards were not normalized: %#v", cards)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardMoveAndCascade(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
service, closeDB := newService(t)
|
||||||
|
defer closeDB()
|
||||||
|
ctx := context.Background()
|
||||||
|
lists, _ := service.Lists(ctx)
|
||||||
|
card, err := service.CreateCard(ctx, lists[0].ID, "move me", "", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
target := lists[1].ID
|
||||||
|
moved, err := service.UpdateCard(ctx, card.ID, app.CardPatch{ListID: &target})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if moved.ListID != target {
|
||||||
|
t.Fatalf("card list = %d, want %d", moved.ListID, target)
|
||||||
|
}
|
||||||
|
if err := service.DeleteList(ctx, target); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := service.Card(ctx, card.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Fatalf("card after list deletion = %v, want not found", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
internal/database/database.go
Normal file
37
internal/database/database.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Open configures the one SQLite connection used by the single-replica app and
|
||||||
|
// migrates it before returning it to callers.
|
||||||
|
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
if dir != "." && dir != "" {
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create database directory: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
if _, err := db.ExecContext(ctx, "PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000; PRAGMA journal_mode = WAL;"); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("configure database: %w", err)
|
||||||
|
}
|
||||||
|
if err := migrate(ctx, db); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
69
internal/database/database_test.go
Normal file
69
internal/database/database_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"havenllo/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenMigratesSeedsAndEnforcesCascade(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "havenllo.db")
|
||||||
|
db, err := database.Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM lists").Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 3 {
|
||||||
|
t.Fatalf("seeded lists = %d, want 3", count)
|
||||||
|
}
|
||||||
|
var listID int64
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT id FROM lists ORDER BY position LIMIT 1").Scan(&listID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, `INSERT INTO cards (list_id, title, description, position, done, created_at, updated_at)
|
||||||
|
VALUES (?, 'test', '', 1024, 0, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`, listID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.ExecContext(ctx, "DELETE FROM lists WHERE id = ?", listID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM cards").Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("cascade left %d cards", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpenIsIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "havenllo.db")
|
||||||
|
db, err := database.Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = db.Close()
|
||||||
|
db, err = database.Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil && err != sql.ErrNoRows {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("migrations = %d, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
internal/database/migrations.go
Normal file
87
internal/database/migrations.go
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type migration struct {
|
||||||
|
version int
|
||||||
|
apply func(context.Context, *sql.Tx) error
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations = []migration{
|
||||||
|
{version: 1, apply: migrationOne},
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrate(ctx context.Context, db *sql.DB) error {
|
||||||
|
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("create migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
var exists int
|
||||||
|
if err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM schema_migrations WHERE version = ?", m.version).Scan(&exists); err != nil {
|
||||||
|
return fmt.Errorf("read migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if exists != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if err := m.apply(ctx, tx); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("apply migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, "INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", m.version, time.Now().UTC().Format(time.RFC3339Nano)); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return fmt.Errorf("record migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("commit migration %d: %w", m.version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrationOne(ctx context.Context, tx *sql.Tx) error {
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE lists (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL CHECK (length(trim(name)) BETWEEN 1 AND 120),
|
||||||
|
position REAL NOT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE cards (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
list_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL CHECK (length(trim(title)) BETWEEN 1 AND 240),
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
position REAL NOT NULL,
|
||||||
|
done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX idx_lists_position ON lists(position, id)`,
|
||||||
|
`CREATE INDEX idx_cards_list_position ON cards(list_id, position, id)`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := tx.ExecContext(ctx, statement); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, name := range []string{"To do", "In progress", "Done"} {
|
||||||
|
if _, err := tx.ExecContext(ctx, "INSERT INTO lists (name, position) VALUES (?, ?)", name, float64(i+1)*1024); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
internal/domain/models.go
Normal file
42
internal/domain/models.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("resource not found")
|
||||||
|
ErrLastList = errors.New("the last remaining list cannot be deleted")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError represents a user-correctable domain validation failure.
|
||||||
|
type ValidationError struct{ Message string }
|
||||||
|
|
||||||
|
func (e ValidationError) Error() string { return e.Message }
|
||||||
|
|
||||||
|
type List struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
CardCount int `json:"card_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Card struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ListID int64 `json:"list_id"`
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ListWithCards struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Position float64 `json:"position"`
|
||||||
|
Cards []Card `json:"cards"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Snapshot struct {
|
||||||
|
Lists []ListWithCards `json:"lists"`
|
||||||
|
}
|
||||||
146
internal/store/sqlite.go
Normal file
146
internal/store/sqlite.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
// Package store contains small, parameterized SQLite queries used by the service.
|
||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"havenllo/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBTX interface {
|
||||||
|
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||||
|
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
|
||||||
|
QueryRowContext(context.Context, string, ...any) *sql.Row
|
||||||
|
}
|
||||||
|
|
||||||
|
func Lists(ctx context.Context, q DBTX, counts bool) ([]domain.List, error) {
|
||||||
|
query := `SELECT l.id, l.name, l.position`
|
||||||
|
if counts {
|
||||||
|
query += `, COUNT(c.id) FROM lists l LEFT JOIN cards c ON c.list_id = l.id GROUP BY l.id, l.name, l.position ORDER BY l.position, l.id`
|
||||||
|
} else {
|
||||||
|
query += ` FROM lists l ORDER BY l.position, l.id`
|
||||||
|
}
|
||||||
|
rows, err := q.QueryContext(ctx, query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
lists := make([]domain.List, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var list domain.List
|
||||||
|
if counts {
|
||||||
|
err = rows.Scan(&list.ID, &list.Name, &list.Position, &list.CardCount)
|
||||||
|
} else {
|
||||||
|
err = rows.Scan(&list.ID, &list.Name, &list.Position)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lists = append(lists, list)
|
||||||
|
}
|
||||||
|
return lists, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func List(ctx context.Context, q DBTX, id int64) (domain.List, error) {
|
||||||
|
var list domain.List
|
||||||
|
err := q.QueryRowContext(ctx, "SELECT id, name, position FROM lists WHERE id = ?", id).Scan(&list.ID, &list.Name, &list.Position)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return domain.List{}, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return list, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
FROM cards WHERE list_id = ? ORDER BY position, id`, listID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
cards := make([]domain.Card, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
card, err := scanCard(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cards = append(cards, card)
|
||||||
|
}
|
||||||
|
return cards, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
FROM cards WHERE id = ?`, id)
|
||||||
|
card, err := scanCard(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return domain.Card{}, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return card, err
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func Snapshot(ctx context.Context, q DBTX) (domain.Snapshot, error) {
|
||||||
|
lists, err := Lists(ctx, q, false)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Snapshot{}, err
|
||||||
|
}
|
||||||
|
snapshot := domain.Snapshot{Lists: make([]domain.ListWithCards, 0, len(lists))}
|
||||||
|
for _, list := range lists {
|
||||||
|
cards, err := Cards(ctx, q, list.ID)
|
||||||
|
if err != nil {
|
||||||
|
return domain.Snapshot{}, err
|
||||||
|
}
|
||||||
|
snapshot.Lists = append(snapshot.Lists, domain.ListWithCards{ID: list.ID, Name: list.Name, Position: list.Position, Cards: cards})
|
||||||
|
}
|
||||||
|
return snapshot, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaxListPosition(ctx context.Context, q DBTX) (float64, error) {
|
||||||
|
var position float64
|
||||||
|
err := q.QueryRowContext(ctx, "SELECT COALESCE(MAX(position), 0) FROM lists").Scan(&position)
|
||||||
|
return position, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func MaxCardPosition(ctx context.Context, q DBTX, listID int64) (float64, error) {
|
||||||
|
var position float64
|
||||||
|
err := q.QueryRowContext(ctx, "SELECT COALESCE(MAX(position), 0) FROM cards WHERE list_id = ?", listID).Scan(&position)
|
||||||
|
return position, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CountLists(ctx context.Context, q DBTX) (int, error) {
|
||||||
|
var count int
|
||||||
|
err := q.QueryRowContext(ctx, "SELECT COUNT(*) FROM lists").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func MustRows(result sql.Result, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Wrap(action string, err error) error {
|
||||||
|
if err == nil || err == domain.ErrNotFound {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s: %w", action, err)
|
||||||
|
}
|
||||||
29
internal/store/sqlite_test.go
Normal file
29
internal/store/sqlite_test.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package store_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"havenllo/internal/database"
|
||||||
|
"havenllo/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSnapshotUsesPositionOrder(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
db, err := database.Open(context.Background(), filepath.Join(t.TempDir(), "havenllo.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.Exec("INSERT INTO lists (name, position) VALUES ('First', 1), ('Last', 9999)"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
snapshot, err := store.Snapshot(context.Background(), db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(snapshot.Lists) != 5 || snapshot.Lists[0].Name != "First" || snapshot.Lists[len(snapshot.Lists)-1].Name != "Last" {
|
||||||
|
t.Fatalf("unexpected order: %#v", snapshot.Lists)
|
||||||
|
}
|
||||||
|
}
|
||||||
1
web/dist/.gitkeep
vendored
Normal file
1
web/dist/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
20
web/embed.go
Normal file
20
web/embed.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed all:dist
|
||||||
|
var embedded embed.FS
|
||||||
|
|
||||||
|
// Files contains the compiled Vite bundle without its dist path prefix.
|
||||||
|
var Files = mustSub(embedded, "dist")
|
||||||
|
|
||||||
|
func mustSub(source fs.FS, directory string) fs.FS {
|
||||||
|
files, err := fs.Sub(source, directory)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
15
web/index.html
Normal file
15
web/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#111b2d" />
|
||||||
|
<meta name="description" content="Havenllo, the Haven homelab board." />
|
||||||
|
<title>Havenllo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
2120
web/package-lock.json
generated
Normal file
2120
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
web/package.json
Normal file
22
web/package.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "havenllo-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.26.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.10.1",
|
||||||
|
"typescript": "^5.8.3",
|
||||||
|
"vite": "^6.3.5"
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"esbuild@0.25.12": true
|
||||||
|
}
|
||||||
|
}
|
||||||
169
web/src/App.tsx
Normal file
169
web/src/App.tsx
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
import { useCallback, useEffect, useReducer, useRef } from "preact/hooks";
|
||||||
|
import { api, APIError } from "./api";
|
||||||
|
import {
|
||||||
|
addCard,
|
||||||
|
addList,
|
||||||
|
boardReducer,
|
||||||
|
cloneBoard,
|
||||||
|
initialBoardState,
|
||||||
|
removeCard,
|
||||||
|
removeList,
|
||||||
|
updateCard,
|
||||||
|
updateList
|
||||||
|
} from "./board-state";
|
||||||
|
import { movePosition } from "./drag";
|
||||||
|
import type { CardPatch, ListPatch } from "./types";
|
||||||
|
import { BoardCanvas } from "./components/BoardCanvas";
|
||||||
|
import { Header } from "./components/Header";
|
||||||
|
import { Toasts } from "./components/Toast";
|
||||||
|
|
||||||
|
let toastID = 0;
|
||||||
|
|
||||||
|
function friendlyError(error: unknown): string {
|
||||||
|
if (error instanceof APIError) return error.message;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
return "Could not save that change. Please try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [state, dispatch] = useReducer(boardReducer, initialBoardState);
|
||||||
|
const boardRef = useRef(state.board);
|
||||||
|
boardRef.current = state.board;
|
||||||
|
|
||||||
|
const toast = useCallback((message: string, tone: "success" | "error" | "info" = "info") => {
|
||||||
|
const id = ++toastID;
|
||||||
|
dispatch({ type: "toast", toast: { id, message, tone } });
|
||||||
|
window.setTimeout(() => dispatch({ type: "dismiss", id }), 4200);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
dispatch({ type: "loading" });
|
||||||
|
try {
|
||||||
|
dispatch({ type: "loaded", board: await api.board() });
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: "error", error: friendlyError(error) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { void load(); }, [load]);
|
||||||
|
|
||||||
|
const mutate = useCallback(async (
|
||||||
|
makeOptimistic: (current: NonNullable<typeof boardRef.current>) => NonNullable<typeof boardRef.current>,
|
||||||
|
request: () => Promise<void>,
|
||||||
|
success?: string
|
||||||
|
) => {
|
||||||
|
const before = boardRef.current;
|
||||||
|
if (!before) return;
|
||||||
|
dispatch({ type: "replace", board: makeOptimistic(cloneBoard(before)) });
|
||||||
|
try {
|
||||||
|
await request();
|
||||||
|
if (success) toast(success, "success");
|
||||||
|
} catch (error) {
|
||||||
|
dispatch({ type: "replace", board: before });
|
||||||
|
toast(friendlyError(error), "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const createList = async (name: string) => {
|
||||||
|
const current = boardRef.current;
|
||||||
|
if (!current) return;
|
||||||
|
const position = Math.max(0, ...current.lists.map((list) => list.position)) + 1024;
|
||||||
|
try {
|
||||||
|
const { list } = await api.createList(name, position);
|
||||||
|
const board = boardRef.current;
|
||||||
|
if (board) dispatch({ type: "replace", board: addList(board, list) });
|
||||||
|
toast("List added", "success");
|
||||||
|
} catch (error) {
|
||||||
|
toast(friendlyError(error), "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editList = async (id: number, patch: ListPatch) => {
|
||||||
|
const old = boardRef.current?.lists.find((list) => list.id === id);
|
||||||
|
if (!old) return;
|
||||||
|
await mutate(
|
||||||
|
(board) => updateList(board, { ...old, ...patch }),
|
||||||
|
async () => {
|
||||||
|
const { list } = await api.patchList(id, patch);
|
||||||
|
const current = boardRef.current;
|
||||||
|
if (current) dispatch({ type: "replace", board: updateList(current, list) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteList = async (id: number) => {
|
||||||
|
await mutate((board) => removeList(board, id), () => api.deleteList(id), "List deleted");
|
||||||
|
};
|
||||||
|
|
||||||
|
const createCard = async (listID: number, title: string) => {
|
||||||
|
const current = boardRef.current;
|
||||||
|
const list = current?.lists.find((item) => item.id === listID);
|
||||||
|
if (!list) return;
|
||||||
|
const position = Math.max(0, ...list.cards.map((card) => card.position)) + 1024;
|
||||||
|
try {
|
||||||
|
const { card } = await api.createCard(listID, title, "", position);
|
||||||
|
const board = boardRef.current;
|
||||||
|
if (board) dispatch({ type: "replace", board: addCard(board, card) });
|
||||||
|
toast("Card added", "success");
|
||||||
|
} catch (error) {
|
||||||
|
toast(friendlyError(error), "error");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editCard = async (id: number, patch: CardPatch) => {
|
||||||
|
const old = boardRef.current?.lists.flatMap((list) => list.cards).find((card) => card.id === id);
|
||||||
|
if (!old) return;
|
||||||
|
const optimistic = { ...old, ...patch, list_id: patch.list_id ?? old.list_id };
|
||||||
|
await mutate(
|
||||||
|
(board) => updateCard(board, optimistic),
|
||||||
|
async () => {
|
||||||
|
const { card } = await api.patchCard(id, patch);
|
||||||
|
const current = boardRef.current;
|
||||||
|
if (current) dispatch({ type: "replace", board: updateCard(current, card) });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteCard = async (id: number) => {
|
||||||
|
await mutate((board) => removeCard(board, id), () => api.deleteCard(id), "Card deleted");
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveCard = async (cardID: number, sourceListID: 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);
|
||||||
|
await editCard(cardID, { list_id: targetListID, position });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main class="app-shell">
|
||||||
|
<Header onReload={load} loading={state.loading} />
|
||||||
|
{state.loading && !state.board ? <section class="loading-card" aria-live="polite">Loading your Haven board…</section> : null}
|
||||||
|
{state.error ? (
|
||||||
|
<section class="fatal-state">
|
||||||
|
<h1>Havenllo could not reach its board</h1>
|
||||||
|
<p>{state.error}</p>
|
||||||
|
<button type="button" class="button button-primary" onClick={() => void load()}>Try again</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
{state.board ? (
|
||||||
|
<BoardCanvas
|
||||||
|
lists={state.board.lists}
|
||||||
|
onCreateList={createList}
|
||||||
|
onEditList={editList}
|
||||||
|
onDeleteList={deleteList}
|
||||||
|
onCreateCard={createCard}
|
||||||
|
onEditCard={editCard}
|
||||||
|
onDeleteCard={deleteCard}
|
||||||
|
onMoveCard={moveCard}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<Toasts toasts={state.toasts} onDismiss={(id) => dispatch({ type: "dismiss", id })} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
63
web/src/api.ts
Normal file
63
web/src/api.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import type { Board, Card, CardPatch, List, ListPatch } from "./types";
|
||||||
|
|
||||||
|
export class APIError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch("/api" + path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.body ? { "Content-Type": "application/json" } : {}),
|
||||||
|
...init?.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = "Something went wrong";
|
||||||
|
let code = "request_failed";
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { error?: { message?: string; code?: string } };
|
||||||
|
message = payload.error?.message || message;
|
||||||
|
code = payload.error?.code || code;
|
||||||
|
} catch {
|
||||||
|
// The server's JSON error body is preferred, but network intermediaries may not have one.
|
||||||
|
}
|
||||||
|
throw new APIError(message, response.status, code);
|
||||||
|
}
|
||||||
|
if (response.status === 204) {
|
||||||
|
return undefined as T;
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (body: object): RequestInit => ({
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
board: () => request<Board>("/board"),
|
||||||
|
lists: () => request<{ lists: List[] }>("/lists"),
|
||||||
|
createList: (name: string, position?: number) =>
|
||||||
|
request<{ list: List }>("/lists", json({ name, ...(position === undefined ? {} : { position }) })),
|
||||||
|
patchList: (id: number, patch: ListPatch) =>
|
||||||
|
request<{ list: List }>("/lists/" + id, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
deleteList: (id: number) => request<void>("/lists/" + id, { method: "DELETE" }),
|
||||||
|
listCards: (id: number) => request<{ cards: Card[] }>("/lists/" + id + "/cards"),
|
||||||
|
createCard: (listID: number, title: string, description = "", position?: number) =>
|
||||||
|
request<{ card: Card }>("/lists/" + listID + "/cards", json({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
...(position === undefined ? {} : { position })
|
||||||
|
})),
|
||||||
|
patchCard: (id: number, patch: CardPatch) =>
|
||||||
|
request<{ card: Card }>("/cards/" + id, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
deleteCard: (id: number) => request<void>("/cards/" + id, { method: "DELETE" })
|
||||||
|
};
|
||||||
|
|
||||||
108
web/src/board-state.ts
Normal file
108
web/src/board-state.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Board, Card, List, ListWithCards } from "./types";
|
||||||
|
|
||||||
|
export type ToastTone = "success" | "error" | "info";
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: number;
|
||||||
|
tone: ToastTone;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardState {
|
||||||
|
board: Board | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoardAction =
|
||||||
|
| { type: "loading" }
|
||||||
|
| { type: "loaded"; board: Board }
|
||||||
|
| { type: "replace"; board: Board }
|
||||||
|
| { type: "error"; error: string }
|
||||||
|
| { type: "toast"; toast: ToastMessage }
|
||||||
|
| { type: "dismiss"; id: number };
|
||||||
|
|
||||||
|
export const initialBoardState: BoardState = {
|
||||||
|
board: null,
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
toasts: []
|
||||||
|
};
|
||||||
|
|
||||||
|
export function boardReducer(state: BoardState, action: BoardAction): BoardState {
|
||||||
|
switch (action.type) {
|
||||||
|
case "loading":
|
||||||
|
return { ...state, loading: true, error: null };
|
||||||
|
case "loaded":
|
||||||
|
return { ...state, board: action.board, loading: false, error: null };
|
||||||
|
case "replace":
|
||||||
|
return { ...state, board: action.board };
|
||||||
|
case "error":
|
||||||
|
return { ...state, loading: false, error: action.error };
|
||||||
|
case "toast":
|
||||||
|
return { ...state, toasts: [...state.toasts, action.toast] };
|
||||||
|
case "dismiss":
|
||||||
|
return { ...state, toasts: state.toasts.filter((toast) => toast.id !== action.id) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneBoard(board: Board): Board {
|
||||||
|
return {
|
||||||
|
lists: board.lists.map((list) => ({ ...list, cards: list.cards.map((card) => ({ ...card })) }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderedLists(lists: ListWithCards[]): ListWithCards[] {
|
||||||
|
return [...lists].sort((a, b) => a.position - b.position || a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderedCards(cards: Card[]): Card[] {
|
||||||
|
return [...cards].sort((a, b) => a.position - b.position || a.id - b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addList(board: Board, list: List): Board {
|
||||||
|
return { lists: orderedLists([...board.lists, { ...list, cards: [] }]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateList(board: Board, list: List): Board {
|
||||||
|
return {
|
||||||
|
lists: orderedLists(board.lists.map((current) => current.id === list.id
|
||||||
|
? { ...current, ...list }
|
||||||
|
: current))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeList(board: Board, listID: number): Board {
|
||||||
|
return { lists: board.lists.filter((list) => list.id !== listID) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addCard(board: Board, card: Card): Board {
|
||||||
|
return {
|
||||||
|
lists: board.lists.map((list) => list.id === card.list_id
|
||||||
|
? { ...list, cards: orderedCards([...list.cards, card]) }
|
||||||
|
: list)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCard(board: Board, card: Card): Board {
|
||||||
|
const without = board.lists.map((list) => ({
|
||||||
|
...list,
|
||||||
|
cards: list.cards.filter((current) => current.id !== card.id)
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
lists: without.map((list) => list.id === card.list_id
|
||||||
|
? { ...list, cards: orderedCards([...list.cards, card]) }
|
||||||
|
: list)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeCard(board: Board, cardID: number): Board {
|
||||||
|
return {
|
||||||
|
lists: board.lists.map((list) => ({
|
||||||
|
...list,
|
||||||
|
cards: list.cards.filter((card) => card.id !== cardID)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
95
web/src/components/BoardCanvas.tsx
Normal file
95
web/src/components/BoardCanvas.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import type { ListWithCards } from "../types";
|
||||||
|
import { ListColumn } from "./ListColumn";
|
||||||
|
|
||||||
|
interface BoardCanvasProps {
|
||||||
|
lists: ListWithCards[];
|
||||||
|
onCreateList: (name: string) => Promise<void>;
|
||||||
|
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>;
|
||||||
|
onDeleteCard: (id: number) => Promise<void>;
|
||||||
|
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BoardCanvas(props: BoardCanvasProps) {
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const addList = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await props.onCreateList(name);
|
||||||
|
setName("");
|
||||||
|
setAdding(false);
|
||||||
|
} catch {
|
||||||
|
// App has shown an error toast.
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class="board-area" aria-label="Haven task board">
|
||||||
|
<div class="board-intro">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">One calm place for your homelab work</p>
|
||||||
|
<h1>Haven board</h1>
|
||||||
|
</div>
|
||||||
|
<p class="board-hint">Drag tasks between lists, or click a title to edit.</p>
|
||||||
|
</div>
|
||||||
|
<div class="board-rail">
|
||||||
|
{props.lists.map((list) => (
|
||||||
|
<ListColumn
|
||||||
|
key={list.id}
|
||||||
|
list={list}
|
||||||
|
onEditList={(patch) => props.onEditList(list.id, patch)}
|
||||||
|
onDeleteList={() => props.onDeleteList(list.id)}
|
||||||
|
onCreateCard={(title) => props.onCreateCard(list.id, title)}
|
||||||
|
onEditCard={props.onEditCard}
|
||||||
|
onDeleteCard={props.onDeleteCard}
|
||||||
|
onMoveCard={props.onMoveCard}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<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 another list
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
web/src/components/CardEditor.tsx
Normal file
51
web/src/components/CardEditor.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import type { Card } from "../types";
|
||||||
|
|
||||||
|
interface CardEditorProps {
|
||||||
|
card: Card;
|
||||||
|
onSave: (description: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardEditor({ card, onSave, onClose, onDelete }: CardEditorProps) {
|
||||||
|
const [description, setDescription] = useState(card.description);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setDescription(card.description), [card.id, card.description]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onSave(description);
|
||||||
|
onClose();
|
||||||
|
} catch {
|
||||||
|
// App already surfaced an actionable toast and has rolled the state back.
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class="card-editor" aria-label={"Edit " + card.title}>
|
||||||
|
<label>
|
||||||
|
Notes
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
maxlength={10000}
|
||||||
|
rows={4}
|
||||||
|
placeholder="Add useful context…"
|
||||||
|
onInput={(event) => setDescription(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button type="button" class="button button-primary" disabled={saving} onClick={() => void save()}>
|
||||||
|
{saving ? "Saving…" : "Save notes"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="button button-quiet" disabled={saving} onClick={onClose}>Close</button>
|
||||||
|
<button type="button" class="text-danger" disabled={saving} onClick={onDelete}>Delete card</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
113
web/src/components/CardItem.tsx
Normal file
113
web/src/components/CardItem.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useState } from "preact/hooks";
|
||||||
|
import { beginCardDrag } from "../drag";
|
||||||
|
import type { Card } from "../types";
|
||||||
|
import { CardEditor } from "./CardEditor";
|
||||||
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
|
|
||||||
|
interface CardItemProps {
|
||||||
|
card: Card;
|
||||||
|
onEdit: (patch: { title?: string; description?: string; done?: boolean }) => Promise<void>;
|
||||||
|
onDelete: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardItem({ card, onEdit, onDelete }: CardItemProps) {
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const [title, setTitle] = useState(card.title);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const saveTitle = async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onEdit({ title });
|
||||||
|
setEditingTitle(false);
|
||||||
|
} catch {
|
||||||
|
setTitle(card.title);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
setConfirming(false);
|
||||||
|
try {
|
||||||
|
await onDelete();
|
||||||
|
} catch {
|
||||||
|
// The global mutation handler reports the failure.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<article
|
||||||
|
class={"card " + (card.done ? "card-done" : "")}
|
||||||
|
draggable={!editingTitle}
|
||||||
|
onDragStart={(event) => beginCardDrag(event as unknown as DragEvent, card)}
|
||||||
|
>
|
||||||
|
<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={() => void onEdit({ done: !card.done })}
|
||||||
|
>
|
||||||
|
{card.done ? "✓" : ""}
|
||||||
|
</button>
|
||||||
|
<div class="card-title-wrap">
|
||||||
|
{editingTitle ? (
|
||||||
|
<input
|
||||||
|
class="card-title-input"
|
||||||
|
value={title}
|
||||||
|
maxlength={240}
|
||||||
|
aria-label="Card title"
|
||||||
|
autoFocus
|
||||||
|
onInput={(event) => setTitle(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void saveTitle();
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setTitle(card.title);
|
||||||
|
setEditingTitle(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => void saveTitle()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button type="button" class="card-title" onClick={() => setEditingTitle(true)}>{card.title}</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="card-menu"
|
||||||
|
title="Open card details"
|
||||||
|
aria-label="Open card details"
|
||||||
|
onClick={() => setExpanded((value) => !value)}
|
||||||
|
>
|
||||||
|
•••
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{card.description && !expanded ? <p class="card-preview">{card.description}</p> : null}
|
||||||
|
{expanded ? (
|
||||||
|
<CardEditor
|
||||||
|
card={card}
|
||||||
|
onSave={(description) => onEdit({ description })}
|
||||||
|
onClose={() => setExpanded(false)}
|
||||||
|
onDelete={() => setConfirming(true)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title="Delete this card?"
|
||||||
|
detail={"“" + card.title + "” will be permanently removed."}
|
||||||
|
confirmLabel="Delete card"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
50
web/src/components/ConfirmDialog.tsx
Normal file
50
web/src/components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from "preact/hooks";
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
confirmLabel,
|
||||||
|
onCancel,
|
||||||
|
onConfirm
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", closeOnEscape);
|
||||||
|
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||||
|
}, [open, onCancel]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div class="dialog-backdrop" role="presentation" onMouseDown={onCancel}>
|
||||||
|
<section
|
||||||
|
class="confirm-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
aria-describedby="confirm-detail"
|
||||||
|
onMouseDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="confirm-title">{title}</h2>
|
||||||
|
<p id="confirm-detail">{detail}</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" class="button button-secondary" onClick={onCancel}>Cancel</button>
|
||||||
|
<button type="button" class="button button-danger" onClick={onConfirm}>{confirmLabel}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
30
web/src/components/Header.tsx
Normal file
30
web/src/components/Header.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
interface HeaderProps {
|
||||||
|
loading: boolean;
|
||||||
|
onReload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ loading, onReload }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="brand" aria-label="Havenllo">
|
||||||
|
<span class="brand-mark" aria-hidden="true">H</span>
|
||||||
|
<span>havenllo</span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-board">
|
||||||
|
<span class="eyebrow">Haven homelab</span>
|
||||||
|
<strong>My board</strong>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-button refresh-button"
|
||||||
|
title="Refresh board"
|
||||||
|
aria-label="Refresh board"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={onReload}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↻</span>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
196
web/src/components/ListColumn.tsx
Normal file
196
web/src/components/ListColumn.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { 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;
|
||||||
|
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>;
|
||||||
|
onDeleteCard: (cardID: number) => Promise<void>;
|
||||||
|
onMoveCard: (cardID: number, sourceListID: number, targetListID: number, index: number) => Promise<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,
|
||||||
|
onEditList,
|
||||||
|
onDeleteList,
|
||||||
|
onCreateCard,
|
||||||
|
onEditCard,
|
||||||
|
onDeleteCard,
|
||||||
|
onMoveCard
|
||||||
|
}: ListColumnProps) {
|
||||||
|
const [editingName, setEditingName] = useState(false);
|
||||||
|
const [name, setName] = useState(list.name);
|
||||||
|
const [addingCard, setAddingCard] = useState(false);
|
||||||
|
const [cardTitle, setCardTitle] = useState("");
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const saveName = async () => {
|
||||||
|
if (!name.trim() || name.trim() === list.name) {
|
||||||
|
setName(list.name);
|
||||||
|
setEditingName(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onEditList({ name });
|
||||||
|
setEditingName(false);
|
||||||
|
} catch {
|
||||||
|
setName(list.name);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addCard = async () => {
|
||||||
|
if (!cardTitle.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await onCreateCard(cardTitle);
|
||||||
|
setCardTitle("");
|
||||||
|
setAddingCard(false);
|
||||||
|
} catch {
|
||||||
|
// App has shown an error toast.
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
setConfirming(false);
|
||||||
|
try {
|
||||||
|
await onDeleteList();
|
||||||
|
} catch {
|
||||||
|
// App has shown an error toast.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<section class="list-column">
|
||||||
|
<div class="list-header">
|
||||||
|
<div class="list-heading">
|
||||||
|
{editingName ? (
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
maxlength={120}
|
||||||
|
autoFocus
|
||||||
|
aria-label="List name"
|
||||||
|
onInput={(event) => setName(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") void saveName();
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setName(list.name);
|
||||||
|
setEditingName(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => void saveName()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button type="button" class="list-name" onClick={() => setEditingName(true)}>{list.name}</button>
|
||||||
|
)}
|
||||||
|
<span class="count-badge">{list.cards.length}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="icon-button list-delete"
|
||||||
|
aria-label={"Delete " + list.name}
|
||||||
|
title="Delete list"
|
||||||
|
onClick={() => setConfirming(true)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="cards-stack">
|
||||||
|
<DropZone index={0} onDropCard={(cardID, sourceListID, index) => onMoveCard(cardID, sourceListID, list.id, index)} />
|
||||||
|
{list.cards.map((card: Card, index) => (
|
||||||
|
<div class="card-slot" key={card.id}>
|
||||||
|
<CardItem
|
||||||
|
card={card}
|
||||||
|
onEdit={(patch) => onEditCard(card.id, patch)}
|
||||||
|
onDelete={() => onDeleteCard(card.id)}
|
||||||
|
/>
|
||||||
|
<DropZone index={index + 1} onDropCard={(cardID, sourceListID, dropIndex) => onMoveCard(cardID, sourceListID, list.id, dropIndex)} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!list.cards.length ? <p class="list-empty">Drop a card here or add the first task.</p> : null}
|
||||||
|
</div>
|
||||||
|
{addingCard ? (
|
||||||
|
<form
|
||||||
|
class="quick-add"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void addCard();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={cardTitle}
|
||||||
|
maxlength={240}
|
||||||
|
autoFocus
|
||||||
|
placeholder="What needs doing?"
|
||||||
|
aria-label="New card title"
|
||||||
|
onInput={(event) => setCardTitle(event.currentTarget.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
setAddingCard(false);
|
||||||
|
setCardTitle("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="quick-add-actions">
|
||||||
|
<button type="submit" class="button button-primary" disabled={saving}>Add card</button>
|
||||||
|
<button type="button" class="button button-quiet" disabled={saving} onClick={() => setAddingCard(false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<button type="button" class="add-card-button" onClick={() => setAddingCard(true)}>
|
||||||
|
<span aria-hidden="true">+</span> Add a card
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirming}
|
||||||
|
title={"Delete “" + list.name + "”? "}
|
||||||
|
detail="All cards in this list will be permanently removed."
|
||||||
|
confirmLabel="Delete list"
|
||||||
|
onCancel={() => setConfirming(false)}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
25
web/src/components/Toast.tsx
Normal file
25
web/src/components/Toast.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useEffect } from "preact/hooks";
|
||||||
|
import type { ToastMessage } from "../board-state";
|
||||||
|
|
||||||
|
interface ToastsProps {
|
||||||
|
toasts: ToastMessage[];
|
||||||
|
onDismiss: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toast({ toast, onDismiss }: { toast: ToastMessage; onDismiss: (id: number) => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(() => onDismiss(toast.id), 4400);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [toast.id, onDismiss]);
|
||||||
|
return (
|
||||||
|
<div class={"toast toast-" + toast.tone} role="status">
|
||||||
|
<span>{toast.message}</span>
|
||||||
|
<button type="button" aria-label="Dismiss notification" onClick={() => onDismiss(toast.id)}>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toasts({ toasts, onDismiss }: ToastsProps) {
|
||||||
|
return <aside class="toast-stack" aria-live="polite">{toasts.map((toast) => <Toast key={toast.id} toast={toast} onDismiss={onDismiss} />)}</aside>;
|
||||||
|
}
|
||||||
|
|
||||||
50
web/src/drag.ts
Normal file
50
web/src/drag.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 previous = cards[index - 1];
|
||||||
|
const next = cards[index];
|
||||||
|
if (!previous && !next) return 1024;
|
||||||
|
if (!previous) return next.position - 1024;
|
||||||
|
if (!next) return previous.position + 1024;
|
||||||
|
return (previous.position + next.position) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
12
web/src/main.tsx
Normal file
12
web/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { render } from "preact";
|
||||||
|
import { App } from "./App";
|
||||||
|
import "./styles.css";
|
||||||
|
|
||||||
|
const root = document.getElementById("app");
|
||||||
|
|
||||||
|
if (!root) {
|
||||||
|
throw new Error("Havenllo root element is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
render(<App />, root);
|
||||||
|
|
||||||
425
web/src/styles.css
Normal file
425
web/src/styles.css
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-synthesis: none;
|
||||||
|
background: #0a1020;
|
||||||
|
color: #e9effc;
|
||||||
|
--ink: #e9effc;
|
||||||
|
--muted: #97a6c1;
|
||||||
|
--faint: #63728d;
|
||||||
|
--canvas: #0a1020;
|
||||||
|
--surface: #111b2d;
|
||||||
|
--surface-raised: #17243a;
|
||||||
|
--surface-hover: #1c2c46;
|
||||||
|
--border: rgba(151, 174, 211, 0.16);
|
||||||
|
--border-strong: rgba(109, 211, 200, 0.42);
|
||||||
|
--teal: #62d5c7;
|
||||||
|
--teal-deep: #168c86;
|
||||||
|
--indigo: #8097ff;
|
||||||
|
--danger: #ff7888;
|
||||||
|
--shadow: 0 18px 42px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body, #app { min-height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-width: 320px;
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 85% -10%, rgba(72, 115, 195, 0.24), transparent 32rem),
|
||||||
|
radial-gradient(circle at 0% 100%, rgba(24, 119, 116, 0.15), transparent 26rem),
|
||||||
|
var(--canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, textarea { font: inherit; }
|
||||||
|
|
||||||
|
button { color: inherit; }
|
||||||
|
|
||||||
|
button:not(:disabled), input:not(:disabled), textarea:not(:disabled) { touch-action: manipulation; }
|
||||||
|
|
||||||
|
button:focus-visible, input:focus-visible, textarea:focus-visible {
|
||||||
|
outline: 3px solid rgba(98, 213, 199, 0.65);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled { cursor: not-allowed; opacity: 0.58; }
|
||||||
|
|
||||||
|
.app-shell { min-height: 100vh; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
min-height: 72px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 12px clamp(18px, 4vw, 52px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: rgba(10, 16, 32, 0.78);
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 1.28rem;
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 11px;
|
||||||
|
color: #09111e;
|
||||||
|
font-size: 1.06rem;
|
||||||
|
font-weight: 900;
|
||||||
|
background: linear-gradient(135deg, var(--teal), var(--indigo));
|
||||||
|
box-shadow: 0 6px 20px rgba(98, 213, 199, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-board {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
padding-left: 18px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-board strong { font-size: 0.92rem; }
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--teal);
|
||||||
|
font-size: 0.69rem;
|
||||||
|
font-weight: 760;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button { margin-left: auto; }
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
background: transparent;
|
||||||
|
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover { color: var(--ink); border-color: var(--border-strong); background: var(--surface-hover); }
|
||||||
|
|
||||||
|
.board-area { padding: clamp(24px, 4vw, 50px); }
|
||||||
|
|
||||||
|
.board-intro {
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-intro h1 { margin: 4px 0 0; font-size: clamp(1.65rem, 3vw, 2.2rem); letter-spacing: -0.045em; }
|
||||||
|
.board-hint { max-width: 315px; margin: 0; color: var(--muted); font-size: 0.9rem; text-align: right; }
|
||||||
|
|
||||||
|
.board-rail {
|
||||||
|
width: max-content;
|
||||||
|
min-width: 100%;
|
||||||
|
max-width: 1500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding-bottom: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column, .add-list-column {
|
||||||
|
width: min(310px, calc(100vw - 48px));
|
||||||
|
flex: 0 0 310px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(17, 27, 45, 0.84);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-column { padding: 14px; }
|
||||||
|
|
||||||
|
.list-header, .list-heading, .card-topline, .editor-actions, .quick-add-actions, .dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-header { justify-content: space-between; gap: 8px; padding: 0 1px 9px; }
|
||||||
|
.list-heading { min-width: 0; gap: 8px; }
|
||||||
|
|
||||||
|
.list-name, .card-title {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 740;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: text;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-name { font-size: 1rem; }
|
||||||
|
.list-heading input { width: 190px; }
|
||||||
|
|
||||||
|
.count-badge {
|
||||||
|
min-width: 23px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(126, 151, 190, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-delete { width: 30px; height: 30px; border-color: transparent; font-size: 1.25rem; }
|
||||||
|
.list-delete:hover { color: var(--danger); border-color: rgba(255, 120, 136, 0.25); }
|
||||||
|
|
||||||
|
.cards-stack { min-height: 40px; }
|
||||||
|
|
||||||
|
.card-slot { position: relative; }
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
height: 8px;
|
||||||
|
margin: 1px 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: height 120ms ease, background 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.is-active { height: 22px; background: rgba(98, 213, 199, 0.2); outline: 1px dashed var(--teal); }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 11px;
|
||||||
|
border: 1px solid rgba(161, 185, 220, 0.16);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(145deg, rgba(28, 44, 70, 0.94), rgba(18, 30, 49, 0.94));
|
||||||
|
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.14);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:active { cursor: grabbing; }
|
||||||
|
.card:hover { border-color: rgba(151, 174, 211, 0.32); }
|
||||||
|
|
||||||
|
.card-done { opacity: 0.7; }
|
||||||
|
.card-done .card-title { color: var(--muted); text-decoration: line-through; text-decoration-thickness: 1.5px; }
|
||||||
|
|
||||||
|
.card-topline { align-items: flex-start; gap: 8px; }
|
||||||
|
.card-title-wrap { min-width: 0; flex: 1; }
|
||||||
|
.card-title { width: 100%; line-height: 1.35; white-space: normal; }
|
||||||
|
.card-title-input { width: 100%; padding: 2px 4px; }
|
||||||
|
|
||||||
|
.done-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
margin-top: 1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1.5px solid var(--faint);
|
||||||
|
border-radius: 7px;
|
||||||
|
color: #081320;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.done-button.is-done { border-color: var(--teal); background: var(--teal); }
|
||||||
|
.card-menu { padding: 0 2px 4px; border: 0; color: var(--faint); font-weight: 800; letter-spacing: 0.08em; cursor: pointer; background: transparent; }
|
||||||
|
.card-menu:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
.card-preview {
|
||||||
|
display: -webkit-box;
|
||||||
|
margin: 8px 1px 0 29px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
line-height: 1.38;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-editor {
|
||||||
|
margin-top: 11px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
label { display: grid; gap: 6px; color: var(--muted); font-size: 0.76rem; font-weight: 650; }
|
||||||
|
|
||||||
|
input, textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid rgba(151, 174, 211, 0.27);
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--ink);
|
||||||
|
line-height: 1.35;
|
||||||
|
background: rgba(7, 14, 27, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea { min-height: 82px; resize: vertical; }
|
||||||
|
|
||||||
|
.editor-actions { flex-wrap: wrap; gap: 7px; margin-top: 9px; }
|
||||||
|
|
||||||
|
.button {
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 720;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 120ms ease, background 120ms ease, border-color 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
.button-primary { color: #08131e; background: linear-gradient(135deg, var(--teal), #75c9d8); }
|
||||||
|
.button-secondary { border-color: var(--border); color: var(--ink); background: var(--surface-hover); }
|
||||||
|
.button-danger { color: #fff1f3; background: #b84155; }
|
||||||
|
.button-quiet { color: var(--muted); background: transparent; }
|
||||||
|
.button-quiet:hover:not(:disabled) { color: var(--ink); background: rgba(151, 174, 211, 0.09); }
|
||||||
|
|
||||||
|
.text-danger { margin-left: auto; padding: 5px; border: 0; color: var(--danger); font-size: 0.77rem; cursor: pointer; background: transparent; }
|
||||||
|
.text-danger:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.quick-add { display: grid; gap: 8px; padding-top: 10px; }
|
||||||
|
.quick-add-actions { gap: 5px; }
|
||||||
|
.add-card-button, .add-list-button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px dashed transparent;
|
||||||
|
border-radius: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-card-button { margin-top: 8px; }
|
||||||
|
.add-card-button:hover, .add-list-button:hover { border-color: var(--border-strong); color: var(--teal); background: rgba(98, 213, 199, 0.06); }
|
||||||
|
|
||||||
|
.add-list-column { padding: 8px; border-style: dashed; box-shadow: none; background: rgba(17, 27, 45, 0.42); }
|
||||||
|
.add-list-button { min-height: 46px; }
|
||||||
|
|
||||||
|
.list-empty { margin: 12px 5px; color: var(--faint); font-size: 0.78rem; line-height: 1.45; text-align: center; }
|
||||||
|
|
||||||
|
.loading-card, .fatal-state {
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 80px auto;
|
||||||
|
padding: 28px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-card::after { content: ""; display: block; width: 52%; height: 6px; margin: 17px auto 0; border-radius: 99px; background: linear-gradient(90deg, transparent, var(--teal), transparent); animation: shimmer 1.25s infinite; }
|
||||||
|
.fatal-state h1 { margin-top: 0; color: var(--ink); font-size: 1.25rem; }
|
||||||
|
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 30;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
background: rgba(2, 7, 15, 0.7);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog {
|
||||||
|
width: min(100%, 410px);
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(151, 174, 211, 0.28);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #14213a;
|
||||||
|
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-dialog h2 { margin: 0; font-size: 1.15rem; }
|
||||||
|
.confirm-dialog p { margin: 10px 0 22px; color: var(--muted); line-height: 1.45; }
|
||||||
|
.dialog-actions { justify-content: flex-end; gap: 8px; }
|
||||||
|
|
||||||
|
.toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 40;
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
width: min(350px, calc(100vw - 36px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 11px 12px 11px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--teal);
|
||||||
|
border-radius: 11px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #17243a;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
animation: toast-in 180ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-error { border-left-color: var(--danger); }
|
||||||
|
.toast-info { border-left-color: var(--indigo); }
|
||||||
|
.toast span { flex: 1; font-size: 0.86rem; }
|
||||||
|
.toast button { width: 26px; height: 26px; border: 0; border-radius: 6px; color: var(--muted); font-size: 1.2rem; cursor: pointer; background: transparent; }
|
||||||
|
.toast button:hover { color: var(--ink); background: rgba(255,255,255,0.07); }
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes shimmer { 50% { opacity: 0.25; transform: scaleX(0.72); } }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.topbar { min-height: 64px; padding: 10px 16px; }
|
||||||
|
.topbar-board { display: none; }
|
||||||
|
.board-area { padding: 24px 16px; overflow: hidden; }
|
||||||
|
.board-intro { align-items: flex-start; margin-bottom: 18px; }
|
||||||
|
.board-hint { display: none; }
|
||||||
|
.board-rail { overflow-x: auto; margin-right: -16px; padding-right: 16px; scroll-snap-type: x proximity; }
|
||||||
|
.list-column, .add-list-column { scroll-snap-align: start; }
|
||||||
|
.button, .icon-button, .add-card-button { min-height: 44px; }
|
||||||
|
.icon-button { width: 44px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||||
|
}
|
||||||
|
|
||||||
46
web/src/types.ts
Normal file
46
web/src/types.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
export interface Card {
|
||||||
|
id: number;
|
||||||
|
list_id: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
position: number;
|
||||||
|
done: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface List {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
card_count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListWithCards extends List {
|
||||||
|
cards: Card[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Board {
|
||||||
|
lists: ListWithCards[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface APIErrorBody {
|
||||||
|
error: {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListPatch {
|
||||||
|
name?: string;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CardPatch {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
done?: boolean;
|
||||||
|
list_id?: number;
|
||||||
|
position?: number;
|
||||||
|
}
|
||||||
|
|
||||||
22
web/tsconfig.json
Normal file
22
web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact"
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
11
web/vite.config.ts
Normal file
11
web/vite.config.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import preact from "@preact/preset-vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user