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:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|||||||
Reference in New Issue
Block a user