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 |
|
||||
| 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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
+26
-221
@@ -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>
|
||||
Explore
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="{ name: 'project-kanban', params: { id: projectId } }"
|
||||
class="tabs__tab"
|
||||
:class="{ 'tabs__tab--active': route.name === 'project-kanban' }"
|
||||
>
|
||||
Kanban
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<!-- 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)"
|
||||
>
|
||||
<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>
|
||||
<RouterView />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user