diff --git a/README.md b/README.md index c143182..202df17 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,10 @@ # PHP Project Manager -A small project-management app: each user owns **projects**, and each -project holds **cards** you can organise as a flat list or a drag-and-drop -kanban board. It's a REST API in PHP (Slim 4) backed by SQLite, with a Vue 3 -+ TypeScript PWA frontend. There's no password — signing in is a link -emailed to you, which creates your account the first time, and you can add -a passkey afterwards for a quicker sign-in next time. +A small project-management app: each user owns **projects**, and each project holds **cards** you can organise as a flat list or a drag-and-drop kanban board. It's a REST API in PHP (Slim 4) backed by SQLite, with a Vue 3 + TypeScript PWA frontend. There's no password — signing in is a link emailed to you, which creates your account the first time, and you can add a passkey afterwards for a quicker sign-in next time. ## Documentation -This page covers running the app. For anything more technical — the REST -API, configuration, running without Docker, project layout, and the -development history — see [docs/](docs/). +This page covers running the app. For anything more technical — the REST API, configuration, running without Docker, project layout, and the development history — see [docs/](docs/). ## Getting started @@ -21,29 +14,17 @@ The only requirement is Docker with the Compose plugin. docker compose up -d ``` -Then open . A **[Mailpit](https://mailpit.axllent.org/)** -mail-catcher also starts alongside the app, at — -since a default local setup has nowhere else to send the sign-in emails. +Then open . A **[Mailpit](https://mailpit.axllent.org/)** mail-catcher also starts alongside the app, at — since a default local setup has nowhere else to send the sign-in emails. ### First-time login -1. Enter any email address and submit. There's no separate sign-up step and - no password to choose — this both creates your account and sends it a - sign-in link. -2. Open (Mailpit) instead of a real inbox, and open - the message that just arrived there. -3. Click the link inside it. You're now signed in, on a new, - already-verified account. +1. Enter any email address and submit. There's no separate sign-up step and no password to choose — this both creates your account and sends it a sign-in link. +2. Open (Mailpit) instead of a real inbox, and open the message that just arrived there. +3. Click the link inside it. You're now signed in, on a new, already-verified account. -From then on, your profile page (top right, your email address) lets you add -a **passkey** — your device's fingerprint, face, or PIN — so you don't need -to wait on an email to sign in next time. +From then on, your profile page (top right, your email address) lets you add a **passkey** — your device's fingerprint, face, or PIN — so you don't need to wait on an email to sign in next time. -Everything the app stores (your account, projects, cards) lives in a Docker -volume, so it survives `docker compose restart` / `down` + `up`; -`docker compose down -v` wipes it for a clean slate. See -[docs/setup.md](docs/setup.md) for configuration, running without Docker, -and running the test suite. +Everything the app stores (your account, projects, cards) lives in a Docker volume, so it survives `docker compose restart` / `down` + `up`; `docker compose down -v` wipes it for a clean slate. See [docs/setup.md](docs/setup.md) for configuration, running without Docker, and running the test suite. ## Provenance diff --git a/docs/README.md b/docs/README.md index 63439e0..ad2dd05 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,15 +1,8 @@ # Documentation -Technical documentation for the PHP Project Manager. Start with the root -[README](../README.md) if you just want to run the app. +Technical documentation for the PHP Project Manager. Start with the root [README](../README.md) if you just want to run the app. -- **[API reference](api.md)** — every REST endpoint: auth (magic links, - passkeys), projects, cards, statuses, error shapes, and a curl walkthrough. -- **[Architecture](architecture.md)** — backend layout and how the pieces fit - together. Frontend architecture lives in - [web/README.md](../web/README.md) instead, since it's a large enough topic - on its own. -- **[Setup & configuration](setup.md)** — running without Docker, every - environment variable, and the test suite. -- **[Development history](history.md)** — a stage-by-stage log of how the app - got here. +- **[API reference](api.md)** — every REST endpoint: auth (magic links, passkeys), projects, cards, statuses, error shapes, and a curl walkthrough. +- **[Architecture](architecture.md)** — backend layout and how the pieces fit together. Frontend architecture lives in [web/README.md](../web/README.md) instead, since it's a large enough topic on its own. +- **[Setup & configuration](setup.md)** — running without Docker, every environment variable, and the test suite. +- **[Development history](history.md)** — a stage-by-stage log of how the app got here. diff --git a/docs/api.md b/docs/api.md index fed9aa3..6b1a863 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,7 +1,6 @@ # API reference -Base path: `/api`. All request and response bodies are JSON; send -`Content-Type: application/json`. +Base path: `/api`. All request and response bodies are JSON; send `Content-Type: application/json`. ### `GET /api/health` @@ -11,10 +10,7 @@ Base path: `/api`. All request and response bodies are JSON; send ## Auth -There is no password and no separate registration endpoint. Entering an email -address and opening the link sent to it is the entire flow, for a brand-new -address and a returning one alike. A user can also register one or more -[passkeys](#passkeys) and use one instead, once signed in at least once. +There is no password and no separate registration endpoint. Entering an email address and opening the link sent to it is the entire flow, for a brand-new address and a returning one alike. A user can also register one or more [passkeys](#passkeys) and use one instead, once signed in at least once. | Method | Path | Auth | Purpose | |--------|------|------|---------| @@ -29,14 +25,7 @@ address and a returning one alike. A user can also register one or more Request: `{ "email": "ada@example.com" }`. -Emails a one-time sign-in link (`/verify-email?token=…`, 15-minute -expiry) and always returns `202` with the same message. If the address has no -account yet, one is created (unverified) right here — that's the only "sign -up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown -address is silently ignored (still `202`, nothing sent) and only an address -that already has an account can sign in. A link is only actually (re-)sent if -this address hasn't been emailed one in the last 60 seconds. `422` if the -address is malformed. +Emails a one-time sign-in link (`/verify-email?token=…`, 15-minute expiry) and always returns `202` with the same message. If the address has no account yet, one is created (unverified) right here — that's the only "sign up" there is — unless `APP_ALLOW_REGISTRATION=false`, in which case an unknown address is silently ignored (still `202`, nothing sent) and only an address that already has an account can sign in. A link is only actually (re-)sent if this address hasn't been emailed one in the last 60 seconds. `422` if the address is malformed. ```json { "message": "Check your email for a link to sign in." } @@ -44,8 +33,7 @@ address is malformed. ### `POST /api/auth/verify-email` -Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is -`400` (distinct messages). Success signs the caller in: +Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is `400` (distinct messages). Success signs the caller in: ```json { @@ -63,24 +51,15 @@ Body: `{ "token": "..." }`. A missing/invalid, already-used, or expired token is } ``` -Opening a link is the only way to obtain a session, so an authenticated request -is always for a verified address — `email_verified` is `true` from the first -token a user's browser ever holds. +Opening a link is the only way to obtain a session, so an authenticated request is always for a verified address — `email_verified` is `true` from the first token a user's browser ever holds. ### `GET /api/me` -Requires `Authorization: Bearer `. `200 OK`: the same `user` object shown -above. `pending_email` is the address a still-valid email-change link is -waiting on, or `null`. `401` if the header is missing, malformed, or the token -is invalid/expired. +Requires `Authorization: Bearer `. `200 OK`: the same `user` object shown above. `pending_email` is the address a still-valid email-change link is waiting on, or `null`. `401` if the header is missing, malformed, or the token is invalid/expired. ### `POST /api/email/change` -Requires `Authorization: Bearer `. Body: `{ "email": "new@example.com" }`. -The address must be free (`409`) and different from the current one (`422`). -Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s -resend window, per user) — `429` with `error.details.retry_after` when too -soon. On success, `202` with `retry_after` and `pending_email`: +Requires `Authorization: Bearer `. Body: `{ "email": "new@example.com" }`. The address must be free (`409`) and different from the current one (`422`). Throttled to **once per 60 seconds** (shared with `/api/auth/magic-link`'s resend window, per user) — `429` with `error.details.retry_after` when too soon. On success, `202` with `retry_after` and `pending_email`: ```json { @@ -90,20 +69,11 @@ soon. On success, `202` with `retry_after` and `pending_email`: } ``` -The change is **not applied until** the magic link sent to the new address is -opened — until then `GET /api/me` still shows the old address, with -`pending_email` set. Opening that link both changes the address and re-verifies -it, via the same `/api/auth/verify-email`. +The change is **not applied until** the magic link sent to the new address is opened — until then `GET /api/me` still shows the old address, with `pending_email` set. Opening that link both changes the address and re-verifies it, via the same `/api/auth/verify-email`. ## Passkeys -WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey -is always registered as a **discoverable, user-verified** credential, which is -what makes login usernameless: the browser prompts the signed-in device for -whichever passkey it has for this site, with no email typed first. There's no -attestation/provenance check (`'none'` format) — this only confirms "the same -device that registered", the standard trust model for a public site's own -users, not a fleet of company-issued security keys. +WebAuthn, via [lbuchs/webauthn](https://github.com/lbuchs/WebAuthn). A passkey is always registered as a **discoverable, user-verified** credential, which is what makes login usernameless: the browser prompts the signed-in device for whichever passkey it has for this site, with no email typed first. There's no attestation/provenance check (`'none'` format) — this only confirms "the same device that registered", the standard trust model for a public site's own users, not a fleet of company-issued security keys. | Method | Path | Auth | Purpose | |--------|------|------|---------| @@ -114,35 +84,16 @@ users, not a fleet of company-issued security keys. | `POST` | `/api/auth/passkey/options` | — | a login challenge (no email — discoverable) | | `POST` | `/api/auth/passkey/verify` | — | verify and sign in | -Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }` -— `options.publicKey` is passed more or less directly to -[`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create) -/ [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) -(binary fields travel as base64url strings; the frontend converts them — -see [web/README.md](../web/README.md)). `challenge_id` identifies a **single-use** -challenge, good for 5 minutes, and must be sent back with the browser's -response: +Both `.../options` endpoints return `{ "challenge_id": 1, "options": { "publicKey": {…} } }` — `options.publicKey` is passed more or less directly to [`navigator.credentials.create()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create) / [`.get()`](https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get) (binary fields travel as base64url strings; the frontend converts them — see [web/README.md](../web/README.md)). `challenge_id` identifies a **single-use** challenge, good for 5 minutes, and must be sent back with the browser's response: -- `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. - `credential` is `{ id, response: { clientDataJSON, attestationObject } }` - (all base64url). `201` with the stored passkey - (`{ id, label, created_at, last_used_at }` — never the credential id or - public key) on success; `400` if the response doesn't check out, `409` if - that credential is already registered. -- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`, - where `credential` also carries `authenticatorData`, `signature`, and - `userHandle`. Success returns the same `{ user, token, expires_at }` envelope - as `/api/auth/verify-email`. `401` if the credential isn't recognised or the - signature doesn't check out. +- `POST /api/passkeys` body: `{ "challenge_id": 1, "credential": {…}, "label": "My laptop" }`. `credential` is `{ id, response: { clientDataJSON, attestationObject } }` (all base64url). `201` with the stored passkey (`{ id, label, created_at, last_used_at }` — never the credential id or public key) on success; `400` if the response doesn't check out, `409` if that credential is already registered. +- `POST /api/auth/passkey/verify` body: `{ "challenge_id": 1, "credential": {…} }`, where `credential` also carries `authenticatorData`, `signature`, and `userHandle`. Success returns the same `{ user, token, expires_at }` envelope as `/api/auth/verify-email`. `401` if the credential isn't recognised or the signature doesn't check out. -`user.has_passkey` (on every user object) is `true` once at least one is -registered — that's what the frontend's "add a passkey" notice keys off. +`user.has_passkey` (on every user object) is `true` once at least one is registered — that's what the frontend's "add a passkey" notice keys off. ## Projects -All routes below require `Authorization: Bearer `. A project belongs to one -owner (the creator); another user's project — or a missing one — always responds -`404`. +All routes below require `Authorization: Bearer `. A project belongs to one owner (the creator); another user's project — or a missing one — always responds `404`. | Method | Path | Purpose | |--------|------|---------| @@ -152,9 +103,7 @@ owner (the creator); another user's project — or a missing one — always resp | `PATCH` | `/api/projects/{id}` | rename the project (`title`) | | `DELETE` | `/api/projects/{id}` | delete the project and its cards (`204`) | -`GET /api/projects` is always ordered alphabetically (case-insensitive) by -title; there is no other sort option. A user may own at most **100 projects** — -creating one beyond that responds `409`. +`GET /api/projects` is always ordered alphabetically (case-insensitive) by title; there is no other sort option. A user may own at most **100 projects** — creating one beyond that responds `409`. Create/update body: `title` (required, 1–255 chars). @@ -176,17 +125,11 @@ Project representation: `GET /api/projects` returns `{ "projects": [ … ] }`. -Creating a project also seeds it with three **statuses** — "To do", "Doing", -"Done" (see [Statuses](#statuses)). +Creating a project also seeds it with three **statuses** — "To do", "Doing", "Done" (see [Statuses](#statuses)). ## Cards -A card either sits in its owner's **inbox** (`project_id` and `status_id` both -`null`) or belongs to exactly one of their projects with a status in it (both -set) — enforced by a database CHECK constraint, never one without the other. -The inbox is global to the user, not per-project. Because a card may have no -project, single-card and ordering routes are addressed globally, by the card's -own id, rather than nested under a project: +A card either sits in its owner's **inbox** (`project_id` and `status_id` both `null`) or belongs to exactly one of their projects with a status in it (both set) — enforced by a database CHECK constraint, never one without the other. The inbox is global to the user, not per-project. Because a card may have no project, single-card and ordering routes are addressed globally, by the card's own id, rather than nested under a project: | Method | Path | Purpose | |--------|------|---------| @@ -197,31 +140,15 @@ own id, rather than nested under a project: | `GET` \| `PATCH` \| `DELETE` | `/api/cards/{cardId}` | one card, owner-scoped (`404` otherwise) | | `PUT` | `/api/cards/order` | set the order/contents of one column | -Create body (either creation route): `text` (required, 1–1000 chars), -`complete` (optional bool, default `false`). The project route also takes an -optional `status_id`, appending the card to the end of that status (must -belong to the project, else `422`) — omitted, it goes in the project's first -status instead. `PATCH` accepts `text` and/or `complete` only — moving a card -is done via the order route below, not PATCH. +Create body (either creation route): `text` (required, 1–1000 chars), `complete` (optional bool, default `false`). The project route also takes an optional `status_id`, appending the card to the end of that status (must belong to the project, else `422`) — omitted, it goes in the project's first status instead. `PATCH` accepts `text` and/or `complete` only — moving a card is done via the order route below, not PATCH. -**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards -that share an `(owner, project, status)`. The inbox is its own column, per -owner. `PUT /api/cards/order` sets one column's contents and order: +**Ordering.** `position` is a dense `0..n-1` rank *within a column* — the cards that share an `(owner, project, status)`. The inbox is its own column, per owner. `PUT /api/cards/order` sets one column's contents and order: ```json { "project_id": 5, "status_id": 12, "card_ids": [3, 1, 2] } ``` -`project_id`/`status_id` are both `null` for the inbox, or both set to a -project owned by the caller and one of its statuses (`404`/`422` otherwise). -`card_ids` must be distinct cards owned by the caller and must include every -card already in the target column (`422` otherwise); it rewrites positions to -`0..n-1`. Any card in the list that wasn't already in that column is -re-parented into it — moving it from another project's status, or the inbox, -or vice versa — and the column it left is re-packed, all in one transaction. -Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card -in the kanban board (or the sidebar's inbox) calls on every drop; moving a -card from one project to another is just two calls, via the inbox in between. +`project_id`/`status_id` are both `null` for the inbox, or both set to a project owned by the caller and one of its statuses (`404`/`422` otherwise). `card_ids` must be distinct cards owned by the caller and must include every card already in the target column (`422` otherwise); it rewrites positions to `0..n-1`. Any card in the list that wasn't already in that column is re-parented into it — moving it from another project's status, or the inbox, or vice versa — and the column it left is re-packed, all in one transaction. Returns `{ "cards": [ … ] }` for the new column. This is what dragging a card in the kanban board (or the sidebar's inbox) calls on every drop; moving a card from one project to another is just two calls, via the inbox in between. Card representation: @@ -241,17 +168,11 @@ Card representation: } ``` -`project_id` and `status_id` are `null` together for an inbox card. `status` is -the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards` -returns `{ "cards": [ … ] }`. +`project_id` and `status_id` are `null` together for an inbox card. `status` is the embedded `{ id, name }` of the linked status, or `null`. `GET …/cards` returns `{ "cards": [ … ] }`. ## Statuses -Every project has an ordered set of card statuses, created with the project: -"To do", "Doing", "Done". They are project-specific — each project owns its own -rows, managed from the project's **configuration** view (create, reorder, -delete). A project always keeps at least one status, since a project card must -have one; deleting the last one is rejected (`409`). +Every project has an ordered set of card statuses, created with the project: "To do", "Doing", "Done". They are project-specific — each project owns its own rows, managed from the project's **configuration** view (create, reorder, delete). A project always keeps at least one status, since a project card must have one; deleting the last one is rejected (`409`). | Method | Path | Purpose | |--------|------|---------| @@ -270,15 +191,9 @@ have one; deleting the last one is rejected (`409`). } ``` -Requires `Authorization: Bearer `; a project that is missing or not owned by -the caller responds `404`. +Requires `Authorization: Bearer `; a project that is missing or not owned by the caller responds `404`. -**Deleting a status that still has cards** fails with `409` and -`error.details.card_count` set, rather than silently orphaning them (a -referenced status can't be deleted at the database level either — the FK is -`ON DELETE RESTRICT`). Retry with `{ "reassign_to": }` in -the same project; those cards are moved there and the status deleted, in one -transaction. +**Deleting a status that still has cards** fails with `409` and `error.details.card_count` set, rather than silently orphaning them (a referenced status can't be deleted at the database level either — the FK is `ON DELETE RESTRICT`). Retry with `{ "reassign_to": }` in the same project; those cards are moved there and the status deleted, in one transaction. ## Error shape @@ -292,10 +207,7 @@ Every error response looks like: ## Try it -Signing in needs the link the API emails, so this pulls it back out of the -bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere). -The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'` -before grep (Mailpit's own JSON doesn't need that): +Signing in needs the link the API emails, so this pulls it back out of the bundled Mailpit catcher (adjust if you've pointed `MAIL_TRANSPORT` elsewhere). The API pretty-prints its JSON, so responses are piped through `tr -d ' \n'` before grep (Mailpit's own JSON doesn't need that): ```bash BASE=http://localhost:8080 diff --git a/docs/architecture.md b/docs/architecture.md index 2ecfef6..fc740f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,8 +1,6 @@ # Architecture -A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 + -TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a -passkey (see [docs/api.md](api.md)) — there's no session store or cookie. +A REST API in PHP 8 (Slim 4) over a single SQLite file, plus a Vue 3 + TypeScript PWA frontend. Auth is a bearer JWT, obtained via a magic link or a passkey (see [docs/api.md](api.md)) — there's no session store or cookie. ## Backend layout @@ -29,23 +27,12 @@ docker/ Apache vhost (mod_rewrite, document root) + entrypo web/ Vue 3 + TypeScript + Vite PWA frontend (dev on the host) ``` -A `ProjectScopedController` base class centralizes "look up a project owned -by the caller, or 404" for the controllers that need it (`Project`, `Card`, -`CardStatus`). Every table a request can reach is scoped to the -authenticated user one way or another — directly (`owner_id`/`user_id`) or -via a project that is. +A `ProjectScopedController` base class centralizes "look up a project owned by the caller, or 404" for the controllers that need it (`Project`, `Card`, `CardStatus`). Every table a request can reach is scoped to the authenticated user one way or another — directly (`owner_id`/`user_id`) or via a project that is. ## Frontend -The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern -with its own conventions (routing, state, styling, drag-and-drop). See -[web/README.md](../web/README.md) for all of that — this document only -covers the backend. +The Vue/TypeScript PWA lives in [web/](../web/) and is a separate concern with its own conventions (routing, state, styling, drag-and-drop). See [web/README.md](../web/README.md) for all of that — this document only covers the backend. ## Database -One SQLite file, migrated forward-only by `bin/migrate.php` from -`migrations/*.sql` (each applied file is recorded in a `schema_migrations` -table, so re-running is safe). See [docs/setup.md](setup.md) for how to run -migrations, and [docs/history.md](history.md) for how the schema and the -rest of the app got here. +One SQLite file, migrated forward-only by `bin/migrate.php` from `migrations/*.sql` (each applied file is recorded in a `schema_migrations` table, so re-running is safe). See [docs/setup.md](setup.md) for how to run migrations, and [docs/history.md](history.md) for how the schema and the rest of the app got here. diff --git a/docs/history.md b/docs/history.md index 46be7bb..ce33df4 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,10 +1,6 @@ # Development history -A stage-by-stage log of major features, in the order they landed. Each row -describes the app as it was *at that point* — later stages sometimes -superseded earlier ones (e.g. password login, added in stage 1, was removed -again in stage 12); see [docs/api.md](api.md) and -[web/README.md](../web/README.md) for how things work now. +A stage-by-stage log of major features, in the order they landed. Each row describes the app as it was *at that point* — later stages sometimes superseded earlier ones (e.g. password login, added in stage 1, was removed again in stage 12); see [docs/api.md](api.md) and [web/README.md](../web/README.md) for how things work now. | Stage | Scope | State | |-------|-------|-------| diff --git a/docs/setup.md b/docs/setup.md index b563678..59b9a76 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,8 +1,6 @@ # Setup & configuration -For the quick version — just running the app — see the root -[README](../README.md#getting-started). This covers the rest: running -without Docker, every configuration option, and the test suite. +For the quick version — just running the app — see the root [README](../README.md#getting-started). This covers the rest: running without Docker, every configuration option, and the test suite. ## Run with Docker @@ -12,35 +10,17 @@ The only requirement is Docker with the Compose plugin. docker compose up -d ``` -This runs a multi-stage build — a Node stage compiles the Vue frontend, then a -PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than -compiling PHP from source, which is most of why the image is ~90MB rather -than several times that) bakes in the PHP source and the built SPA — applies -migrations, and serves the whole app at : the SPA at `/` -(assets and all) and the REST API under `/api` (e.g. -`curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA -shell for client-side routing. +This runs a multi-stage build — a Node stage compiles the Vue frontend, then a PHP 8.3 + Apache stage (Alpine-based; apk's own prebuilt packages rather than compiling PHP from source, which is most of why the image is ~90MB rather than several times that) bakes in the PHP source and the built SPA — applies migrations, and serves the whole app at : the SPA at `/` (assets and all) and the REST API under `/api` (e.g. `curl http://localhost:8080/api/health`). Unknown paths fall back to the SPA shell for client-side routing. -- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog - successor — one ~15 MB Go binary, messages kept in memory) also starts. The API - sends all email to it; read it at . Set - `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real. -- The image is the artifact: PHP source and the compiled frontend are copied in - at build time, not bind-mounted. Rebuild to pick up any code change: - `docker compose up -d --build`. For iterating on the frontend, run the Vite - dev server on the host instead (see [web/README.md](../web/README.md)). -- The SQLite database and the generated JWT signing key live in the `storage` - named volume, mounted at `/var/www/storage`, so they survive - `docker compose restart` / `down` + `up`. +- A **[Mailpit](https://mailpit.axllent.org/)** container (the maintained MailHog successor — one ~15 MB Go binary, messages kept in memory) also starts. The API sends all email to it; read it at . Set `MAIL_TRANSPORT=mail` or `=smtp` (with `MAIL_SMTP_*`) to send for real. +- The image is the artifact: PHP source and the compiled frontend are copied in at build time, not bind-mounted. Rebuild to pick up any code change: `docker compose up -d --build`. For iterating on the frontend, run the Vite dev server on the host instead (see [web/README.md](../web/README.md)). +- The SQLite database and the generated JWT signing key live in the `storage` named volume, mounted at `/var/www/storage`, so they survive `docker compose restart` / `down` + `up`. - `docker compose down -v` removes the volume and gives you a clean database. -- Override settings via the environment or a `.env` file in the repo root - (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see - [docker-compose.yml](../docker-compose.yml)). +- Override settings via the environment or a `.env` file in the repo root (Compose substitutes `APP_DEBUG`, `JWT_SECRET`, `JWT_TTL` — see [docker-compose.yml](../docker-compose.yml)). ## Run without Docker -Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus -[Composer](https://getcomposer.org/). On Fedora: +Requires PHP 8.1+ with the `pdo_sqlite` and `mbstring` extensions, plus [Composer](https://getcomposer.org/). On Fedora: ```bash sudo dnf install php-cli php-pdo php-mbstring composer @@ -60,18 +40,13 @@ composer migrate # creates storage/database.sqlite and its tables composer serve # http://localhost:8080 (php -S localhost:8080 -t public) ``` -Any web server can serve the API as long as the document root is `public/` and -unknown paths fall through to `public/index.php`. `public/.htaccess` also serves -a built frontend from `public/` (copy `web/dist/` there) and only falls back to -`index.php` for `/api` and when no `index.html` is present. +Any web server can serve the API as long as the document root is `public/` and unknown paths fall through to `public/index.php`. `public/.htaccess` also serves a built frontend from `public/` (copy `web/dist/` there) and only falls back to `index.php` for `/api` and when no `index.html` is present. -The frontend itself still needs its own toolchain — see -[web/README.md](../web/README.md). +The frontend itself still needs its own toolchain — see [web/README.md](../web/README.md). ## Configuration -All settings are optional environment variables (read from `.env` or the real -environment). See [.env.example](../.env.example). +All settings are optional environment variables (read from `.env` or the real environment). See [.env.example](../.env.example). | Variable | Default | Purpose | |----------|---------|---------| @@ -89,10 +64,7 @@ environment). See [.env.example](../.env.example). | `MAIL_LOG_PATH` | `storage/mail.log` | Where `log` transport writes | | `MAIL_SMTP_HOST` / `_PORT` / `_USERNAME` / `_PASSWORD` / `_ENCRYPTION` | — / `587` / — / — / `tls` | Used only when `MAIL_TRANSPORT=smtp` | -Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`. -Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled -Mailpit container (`mailpit:1025`, no auth/TLS); open to -read what was "sent". +Standalone, SMTP is opt-in and the API otherwise falls back to PHP's `mail()`. Under Docker Compose the default is `MAIL_TRANSPORT=smtp` pointed at the bundled Mailpit container (`mailpit:1025`, no auth/TLS); open to read what was "sent". ## Tests @@ -101,5 +73,4 @@ composer install # installs phpunit (require-dev) vendor/bin/phpunit ``` -Each test run applies every file in `migrations/*.sql` to a fresh SQLite -database, so it always exercises the current schema from scratch. +Each test run applies every file in `migrations/*.sql` to a fresh SQLite database, so it always exercises the current schema from scratch. diff --git a/web/README.md b/web/README.md index 29e46cd..e5f59ce 100644 --- a/web/README.md +++ b/web/README.md @@ -9,10 +9,7 @@ npm install npm run dev # http://localhost:5173 ``` -The dev server proxies `/api` to `http://localhost:8080` (the Dockerised API — -run `docker compose up -d` in the parent directory first). Override the target -with `VITE_PROXY_TARGET`, or point the app at a different API entirely with -`VITE_API_BASE_URL` (see [.env.example](.env.example)). +The dev server proxies `/api` to `http://localhost:8080` (the Dockerised API — run `docker compose up -d` in the parent directory first). Override the target with `VITE_PROXY_TARGET`, or point the app at a different API entirely with `VITE_API_BASE_URL` (see [.env.example](.env.example)). ## Build @@ -21,10 +18,7 @@ npm run build # type-checks, then emits dist/ npm run preview ``` -The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into -the PHP image's `public/`, so the `app` container serves the compiled SPA at `/`. -There is no separate frontend container — a production image is `docker compose -build app` from the parent directory. +The parent `Dockerfile` runs this build in a Node stage and copies `dist/` into the PHP image's `public/`, so the `app` container serves the compiled SPA at `/`. There is no separate frontend container — a production image is `docker composebuild app` from the parent directory. ## Layout @@ -64,270 +58,81 @@ src/views/ DashboardView, LoginView, ProfileView, VerifyEmailView ProjectConfigureView, CardView, CardConfigureView ``` -Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: -the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view. -The sidebar stays mounted across navigation — it holds a **Dashboard** link, a -divider, a project ``, another divider, then the **Inbox** (see below). The dropdown is a `v-model`-bound writable `computed` (`selectedProjectId`): its getter reads the open project from `route.params.id`, so it tracks whichever project is current; its setter `router.push`es to the chosen one, so it also works as a project switcher from anywhere. `App.vue`'s top-level `` is keyed so switching projects (or cards) always does a fresh load -- see [Project detail](#project-detail) for why that key isn't simply the path. -Below `768px` (`style.css`'s one layout breakpoint) the sidebar becomes an -off-canvas drawer instead of sitting beside the page: `position: fixed`, -translated out of view by default, slid in via a `.sidebar--open` class. -`App.vue` owns the `drawerOpen` state -- a `☰` button in the header (hidden -above the breakpoint) opens it; a backdrop tap, the drawer's own `✕` (which -`AppSidebar` emits `close` for), or any navigation (a `route.fullPath` -watcher) closes it. Above the breakpoint `drawerOpen` just goes unused, since -nothing renders the button that would set it. +Below `768px` (`style.css`'s one layout breakpoint) the sidebar becomes an off-canvas drawer instead of sitting beside the page: `position: fixed`, translated out of view by default, slid in via a `.sidebar--open` class. `App.vue` owns the `drawerOpen` state -- a `☰` button in the header (hidden above the breakpoint) opens it; a backdrop tap, the drawer's own `✕` (which `AppSidebar` emits `close` for), or any navigation (a `route.fullPath` watcher) closes it. Above the breakpoint `drawerOpen` just goes unused, since nothing renders the button that would set it. -`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking -to each project (title + card count), with a **"Create a project" tile** -styled to match sitting last in the same grid (creating one stays on the -dashboard; the grid and the sidebar dropdown both pick it up via the shared -`projects` store). Signed-out routes -(`/login`, `/verify-email`) render without the sidebar. +`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking to each project (title + card count), with a **"Create a project" tile** styled to match sitting last in the same grid (creating one stays on the dashboard; the grid and the sidebar dropdown both pick it up via the shared `projects` store). Signed-out routes (`/login`, `/verify-email`) render without the sidebar. ## Styling -`style.css` is one global stylesheet (no scoped/component styles) with a -handful of conventions worth knowing before adding to it: +`style.css` is one global stylesheet (no scoped/component styles) with a handful of conventions worth knowing before adding to it: -- **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`, - `--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/ - `-border`), a border-radius scale (`--radius-sm` 6px compact controls, - `--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl` - 12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled` - 0.6, `--opacity-ghost` 0.5 for a dragged item's placeholder). Reach for one - of these before hand-writing a value that's really just "the same grey - border again" or "the same rounding as every other button." -- **`.field`** is the one themed ``/`