2026-09-05 00:46:17 +01:00
|
|
|
<script setup lang="ts">
|
2026-09-05 01:04:50 +01:00
|
|
|
import { computed, ref, watch } from 'vue'
|
|
|
|
|
import draggable from 'vuedraggable'
|
2026-09-05 00:46:17 +01:00
|
|
|
import CardRow from '../components/CardRow.vue'
|
|
|
|
|
import { ApiError } from '../lib/api'
|
|
|
|
|
import { useCardsStore } from '../stores/cards'
|
2026-09-05 01:04:50 +01:00
|
|
|
import type { Card } from '../types'
|
2026-09-05 00:46:17 +01:00
|
|
|
|
|
|
|
|
// 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'}.`
|
|
|
|
|
})
|
|
|
|
|
|
2026-09-05 01:04:50 +01:00
|
|
|
// No manual order here -- sorted by name, case-insensitively -- but a plain
|
|
|
|
|
// computed can't be handed to <draggable> (it splices its bound list in
|
|
|
|
|
// place as the user drags; a computed would just be recalculated over that
|
|
|
|
|
// and discard it). A ref rebuilt on every underlying change gives it
|
|
|
|
|
// something real to mutate, the same way the kanban board's columns do.
|
|
|
|
|
const sortedCards = ref<Card[]>([])
|
|
|
|
|
function rebuildSortedCards() {
|
|
|
|
|
sortedCards.value = [...cards.cards].sort((a, b) => a.text.localeCompare(b.text, undefined, { sensitivity: 'base' }))
|
|
|
|
|
}
|
|
|
|
|
watch(() => cards.cards, rebuildSortedCards, { deep: true, immediate: true })
|
2026-09-05 00:46:17 +01:00
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
|
2026-09-05 01:04:50 +01:00
|
|
|
<!-- No handler needed: dragging a card out (the only thing possible --
|
|
|
|
|
put: false, sort: false) splices it out of `sortedCards` locally, and
|
|
|
|
|
wherever it lands (the sidebar's inbox) persists the move and
|
|
|
|
|
reloads this project's cards, which rebuilds this list from the
|
|
|
|
|
authoritative result either way. -->
|
|
|
|
|
<draggable
|
|
|
|
|
v-else-if="sortedCards.length"
|
|
|
|
|
:list="sortedCards"
|
|
|
|
|
:group="{ name: 'kanban', put: false }"
|
|
|
|
|
:sort="false"
|
|
|
|
|
item-key="id"
|
|
|
|
|
tag="ul"
|
|
|
|
|
class="cards"
|
|
|
|
|
ghost-class="card-row--ghost"
|
|
|
|
|
:animation="150"
|
|
|
|
|
>
|
|
|
|
|
<template #item="{ element: card }: { element: Card }">
|
|
|
|
|
<CardRow :card="card" />
|
|
|
|
|
</template>
|
|
|
|
|
</draggable>
|
2026-09-05 00:46:17 +01:00
|
|
|
|
|
|
|
|
<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>
|