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:
@@ -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')">
|
||||
✕
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">←</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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user