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
+2
View File
@@ -24,6 +24,8 @@ Each user owns **projects**, and each project holds ordered **cards**.
| 14 | New-project form moved to the dashboard; sidebar project list is now a switcher dropdown; Kanban is a project's default tab | ✅ done |
| 15 | Passkeys (WebAuthn) — register from the profile page, sign in with one instead of a magic link; a dismissible notice nudges users with none | ✅ done |
| 16 | Project configuration view — manage a project's statuses: add, drag to reorder, delete (reassigning any cards on it first) | ✅ done |
| 17 | Card detail view (`/cards/:id`) + its own configuration view — a card's text is no longer inline-editable; every list links to its own page instead | ✅ done |
| 18 | Project view split into real routes — Explore (`/projects/:id`) and Kanban (`/projects/:id/kanban`) are separate pages under a shared layout, not client-side tab state | ✅ 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
+26 -13
View File
@@ -135,18 +135,27 @@ 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, with an inline `.title-back` arrow to the dashboard right
before the text -- 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.
`/projects/:id` shows one project. `ProjectView.vue` is a **layout**, not a
page of its own: it renders on a **full-width** layout (the parent route sets
`meta.wide`, inherited by its children, which widens `.app__main` in
`App.vue`), loads the project and its cards, and renders the header + a small
sub-nav — its two children (below) render into its `<RouterView>`.
Below the header are two tabs (local `activeTab` state, `v-show` so both stay
mounted). The tab order is fixed — **Explore** first, **Kanban** second — but
`activeTab` initialises to `'kanban'`, so a project opens on the board.
The header: a plain title heading, with an inline `.title-back` arrow to the
dashboard right before the text -- renaming lives on the configuration view
(below) -- and `ProjectManageMenu.vue` top right, with 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.
The sub-nav (`RouterLink`s styled as tabs, active one matched on `route.name`)
is real navigation, not client-side tab state -- **Explore** is the project's
own route (`/projects/:id`, name `project`), **Kanban** a child beneath it
(`/projects/:id/kanban`, name `project-kanban`). Both read the `cards` store
the layout already loaded; App.vue's top-level `<RouterView>` key is derived
from the matched route's *top-level* path plus params rather than the full
path, so switching between them doesn't remount the layout (and re-fetch the
project) the way switching to a different project's id still does.
### Explore
@@ -160,8 +169,12 @@ sits outside that link.
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.
source/target (the only view where that's true -- Explore has no draggable
list of its own, which the sidebar accounts for when deciding whether to
refresh a project's cards after an inbox drag). Unlike the cards, statuses
are this route's own fetch (`GET /api/projects/:id/statuses`) -- Explore has
no use for them. `board` is derived from `cards.cards` + those 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
+12 -3
View File
@@ -27,6 +27,14 @@ const showSidebar = computed(() => auth.isAuthenticated && route.meta.requiresAu
const drawerOpen = ref(false)
watch(() => route.fullPath, () => (drawerOpen.value = false))
// Keys the top-level RouterView by the current page and its params (id,
// mainly) rather than the full path -- so switching to a different project
// or card gets a fresh instance (a fresh load for its new :id), but a
// project's own Explore/Kanban routes -- children of the same top-level
// page, differing only in the trailing path segment -- share one, since
// ProjectView itself (their common layout) already handles that navigation.
const routeKey = computed(() => `${route.matched[0]?.path ?? route.path}:${JSON.stringify(route.params)}`)
async function onLogout() {
auth.logout()
projects.reset()
@@ -70,9 +78,10 @@ async function onLogout() {
<AppSidebar v-if="showSidebar" :class="{ 'sidebar--open': drawerOpen }" @close="drawerOpen = false" />
<main class="app__main" :class="{ 'app__main--wide': route.meta.wide }">
<!-- Key by path so navigating between projects via the sidebar remounts
the view (each :id is a fresh load) rather than reusing the instance. -->
<RouterView :key="route.path" />
<!-- See routeKey above -- forces a fresh instance per project/card id
(e.g. switching projects via the sidebar) without also remounting
a project's layout every time Explore/Kanban switch beneath it. -->
<RouterView :key="routeKey" />
</main>
</div>
</div>
+10 -3
View File
@@ -37,10 +37,15 @@ async function loadProjects() {
}
}
// Explore and Kanban are both "viewing this project" for the switcher's
// purposes -- just its two different routes now, not a project-configure
// or card page also nested under /projects or /cards.
const ON_PROJECT_ROUTES = new Set(['project', 'project-kanban'])
// 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) : ''),
get: () => (ON_PROJECT_ROUTES.has(String(route.name)) ? Number(route.params.id) : ''),
set: (id) => {
if (id !== '') void router.push({ name: 'project', params: { id } })
},
@@ -84,8 +89,10 @@ async function onInboxChange(change: ColumnChange) {
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// The card may have come from (or gone to) the project currently open.
const openProjectId = route.name === 'project' ? Number(route.params.id) : null
// The card may have come from (or gone to) the kanban board currently
// open -- the only place a project's cards are a drag target/source
// alongside the inbox (Explore has no draggable list of its own).
const openProjectId = route.name === 'project-kanban' ? Number(route.params.id) : null
await Promise.all([
loadInbox(),
openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(),
+7 -1
View File
@@ -12,10 +12,16 @@ const router = createRouter({
meta: { requiresAuth: true, wide: true },
},
{
// ProjectView is a layout: header + tab nav, with Explore/Kanban as
// its own routes below (their meta -- requiresAuth/wide -- comes from
// this parent record, which Vue Router merges into theirs).
path: '/projects/:id(\\d+)',
name: 'project',
component: () => import('../views/ProjectView.vue'),
meta: { requiresAuth: true, wide: true },
children: [
{ path: '', name: 'project', component: () => import('../views/ProjectExploreView.vue') },
{ path: 'kanban', name: 'project-kanban', component: () => import('../views/ProjectKanbanView.vue') },
],
},
{
path: '/projects/:id(\\d+)/configure',
+5 -1
View File
@@ -725,7 +725,7 @@ h1 {
color: var(--error);
}
/* --- tabs -------------------------------------------------------------- */
/* --- project sub-nav (Explore/Kanban routes) --------------------------- */
.tabs {
display: flex;
@@ -734,11 +734,15 @@ h1 {
margin: 1.25rem 0 1rem;
}
/* Real links (RouterLink) now, not buttons -- text-decoration/display reset
accordingly; everything else carries over unchanged. */
.tabs__tab {
display: inline-block;
border: none;
background: none;
font: inherit;
color: var(--muted);
text-decoration: none;
cursor: pointer;
padding: 0.5rem 0.9rem;
border-bottom: 2px solid transparent;
+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>
+153
View File
@@ -0,0 +1,153 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import draggable from 'vuedraggable'
import KanbanCard from '../components/KanbanCard.vue'
import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import type { Card, CardStatus } from '../types'
// One column per project status -- the inbox lives in the sidebar, not here,
// though it's still a valid drag source/target (shared "kanban" group). The
// cards themselves are already loaded (by ProjectView, the parent layout);
// statuses are this view's own concern, since only it needs them.
const route = useRoute()
const projectId = Number(route.params.id)
const cards = useCardsStore()
const inbox = useInboxStore()
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null)
const actionError = ref<string | null>(null)
onMounted(() => void loadStatuses())
async function loadStatuses() {
loadError.value = null
try {
const { statuses: fetched } = await apiRequest<{ statuses: CardStatus[] }>(
`/projects/${projectId}/statuses`,
{ auth: true },
)
statuses.value = fetched
rebuildBoard()
} catch (e) {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the statuses.'
}
}
interface Column {
key: string
title: string
statusId: number
cards: Card[]
}
const board = ref<Column[]>([])
function buildColumns(): Column[] {
return statuses.value.map((s) => ({
key: `status-${s.id}`,
title: s.name,
statusId: s.id,
cards: cards.cards.filter((card) => card.status_id === s.id),
}))
}
function rebuildBoard() {
board.value = buildColumns()
}
// Re-derive the columns whenever the underlying cards or the status set change
// (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
async function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in, from another column here or from the sidebar's
// inbox) or `moved` (reordered within this one): persist this column's new
// id order. The column it left -- another status, or the inbox -- is
// re-packed server-side. `removed` needs no action here.
if (!change.added && !change.moved) return
actionError.value = null
try {
await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id))
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// Either side of the drag could have been the inbox, so refresh both.
await Promise.all([inbox.load(), cards.load(projectId)])
}
}
// --- add a card directly into one column -- one draft per status, so typing
// in one column's form doesn't touch another's.
const newColumnCardText = ref<Record<number, string>>({})
const addingToStatusId = ref<number | null>(null)
async function onCreateInColumn(column: Column) {
addingToStatusId.value = column.statusId
actionError.value = null
try {
await cards.add(newColumnCardText.value[column.statusId] ?? '', column.statusId)
newColumnCardText.value[column.statusId] = ''
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToStatusId.value = null
}
}
</script>
<template>
<div>
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<div v-else class="kanban">
<section v-for="column in board" :key="column.key" class="kanban__col">
<header class="kanban__head">
<span class="kanban__title">{{ column.title }}</span>
<span class="kanban__count">{{ column.cards.length }}</span>
</header>
<draggable
:list="column.cards"
:group="{ name: 'kanban' }"
item-key="id"
class="kanban__cards"
ghost-class="kanban-card--ghost"
:animation="150"
@change="(e: ColumnChange) => onColumnChange(e, column)"
>
<template #item="{ element }: { element: Card }">
<KanbanCard :card="element" />
</template>
</draggable>
<form class="kanban__new" @submit.prevent="onCreateInColumn(column)">
<input
v-model="newColumnCardText[column.statusId]"
class="field field--compact"
type="text"
maxlength="1000"
required
placeholder="New card"
:aria-label="`New card in ${column.title}`"
/>
<button
type="submit"
class="btn-icon"
:disabled="addingToStatusId === column.statusId"
:title="addingToStatusId === column.statusId ? 'Adding…' : `Add card to ${column.title}`"
:aria-label="addingToStatusId === column.statusId ? 'Adding…' : `Add card to ${column.title}`"
>+</button>
</form>
</section>
</div>
</div>
</template>
+25 -220
View File
@@ -1,107 +1,34 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import draggable from 'vuedraggable'
import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue'
import ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import type { Card, CardStatus, Project } from '../types'
import type { Project } from '../types'
// Layout for a project: header (back arrow + title + Manage) and a tab nav,
// shared by its Explore and Kanban routes (rendered below via RouterView).
// Loads the project itself and its cards -- both children need the cards
// list; Explore has nothing else to fetch, and Kanban additionally loads its
// own statuses, since only it needs them.
const route = useRoute()
const cards = useCardsStore()
const inbox = useInboxStore()
const projectId = Number(route.params.id)
const project = ref<Project | null>(null)
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null)
const actionError = ref<string | null>(null)
// Tab order is unchanged (Explore first); Kanban is just the default view.
const tabs = [
{ key: 'all', label: 'Explore' },
{ key: 'kanban', label: 'Kanban' },
] as const
const activeTab = ref<(typeof tabs)[number]['key']>('kanban')
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'}.`
})
// The "all tasks" list has no manual order sort by name, case-insensitively.
const sortedCards = computed(() =>
[...cards.cards].sort((a, b) => a.text.localeCompare(b.text, undefined, { sensitivity: 'base' })),
)
// --- Kanban board --------------------------------------------------------
// One column per project status -- the inbox lives in the sidebar now, not
// here, though it's still a valid drag source/target (shared "kanban" group).
interface Column {
key: string
title: string
statusId: number
cards: Card[]
}
const board = ref<Column[]>([])
function buildColumns(): Column[] {
return statuses.value.map((s) => ({
key: `status-${s.id}`,
title: s.name,
statusId: s.id,
cards: cards.cards.filter((card) => card.status_id === s.id),
}))
}
function rebuildBoard() {
board.value = buildColumns()
}
// Re-derive the columns whenever the underlying cards or the status set change
// (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
async function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in, from another column here or from the sidebar's
// inbox) or `moved` (reordered within this one): persist this column's new
// id order. The column it left -- another status, or the inbox -- is
// re-packed server-side. `removed` needs no action here.
if (!change.added && !change.moved) return
actionError.value = null
try {
await reorderColumn(projectId, column.statusId, column.cards.map((c) => c.id))
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally {
// Either side of the drag could have been the inbox, so refresh both.
await Promise.all([inbox.load(), cards.load(projectId)])
}
}
onMounted(() => void load())
async function load() {
loadError.value = null
try {
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }),
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }),
await Promise.all([
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }).then(({ project: fetched }) => {
project.value = fetched
}),
cards.load(projectId),
])
project.value = fetched
statuses.value = fetchedStatuses
rebuildBoard()
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
loadError.value = 'That project does not exist.'
@@ -110,48 +37,6 @@ async function load() {
}
}
}
/** 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.'
await cards.load(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
}
}
// --- add a card directly into one kanban column -- one draft per status, so
// typing in one column's form doesn't touch another's.
const newColumnCardText = ref<Record<number, string>>({})
const addingToStatusId = ref<number | null>(null)
async function onCreateInColumn(column: Column) {
addingToStatusId.value = column.statusId
actionError.value = null
try {
await cards.add(newColumnCardText.value[column.statusId] ?? '', column.statusId)
newColumnCardText.value[column.statusId] = ''
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToStatusId.value = null
}
}
</script>
<template>
@@ -174,104 +59,24 @@ async function onCreateInColumn(column: Column) {
</div>
</div>
<p v-if="actionError" class="form-error">{{ actionError }}</p>
<div class="tabs" role="tablist">
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
role="tab"
:aria-selected="activeTab === tab.key"
<nav class="tabs" aria-label="Project view">
<RouterLink
:to="{ name: 'project', params: { id: projectId } }"
class="tabs__tab"
:class="{ 'tabs__tab--active': activeTab === tab.key }"
@click="activeTab = tab.key"
:class="{ 'tabs__tab--active': route.name === 'project' }"
>
{{ tab.label }}
</button>
</div>
<!-- Tab: Explore -->
<div v-show="activeTab === 'all'" class="tabs__panel" role="tabpanel">
<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>
<!-- Tab: Kanban -->
<div v-show="activeTab === 'kanban'" class="tabs__panel" role="tabpanel">
<p v-if="cards.loading && !cards.loaded" class="muted">Loading</p>
<div v-else class="kanban">
<section v-for="column in board" :key="column.key" class="kanban__col">
<header class="kanban__head">
<span class="kanban__title">{{ column.title }}</span>
<span class="kanban__count">{{ column.cards.length }}</span>
</header>
<draggable
:list="column.cards"
:group="{ name: 'kanban' }"
item-key="id"
class="kanban__cards"
ghost-class="kanban-card--ghost"
:animation="150"
@change="(e: ColumnChange) => onColumnChange(e, column)"
Explore
</RouterLink>
<RouterLink
:to="{ name: 'project-kanban', params: { id: projectId } }"
class="tabs__tab"
:class="{ 'tabs__tab--active': route.name === 'project-kanban' }"
>
<template #item="{ element }: { element: Card }">
<KanbanCard :card="element" />
</template>
</draggable>
Kanban
</RouterLink>
</nav>
<form class="kanban__new" @submit.prevent="onCreateInColumn(column)">
<input
v-model="newColumnCardText[column.statusId]"
class="field field--compact"
type="text"
maxlength="1000"
required
placeholder="New card"
:aria-label="`New card in ${column.title}`"
/>
<button
type="submit"
class="btn-icon"
:disabled="addingToStatusId === column.statusId"
:title="addingToStatusId === column.statusId ? 'Adding…' : `Add card to ${column.title}`"
:aria-label="addingToStatusId === column.statusId ? 'Adding…' : `Add card to ${column.title}`"
>+</button>
</form>
</section>
</div>
</div>
<RouterView />
</template>
</section>
</template>