Move new-project form to the dashboard; sidebar project list becomes a switcher; Kanban is the default project tab

- DashboardView: a "New project title" form now sits directly below the
  project grid (creating one stays on the dashboard -- the grid and the
  sidebar dropdown pick it up via the shared projects store, no navigation).
- AppSidebar: the per-project RouterLink list is replaced by a <select>. It's
  a v-model-bound writable computed (selectedProjectId): the getter tracks
  route.params.id so it reflects whichever project is open, the setter
  router.pushes to the chosen one -- so it doubles as a project switcher from
  anywhere, not just a picker from the dashboard. Removed the now-unused
  .sidebar__link--project/.sidebar__projects/.sidebar__form CSS; added
  .sidebar__select, and .form--new-project back for the dashboard form.
- ProjectView: activeTab now initialises to 'kanban' instead of 'all'. The
  tabs array (and so the tab bar's DOM order, All tasks first) is untouched --
  only the default selection changed.

Verified via headless Chrome: sidebar has no per-project links (a <select>
listing both projects instead); the new-project form follows the grid and
creating from it stays on /dashboard while the grid and dropdown both update;
selecting a project in the dropdown navigates there and the dropdown reflects
it once there; a project opens on Kanban with the tab bar still reading "All
tasks, Kanban" in that order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:31:30 +01:00
co-authored by Claude Sonnet 5
parent 4ee0f24078
commit d8a4183e4d
6 changed files with 104 additions and 103 deletions
+2 -1
View File
@@ -18,9 +18,10 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done | | 8 | Passwordless login — magic-link by default, password login behind a toggle | ✅ done |
| 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done | | 9 | Per-project card statuses ("To do" / "Doing" / "Done"); status chip, new cards start with none | ✅ done |
| 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done | | 10 | Project view — full-width, tabbed: alphabetical "All tasks" list + "Kanban" board, per-column drag ordering | ✅ done |
| 11 | Persistent left sidebar (Dashboard link + project list/new-project form); dashboard = grid of project tiles | ✅ done | | 11 | Persistent left sidebar (Dashboard link + project dropdown); dashboard = grid of project tiles + new-project form | ✅ done |
| 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done | | 12 | Passwordless-only auth — registration and password login removed; a magic link is the sole way in, and creates the account if needed | ✅ done |
| 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done | | 13 | Global inbox — cards can have no project; moved into the sidebar, drag in/out of any project's kanban columns | ✅ done |
| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done |
There is no password. Signing in is entering an email address and opening the There is no password. Signing in is entering an email address and opening the
magic link sent to it — the same step creates the account the first time. See magic link sent to it — the same step creates the account the first time. See
+15 -9
View File
@@ -37,7 +37,7 @@ src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering --
src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create) 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/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/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/AppSidebar.vue left nav: Dashboard link, project dropdown, Inbox + form
src/components/CardRow.vue editable text + status chip + delete, one card 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/KanbanCard.vue small draggable card for the board columns and the inbox
src/views/ DashboardView, ProjectView, LoginView, ProfileView, src/views/ DashboardView, ProjectView, LoginView, ProfileView,
@@ -47,14 +47,19 @@ src/views/ DashboardView, ProjectView, LoginView, ProfileView,
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell: 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 top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view.
The sidebar stays mounted across navigation — it holds a **Dashboard** link, a The sidebar stays mounted across navigation — it holds a **Dashboard** link, a
divider, the project list + new-project form, another divider, then the divider, a project `<select>`, another divider, then the **Inbox** (see below).
**Inbox** (see below). The current page is highlighted via RouterLink's The dropdown is a `v-model`-bound writable `computed` (`selectedProjectId`):
`active-class`. `<RouterView :key="route.path">` remounts the view on every its getter reads the open project from `route.params.id`, so it tracks
path change so sidebar → project → project navigation always does a fresh load. 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.
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid linking `/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking
to each project, showing its title and card count. Signed-out routes (`/login`, to each project (title + card count), then a **new-project form** directly
`/verify-email`) render without the sidebar. 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 ## Inbox
@@ -79,7 +84,8 @@ menu (top right) has a **Delete project** action that opens a confirmation modal
confirming calls `DELETE /api/projects/:id` and returns to the dashboard. 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 Below the header are two tabs (local `activeTab` state, `v-show` so both stay
mounted): 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 ### All tasks
+22 -60
View File
@@ -6,7 +6,7 @@ import { ApiError } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder' import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox' import { useInboxStore } from '../stores/inbox'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects' import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types' import type { Card } from '../types'
import KanbanCard from './KanbanCard.vue' import KanbanCard from './KanbanCard.vue'
@@ -16,13 +16,8 @@ const inbox = useInboxStore()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const title = ref('')
const createError = ref<ApiError | null>(null)
const submitting = ref(false)
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
onMounted(() => { onMounted(() => {
if (!projects.loaded) void loadProjects() if (!projects.loaded) void loadProjects()
if (!inbox.loaded) void loadInbox() if (!inbox.loaded) void loadInbox()
@@ -37,19 +32,14 @@ async function loadProjects() {
} }
} }
async function onCreate() { // The dropdown doubles as a project switcher: it reflects whichever project
submitting.value = true // (if any) is currently open, and selecting one navigates there.
createError.value = null const selectedProjectId = computed<number | ''>({
try { get: () => (route.name === 'project' ? Number(route.params.id) : ''),
const project = await projects.createProject(title.value) set: (id) => {
title.value = '' if (id !== '') void router.push({ name: 'project', params: { id } })
await router.push({ name: 'project', params: { id: project.id } }) },
} catch (e) { })
createError.value = e instanceof ApiError ? e : new ApiError('Could not create the project.', 0)
} finally {
submitting.value = false
}
}
// --- Inbox: a global holding area, not tied to any project. Cards drag in // --- Inbox: a global holding area, not tied to any project. Cards drag in
// and out of it from a project's kanban board (shared "kanban" group). // and out of it from a project's kanban board (shared "kanban" group).
@@ -126,47 +116,19 @@ async function onInboxChange(change: ColumnChange) {
<p v-else-if="projects.loading && !projects.loaded" class="sidebar__note muted">Loading</p> <p v-else-if="projects.loading && !projects.loaded" class="sidebar__note muted">Loading</p>
<p v-else-if="projects.projects.length === 0" class="sidebar__note muted">No projects yet.</p> <p v-else-if="projects.projects.length === 0" class="sidebar__note muted">No projects yet.</p>
<nav v-else class="sidebar__nav sidebar__projects"> <div v-else class="sidebar__select">
<RouterLink <svg class="sidebar__icon" viewBox="0 0 16 16" aria-hidden="true">
v-for="project in projects.projects" <rect x="1" y="2" width="3.5" height="12" rx="1" />
:key="project.id" <rect x="6.25" y="2" width="3.5" height="9" rx="1" />
:to="{ name: 'project', params: { id: project.id } }" <rect x="11.5" y="2" width="3.5" height="6" rx="1" />
class="sidebar__link sidebar__link--project" </svg>
active-class="sidebar__link--active" <select v-model="selectedProjectId" aria-label="Go to project">
> <option value="" disabled>Choose a project</option>
<svg class="sidebar__icon" viewBox="0 0 16 16" aria-hidden="true"> <option v-for="project in projects.projects" :key="project.id" :value="project.id">
<rect x="1" y="2" width="3.5" height="12" rx="1" /> {{ project.title }}
<rect x="6.25" y="2" width="3.5" height="9" rx="1" /> </option>
<rect x="11.5" y="2" width="3.5" height="6" rx="1" /> </select>
</svg> </div>
<span class="sidebar__link-text">{{ project.title }}</span>
</RouterLink>
</nav>
<form class="sidebar__form" @submit.prevent="onCreate">
<input
v-model="title"
type="text"
maxlength="255"
required
:disabled="atLimit"
placeholder="New project"
aria-label="New project title"
/>
<small v-if="createError?.fieldError('title')" class="field-error">
{{ createError.fieldError('title') }}
</small>
<small
v-else-if="createError && Object.keys(createError.details).length === 0"
class="field-error"
>
{{ createError.message }}
</small>
<button type="submit" :disabled="submitting || atLimit">
{{ submitting ? 'Creating…' : 'Add project' }}
</button>
<small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small>
</form>
<hr class="sidebar__divider" /> <hr class="sidebar__divider" />
+18 -28
View File
@@ -158,9 +158,23 @@ body {
color: var(--muted); color: var(--muted);
} }
.sidebar__projects { .sidebar__select {
max-height: 45vh; display: flex;
overflow-y: auto; align-items: center;
gap: 0.6rem;
padding: 0.2rem 0.6rem;
}
.sidebar__select select {
flex: 1;
min-width: 0;
font: inherit;
font-size: 0.9rem;
padding: 0.35rem 0.4rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
} }
.sidebar__inbox-cards { .sidebar__inbox-cards {
@@ -174,31 +188,6 @@ body {
font-size: 0.85rem; font-size: 0.85rem;
} }
.sidebar__form {
display: flex;
flex-direction: column;
gap: 0.4rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
}
.sidebar__form input {
font: inherit;
font-size: 0.9rem;
padding: 0.4rem 0.55rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.sidebar__form button[type='submit'] {
padding: 0.45rem 0.7rem;
font-size: 0.9rem;
border-radius: 6px;
}
.card { .card {
background: var(--surface); background: var(--surface);
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -644,6 +633,7 @@ h1 {
margin: 1.25rem 0; margin: 1.25rem 0;
} }
.form--new-project,
.form--new-card { .form--new-card {
margin-top: 1.5rem; margin-top: 1.5rem;
padding-top: 1.25rem; padding-top: 1.25rem;
+45 -4
View File
@@ -1,13 +1,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { ApiError } from '../lib/api' import { ApiError } from '../lib/api'
import { useProjectsStore } from '../stores/projects' import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
const projects = useProjectsStore() const projects = useProjectsStore()
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const title = ref('')
const createError = ref<ApiError | null>(null)
const submitting = ref(false)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
onMounted(load) onMounted(load)
async function load() { async function load() {
@@ -18,6 +24,19 @@ async function load() {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.' loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.'
} }
} }
async function onCreate() {
submitting.value = true
createError.value = null
try {
await projects.createProject(title.value)
title.value = ''
} catch (e) {
createError.value = e instanceof ApiError ? e : new ApiError('Could not create the project.', 0)
} finally {
submitting.value = false
}
}
</script> </script>
<template> <template>
@@ -25,8 +44,8 @@ async function load() {
<p v-if="loadError" class="form-error">{{ loadError }}</p> <p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-else-if="projects.loading && !projects.loaded" class="muted">Loading</p> <p v-else-if="projects.loading && !projects.loaded" class="muted">Loading</p>
<p v-else-if="projects.projects.length === 0" class="muted"> <p v-else-if="projects.projects.length === 0" class="muted">
No projects yet create one from the sidebar. Anything uncategorised No projects yet create one below. Anything uncategorised lives in the
lives in the inbox, also in the sidebar. inbox, in the sidebar.
</p> </p>
<div v-else class="dashboard__grid"> <div v-else class="dashboard__grid">
@@ -42,5 +61,27 @@ async function load() {
</span> </span>
</RouterLink> </RouterLink>
</div> </div>
<form class="form form--new-project" @submit.prevent="onCreate">
<label>
<span>New project title</span>
<input v-model="title" type="text" maxlength="255" required :disabled="atLimit" />
<small v-if="createError?.fieldError('title')" class="field-error">
{{ createError.fieldError('title') }}
</small>
</label>
<p v-if="createError && Object.keys(createError.details).length === 0" class="form-error">
{{ createError.message }}
</p>
<button type="submit" :disabled="submitting || atLimit">
{{ submitting ? 'Creating…' : 'Create project' }}
</button>
<p v-if="atLimit" class="hint">
You have reached the maximum of {{ MAX_PROJECTS }} projects.
</p>
</form>
</div> </div>
</template> </template>
+2 -1
View File
@@ -23,11 +23,12 @@ const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null) const loadError = ref<string | null>(null)
const actionError = ref<string | null>(null) const actionError = ref<string | null>(null)
// Tab order is unchanged (All tasks first); Kanban is just the default view.
const tabs = [ const tabs = [
{ key: 'all', label: 'All tasks' }, { key: 'all', label: 'All tasks' },
{ key: 'kanban', label: 'Kanban' }, { key: 'kanban', label: 'Kanban' },
] as const ] as const
const activeTab = ref<(typeof tabs)[number]['key']>('all') const activeTab = ref<(typeof tabs)[number]['key']>('kanban')
const titleDraft = ref('') const titleDraft = ref('')
const descriptionDraft = ref('') const descriptionDraft = ref('')