- ProjectView: title is now a plain <h1>, no longer inline-editable (dropped titleDraft/saveTitle/patchProject and the input markup -- nothing else used patchProject). - ProjectConfigureView: new 'Project name' section above 'Statuses', a small PATCH /api/projects/:id form. On success it also calls the projects store's fetchProjects(), since the dashboard grid and the sidebar's project dropdown read from that store and otherwise wouldn't pick up the new name (or the project's new alphabetical position) until some unrelated reload. - style.css: dropped the now-dead .project-head__title input rules (both views render a plain heading now); .project-head__title gains word-break so a long static title still wraps instead of overflowing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
226 lines
12 KiB
Markdown
226 lines
12 KiB
Markdown
# 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/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.push`es 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 a
|
|
plain heading here -- renaming lives on the configuration view (below). Manage
|
|
(`ProjectManageMenu.vue`) has 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.
|
|
|
|
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, then a
|
|
"← Back to project" link and `ProjectManageMenu` together, top right, styled
|
|
alike (both `.menu__toggle`) (Manage's own Configure link is hidden here,
|
|
since it would just point at the current page).
|
|
|
|
A **Project name** 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.
|
|
|
|
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](#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 `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.
|
|
|
|
- **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.
|