Split card editing/delete out into a CardConfigureView, mirroring projects
CardView is now just the read view -- header (inline back arrow + card text) and its status badge, no tabs (there's nothing to tab between). New CardConfigureView (/cards/:id/configure) holds the "Edit text" form that used to live inline in CardView. New CardManageMenu, mirroring ProjectManageMenu: a "Manage" dropdown with "Configure" (hidden while already on the configure view) and "Delete card" (with its own confirmation modal). Both CardView and CardConfigureView use it in their header actions, same as the project view and its own configure view. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useDialog } from '../composables/useDialog'
|
||||||
|
import { ApiError, apiRequest } from '../lib/api'
|
||||||
|
import { useCardsStore } from '../stores/cards'
|
||||||
|
import { useInboxStore } from '../stores/inbox'
|
||||||
|
|
||||||
|
// The "Manage" dropdown + its delete-card confirmation modal, shared by the
|
||||||
|
// card view and its configuration view -- each links to the other, and
|
||||||
|
// either can delete the card. Mirrors ProjectManageMenu.
|
||||||
|
const props = defineProps<{
|
||||||
|
cardId: number
|
||||||
|
cardText: string
|
||||||
|
/** null for an inbox card -- there's no project to return to, so a delete
|
||||||
|
* (or the "Back to project" link on the pages using this menu) goes to
|
||||||
|
* the dashboard instead. */
|
||||||
|
projectId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const cards = useCardsStore()
|
||||||
|
const inbox = useInboxStore()
|
||||||
|
|
||||||
|
const menuOpen = ref(false)
|
||||||
|
const confirmingDelete = ref(false)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref<string | null>(null)
|
||||||
|
const cancelButton = ref<HTMLButtonElement>()
|
||||||
|
|
||||||
|
function askDelete() {
|
||||||
|
menuOpen.value = false
|
||||||
|
deleteError.value = null
|
||||||
|
confirmingDelete.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
try {
|
||||||
|
await apiRequest(`/cards/${props.cardId}`, { method: 'DELETE', auth: true })
|
||||||
|
// Refresh whichever store holds this card -- a project's board, or the
|
||||||
|
// sidebar inbox -- so it no longer shows the card we just deleted.
|
||||||
|
await (props.projectId !== null ? cards.load(props.projectId) : inbox.load())
|
||||||
|
await router.push(
|
||||||
|
props.projectId !== null ? { name: 'project', params: { id: props.projectId } } : { name: 'dashboard' },
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e instanceof ApiError ? e.message : 'Could not delete the card.'
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The modal takes priority: while it's open, Escape closes it, not the menu
|
||||||
|
// underneath (askDelete() already closes the menu when the modal opens, so
|
||||||
|
// the two are never both open at once).
|
||||||
|
useDialog(confirmingDelete, () => (confirmingDelete.value = false), cancelButton)
|
||||||
|
useDialog(menuOpen, () => (menuOpen.value = false))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="menu">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="menu__toggle"
|
||||||
|
aria-haspopup="true"
|
||||||
|
:aria-expanded="menuOpen"
|
||||||
|
@click="menuOpen = !menuOpen"
|
||||||
|
>
|
||||||
|
Manage ▾
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="menuOpen">
|
||||||
|
<div class="menu__backdrop" @click="menuOpen = false" />
|
||||||
|
<ul class="menu__list" role="menu">
|
||||||
|
<li v-if="route.name !== 'card-configure'" role="none">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'card-configure', params: { id: cardId } }"
|
||||||
|
role="menuitem"
|
||||||
|
class="menu__item"
|
||||||
|
@click="menuOpen = false"
|
||||||
|
>
|
||||||
|
Configure
|
||||||
|
</RouterLink>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button type="button" role="menuitem" class="menu__item menu__item--danger" @click="askDelete">
|
||||||
|
Delete card
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="confirmingDelete"
|
||||||
|
class="modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-delete-title"
|
||||||
|
>
|
||||||
|
<div class="modal__backdrop" @click="confirmingDelete = false" />
|
||||||
|
<div class="modal__dialog">
|
||||||
|
<h2 id="confirm-delete-title">Delete this card?</h2>
|
||||||
|
<p class="muted">“{{ cardText }}” will be permanently deleted.</p>
|
||||||
|
<p v-if="deleteError" class="form-error">{{ deleteError }}</p>
|
||||||
|
<div class="modal__actions">
|
||||||
|
<button
|
||||||
|
ref="cancelButton"
|
||||||
|
type="button"
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="deleting"
|
||||||
|
@click="confirmingDelete = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-danger" :disabled="deleting" @click="confirmDelete">
|
||||||
|
{{ deleting ? 'Deleting…' : 'Delete card' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -29,6 +29,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/CardView.vue'),
|
component: () => import('../views/CardView.vue'),
|
||||||
meta: { requiresAuth: true, wide: true },
|
meta: { requiresAuth: true, wide: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/cards/:id(\\d+)/configure',
|
||||||
|
name: 'card-configure',
|
||||||
|
component: () => import('../views/CardConfigureView.vue'),
|
||||||
|
meta: { requiresAuth: true, wide: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
name: 'profile',
|
name: 'profile',
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import CardManageMenu from '../components/CardManageMenu.vue'
|
||||||
|
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 cardId = Number(route.params.id)
|
||||||
|
const cards = useCardsStore()
|
||||||
|
const inbox = useInboxStore()
|
||||||
|
|
||||||
|
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.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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
|
||||||
|
// The board/column (or the sidebar inbox) this card came from reads from
|
||||||
|
// one of these stores -- refresh so the new text shows up there too, not
|
||||||
|
// just in this view's own header.
|
||||||
|
await (card.value.project_id !== null ? cards.load(card.value.project_id) : inbox.load())
|
||||||
|
} catch (e) {
|
||||||
|
saveError.value = e instanceof ApiError ? e : new ApiError('Could not save the card.', 0)
|
||||||
|
} finally {
|
||||||
|
saving.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="{ name: 'card', params: { id: cardId } }" class="title-back" aria-label="Back to card">
|
||||||
|
←
|
||||||
|
</RouterLink>
|
||||||
|
{{ card.text }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="project-head__actions">
|
||||||
|
<CardManageMenu :card-id="cardId" :card-text="card.text" :project-id="card.project_id" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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,16 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter, type RouteLocationRaw } from 'vue-router'
|
import { useRoute, type RouteLocationRaw } from 'vue-router'
|
||||||
|
import CardManageMenu from '../components/CardManageMenu.vue'
|
||||||
import { ApiError, apiRequest } from '../lib/api'
|
import { ApiError, apiRequest } from '../lib/api'
|
||||||
import { useCardsStore } from '../stores/cards'
|
|
||||||
import { useInboxStore } from '../stores/inbox'
|
|
||||||
import type { Card } from '../types'
|
import type { Card } from '../types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const cards = useCardsStore()
|
|
||||||
const inbox = useInboxStore()
|
|
||||||
|
|
||||||
const cardId = Number(route.params.id)
|
const cardId = Number(route.params.id)
|
||||||
|
|
||||||
const card = ref<Card | null>(null)
|
const card = ref<Card | null>(null)
|
||||||
@@ -23,7 +18,6 @@ async function load() {
|
|||||||
try {
|
try {
|
||||||
const { card: fetched } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, { auth: true })
|
const { card: fetched } = await apiRequest<{ card: Card }>(`/cards/${cardId}`, { auth: true })
|
||||||
card.value = fetched
|
card.value = fetched
|
||||||
textDraft.value = fetched.text
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
loadError.value = 'That card does not exist.'
|
loadError.value = 'That card does not exist.'
|
||||||
@@ -42,61 +36,6 @@ const backTarget = computed<RouteLocationRaw>(() =>
|
|||||||
: { name: 'dashboard' },
|
: { name: 'dashboard' },
|
||||||
)
|
)
|
||||||
const backLabel = computed(() => (card.value?.project_id != null ? 'Back to project' : 'Back to 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -111,9 +50,7 @@ async function onDelete() {
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div class="project-head__actions">
|
<div class="project-head__actions">
|
||||||
<button type="button" class="btn-danger" :disabled="deleting" @click="onDelete">
|
<CardManageMenu :card-id="cardId" :card-text="card.text" :project-id="card.project_id" />
|
||||||
{{ deleting ? 'Deleting…' : 'Delete card' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,25 +59,6 @@ async function onDelete() {
|
|||||||
{{ card.status?.name ?? 'No status' }}
|
{{ card.status?.name ?? 'No status' }}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</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>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user