# Project Manager — web Vue 3 + TypeScript + Vite PWA. Talks to the REST API in the parent directory. ## Develop ```bash 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)). ## Build ```bash 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 list + form, 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, the project list + new-project form, another divider, then the **Inbox** (see below). The current page is highlighted via RouterLink's `active-class`. `` remounts the view on every path change so sidebar → project → project navigation always does a fresh load. `/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid linking to each project, showing its title and card count. 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): ### 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). `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, `user.email_verified` is always `true` for 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.