Make Explore and Kanban real routes, not tab state

/projects/:id (name "project") is now Explore; /projects/:id/kanban
(name "project-kanban") is Kanban. ProjectView.vue becomes a layout:
header + sub-nav, loading the project and its cards (both children
need the cards list) and rendering the active one via <RouterView>.
ProjectExploreView.vue and ProjectKanbanView.vue hold what used to be
each tab's own template/logic; Kanban additionally loads its own
statuses, since Explore has no use for them.

The sub-nav is now RouterLinks (active state matched on route.name),
not buttons toggling local state.

App.vue: the top-level <RouterView> was keyed by the full route path
to force a fresh instance per project/card id -- with Explore/Kanban
now separate paths under one layout, that would also remount the
layout (and re-fetch the project) on every tab switch. Keyed by the
matched route's top-level path + params instead, which is the same
value for both of a project's child routes.

AppSidebar: the project switcher and the inbox-drag refresh both used
to check route.name === 'project' for "this project is open" -- fixed
to cover both routes for the switcher, and narrowed to
'project-kanban' specifically for the inbox-drag refresh, since Kanban
is the only route with a draggable list a card could have moved
to/from.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 00:46:17 +01:00
co-authored by Claude Sonnet 5
parent 81900f4d06
commit 8641c3d013
9 changed files with 329 additions and 242 deletions
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import CardRow from '../components/CardRow.vue'
import { ApiError } from '../lib/api'
import { useCardsStore } from '../stores/cards'
// The project's cards, flat and sorted by name -- ProjectView (the parent
// layout) has already loaded them into this store, keyed to the current
// project, so there's nothing to fetch here.
const cards = useCardsStore()
const actionError = ref<string | null>(null)
const newText = ref('')
const submitting = ref(false)
const summary = computed(() => {
const total = cards.cards.length
if (total === 0) return 'No cards yet.'
return `${total} card${total === 1 ? '' : 's'}.`
})
// No manual order here -- sort by name, case-insensitively.
const sortedCards = computed(() =>
[...cards.cards].sort((a, b) => a.text.localeCompare(b.text, undefined, { sensitivity: 'base' })),
)
/** Run a store mutation, surfacing failures and resyncing from the server. */
async function run(op: Promise<unknown>) {
actionError.value = null
try {
await op
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
if (cards.projectId !== null) await cards.load(cards.projectId)
}
}
async function onCreate() {
submitting.value = true
actionError.value = null
try {
await cards.add(newText.value)
newText.value = ''
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
submitting.value = false
}
}
</script>
<template>
<div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<p class="muted">{{ summary }}</p>
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<ul v-else-if="sortedCards.length" class="cards">
<CardRow
v-for="card in sortedCards"
:key="card.id"
:card="card"
@delete="run(cards.remove(card))"
/>
</ul>
<form class="kanban__new form--new-card" @submit.prevent="onCreate">
<input
v-model="newText"
class="field field--compact"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button
type="submit"
class="btn-icon"
:disabled="submitting"
:title="submitting ? 'Adding…' : 'Add card'"
:aria-label="submitting ? 'Adding' : 'Add card'"
>+</button>
</form>
</div>
</template>