Add a dedicated card view; make card text no longer inline-editable

Clicking a card -- in the "all tasks" list, a kanban column, or the
inbox -- now opens /cards/:id instead of editing the text in place:

- CardRow's text is now plain (its RouterLink wraps the text + status
  badge; the delete button stays a sibling so it isn't nested inside
  the link). KanbanCard is now itself a RouterLink.
- New CardView.vue: header follows the project view's layout, but the
  back link sits inline inside the title (before the card's text)
  rather than off in the actions corner, since it isn't paired with a
  manage menu here. Inbox cards have no project to link back to, so
  they go to the dashboard instead. Below the header, an "Edit text"
  section (styled like the project rename form) replaces the inline
  editing that used to live in the list row, and a delete button in
  the header actions replaces CardRow's per-row delete for cards
  reached via kanban/inbox (which never had one).
- No backend changes: GET/PATCH/DELETE /cards/{id} already existed.
- Renamed .card-row__status to the more general .status-badge, now
  shared by the list row and the card view.
- cards store: dropped setText, now unused now that editing goes
  through a direct PATCH + store refresh in CardView instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 23:51:06 +01:00
co-authored by Claude Sonnet 5
parent c2c988d928
commit fb329bf693
7 changed files with 211 additions and 66 deletions
+8 -36
View File
@@ -1,46 +1,18 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { Card } from '../types'
const props = defineProps<{ card: Card }>()
const emit = defineEmits<{
'save-text': [text: string]
delete: []
}>()
const text = ref(props.card.text)
watch(
() => props.card.text,
(value) => {
text.value = value
},
)
function commit() {
const next = text.value.trim()
if (next === '') {
text.value = props.card.text // the API requires a non-empty text
return
}
if (next !== props.card.text) emit('save-text', next)
}
defineProps<{ card: Card }>()
const emit = defineEmits<{ delete: [] }>()
</script>
<template>
<li class="card-row">
<input
v-model="text"
class="card-row__text"
type="text"
maxlength="1000"
aria-label="Card text"
@blur="commit"
@keyup.enter="($event.target as HTMLInputElement).blur()"
/>
<span class="card-row__status" :class="{ 'card-row__status--none': !card.status }">
{{ card.status?.name ?? 'No status' }}
</span>
<RouterLink :to="{ name: 'card', params: { id: card.id } }" class="card-row__link">
<span class="card-row__text">{{ card.text }}</span>
<span class="status-badge" :class="{ 'status-badge--none': !card.status }">
{{ card.status?.name ?? 'No status' }}
</span>
</RouterLink>
<button type="button" class="card-row__delete" aria-label="Delete card" @click="emit('delete')">
+1 -1
View File
@@ -5,5 +5,5 @@ defineProps<{ card: Card }>()
</script>
<template>
<div class="kanban-card">{{ card.text }}</div>
<RouterLink :to="{ name: 'card', params: { id: card.id } }" class="kanban-card">{{ card.text }}</RouterLink>
</template>
+6
View File
@@ -23,6 +23,12 @@ const router = createRouter({
component: () => import('../views/ProjectConfigureView.vue'),
meta: { requiresAuth: true, wide: true },
},
{
path: '/cards/:id(\\d+)',
name: 'card',
component: () => import('../views/CardView.vue'),
meta: { requiresAuth: true, wide: true },
},
{
path: '/profile',
name: 'profile',
-2
View File
@@ -53,7 +53,6 @@ export const useCardsStore = defineStore('cards', () => {
}
const setComplete = (card: Card, complete: boolean) => patch(card, { complete })
const setText = (card: Card, text: string) => patch(card, { text })
async function remove(card: Card): Promise<void> {
await apiRequest(`/cards/${card.id}`, { method: 'DELETE', auth: true })
@@ -75,7 +74,6 @@ export const useCardsStore = defineStore('cards', () => {
load,
add,
setComplete,
setText,
remove,
reset,
}
+50 -26
View File
@@ -48,9 +48,7 @@
}
/* No browser focus ring on form controls -- the border colour change is
highlight enough (components with more elaborate focus styles, e.g.
.card-row__text, override this with their own higher-specificity rule).
Specificity is a single pseudo-class + element, (0,0,1,1) -- .field below
highlight enough. Specificity is a single pseudo-class + element, (0,0,1,1) -- .field below
is deliberately a single class with no element in the selector, (0,0,1,0),
so it can never win a specificity tie against this regardless of source
order (a wrapper-class + descendant-element rule, e.g. the old ".form
@@ -550,7 +548,34 @@ h1 {
background: var(--surface);
}
.card-row__status {
/* Wraps the text + status badge -- the whole row is a link to the card's own
view, except the delete button (a sibling, so it isn't nested inside it). */
.card-row__link {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.4rem;
border-radius: var(--radius-sm);
color: inherit;
text-decoration: none;
}
.card-row__link:hover {
background: var(--bg);
}
.card-row__text {
flex: 1;
min-width: 0;
font-size: 1rem;
word-break: break-word;
}
/* A small pill naming a card's status -- the card list row and the card's
own view both show one. */
.status-badge {
flex: none;
padding: 0.15rem 0.55rem;
border-radius: var(--radius-pill);
@@ -562,32 +587,11 @@ h1 {
color: var(--muted);
}
.card-row__status--none {
.status-badge--none {
font-weight: 400;
font-style: italic;
}
.card-row__text {
flex: 1;
min-width: 0;
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
font-size: 1rem;
padding: 0.3rem 0.4rem;
}
.card-row__text:hover {
border-color: var(--border);
}
.card-row__text:focus {
outline: none;
border-color: var(--accent);
background: var(--bg);
}
.card-row__delete {
flex: none;
border: none;
@@ -649,6 +653,19 @@ h1 {
text-decoration: none;
}
/* The card view's back link sits inside its title, right before the card's
text, rather than up in .project-head__actions like the other views. */
.card-view__back {
display: inline-flex;
margin-right: 0.4rem;
color: var(--muted);
text-decoration: none;
}
.card-view__back:hover {
color: var(--accent);
}
.menu {
position: relative;
flex: none;
@@ -889,14 +906,21 @@ h1 {
}
.kanban-card {
display: block;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 0.55rem 0.65rem;
font-size: 0.9rem;
color: inherit;
text-decoration: none;
cursor: grab;
}
.kanban-card:hover {
border-color: var(--accent);
}
.kanban-card--ghost {
opacity: var(--opacity-ghost);
}
+146
View File
@@ -0,0 +1,146 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
import { ApiError, apiRequest } from '../lib/api'
import { useCardsStore } from '../stores/cards'
import { useInboxStore } from '../stores/inbox'
import type { Card } from '../types'
const route = useRoute()
const router = useRouter()
const cards = useCardsStore()
const inbox = useInboxStore()
const cardId = Number(route.params.id)
const card = ref<Card | null>(null)
const loadError = ref<string | null>(null)
onMounted(() => void load())
async function load() {
loadError.value = null
try {
const { card: fetched } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, { auth: true })
card.value = fetched
textDraft.value = fetched.text
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
loadError.value = 'That card does not exist.'
} else {
loadError.value = e instanceof ApiError ? e.message : 'Could not load the card.'
}
}
}
// A card with no project lives in the inbox -- there's no view of the inbox
// on its own (it's always in the sidebar), so its back link goes to the
// dashboard instead.
const backTarget = computed<RouteLocationRaw>(() =>
card.value?.project_id != null
? { name: 'project', params: { id: card.value.project_id } }
: { name: 'dashboard' },
)
const backLabel = computed(() => (card.value?.project_id != null ? 'Back to project' : 'Back to dashboard'))
/** Refresh whichever store holds this card, so wherever it came from -- a
* project's board, or the sidebar inbox -- reflects the change. */
async function refreshSource() {
if (!card.value) return
await (card.value.project_id !== null ? cards.load(card.value.project_id) : inbox.load())
}
// --- edit text -------------------------------------------------------------
const textDraft = ref('')
const saving = ref(false)
const saveError = ref<ApiError | null>(null)
async function onSaveText() {
if (!card.value) return
const next = textDraft.value.trim()
if (next === card.value.text) return
saving.value = true
saveError.value = null
try {
const { card: updated } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, {
method: 'PATCH',
auth: true,
body: { text: next },
})
card.value = updated
textDraft.value = updated.text
await refreshSource()
} catch (e) {
saveError.value = e instanceof ApiError ? e : new ApiError('Could not save the card.', 0)
} finally {
saving.value = false
}
}
// --- delete ------------------------------------------------------------
const deleting = ref(false)
const deleteError = ref<string | null>(null)
async function onDelete() {
if (!card.value) return
deleting.value = true
deleteError.value = null
const target = backTarget.value
try {
await apiRequest(`/cards/${cardId}`, { method: 'DELETE', auth: true })
await refreshSource()
await router.push(target)
} catch (e) {
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the card.'
deleting.value = false
}
}
</script>
<template>
<section class="card project">
<p v-if="loadError" class="form-error">{{ loadError }}</p>
<template v-else-if="card">
<div class="project-head">
<h1 class="project-head__title">
<RouterLink :to="backTarget" class="card-view__back" :aria-label="backLabel">&larr;</RouterLink>
{{ card.text }}
</h1>
<div class="project-head__actions">
<button type="button" class="btn-danger" :disabled="deleting" @click="onDelete">
{{ deleting ? 'Deleting' : 'Delete card' }}
</button>
</div>
</div>
<p>
<span class="status-badge" :class="{ 'status-badge--none': !card.status }">
{{ card.status?.name ?? 'No status' }}
</span>
</p>
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
<section class="config-section">
<h2>Edit text</h2>
<form class="field-row" @submit.prevent="onSaveText">
<input
v-model="textDraft"
class="field field--compact"
type="text"
maxlength="1000"
required
aria-label="Card text"
/>
<button type="submit" :disabled="saving || textDraft.trim() === card.text">
{{ saving ? 'Saving…' : 'Save' }}
</button>
</form>
<p v-if="saveError" class="form-error">{{ saveError.message }}</p>
</section>
</template>
</section>
</template>
-1
View File
@@ -181,7 +181,6 @@ async function onCreate() {
v-for="card in sortedCards"
:key="card.id"
:card="card"
@save-text="(v) => run(cards.setText(card, v))"
@delete="run(cards.remove(card))"
/>
</ul>