Files

139 lines
19 KiB
Markdown
Raw Permalink Normal View History

# 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
```
2026-09-05 02:29:08 +01:00
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
```
2026-09-05 02:29:08 +01:00
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 composebuild 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) -- a
project's Explore/Kanban are nested children of ProjectView
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 -- load/add/patch-in-place;
delete and reordering happen elsewhere (ManageMenu.vue,
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, notFoundOr()
(a caught error -> a 404-aware user-facing message)
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/composables/useDialog.ts Escape-to-close + focus-on-open for a dialog or menu --
shared by ManageMenu.vue and StatusManager's reassign modal
src/components/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
src/components/CardRow.vue one row in the Explore list: links to the card's own
view + a status chip -- no inline editing, no delete (both
live on that view now)
src/components/KanbanCard.vue small draggable card, itself a link to the card's own
view; used by the kanban board and the inbox
src/components/PasskeyNotice.vue dismissible "add a passkey" banner across the top of the page
src/components/ManageMenu.vue generic "Manage" dropdown + delete-confirmation modal,
shared by both entity pairs below
src/components/ProjectManageMenu.vue / CardManageMenu.vue thin wrappers over
ManageMenu.vue supplying what's project-/card-specific: routes,
labels, and what deleting actually does
src/components/StatusManager.vue a project's status list -- drag to reorder, add, delete
with reassignment; used by ProjectConfigureView
src/views/ DashboardView, LoginView, ProfileView, VerifyEmailView,
ProjectView (layout) + ProjectExploreView + ProjectKanbanView,
ProjectConfigureView, CardView, CardConfigureView
```
2026-09-05 02:29:08 +01:00
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.push`es to the chosen one, so it also works as a project switcher from anywhere. `App.vue`'s top-level `<RouterView>` is keyed so switching projects (or cards) always does a fresh load -- see [Project detail](#project-detail) for why that key isn't simply the path.
2026-09-05 02:29:08 +01:00
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.
2026-09-04 20:54:18 +01:00
2026-09-05 02:29:08 +01:00
`/` 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.
## Styling
2026-09-05 02:29:08 +01:00
`style.css` is one global stylesheet (no scoped/component styles) with a handful of conventions worth knowing before adding to it:
2026-09-05 02:29:08 +01:00
- **Tokens** (`:root` custom properties): colours (`--bg`, `--surface`, `--border`, `--text`, `--muted`, `--accent`(-text), `--error`, `--warn-bg`/`-border`), a border-radius scale (`--radius-sm` 6px compact controls, `--radius-md` 8px buttons/inputs, `--radius-lg` 10px tiles, `--radius-xl` 12px panels, `--radius-pill`), and two opacity values (`--opacity-disabled` 0.6, `--opacity-ghost` 0.5 for a dragged item's placeholder). Reach for one of these before hand-writing a value that's really just "the same grey border again" or "the same rounding as every other button."
- **`.field`** is the one themed `<input>`/`<textarea>`/`<select>` look, used everywhere from the login form to the sidebar's project switcher. `.field--compact` is the same thing smaller, for a control that's a flex child beside a button (an inline "add" row) or squeezed into the sidebar. `.field--autosize` adds the `resize:none; overflow:hidden` the dashboard's JS-driven auto-growing textarea needs. Crucially, **`.field` is applied directly to the control**, not to a wrapper (`.form input` no longer exists) -- see the next point for why that's load-bearing, not just style.
- **Put component classes on the control itself, not a wrapping element**, for anything that needs a `:focus` state. `input:focus`/`textarea:focus`/`select:focus` (global, removes the default outline and colours the border with `--accent` instead) has specificity `(0,0,1,1)` -- one pseudo-class, one element. A rule shaped `.wrapper input { border-color: var(--border) }` ties it exactly, and being unconditional, silently wins that tie by simply appearing later in the file -- the input keeps `var(--border)` forever, focused or not, no matter what `:focus` says. `.field`, applied straight to the element, is `(0,0,1,0)` -- strictly lower, so it can never win that tie regardless of source order. (This bit a real shipped version of the app: half the inputs had a working focus ring and half silently didn't, purely from which pattern each one happened to use.) A component that needs its own more elaborate `:focus` state (`.card-row__text`, background swap included) writes `.card-row__text:focus` explicitly -- specificity `(0,0,2,0)`, genuinely higher, wins outright rather than by luck of ordering.
## Inbox
2026-09-05 02:29:08 +01:00
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 a project's Kanban columns and Explore list (below), so a card can be dragged straight out of the sidebar into any status column of whichever project is currently open (or Explore can send one the other way, though not receive one -- see [Explore](#explore)). `AppSidebar`'s `onInboxChange` persists a drop via `reorderColumn(null, null, ids)`, then reloads the inbox and, if a project's Explore or Kanban route is currently open, 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.
2026-09-05 02:29:08 +01:00
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
2026-09-05 02:29:08 +01:00
`/projects/:id` shows one project. `ProjectView.vue` is a **layout**, not a page of its own: it renders on a **full-width** layout (the parent route sets `meta.wide`, inherited by its children, which widens `.app__main` in `App.vue`), loads the project and its cards, and renders the header + a small sub-nav — its two children (below) render into its `<RouterView>`.
2026-09-05 02:29:08 +01:00
The header: a plain title heading, with an inline `.title-back` arrow to the dashboard right before the text -- renaming lives on the configuration view (below) -- and `ProjectManageMenu.vue` top right, with a **Configure** link (to that view) and a **Delete project** action that opens a confirmation modal; confirming calls `DELETE /api/projects/:id` and returns to the dashboard.
2026-09-05 02:29:08 +01:00
The sub-nav (`RouterLink`s styled as tabs, active one matched on `route.name`) is real navigation, not client-side tab state -- **Explore** is the project's own route (`/projects/:id`, name `project`), **Kanban** a child beneath it (`/projects/:id/kanban`, name `project-kanban`). Both read the `cards` store the layout already loaded; App.vue's top-level `<RouterView>` key is derived from the matched route's *top-level* path plus params rather than the full path, so switching between them doesn't remount the layout (and re-fetch the project) the way switching to a different project's id still does.
2026-09-05 00:36:38 +01:00
### Explore
2026-09-05 02:29:08 +01:00
The flat card list, **sorted by name (case-insensitive)** via a `sortedCards` ref (rebuilt by a `watch` on the store's `cards.cards` -- a plain computed can't be handed to `<draggable>`, which splices its bound list in place as the user drags). Each row links to the card's own view (`/cards/:id` — see [Card detail](#card-detail)); there is no manual order here, and no delete button either -- deleting lives on that view now.
2026-09-05 02:29:08 +01:00
The list is a `<draggable>` too, but one-directional: `group: { name:'kanban', put: false }` and `sort: false` mean a card can be dragged *out* -- to the sidebar's inbox, unfiling it from the project -- but Explore can't receive a drop itself (there's no status to put an incoming card in), nor reorder on its own drag (it's sorted by name regardless). No `@change` handler is needed on this side: `<draggable>` already splices the card out of `sortedCards` locally, and the inbox's own handler (see above) persists the move and reloads this project's `cards`, which rebuilds the list from the authoritative result regardless of which side reacted to the drop.
### Kanban
2026-09-05 02:29:08 +01:00
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 target as well as a source (unlike Explore, which can only send a card *to* the inbox, not receive one). Unlike the cards, statuses are this route's own fetch (`GET /api/projects/:id/statuses`) -- Explore has no use for them. `board` is derived from `cards.cards` + those statuses and rebuilt by a `watch` whenever either changes.
2026-09-05 02:29:08 +01:00
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
2026-09-05 02:29:08 +01:00
`/projects/:id/configure` (`ProjectConfigureView.vue`) manages a project's statuses. The header mirrors the project view's — an inline `.title-back` arrow before the title, this time back to the project, and `ProjectManageMenu` top right (its own Configure link is hidden here, since it would just point at the current page).
2026-09-05 02:29:08 +01:00
A **Rename project** section (a plain `PATCH /api/projects/:id` form, `{ title }`) sits above **Statuses**. On success it also calls the `projects` store's `fetchProjects()` -- the local `project` ref (and this view's own header) update from the PATCH response directly, but the dashboard grid and the sidebar's project dropdown read from that store, so without the extra fetch the new name (and alphabetical position -- projects are API-ordered by title) wouldn't show up there until some other reload.
2026-09-05 02:29:08 +01:00
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.
2026-09-05 02:29:08 +01:00
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`.
2026-09-05 00:36:38 +01:00
## Card detail
2026-09-05 02:29:08 +01:00
A card's text is no longer inline-editable anywhere it's listed -- the Explore row, a kanban column, and the sidebar inbox all just link to `/cards/:id` (`CardView.vue`) instead. Its header follows the same pattern as every other view now: an inline `.title-back` arrow before the title text, `Manage` in the actions corner. The arrow goes to the card's project, or the dashboard for an inbox card (there's no standalone view of the inbox to return to). Below the header sits just the status badge -- no tabs, since there's nothing else to show for a single card.
2026-09-05 00:36:38 +01:00
2026-09-05 02:29:08 +01:00
`/cards/:id/configure` (`CardConfigureView.vue`) mirrors the project configuration view once more: same header (its back arrow instead returns to the card view), with a **Rename card** form below it (`PATCH /api/cards/:id`, `{ text }`). `CardManageMenu.vue` -- the card equivalent of `ProjectManageMenu.vue` -- provides both views' **Manage** menu: a **Configure** link (hidden on the configure view itself) and a **Delete card** action behind a confirmation modal.
2026-09-05 00:36:38 +01:00
2026-09-05 02:29:08 +01:00
Saving a rename or confirming a delete refreshes whichever store holds the card -- the `cards` store (for a project card) or the `inbox` store -- so the board or sidebar it came from picks up the change; deleting also navigates back to wherever its back arrow points.
2026-09-05 00:36:38 +01:00
## Auth flow
2026-09-05 02:29:08 +01:00
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](#passkeys)) — that one *does* sign the caller in directly, no email round trip.
2026-09-05 02:29:08 +01:00
- `/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
2026-09-05 02:29:08 +01:00
`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 `ArrayBuffer`s, 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.
2026-09-05 02:29:08 +01:00
- **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
2026-09-05 02:29:08 +01:00
`/profile` (`ProfileView`) sets `meta.wide` like the dashboard/project views, rather than sitting in the default narrow column (that's now just the login form). It 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.