aneurinandClaude Sonnet 5 8f8ad8593d Make the dashboard a grid of projects with their inbox
DashboardView drops the placeholder for a full-width grid of project cards.
Each card links to the project and lists its inbox cards (status_id === null)
under a "New" heading, or "Nothing new." when empty. Cards are fetched per
project (one GET /projects/{id}/cards each) after the project list resolves.

projects store: fetchProjects() now shares one in-flight request between
concurrent callers (the sidebar and the dashboard mount together).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 14:02:47 +01:00
2026-09-03 17:35:10 +01:00
2026-09-03 17:35:10 +01:00

PHP Project Manager

A small project-management application: a REST API written in PHP (Slim 4) backed by an SQLite file, plus a Vue 3 + TypeScript PWA frontend in web/. Each user owns projects, and each project holds ordered cards.

Status

Stage Scope State
1 Auth API — register, login, GET /me done
2 Frontend shell — Vite PWA, auth-gated routing, register/login pages done
3 Project + card CRUD API done
4 Frontend projects view — project index + create form done
5 Frontend project detail — cards UI with drag-and-drop reorder done
6 Project view — inline title/description editing, delete via a Manage menu done
7 Email verification (magic links) + profile page (resend, change email) done
8 Passwordless login — magic-link by default, password login behind a toggle done
9 Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none done
10 Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering done
11 Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of projects with their "New" inbox cards done

Registration signs the user in immediately and emails a magic link that verifies the address; user.email_verified stays false until the link is opened. See Email verification.

Run with Docker

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 bakes in the PHP source and the built SPA — applies migrations, and serves the whole app at http://localhost:8080: 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 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 http://localhost:8025. 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 Frontend).
  • 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 this directory (Compose substitutes APP_DEBUG, JWT_SECRET, JWT_TTL — see docker-compose.yml).

Run without Docker

Requires PHP 8.1+ with the pdo_sqlite and mbstring extensions, plus Composer. On Fedora:

sudo dnf install php-cli php-pdo php-mbstring composer

Setup

composer install
cp .env.example .env      # optional; sane defaults are used without it
composer migrate          # creates storage/database.sqlite and its tables

Running

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.

Frontend

The Vue/TypeScript PWA lives in web/. The production build is compiled into the Docker image and served from the app container at /. For frontend development, run the Vite dev server on the host — with the API container running (docker compose up -d):

cd web
npm install
npm run dev        # http://localhost:5173, proxies /api to localhost:8080

Unauthenticated visitors are redirected to /login; /register creates an account and signs in immediately. See web/README.md.

Configuration

All settings are optional environment variables (read from .env or the real environment). See .env.example.

Variable Default Purpose
APP_DEBUG false Include exception details in error responses
DATABASE_PATH storage/database.sqlite SQLite file location
JWT_SECRET auto-generated into storage/secret.key Token signing key
JWT_TTL 86400 Token lifetime in seconds
APP_URL http://localhost:8080 Base URL used to build magic links (http://localhost:5173 for a host npm run dev)
MAIL_TRANSPORT mail mail (PHP mail()), smtp, or log (append to a file)
MAIL_FROM / MAIL_FROM_NAME no-reply@todo.test / Projects Envelope sender
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 http://localhost:8025 to read what was "sent".

API

Base path: /api. All request and response bodies are JSON; send Content-Type: application/json.

GET /api/health

{ "status": "ok" }

POST /api/auth/register

Request:

{ "email": "ada@example.com", "password": "correct horse battery staple" }

201 Created:

{
  "user": {
    "id": 1,
    "email": "ada@example.com",
    "email_verified": false,
    "email_verified_at": null,
    "pending_email": null,
    "created_at": "2026-09-03T12:00:00Z"
  },
  "token": "<jwt>",
  "expires_at": "2026-09-04T12:00:00+00:00"
}

New accounts are created with an unverified email (email_verified: false).

Errors: 422 invalid input, 409 email already registered.

Validation: email must be a valid address (≤ 255 chars); password must be 872 characters.

POST /api/auth/login

Request:

{ "email": "ada@example.com", "password": "correct horse battery staple" }

200 OK: same shape as register. 401 on bad credentials (the message does not say whether it was the email or the password that was wrong).

POST /api/auth/magic-link

Request: { "email": "ada@example.com" }.

Emails a one-time login link (<APP_URL>/verify-email?token=…, 15-minute expiry). Always returns 202 with the same message regardless of whether the address is registered, so accounts can't be enumerated; a link is only actually sent when the account exists and hasn't been emailed in the last 60 seconds. Opening the link (POST /api/auth/verify-email) signs the user in and verifies the address if it wasn't already. 422 if the address is malformed.

GET /api/me

Requires Authorization: Bearer <jwt>.

200 OK:

{
  "user": {
    "id": 1,
    "email": "ada@example.com",
    "email_verified": false,
    "email_verified_at": null,
    "pending_email": null,
    "created_at": "2026-09-03T12:00:00Z"
  }
}

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.

Email verification & profile

Magic links — <APP_URL>/verify-email?token=<opaque> — expire 15 minutes after they are sent; only a hash of the token is stored. The same link/route backs three things: verifying a new account, passwordless login, and confirming an email change.

Method Path Auth Purpose
POST /api/auth/verify-email consume a token: verify the address (or apply a pending change), then return a session so the caller is logged in
POST /api/auth/magic-link email a passwordless login link (see above)
POST /api/email/verification resend the verification email; 409 if already verified
POST /api/email/change request a deferred email change

POST /api/auth/verify-email body: { "token": "..." }. Success returns the same { user, token, expires_at } envelope as login. A missing/invalid, already used, or expired token is 400 (distinct messages).

POST /api/email/verification and /api/email/change are throttled to once per 60 seconds per user (shared window). When throttled they return 429 with error.details.retry_after (seconds). On success they return 202 with retry_after, and /api/email/change also returns pending_email.

POST /api/email/change body: { "email": "new@example.com", "password": "<current>" }. The current password is required (422 if wrong). The address must be free (409) and different from the current one (422). The change is not applied until the magic link sent to the new address is opened — until then GET /api/me shows the old address with pending_email set.

Projects

All routes below require Authorization: Bearer <jwt>. A project belongs to one owner (the creator); another user's project — or a missing one — always responds 404.

Method Path Purpose
GET /api/projects the caller's projects, sorted A→Z by title
POST /api/projects create a project
GET /api/projects/{id} one project
PATCH /api/projects/{id} update title and/or description
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.

Create/update body: title (required on create, 1255 chars), description (optional, ≤ 2000 chars, defaults to ""). PATCH needs at least one field.

Project representation:

{
  "project": {
    "id": 1,
    "title": "Website relaunch",
    "description": "Q3",
    "owner_id": 1,
    "card_count": 3,
    "completed_count": 1,
    "created_at": "2026-09-03T12:00:00Z",
    "updated_at": "2026-09-03T12:00:00Z"
  }
}

GET /api/projects returns { "projects": [ … ] }.

Creating a project also seeds it with three statuses — "To do", "Doing", "Done" (see Statuses).

Cards

Scoped to a project; the parent project's ownership is checked first (404 otherwise).

Method Path Purpose
GET /api/projects/{id}/cards every card, grouped by column (inbox first) then position
POST /api/projects/{id}/cards add a card
PUT /api/projects/{id}/cards/order set the order/contents of one status column
GET /api/projects/{id}/cards/{cardId} one card
PATCH /api/projects/{id}/cards/{cardId} update text, complete, and/or status_id
DELETE /api/projects/{id}/cards/{cardId} delete the card (204)

Create body: text (required, 11000 chars), complete (optional bool, default false). PATCH needs at least one field.

Ordering. position is a dense 0..n-1 rank within a column — the cards that share a (project_id, status_id). The inbox (status_id IS NULL) is its own column. New cards go to the end of the inbox. There is no project-wide order.

A new card has no status (status_id: null) — it sits in the project "inbox" until the user gives it one. Two ways to move it:

  • PATCH …/cards/{cardId} with status_id (a status id in this project, or null for the inbox) — appends the card to the end of the destination column and re-packs the one it left. 422 for an unknown or foreign status.
  • PUT …/cards/order with { "status_id": <id|null>, "card_ids": [3, 1, 2] } — makes those cards the exact contents of that column, in that order (positions rewritten to 0..n-1). Any card dragged in from another column is re-parented and its old column re-packed, all in one transaction. card_ids must be distinct cards of this project and must include every card already in the target column (422 otherwise). Returns { "cards": [ … ] } for the whole project. This is what the kanban board calls on every drop.

A status row that is deleted clears itself from its cards rather than deleting them.

Card representation:

{
  "card": {
    "id": 10,
    "project_id": 1,
    "text": "Design homepage",
    "complete": false,
    "position": 0,
    "status_id": null,
    "status": null,
    "created_at": "2026-09-03T12:00:00Z",
    "updated_at": "2026-09-03T12:00:00Z"
  }
}

status is the embedded { id, name } of the linked status, or null when the card has none. 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. There is no create/update/delete for the statuses themselves yet; a card is moved between them (or to the inbox) via PATCH …/cards/{cardId}.

Method Path Purpose
GET /api/projects/{id}/statuses the project's statuses, ordered by position
{
  "statuses": [
    { "id": 1, "project_id": 1, "name": "To do", "position": 0 },
    { "id": 2, "project_id": 1, "name": "Doing", "position": 1 },
    { "id": 3, "project_id": 1, "name": "Done",  "position": 2 }
  ]
}

Requires Authorization: Bearer <jwt>; a project that is missing or not owned by the caller responds 404.

Error shape

Every error response looks like:

{ "error": { "message": "The submitted data was invalid.", "details": { "email": ["Email must be a valid address."] } } }

details is present only when relevant (e.g. validation).

Try it

BASE=http://localhost:8080

curl -s -X POST $BASE/api/auth/register \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"password123"}'

TOKEN=$(curl -s -X POST $BASE/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"password123"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)

curl -s $BASE/api/me -H "Authorization: Bearer $TOKEN"

PROJECT=$(curl -s -X POST $BASE/api/projects \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"title":"Website relaunch","description":"Q3"}' \
  | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)

curl -s $BASE/api/projects/$PROJECT/statuses -H "Authorization: Bearer $TOKEN"

curl -s -X POST $BASE/api/projects/$PROJECT/cards \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"text":"Design homepage"}'

curl -s $BASE/api/projects/$PROJECT/cards -H "Authorization: Bearer $TOKEN"

Tests

composer install         # installs phpunit (require-dev)
vendor/bin/phpunit

Layout

public/index.php              Front controller
src/bootstrap.php             App wiring and route definitions
src/Support/Config.php        Environment-driven configuration
src/Support/Database.php      PDO/SQLite connection
src/Auth/JwtService.php       Issue/verify JWTs
src/Auth/AuthMiddleware.php   Bearer-token authentication
src/Auth/SessionPayload.php   Shared user + session JSON shape
src/Mail/                     Mailer interface, SMTP/mail()/log transports, EmailVerifier
src/Http/JsonErrorHandler.php Uniform JSON error envelope
src/Http/Controllers/        Request handlers (Auth, EmailVerification, Project, Card, CardStatus)
src/Repository/              Database access (User, EmailVerification, Project, Card, CardStatus)
src/Support/Validator.php    Request-body validation helper
migrations/*.sql             Schema, applied by bin/migrate.php
Dockerfile                   Multi-stage: Node frontend build + PHP 8.3/Apache runtime
docker-compose.yml           Local stack: app (SPA + API) + Mailpit
docker/                      Apache vhost + container entrypoint
web/                         Vue 3 + TypeScript + Vite PWA frontend (dev on the host)

Provenance

This project was generated with Claude Code.

S
Description
No description provided
Readme
588 KiB
Languages
PHP 61%
Vue 21.4%
TypeScript 8.1%
CSS 7.8%
Dockerfile 1.3%
Other 0.4%