The sidebar sat flush against the window edge and the header, with the main content floating with its own margin -- visually unbalanced. .app__body now carries the outer spacing (1.5rem top/bottom, 1.25rem sides, 1.5rem gap between sidebar and main) that .app__main used to own alone, so both columns start at the same position and read as a matched pair. The sidebar is now a floating panel to match (full border + border-radius, not just a right edge). The sidebar's height is set explicitly -- calc(100vh - 6.0625rem), tuned to the app's actual ~3.0625rem header plus a 1.5rem gap top and bottom -- rather than capped, so it fills the screen height even when its own content (project dropdown, inbox) is short, with room to spare at the bottom instead of overflowing past the fold. Its sticky `top` matches the outer top gap so that gap holds once it starts sticking during a scroll. Verified via headless Chrome at a couple of viewport heights and on both a narrow (Profile) and wide (Dashboard) page: sidebar and main content start level, the sidebar is inset ~1.25rem from the window edge, and its box ends with a ~1.5rem gap above the viewport bottom rather than touching or overflowing it. Confirmed the sticky behavior while scrolling a long project page holds the same 1.5rem gap once pinned. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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/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/views/ DashboardView, ProjectView, 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.
/ redirects to /dashboard (DashboardView.vue): a full-width grid linking
to each project (title + card count), then a new-project form directly
below it (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.
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). The title and
description are inline-editable (saved on blur via PATCH /api/projects/:id; the
description shows an "Add a description" placeholder when empty). A Manage
menu (top right) has 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.
Auth flow
There is no password and no separate sign-up — LoginView is just 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.
/verify-email?token=…is the target for every magic link (sign-in and email-change confirmation both).VerifyEmailViewPOSTs the token viaauth.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
localStorageand sent asAuthorization: Bearer …. On load,fetchMe()validates it viaGET /api/me; a failure clears it. - Routes with
meta.requiresAuthredirect to/login(preserving the intended path) when there is no authenticated user. - Because the only way to get a session is opening a link,
user.email_verifiedis alwaystruefor a signed-in user — the frontend doesn't show any verification nagging or resend UI.
Profile
/profile (ProfileView) shows the current address 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.