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 | | 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 | | 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 | | 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 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
+26 -13
View File
@@ -135,18 +135,27 @@ ghost. The same rule covers every kanban status column too (below).
## Project detail ## Project detail
`/projects/:id` shows one project. It renders on a **full-width** layout (the `/projects/:id` shows one project. `ProjectView.vue` is a **layout**, not a
route sets `meta.wide`, which widens `.app__main` in `App.vue`), so the header page of its own: it renders on a **full-width** layout (the parent route sets
spans the full width and the **Manage** menu sits top right. The title is a `meta.wide`, inherited by its children, which widens `.app__main` in
plain heading, with an inline `.title-back` arrow to the dashboard right `App.vue`), loads the project and its cards, and renders the header + a small
before the text -- renaming lives on the configuration view (below). Manage sub-nav — its two children (below) render into its `<RouterView>`.
(`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.
Below the header are two tabs (local `activeTab` state, `v-show` so both stay The header: a plain title heading, with an inline `.title-back` arrow to the
mounted). The tab order is fixed — **Explore** first, **Kanban** second — but dashboard right before the text -- renaming lives on the configuration view
`activeTab` initialises to `'kanban'`, so a project opens on the board. (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 ### Explore
@@ -160,8 +169,12 @@ sits outside that link.
One column per project status, in `position` order -- the inbox is *not* a 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 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 source/target (the only view where that's true -- Explore has no draggable
and rebuilt by a `watch` whenever either changes. 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 Every drop — whether reordering within a column (`moved`) or dragging in from
another column or the sidebar's inbox (`added`) — calls 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) const drawerOpen = ref(false)
watch(() => route.fullPath, () => (drawerOpen.value = 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() { async function onLogout() {
auth.logout() auth.logout()
projects.reset() projects.reset()
@@ -70,9 +78,10 @@ async function onLogout() {
<AppSidebar v-if="showSidebar" :class="{ 'sidebar--open': drawerOpen }" @close="drawerOpen = false" /> <AppSidebar v-if="showSidebar" :class="{ 'sidebar--open': drawerOpen }" @close="drawerOpen = false" />
<main class="app__main" :class="{ 'app__main--wide': route.meta.wide }"> <main class="app__main" :class="{ 'app__main--wide': route.meta.wide }">
<!-- Key by path so navigating between projects via the sidebar remounts <!-- See routeKey above -- forces a fresh instance per project/card id
the view (each :id is a fresh load) rather than reusing the instance. --> (e.g. switching projects via the sidebar) without also remounting
<RouterView :key="route.path" /> a project's layout every time Explore/Kanban switch beneath it. -->
<RouterView :key="routeKey" />
</main> </main>
</div> </div>
</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 // The dropdown doubles as a project switcher: it reflects whichever project
// (if any) is currently open, and selecting one navigates there. // (if any) is currently open, and selecting one navigates there.
const selectedProjectId = computed<number | ''>({ 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) => { set: (id) => {
if (id !== '') void router.push({ name: 'project', params: { id } }) if (id !== '') void router.push({ name: 'project', params: { id } })
}, },
@@ -84,8 +89,10 @@ async function onInboxChange(change: ColumnChange) {
} catch (e) { } catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Something went wrong.' inboxError.value = e instanceof ApiError ? e.message : 'Something went wrong.'
} finally { } finally {
// The card may have come from (or gone to) the project currently open. // The card may have come from (or gone to) the kanban board currently
const openProjectId = route.name === 'project' ? Number(route.params.id) : null // 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([ await Promise.all([
loadInbox(), loadInbox(),
openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(), openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(),
+7 -1
View File
@@ -12,10 +12,16 @@ const router = createRouter({
meta: { requiresAuth: true, wide: true }, 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+)', path: '/projects/:id(\\d+)',
name: 'project',
component: () => import('../views/ProjectView.vue'), component: () => import('../views/ProjectView.vue'),
meta: { requiresAuth: true, wide: true }, 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', path: '/projects/:id(\\d+)/configure',
+5 -1
View File
@@ -725,7 +725,7 @@ h1 {
color: var(--error); color: var(--error);
} }
/* --- tabs -------------------------------------------------------------- */ /* --- project sub-nav (Explore/Kanban routes) --------------------------- */
.tabs { .tabs {
display: flex; display: flex;
@@ -734,11 +734,15 @@ h1 {
margin: 1.25rem 0 1rem; margin: 1.25rem 0 1rem;
} }
/* Real links (RouterLink) now, not buttons -- text-decoration/display reset
accordingly; everything else carries over unchanged. */
.tabs__tab { .tabs__tab {
display: inline-block;
border: none; border: none;
background: none; background: none;
font: inherit; font: inherit;
color: var(--muted); color: var(--muted);
text-decoration: none;
cursor: pointer; cursor: pointer;
padding: 0.5rem 0.9rem; padding: 0.5rem 0.9rem;
border-bottom: 2px solid transparent; 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"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router' 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 ProjectManageMenu from '../components/ProjectManageMenu.vue'
import { ApiError, apiRequest } from '../lib/api' import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn, type ColumnChange } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards' import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox' import type { Project } from '../types'
import type { Card, CardStatus, 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 route = useRoute()
const cards = useCardsStore() const cards = useCardsStore()
const inbox = useInboxStore()
const projectId = Number(route.params.id) const projectId = Number(route.params.id)
const project = ref<Project | null>(null) const project = ref<Project | null>(null)
const statuses = ref<CardStatus[]>([])
const loadError = ref<string | null>(null) 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()) onMounted(() => void load())
async function load() { async function load() {
loadError.value = null loadError.value = null
try { try {
const [{ project: fetched }, { statuses: fetchedStatuses }] = await Promise.all([ await Promise.all([
apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }), apiRequest<{ project: Project }>(`/projects/${projectId}`, { auth: true }).then(({ project: fetched }) => {
apiRequest<{ statuses: CardStatus[] }>(`/projects/${projectId}/statuses`, { auth: true }), project.value = fetched
}),
cards.load(projectId), cards.load(projectId),
]) ])
project.value = fetched
statuses.value = fetchedStatuses
rebuildBoard()
} catch (e) { } catch (e) {
if (e instanceof ApiError && e.status === 404) { if (e instanceof ApiError && e.status === 404) {
loadError.value = 'That project does not exist.' 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> </script>
<template> <template>
@@ -174,104 +59,24 @@ async function onCreateInColumn(column: Column) {
</div> </div>
</div> </div>
<p v-if="actionError" class="form-error">{{ actionError }}</p> <nav class="tabs" aria-label="Project view">
<RouterLink
<div class="tabs" role="tablist"> :to="{ name: 'project', params: { id: projectId } }"
<button
v-for="tab in tabs"
:key="tab.key"
type="button"
role="tab"
:aria-selected="activeTab === tab.key"
class="tabs__tab" class="tabs__tab"
:class="{ 'tabs__tab--active': activeTab === tab.key }" :class="{ 'tabs__tab--active': route.name === 'project' }"
@click="activeTab = tab.key"
> >
{{ tab.label }} Explore
</button> </RouterLink>
</div> <RouterLink
:to="{ name: 'project-kanban', params: { id: projectId } }"
<!-- Tab: Explore --> class="tabs__tab"
<div v-show="activeTab === 'all'" class="tabs__panel" role="tabpanel"> :class="{ 'tabs__tab--active': route.name === 'project-kanban' }"
<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)"
> >
<template #item="{ element }: { element: Card }"> Kanban
<KanbanCard :card="element" /> </RouterLink>
</template> </nav>
</draggable>
<form class="kanban__new" @submit.prevent="onCreateInColumn(column)"> <RouterView />
<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> </template>
</section> </section>
</template> </template>