feat(series): browse search + per-card kebab (rename/delete)
The Series browse tab had no way to find a series in a long grid and no per-series actions. Add a search field (instant client-side name/artist filter over the already-loaded list) and a kebab on each card with Rename (reuses TagRenameDialog → PATCH /api/tags/<id>, with its collision-merge flow) and Delete (confirm dialog → DELETE /api/admin/tags/<id>; series_page/chapter/ suggestion cascade, images kept). Gap badge moved to the cover's top-left so the kebab can sit top-right. Operator-asked 2026-06-09. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,5 +27,22 @@ export const useSeriesBrowseStore = defineStore('seriesBrowse', () => {
|
|||||||
return load()
|
return load()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { series, sort, artistId, loading, error, load, setSort }
|
// A series IS a Tag(kind=series); deleting it removes the series structure
|
||||||
|
// (pages/chapters cascade) via the shared admin tag-delete. Splice the card
|
||||||
|
// out for instant feedback rather than reloading the whole grid.
|
||||||
|
async function remove(id) {
|
||||||
|
await api.delete(`/api/admin/tags/${id}`)
|
||||||
|
series.value = series.value.filter(s => s.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect an in-place rename (PATCH /api/tags/<id>) without a full reload.
|
||||||
|
function applyRename(id, name) {
|
||||||
|
const row = series.value.find(s => s.id === id)
|
||||||
|
if (row) row.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
series, sort, artistId, loading, error,
|
||||||
|
load, setSort, remove, applyRename,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
<!-- ---- Browse ---- -->
|
<!-- ---- Browse ---- -->
|
||||||
<v-window-item value="browse">
|
<v-window-item value="browse">
|
||||||
<div class="fc-series-browse__controls">
|
<div class="fc-series-browse__controls">
|
||||||
|
<v-text-field
|
||||||
|
v-model="query"
|
||||||
|
density="compact" variant="outlined" hide-details clearable
|
||||||
|
label="Search series" prepend-inner-icon="mdi-magnify"
|
||||||
|
style="max-width: 320px;"
|
||||||
|
/>
|
||||||
<v-select
|
<v-select
|
||||||
:model-value="store.sort"
|
:model-value="store.sort"
|
||||||
:items="sortItems"
|
:items="sortItems"
|
||||||
@@ -34,10 +40,16 @@
|
|||||||
No series yet. Make one from a post's “Series ▾ → New series from this
|
No series yet. Make one from a post's “Series ▾ → New series from this
|
||||||
post”, or create a <code>series:</code> tag and add images.
|
post”, or create a <code>series:</code> tag and add images.
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!store.loading && visibleSeries.length === 0"
|
||||||
|
class="fc-series-browse__empty"
|
||||||
|
>
|
||||||
|
No series match “{{ query }}”.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fc-series-browse__grid">
|
<div class="fc-series-browse__grid">
|
||||||
<article
|
<article
|
||||||
v-for="s in store.series" :key="s.id" class="fc-sbcard"
|
v-for="s in visibleSeries" :key="s.id" class="fc-sbcard"
|
||||||
@click="manage(s.id)"
|
@click="manage(s.id)"
|
||||||
>
|
>
|
||||||
<div class="fc-sbcard__cover">
|
<div class="fc-sbcard__cover">
|
||||||
@@ -48,6 +60,27 @@
|
|||||||
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
||||||
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
||||||
</span>
|
</span>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="menuProps"
|
||||||
|
icon="mdi-dots-vertical" size="x-small" variant="flat"
|
||||||
|
class="fc-sbcard__kebab" :aria-label="`Actions for ${s.name}`"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
|
||||||
|
<v-list-item-title>Rename</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-delete" base-color="error"
|
||||||
|
@click.stop="openDelete(s)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Delete</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-sbcard__body">
|
<div class="fc-sbcard__body">
|
||||||
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
||||||
@@ -73,6 +106,38 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-dialog v-model="renameDialog" max-width="420">
|
||||||
|
<TagRenameDialog
|
||||||
|
v-if="renameTarget"
|
||||||
|
:tag="renameTarget"
|
||||||
|
@renamed="onRenamed" @cancel="renameDialog = false"
|
||||||
|
/>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="440">
|
||||||
|
<v-card v-if="deleteTarget">
|
||||||
|
<v-card-title class="text-body-1">Delete “{{ deleteTarget.name }}”?</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="text-body-2">
|
||||||
|
Removes the series and its chapter/page ordering. The images
|
||||||
|
themselves are kept — only the series grouping is deleted.
|
||||||
|
</p>
|
||||||
|
<v-alert
|
||||||
|
v-if="deleteError" type="error" variant="tonal"
|
||||||
|
density="compact" class="mt-3"
|
||||||
|
>{{ deleteError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn :disabled="deleting" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
:loading="deleting" @click="confirmDelete"
|
||||||
|
>Delete series</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<div v-if="store.loading" class="fc-series-browse__sentinel">
|
<div v-if="store.loading" class="fc-series-browse__sentinel">
|
||||||
<v-progress-circular indeterminate color="accent" size="28" />
|
<v-progress-circular indeterminate color="accent" size="28" />
|
||||||
</div>
|
</div>
|
||||||
@@ -140,10 +205,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
||||||
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
||||||
|
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useSeriesBrowseStore()
|
const store = useSeriesBrowseStore()
|
||||||
@@ -156,6 +222,18 @@ const sortItems = [
|
|||||||
{ title: 'Size', value: 'size' }
|
{ title: 'Size', value: 'size' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Client-side name/artist filter — the whole list is already loaded (homelab
|
||||||
|
// scale), so search is instant and needs no round-trip.
|
||||||
|
const query = ref('')
|
||||||
|
const visibleSeries = computed(() => {
|
||||||
|
const q = (query.value || '').trim().toLowerCase()
|
||||||
|
if (!q) return store.series
|
||||||
|
return store.series.filter(s =>
|
||||||
|
s.name?.toLowerCase().includes(q)
|
||||||
|
|| s.artist_name?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function manage(id) {
|
function manage(id) {
|
||||||
router.push({ name: 'series-manage', params: { tagId: id } })
|
router.push({ name: 'series-manage', params: { tagId: id } })
|
||||||
}
|
}
|
||||||
@@ -163,6 +241,44 @@ function read(id) {
|
|||||||
router.push({ name: 'series-read', params: { tagId: id } })
|
router.push({ name: 'series-read', params: { tagId: id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- per-series kebab: rename + delete ----------------------------------
|
||||||
|
const renameDialog = ref(false)
|
||||||
|
const renameTarget = ref(null)
|
||||||
|
function openRename(s) {
|
||||||
|
// TagRenameDialog renames the underlying Tag(kind=series); pass the kind so
|
||||||
|
// its collision copy reads correctly.
|
||||||
|
renameTarget.value = { id: s.id, name: s.name, kind: 'series' }
|
||||||
|
renameDialog.value = true
|
||||||
|
}
|
||||||
|
function onRenamed(updated) {
|
||||||
|
renameDialog.value = false
|
||||||
|
if (updated?.id != null && updated?.name) {
|
||||||
|
store.applyRename(updated.id, updated.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDialog = ref(false)
|
||||||
|
const deleteTarget = ref(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref(null)
|
||||||
|
function openDelete(s) {
|
||||||
|
deleteTarget.value = s
|
||||||
|
deleteError.value = null
|
||||||
|
deleteDialog.value = true
|
||||||
|
}
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
try {
|
||||||
|
await store.remove(deleteTarget.value.id)
|
||||||
|
deleteDialog.value = false
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e.message
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pct(v) { return `${Math.round((v || 0) * 100)}%` }
|
function pct(v) { return `${Math.round((v || 0) * 100)}%` }
|
||||||
// Only the signals that actually fired (strength > 0), in a stable order.
|
// Only the signals that actually fired (strength > 0), in a stable order.
|
||||||
function signalKeys(s) {
|
function signalKeys(s) {
|
||||||
@@ -178,7 +294,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-series-browse__controls {
|
.fc-series-browse__controls {
|
||||||
display: flex; gap: 12px; margin-bottom: 16px;
|
display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.fc-series-browse__empty {
|
.fc-series-browse__empty {
|
||||||
padding: 48px; text-align: center;
|
padding: 48px; text-align: center;
|
||||||
@@ -209,12 +325,20 @@ onMounted(() => {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-sbcard__gap {
|
.fc-sbcard__gap {
|
||||||
position: absolute; top: 6px; right: 6px;
|
position: absolute; top: 6px; left: 6px;
|
||||||
display: inline-flex; align-items: center; gap: 2px;
|
display: inline-flex; align-items: center; gap: 2px;
|
||||||
padding: 1px 6px; border-radius: 999px; font-size: 11px;
|
padding: 1px 6px; border-radius: 999px; font-size: 11px;
|
||||||
background: rgba(20, 23, 26, 0.75);
|
background: rgba(20, 23, 26, 0.75);
|
||||||
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
||||||
}
|
}
|
||||||
|
/* Kebab sits top-right of the cover, opposite the gap badge. Tinted backing so
|
||||||
|
it stays legible over any cover image. */
|
||||||
|
.fc-sbcard__kebab {
|
||||||
|
position: absolute; top: 4px; right: 4px;
|
||||||
|
background: rgba(20, 23, 26, 0.6) !important;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-sbcard__kebab:hover { background: rgba(20, 23, 26, 0.85) !important; }
|
||||||
.fc-sbcard__body { padding: 8px 10px; }
|
.fc-sbcard__body { padding: 8px 10px; }
|
||||||
.fc-sbcard__name {
|
.fc-sbcard__name {
|
||||||
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
|
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
|
||||||
|
|||||||
Reference in New Issue
Block a user