Make the inbox global instead of per-project

A card either sits in its owner's inbox (project_id AND status_id both NULL)
or belongs to exactly one project with a status in it (both set) -- enforced
by a CHECK constraint, never one without the other. The inbox is global to a
user now, not per-project: cards can move from a project into the inbox and
back into any status column of any project.

Backend
- migrations/009: rebuilds `cards` (SQLite can't relax NOT NULL / add a CHECK
  in place) with a nullable project_id, a new owner_id (cards need direct
  ownership once they can have no project), and the CHECK constraint. Cards
  that had no status (the old per-project inbox) move to the new global inbox.
  status_id's FK is now ON DELETE RESTRICT, not SET NULL -- nulling it alone
  would violate the invariant, and there's no status-delete endpoint anyway.
- CardRepository: "column" is now (owner_id, project_id, status_id); every
  method that dealt with a project's columns is generalised to also cover the
  inbox and cross-project moves (orderColumn, idsInColumn, repack, ...).
- CardController/routes: single-card and ordering routes move to global,
  since a card may have no project to nest them under --
  GET/PATCH/DELETE /api/cards/{id}, PUT /api/cards/order (body now takes
  project_id + status_id, both null for the inbox). New GET/POST
  /api/inbox/cards. PATCH no longer accepts status_id -- moving a card, in or
  out of a project, is exclusively PUT /api/cards/order now. A card created
  directly in a project (POST /api/projects/{id}/cards) lands in its first
  status, since a project card can't have no status.
- Tests: ProjectTest/CardStatusTest updated for the new routes; CardOrderTest
  rewritten with full inbox/cross-project coverage. 57 tests pass.

Frontend
- New stores/inbox.ts (the global inbox) and lib/cardOrder.ts (the shared
  PUT /api/cards/order call, used by both the sidebar and a project's board).
- AppSidebar: an Inbox section under the project list -- a vuedraggable list
  in the same "kanban" drag group as every project's kanban columns, so a
  card drags straight from the sidebar into whichever project is open, or
  back out. (The empty-inbox state needed a real bugfix: it wasn't rendering
  a <draggable> at all, so there was nowhere to drop a card back into an
  empty inbox.) A drop reloads the inbox and, if a project is open, its cards.
- ProjectView's kanban board drops its synthetic Inbox column -- just the
  real statuses now.
- DashboardView simplified to a plain grid of project tiles (name + card
  count); its per-project "New" section is gone, since a project card can no
  longer have no status.
- stores/cards.ts: patch/remove move to the global /api/cards/{id} routes.

Verified end-to-end against the rebuilt container (existing per-project-inbox
cards correctly migrated to the global inbox, 0 invariant violations) and the
dev server via headless Chrome: sidebar inbox -> project A "To do" -> back to
inbox -> project B "Done", full journey confirmed via the API at each step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 15:22:33 +01:00
co-authored by Claude Sonnet 5
parent c82bdbbf0e
commit 4ee0f24078
19 changed files with 957 additions and 674 deletions
+35 -21
View File
@@ -33,11 +33,13 @@ src/main.ts App bootstrap; resolves the stored session before mount
src/router/index.ts Routes + guard (redirects to /login when unauthenticated)
src/stores/auth.ts Pinia store: token in localStorage, magic-link + fetchMe
src/stores/projects.ts Pinia store: the user's projects (fetch + create)
src/stores/cards.ts Pinia store: one project's cards (CRUD + reorderColumn)
src/stores/cards.ts Pinia store: one project's cards (CRUD; no reordering -- see lib/cardOrder.ts)
src/stores/inbox.ts Pinia store: the caller's global inbox (fetch + create)
src/lib/api.ts fetch wrapper, bearer token, typed ApiError
src/components/AppSidebar.vue left nav: Dashboard link, divider, project list + new-project form
src/lib/cardOrder.ts reorderColumn() -- PUT /api/cards/order, shared by the sidebar and kanban board
src/components/AppSidebar.vue left nav: Dashboard link, project list + form, Inbox + form
src/components/CardRow.vue editable text + status chip + delete, one card
src/components/KanbanCard.vue small draggable card for the board columns
src/components/KanbanCard.vue small draggable card for the board columns and the inbox
src/views/ DashboardView, ProjectView, LoginView, ProfileView,
VerifyEmailView
```
@@ -45,15 +47,27 @@ src/views/ DashboardView, ProjectView, LoginView, ProfileView,
Signed-in "app" routes (`meta.requiresAuth`) render inside a persistent shell:
the top bar, then a left **sidebar** (`AppSidebar.vue`) beside the routed view.
The sidebar stays mounted across navigation — it holds a **Dashboard** link, a
divider, the project list, and a compact new-project form (creating one jumps to
it). The current page is highlighted via RouterLink's `active-class`.
`<RouterView :key="route.path">` remounts the view on every path change so
sidebar → project → project navigation always does a fresh load.
divider, the project list + new-project form, another divider, then the
**Inbox** (see below). The current page is highlighted via RouterLink's
`active-class`. `<RouterView :key="route.path">` remounts the view on every
path change so sidebar → project → project navigation always does a fresh load.
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid of
project cards — each shows the project name and, under a **New** heading, its
inbox cards (`status_id === null`), fetched per project. Signed-out routes
(`/login`, `/verify-email`) render without the sidebar.
`/` redirects to `/dashboard` (`DashboardView.vue`), a full-width grid linking
to each project, showing its title and card count. Signed-out routes (`/login`,
`/verify-email`) render without the sidebar.
## Inbox
A card with no project lives in the caller's inbox (`useInboxStore`), rendered
in the sidebar under the project list -- not per-project, and not tied to
whatever page is open. It's a `vuedraggable` list in the same `"kanban"` drag
group as every project's kanban columns (below), so a card can be dragged
straight out of the sidebar into any status column of whichever project is
currently open, or the other way. `AppSidebar`'s `onInboxChange` persists a
drop via `reorderColumn(null, null, ids)`, then reloads the inbox and, if a
project view is currently mounted (`route.name === 'project'`), that project's
cards too -- either side of a drag could have been the inbox. A small form
under the list adds a card straight to the inbox.
## Project detail
@@ -76,18 +90,18 @@ delete button.
### Kanban
Columns, left to right: **Inbox** (cards with no status) then each project
status in `position` order. `board` is derived from `cards.cards` + the
project's statuses and rebuilt by a `watch` whenever either changes.
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.
Every drop — whether reordering within a column (`moved`) or dragging in from
another (`added`) — calls `cards.reorderColumn(column.statusId, ids)`
`PUT /api/projects/:id/cards/order` with `{ status_id, card_ids }`. The server
re-parents any moved-in card, re-packs the source column, and returns the whole
project's cards, which replaces local state; on failure the board reloads.
The Inbox column has a small name + **Add** form at the bottom (`cards.add`);
new cards have no status, so they land straight in it.
another column or the sidebar's inbox (`added`) — calls
`reorderColumn(projectId, column.statusId, ids)` from `lib/cardOrder.ts` (shared
with the sidebar) → `PUT /api/cards/order`. The server re-parents any moved-in
card and re-packs whatever column it left; afterwards the view always reloads
both `inbox` and this project's `cards`, since either could have been the other
side of the move.
## Auth flow
+3
View File
@@ -4,11 +4,13 @@ import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import AppSidebar from './components/AppSidebar.vue'
import { useAuthStore } from './stores/auth'
import { useCardsStore } from './stores/cards'
import { useInboxStore } from './stores/inbox'
import { useProjectsStore } from './stores/projects'
const auth = useAuthStore()
const projects = useProjectsStore()
const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
@@ -20,6 +22,7 @@ async function onLogout() {
auth.logout()
projects.reset()
cards.reset()
inbox.reset()
await router.push({ name: 'login' })
}
</script>
+103 -3
View File
@@ -1,11 +1,20 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { RouterLink, useRoute, useRouter } from 'vue-router'
import draggable from 'vuedraggable'
import { ApiError } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { MAX_PROJECTS, useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
import KanbanCard from './KanbanCard.vue'
const projects = useProjectsStore()
const cards = useCardsStore()
const inbox = useInboxStore()
const router = useRouter()
const route = useRoute()
const title = ref('')
const createError = ref<ApiError | null>(null)
@@ -15,10 +24,11 @@ const loadError = ref<string | null>(null)
const atLimit = computed(() => projects.projects.length >= MAX_PROJECTS)
onMounted(() => {
if (!projects.loaded) void load()
if (!projects.loaded) void loadProjects()
if (!inbox.loaded) void loadInbox()
})
async function load() {
async function loadProjects() {
loadError.value = null
try {
await projects.fetchProjects()
@@ -40,6 +50,58 @@ async function onCreate() {
submitting.value = false
}
}
// --- Inbox: a global holding area, not tied to any project. Cards drag in
// and out of it from a project's kanban board (shared "kanban" group).
const newInboxText = ref('')
const addingToInbox = ref(false)
const inboxError = ref<string | null>(null)
type ColumnChange = {
added?: { element: Card; newIndex: number }
moved?: { element: Card; oldIndex: number; newIndex: number }
}
async function loadInbox() {
inboxError.value = null
try {
await inbox.load()
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Could not load the inbox.'
}
}
async function onCreateInboxCard() {
if (!newInboxText.value.trim()) return
addingToInbox.value = true
inboxError.value = null
try {
await inbox.add(newInboxText.value)
newInboxText.value = ''
} catch (e) {
inboxError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToInbox.value = false
}
}
async function onInboxChange(change: ColumnChange) {
if (!change.added && !change.moved) return
inboxError.value = null
try {
await reorderColumn(null, null, inbox.cards.map((c) => c.id))
} 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
await Promise.all([
loadInbox(),
openProjectId !== null ? cards.load(openProjectId) : Promise.resolve(),
])
}
}
</script>
<template>
@@ -105,5 +167,43 @@ async function onCreate() {
</button>
<small v-if="atLimit" class="hint">Limit of {{ MAX_PROJECTS }} projects reached.</small>
</form>
<hr class="sidebar__divider" />
<p class="sidebar__heading">Inbox</p>
<p v-if="inboxError" class="sidebar__note form-error">{{ inboxError }}</p>
<p v-else-if="inbox.loading && !inbox.loaded" class="sidebar__note muted">Loading</p>
<template v-else>
<!-- Always rendered (even empty) so it stays a valid drop target for a
card dragged out of a project's kanban board. -->
<p v-if="inbox.cards.length === 0" class="sidebar__note muted">Nothing in the inbox.</p>
<draggable
:list="inbox.cards"
:group="{ name: 'kanban' }"
item-key="id"
class="kanban__cards sidebar__inbox-cards"
ghost-class="kanban-card--ghost"
:animation="150"
@change="onInboxChange"
>
<template #item="{ element }: { element: Card }">
<KanbanCard :card="element" />
</template>
</draggable>
</template>
<form class="kanban__new" @submit.prevent="onCreateInboxCard">
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</aside>
</template>
+21
View File
@@ -0,0 +1,21 @@
import { apiRequest } from './api'
import type { Card } from '../types'
/**
* Set the contents and order of one column -- the inbox (both null) or a
* project's status (both set). Shared by the sidebar's inbox list and a
* project's kanban columns, since either can be a drag source or target for
* the other (dragging a card in re-parents it; its old column is re-packed
* server-side).
*/
export async function reorderColumn(
projectId: number | null,
statusId: number | null,
cardIds: number[],
): Promise<{ cards: Card[] }> {
return apiRequest<{ cards: Card[] }>('/cards/order', {
method: 'PUT',
auth: true,
body: { project_id: projectId, status_id: statusId, card_ids: cardIds },
})
}
+10 -21
View File
@@ -6,8 +6,10 @@ import type { Card } from '../types'
type CardPatch = Partial<Pick<Card, 'text' | 'complete'>>
export const useCardsStore = defineStore('cards', () => {
// Every card in the project, grouped by column (inbox first) then position.
// Views re-sort as needed (the "all tasks" list is alphabetical).
// One project's cards, grouped by status then position. Views re-sort as
// needed (the "all tasks" list is alphabetical). Moving a card in or out of
// this project (including via the inbox) goes through lib/cardOrder.ts,
// not this store -- callers re-load() afterwards.
const cards = ref<Card[]>([])
const projectId = ref<number | null>(null)
const loading = ref(false)
@@ -41,10 +43,11 @@ export const useCardsStore = defineStore('cards', () => {
}
async function patch(card: Card, fields: CardPatch): Promise<void> {
const { card: updated } = await apiRequest<{ card: Card }>(
`/projects/${projectId.value}/cards/${card.id}`,
{ method: 'PATCH', auth: true, body: fields },
)
const { card: updated } = await apiRequest<{ card: Card }>(`/cards/${card.id}`, {
method: 'PATCH',
auth: true,
body: fields,
})
const i = cards.value.findIndex((x) => x.id === updated.id)
if (i !== -1) cards.value[i] = updated
}
@@ -53,23 +56,10 @@ export const useCardsStore = defineStore('cards', () => {
const setText = (card: Card, text: string) => patch(card, { text })
async function remove(card: Card): Promise<void> {
await apiRequest(`/projects/${projectId.value}/cards/${card.id}`, { method: 'DELETE', auth: true })
await apiRequest(`/cards/${card.id}`, { method: 'DELETE', auth: true })
cards.value = cards.value.filter((c) => c.id !== card.id)
}
/**
* Set the contents and order of one status column (`null` = inbox). Cards
* dragged in from another column are re-parented server-side; the response is
* the whole project's cards, which replaces local state.
*/
async function reorderColumn(statusId: number | null, cardIds: number[]): Promise<void> {
const { cards: fresh } = await apiRequest<{ cards: Card[] }>(
`/projects/${projectId.value}/cards/order`,
{ method: 'PUT', auth: true, body: { status_id: statusId, card_ids: cardIds } },
)
cards.value = fresh
}
function reset(): void {
cards.value = []
projectId.value = null
@@ -87,7 +77,6 @@ export const useCardsStore = defineStore('cards', () => {
setComplete,
setText,
remove,
reorderColumn,
reset,
}
})
+43
View File
@@ -0,0 +1,43 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { apiRequest } from '../lib/api'
import type { Card } from '../types'
/**
* The signed-in user's global inbox: cards with no project. Loaded once and
* kept mounted in the sidebar for the whole session, so it stays visible (and
* a drag target/source) while navigating between the dashboard and projects.
*/
export const useInboxStore = defineStore('inbox', () => {
const cards = ref<Card[]>([])
const loaded = ref(false)
const loading = ref(false)
async function load(): Promise<void> {
loading.value = true
try {
const { cards: fetched } = await apiRequest<{ cards: Card[] }>('/inbox/cards', { auth: true })
cards.value = fetched
loaded.value = true
} finally {
loading.value = false
}
}
async function add(text: string): Promise<void> {
const { card } = await apiRequest<{ card: Card }>('/inbox/cards', {
method: 'POST',
auth: true,
body: { text },
})
// The API appends the card, so the end of the array is its correct place.
cards.value.push(card)
}
function reset(): void {
cards.value = []
loaded.value = false
}
return { cards, loaded, loading, load, add, reset }
})
+15 -38
View File
@@ -163,6 +163,12 @@ body {
overflow-y: auto;
}
.sidebar__inbox-cards {
max-height: 40vh;
overflow-y: auto;
padding: 0 0.1rem; /* room for the ghost card's outline while dragging */
}
.sidebar__note {
padding: 0 0.6rem;
font-size: 0.85rem;
@@ -219,12 +225,12 @@ h1 {
font-size: 0.9rem;
}
/* --- dashboard: grid of projects with their inbox ------------------- */
/* --- dashboard: grid of projects --------------------------------------- */
.dashboard__grid {
margin-top: 1rem;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
gap: 1rem;
align-items: start;
}
@@ -237,50 +243,21 @@ h1 {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
color: inherit;
text-decoration: none;
}
.project-card:hover {
border-color: var(--accent);
}
.project-card__title {
font-weight: 600;
font-size: 1.05rem;
color: inherit;
text-decoration: none;
word-break: break-word;
}
.project-card__title:hover {
color: var(--accent);
}
.project-card__heading {
margin: 0.3rem 0 0;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--muted);
}
.project-card__cards {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 16rem;
overflow-y: auto;
}
.project-card__cards li {
font-size: 0.9rem;
padding: 0.35rem 0.5rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.project-card__empty {
margin: 0;
.project-card__meta {
font-size: 0.85rem;
}
+5 -1
View File
@@ -31,9 +31,13 @@ export interface CardStatus {
name: string
}
/**
* A card either sits in its owner's global inbox (project_id and status_id
* both null) or belongs to one project with a status in it (both set).
*/
export interface Card {
id: number
project_id: number
project_id: number | null
text: string
complete: boolean
position: number
+15 -34
View File
@@ -1,37 +1,21 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { ApiError, apiRequest } from '../lib/api'
import { ApiError } from '../lib/api'
import { useProjectsStore } from '../stores/projects'
import type { Card } from '../types'
const projects = useProjectsStore()
const loading = ref(true)
const loadError = ref<string | null>(null)
/** project id -> its inbox cards (status_id === null) */
const inbox = ref<Record<number, Card[]>>({})
onMounted(load)
async function load() {
loading.value = true
loadError.value = null
try {
await projects.fetchProjects()
const entries = await Promise.all(
projects.projects.map(async (project): Promise<[number, Card[]]> => {
const { cards } = await apiRequest<{ cards: Card[] }>(`/projects/${project.id}/cards`, {
auth: true,
})
return [project.id, cards.filter((card) => card.status_id === null)]
}),
)
inbox.value = Object.fromEntries(entries)
} catch (e) {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the dashboard.'
} finally {
loading.value = false
}
}
</script>
@@ -39,27 +23,24 @@ async function load() {
<template>
<div class="dashboard">
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<p v-else-if="loading" class="muted">Loading</p>
<p v-else-if="projects.loading && !projects.loaded" class="muted">Loading</p>
<p v-else-if="projects.projects.length === 0" class="muted">
No projects yet create one from the sidebar.
No projects yet create one from the sidebar. Anything uncategorised
lives in the inbox, also in the sidebar.
</p>
<div v-else class="dashboard__grid">
<article v-for="project in projects.projects" :key="project.id" class="project-card">
<RouterLink
:to="{ name: 'project', params: { id: project.id } }"
class="project-card__title"
>
{{ project.title }}
</RouterLink>
<p class="project-card__heading">New</p>
<ul v-if="inbox[project.id]?.length" class="project-card__cards">
<li v-for="card in inbox[project.id]" :key="card.id">{{ card.text }}</li>
</ul>
<p v-else class="project-card__empty muted">Nothing new.</p>
</article>
<RouterLink
v-for="project in projects.projects"
:key="project.id"
:to="{ name: 'project', params: { id: project.id } }"
class="project-card"
>
<span class="project-card__title">{{ project.title }}</span>
<span class="project-card__meta muted">
{{ project.card_count }} card{{ project.card_count === 1 ? '' : 's' }}
</span>
</RouterLink>
</div>
</div>
</template>
+27 -54
View File
@@ -5,13 +5,16 @@ import draggable from 'vuedraggable'
import CardRow from '../components/CardRow.vue'
import KanbanCard from '../components/KanbanCard.vue'
import { ApiError, apiRequest } from '../lib/api'
import { reorderColumn } from '../lib/cardOrder'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import { useProjectsStore } from '../stores/projects'
import type { Card, CardStatus, Project } from '../types'
const route = useRoute()
const router = useRouter()
const cards = useCardsStore()
const inbox = useInboxStore()
const projects = useProjectsStore()
const projectId = Number(route.params.id)
@@ -38,9 +41,6 @@ const cancelButton = ref<HTMLButtonElement>()
const newText = ref('')
const submitting = ref(false)
const newInboxText = ref('')
const addingToInbox = ref(false)
const summary = computed(() => {
const total = cards.cards.length
if (total === 0) return 'No cards yet.'
@@ -53,10 +53,12 @@ const sortedCards = computed(() =>
)
// --- 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 | null
statusId: number
cards: Card[]
}
type ColumnChange = {
@@ -68,13 +70,11 @@ type ColumnChange = {
const board = ref<Column[]>([])
function buildColumns(): Column[] {
const defs: Omit<Column, 'cards'>[] = [
{ key: 'inbox', title: 'Inbox', statusId: null },
...statuses.value.map((s) => ({ key: `status-${s.id}`, title: s.name, statusId: s.id })),
]
return defs.map((def) => ({
...def,
cards: cards.cards.filter((card) => card.status_id === def.statusId),
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),
}))
}
@@ -86,12 +86,21 @@ function rebuildBoard() {
// (e.g. after a move is persisted, or a failed move is rolled back).
watch([() => cards.cards, statuses], rebuildBoard, { deep: true })
function onColumnChange(change: ColumnChange, column: Column) {
// `added` (card dragged in from another column) or `moved` (reordered within
// this one): persist this column's new id order. The source column, if any,
// is re-packed server-side. `removed` needs no action here.
if (change.added || change.moved) {
void run(cards.reorderColumn(column.statusId, column.cards.map((c) => c.id)))
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)])
}
}
@@ -218,21 +227,6 @@ async function onCreate() {
submitting.value = false
}
}
// New cards always land in the inbox (no status), so this just adds one.
async function onCreateInbox() {
if (!newInboxText.value.trim()) return
addingToInbox.value = true
actionError.value = null
try {
await cards.add(newInboxText.value)
newInboxText.value = ''
} catch (e) {
actionError.value = e instanceof ApiError ? e.message : 'Could not add the card.'
} finally {
addingToInbox.value = false
}
}
</script>
<template>
@@ -344,12 +338,7 @@ async function onCreateInbox() {
<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"
:class="{ 'kanban__col--inbox': column.statusId === null }"
>
<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>
@@ -368,22 +357,6 @@ async function onCreateInbox() {
<KanbanCard :card="element" />
</template>
</draggable>
<form
v-if="column.statusId === null"
class="kanban__new"
@submit.prevent="onCreateInbox"
>
<input
v-model="newInboxText"
type="text"
maxlength="1000"
required
placeholder="New card"
aria-label="New card"
/>
<button type="submit" :disabled="addingToInbox">Add</button>
</form>
</section>
</div>
</div>