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