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 |
| 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 |
| 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 |
| 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
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/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/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/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:
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`. `<RouterView :key="route.path">` remounts the view on every
path change so sidebar → project → project navigation always does a fresh load.
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.
`/` 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.
`/` redirects to `/dashboard` (`DashboardView.vue`): a full-width grid linking
to each project (title + card count), then a **new-project form** directly
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
@@ -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.
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
+22 -60
View File
@@ -6,7 +6,7 @@ import { ApiError } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
import KanbanCard from './KanbanCard.vue'
@@ -16,13 +16,8 @@ const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
const title = ref('')
const createError = ref<ApiError | null>(null)
const submitting = ref(false)
const loadError = ref<string | null>(null)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
onMounted(() => {
if (!projects.loaded) void loadProjects()
if (!inbox.loaded) void loadInbox()
@@ -37,19 +32,14 @@ async function loadProjects() {
}
}
async function onCreate() {
submitting.value = true
createError.value = null
try {
const project = await projects.createProject(title.value)
title.value = ''
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
}
}
// The dropdown doubles as a project switcher: it reflects whichever project
// (if any) is currently open, and selecting one navigates there.
const selectedProjectId = computed<number | ''>({
get: () => (route.name === 'project' ? Number(route.params.id) : ''),
set: (id) => {
if (id !== '') void router.push({ name: 'project', params: { id } })
},
})
// --- 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).
@@ -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.projects.length === 0" class="sidebar__note muted">No projects yet.</p>
<nav v-else class="sidebar__nav sidebar__projects">
<RouterLink
v-for="project in projects.projects"
:key="project.id"
:to="{ name: 'project', params: { id: project.id } }"
class="sidebar__link sidebar__link--project"
active-class="sidebar__link--active"
>
<svg class="sidebar__icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="1" y="2" width="3.5" height="12" rx="1" />
<rect x="6.25" y="2" width="3.5" height="9" rx="1" />
<rect x="11.5" y="2" width="3.5" height="6" rx="1" />
</svg>
<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>
<div v-else class="sidebar__select">
<svg class="sidebar__icon" viewBox="0 0 16 16" aria-hidden="true">
<rect x="1" y="2" width="3.5" height="12" rx="1" />
<rect x="6.25" y="2" width="3.5" height="9" rx="1" />
<rect x="11.5" y="2" width="3.5" height="6" rx="1" />
</svg>
<select v-model="selectedProjectId" aria-label="Go to project">
<option value="" disabled>Choose a project</option>
<option v-for="project in projects.projects" :key="project.id" :value="project.id">
{{ project.title }}
</option>
</select>
</div>
<hr class="sidebar__divider" />
+18 -28
View File
@@ -158,9 +158,23 @@ body {
color: var(--muted);
}
.sidebar__projects {
max-height: 45vh;
overflow-y: auto;
.sidebar__select {
display: flex;
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 {
@@ -174,31 +188,6 @@ body {
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 {
background: var(--surface);
border: 1px solid var(--border);
@@ -644,6 +633,7 @@ h1 {
margin: 1.25rem 0;
}
.form--new-project,
.form--new-card {
margin-top: 1.5rem;
padding-top: 1.25rem;
+45 -4
View File
@@ -1,13 +1,19 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ApiError } from '../lib/api'
import { useProjectsStore } from '../stores/projects'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
const projects = useProjectsStore()
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)
async function load() {
@@ -18,6 +24,19 @@ async function load() {
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>
<template>
@@ -25,8 +44,8 @@ async function load() {
<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.projects.length === 0" class="muted">
No projects yet create one from the sidebar. Anything uncategorised
lives in the inbox, also in the sidebar.
No projects yet create one below. Anything uncategorised lives in the
inbox, in the sidebar.
</p>
<div v-else class="dashboard__grid">
@@ -42,5 +61,27 @@ async function load() {
</span>
</RouterLink>
</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>
</template>
+2 -1
View File
@@ -23,11 +23,12 @@ const statuses = ref<CardStatus[]>([])
const loadError = 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 = [
{ key: 'all', label: 'All tasks' },
{ key: 'kanban', label: 'Kanban' },
] as const
const activeTab = ref<(typeof tabs)[number]['key']>('all')
const activeTab = ref<(typeof tabs)[number]['key']>('kanban')
const titleDraft = ref('')
const descriptionDraft = ref('')