# 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 + reorderColumn) src/lib/api.ts fetch wrapper, bearer token, typed ApiError src/components/AppSidebar.vue left nav: Dashboard link, divider, project list + new-project form src/components/CardRow.vue editable text + status chip + delete, one card src/components/KanbanCard.vue small draggable card for the board columns 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, and a compact new-project form (creating one jumps to it). 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 of project cards — each shows the project name and, under a **New** heading, its inbox cards (`status_id === null`), fetched per project. Signed-out routes (`/login`, `/verify-email`) render without the sidebar. ## 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 Columns, left to right: **Inbox** (cards with no status) then each project status in `position` order. `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 (`added`) — calls `cards.reorderColumn(column.statusId, ids)` → `PUT /api/projects/:id/cards/order` with `{ status_id, card_ids }`. The server re-parents any moved-in card, re-packs the source column, and returns the whole project's cards, which replaces local state; on failure the board reloads. The Inbox column has a small name + **Add** form at the bottom (`cards.add`); new cards have no status, so they land straight in it. ## 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.