Files
project-manager/web/README.md
T
aneurinandClaude Sonnet 5 89a95f1885 Sidebar becomes a mobile drawer below 768px
The sidebar was flex:0 0 15rem inside .app__body unconditionally, so
on a narrow viewport it and the main content just fought over space.
Below 768px it now leaves the flex flow entirely (position: fixed,
translated off-canvas by default) and slides in as a drawer instead,
leaving .app__main the full width.

- App.vue: a ☰ button in the header (hidden above the breakpoint)
  opens it; a backdrop tap, the drawer's own close button, or any
  navigation (route.fullPath watcher) closes it. State lives in
  App.vue since the toggle button is in the header, not the sidebar.
- AppSidebar.vue: a close button (✕), only visible in the drawer,
  emitting 'close'.
- Desktop (>=768px) layout and behaviour is untouched -- the drawer
  CSS and the toggle button both live behind the same media query.

Drag-and-drop is untouched, as asked -- this only affects layout, and
the sidebar's inbox draggable works the same as before either way.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 20:54:18 +01:00

12 KiB

Project Manager — web

Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory.

Develop

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).

Build

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.

Layout

src/main.ts             App bootstrap; resolves the stored session before mount
src/router/index.ts     Routes + guard (redirects to /login when unauthenticated)
src/stores/auth.ts      Pinia store: token in localStorage, magic-link + fetchMe
src/stores/projects.ts  Pinia store: the user's projects (fetch + create)
src/stores/cards.ts     Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts)
src/stores/inbox.ts     Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts          fetch wrapper, bearer token, typed ApiError
src/lib/cardOrder.ts    reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/lib/webauthn.ts     base64url <-> ArrayBuffer + the register/login passkey ceremonies
src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
src/components/CardRow.vue    editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
src/components/ProjectManageMenu.vue "Manage" dropdown + delete-project modal, shared by ProjectView and ProjectConfigureView
src/views/              DashboardView, ProjectView, ProjectConfigureView, LoginView,
                        ProfileView, VerifyEmailView

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 <select>, 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.pushes to the chosen one, so it also works as a project switcher from anywhere. <RouterView :key="route.path"> remounts the view on every path change so switching projects always does a fresh load.

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.

Inbox

A card with no project lives in the caller's inbox (useInboxStore), rendered in the sidebar under the project list -- not per-project, and not tied to whatever page is open. It's a vuedraggable list in the same "kanban" drag group as every project's kanban columns (below), so a card can be dragged straight out of the sidebar into any status column of whichever project is currently open, or the other way. AppSidebar's onInboxChange persists a drop via reorderColumn(null, null, ids), then reloads the inbox and, if a project view is currently mounted (route.name === 'project'), that project's cards too -- either side of a drag could have been the inbox. A small form under the list adds a card straight to the inbox.

Empty (no cards) is shown as a subtle dashed drop-area rather than blank space -- .kanban__cards:empty in style.css, so it's pure CSS keyed off the real DOM child count. Since a card's kanban column/inbox <draggable> is always rendered even with nothing in it (see above), that container is genuinely childless when empty, so the rule applies with no extra markup or JS state; it steps aside automatically once Sortable inserts its drag-over ghost. The same rule covers every kanban status column too (below).

Project detail

/projects/:id shows one project. It renders on a full-width layout (the route sets meta.wide, which widens .app__main in App.vue), so the header spans the full width and the Manage menu sits top right. The title is inline-editable (saved on blur via PATCH /api/projects/:id). Manage (ProjectManageMenu.vue) has a Configure link (to the status-management view below) and a Delete project action that opens a confirmation modal; confirming calls DELETE /api/projects/:id and returns to the dashboard.

Below the header are two tabs (local activeTab state, v-show so both stay mounted). The tab order is fixed — All tasks first, Kanban second — but activeTab initialises to 'kanban', so a project opens on the board.

All tasks

The flat card list, sorted by name (case-insensitive) via a sortedCards computed — there is no manual order here. Each row is an inline-editable text field (saved on blur), a status chip (card.status.name or "No status"), and a delete button.

Kanban

One column per project status, in position order -- the inbox is not a column here; it's in the sidebar (see above), though it's still a valid drag source/target. board is derived from cards.cards + the project's statuses and rebuilt by a watch whenever either changes.

Every drop — whether reordering within a column (moved) or dragging in from another column or the sidebar's inbox (added) — calls reorderColumn(projectId, column.statusId, ids) from lib/cardOrder.ts (shared with the sidebar) → PUT /api/cards/order. The server re-parents any moved-in card and re-packs whatever column it left; afterwards the view always reloads both inbox and this project's cards, since either could have been the other side of the move.

Project configuration

/projects/:id/configure (ProjectConfigureView.vue) manages a project's statuses. The header mirrors the project view's — title, ProjectManageMenu top right — plus a "← Back to project" link (Manage's own Configure link is hidden here, since it would just point at the current page).

The status list is a vuedraggable list (its own list, no shared drag group with the kanban board) bound directly to a local statuses ref; dragging mutates it in place, and @change persists the whole new order via PUT /api/projects/:id/statuses/order, reverting to the server's copy on failure. A small form below it adds a status (POST /api/projects/:id/statuses) at the end of the list.

Each row has a delete button. A status with no cards deletes immediately; one still holding cards gets 409 back from DELETE .../statuses/:statusId with error.details.card_count (surfaced as ApiError#cardCount) -- that opens a modal asking which other status to move its cards to, then resubmits the same delete with { reassign_to }, which reassigns and deletes in one request. The last remaining status can't be deleted (a project card always needs one); its row's delete button is disabled once statuses.length <= 1.

Auth flow

There is no password and no separate sign-up — LoginView is an email field and a "Send sign-in link" button (POST /api/auth/magic-link), for a new address or a returning one alike. On success it shows a "check your email" message; it does not sign the caller in itself. If the browser supports WebAuthn, a "Log in with a passkey" button sits below the form, past a divider (see Passkeys) — that one does sign the caller in directly, no email round trip.

  • /verify-email?token=… is the target for every magic link (sign-in and email-change confirmation both). VerifyEmailView POSTs the token via auth.verifyEmail(), which returns a session — opening the link is what actually signs the caller in — then redirects to the dashboard.
  • The token is kept in localStorage and sent as Authorization: Bearer …. On load, fetchMe() validates it via GET /api/me; a failure clears it.
  • Routes with meta.requiresAuth redirect to /login (preserving the intended path) when there is no authenticated user.
  • Because the only way to get a session is opening a link or using a passkey (which itself requires a prior link-based sign-in to register), user. email_verified is always true for a signed-in user — the frontend doesn't show any verification nagging or resend UI.

Passkeys

src/lib/webauthn.ts wraps the two ceremonies. Both fetch a { challenge_id, options } pair from the API, decode options.publicKey's base64url fields (challenge, user.id, *Credentials[].id) into ArrayBuffers, call navigator.credentials.create() / .get(), then base64url-encode the resulting PublicKeyCredential's response back into JSON for the API ({ id, response: { clientDataJSON, ... } }). passkeysSupported() is a one-line window.PublicKeyCredential check gating the UI everywhere below.

  • Register (ProfileView, "Passkeys" section) — lists the caller's passkeys (GET /api/passkeys) with a Remove button each (DELETE /api/passkeys/{id}), and an "Add a passkey" form: a label input (pre-filled with a guess from navigator.userAgent, e.g. "Mac") and a button calling registerPasskey(label). On success it appends to the local list and calls auth.fetchMe() so user.has_passkey (and the notice below) updates immediately.
  • Login (LoginView) — the passkey button calls auth.loginWithPasskey(), which adopts the returned session exactly like verifyEmail(), then redirects to route.query.redirect or /. A cancelled prompt (DOMException named NotAllowedError) shows "Cancelled." rather than a generic error.
  • PasskeyNotice.vue (mounted in App.vue, between the header and the sidebar/main body — spans the full page width) shows when signed in with user.has_passkey === false. Dismissing it writes localStorage['passkeyNoticeDismissedUntil'] = Date.now() + 7 days; the banner stays hidden until that passes, and reappears immediately (no reload needed, since has_passkey is reactive on the shared auth.user) if every passkey is later removed.

Profile

/profile (ProfileView) shows the current address, the Passkeys section described above, and a Change email form (new address only, no password). On success the API has emailed a confirmation link to the new address and set user.pending_email (shown as a notice until it's opened); the change only lands once that link is opened. The button shows a live countdown driven by retry_after and by 429 responses.