feat(series): Add-to-series control + Series browse view + nav (FC-6.2)
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 25s
CI / integration (push) Successful in 3m4s

Completes FC-6.2 with the UI.

- PostSeriesMenu: a "Series ▾" control on each post card — "New series from
  this post" (promote → navigates to manage) and "Add to existing series…"
  (dialog with a browsable picker loaded from GET /api/series, client-side
  filtered — avoids the empty-autocomplete #712 trap).
- SeriesView (/series): a top-level Series browse grid — cover, name, artist,
  chapter/page counts, gap badge; sort recent|name|size; cards → manage/read.
  meta.title adds it to the nav automatically (peer of Posts).
- seriesBrowse store for the list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 18:38:37 -04:00
parent db490e92df
commit 9e262cc5f0
5 changed files with 305 additions and 1 deletions
@@ -15,6 +15,7 @@
· {{ totalImages }} image{{ totalImages === 1 ? '' : 's' }}
</span>
<v-spacer />
<PostSeriesMenu :post="post" />
<v-btn
v-if="post.post_url"
:href="post.post_url" target="_blank" rel="noopener"
@@ -101,6 +102,7 @@ import { useModalStore } from '../../stores/modal.js'
import { usePostsStore } from '../../stores/posts.js'
import { toPlainText } from '../../utils/htmlSanitize.js'
import PostEmptyThumbs from './PostEmptyThumbs.vue'
import PostSeriesMenu from './PostSeriesMenu.vue'
const props = defineProps({
post: { type: Object, required: true },
@@ -0,0 +1,123 @@
<template>
<span class="fc-post-series">
<v-btn
size="x-small" variant="text" prepend-icon="mdi-book-plus-outline"
append-icon="mdi-menu-down"
:aria-label="`Add post ${post.id} to a series`"
@click.stop="menuOpen = !menuOpen"
>Series</v-btn>
<v-menu v-model="menuOpen" activator="parent" :open-on-click="false">
<v-list density="compact">
<v-list-item :disabled="busy" @click="onPromote">
<template #prepend>
<v-icon size="small">mdi-book-plus</v-icon>
</template>
<v-list-item-title>New series from this post</v-list-item-title>
</v-list-item>
<v-list-item @click="openPicker">
<template #prepend>
<v-icon size="small">mdi-playlist-plus</v-icon>
</template>
<v-list-item-title>Add to existing series</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="pickerOpen" max-width="460">
<v-card>
<v-card-title>Add to series</v-card-title>
<v-card-text>
<p class="text-caption mb-2">
Appends this post as the next chapter of the chosen series.
</p>
<v-autocomplete
v-model="picked"
:items="hits"
:item-title="(s) => s.name"
:item-value="(s) => s.id"
:loading="searching"
label="Series" density="compact" autofocus clearable
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="pickerOpen = false">Cancel</v-btn>
<v-btn
color="primary" rounded="pill"
:disabled="picked == null || busy" @click="onAddExisting"
>Add</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</span>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useApi } from '../../composables/useApi.js'
import { toast } from '../../utils/toast.js'
const props = defineProps({ post: { type: Object, required: true } })
const api = useApi()
const router = useRouter()
const menuOpen = ref(false)
const busy = ref(false)
const pickerOpen = ref(false)
const picked = ref(null)
const hits = ref([])
const searching = ref(false)
async function onPromote() {
menuOpen.value = false
busy.value = true
try {
const out = await api.post('/api/series/from-post', {
body: { post_id: props.post.id }
})
toast({ text: `Series created: ${out.name}`, type: 'success' })
router.push({ name: 'series-manage', params: { tagId: out.series_tag_id } })
} catch (e) {
toast({ text: `Could not create series: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
}
async function openPicker() {
menuOpen.value = false
picked.value = null
hits.value = []
pickerOpen.value = true
// Load the full series list so the picker is browsable (the tag autocomplete
// returns nothing for an empty query — the #712 trap). v-autocomplete filters
// client-side by name as the operator types.
searching.value = true
try {
const body = await api.get('/api/series', { params: { sort: 'name' } })
hits.value = body.series || []
} catch (e) {
toast({ text: `Could not load series: ${e.message}`, type: 'error' })
} finally {
searching.value = false
}
}
async function onAddExisting() {
if (picked.value == null) return
busy.value = true
try {
await api.post(`/api/series/${picked.value}/add-post`, {
body: { post_id: props.post.id }
})
pickerOpen.value = false
toast({ text: 'Added to series', type: 'success' })
} catch (e) {
toast({ text: `Could not add to series: ${e.message}`, type: 'error' })
} finally {
busy.value = false
}
}
</script>
+4 -1
View File
@@ -4,6 +4,7 @@ import GalleryView from './views/GalleryView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
import TagsView from './views/TagsView.vue'
import ArtistView from './views/ArtistView.vue'
import SeriesView from './views/SeriesView.vue'
import SeriesManageView from './views/SeriesManageView.vue'
import SeriesReaderView from './views/SeriesReaderView.vue'
import SubscriptionsView from './views/SubscriptionsView.vue'
@@ -25,7 +26,9 @@ const routes = [
{ path: '/tags', name: 'tags', component: TagsView, meta: { title: 'Tags' } },
// Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series management — no meta.title (reached from a series tag card).
// Series browse — a nav entry (meta.title). Peer of Posts.
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series' } },
// Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title).
{ path: '/series/:tagId/read', name: 'series-read', component: SeriesReaderView, meta: { immersive: true } },
+31
View File
@@ -0,0 +1,31 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { useApi } from '../composables/useApi.js'
import { useAsyncAction } from '../composables/useAsyncAction.js'
// Backs the Series browse view (FC-6.2). list_series returns every series as a
// card; homelab scale is small enough to load the whole list and sort/filter
// server-side.
export const useSeriesBrowseStore = defineStore('seriesBrowse', () => {
const api = useApi()
const series = ref([])
const sort = ref('recent')
const artistId = ref(null)
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
async function load() {
await run(async () => {
const params = { sort: sort.value }
if (artistId.value != null) params.artist_id = artistId.value
const body = await api.get('/api/series', { params })
series.value = body.series || []
})
}
function setSort(s) {
sort.value = s
return load()
}
return { series, sort, artistId, loading, error, load, setSort }
})
+145
View File
@@ -0,0 +1,145 @@
<template>
<v-container fluid class="pt-2 pb-6">
<div class="fc-series-browse__controls">
<v-select
:model-value="store.sort"
:items="sortItems"
density="compact" variant="outlined" hide-details
label="Sort" style="max-width: 220px;"
@update:model-value="store.setSort($event)"
/>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
Failed to load: {{ store.error }}
</v-alert>
<div
v-else-if="!store.loading && store.series.length === 0"
class="fc-series-browse__empty"
>
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.
</div>
<div class="fc-series-browse__grid">
<article
v-for="s in store.series" :key="s.id" class="fc-sbcard"
@click="manage(s.id)"
>
<div class="fc-sbcard__cover">
<img v-if="s.cover_thumbnail_url" :src="s.cover_thumbnail_url" alt="" loading="lazy" />
<div v-else class="fc-sbcard__cover--empty">
<v-icon size="large">mdi-book-open-page-variant</v-icon>
</div>
<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
</span>
</div>
<div class="fc-sbcard__body">
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
<div class="fc-sbcard__meta">
<span v-if="s.artist_name" class="fc-sbcard__artist">{{ s.artist_name }}</span>
<span class="fc-sbcard__counts">
{{ s.chapter_count }} ch · {{ s.page_count }} pg
</span>
</div>
<div class="fc-sbcard__actions">
<v-btn
size="x-small" variant="tonal" color="accent"
prepend-icon="mdi-book-open"
:disabled="s.page_count === 0"
@click.stop="read(s.id)"
>Read</v-btn>
<v-btn
size="x-small" variant="text"
prepend-icon="mdi-pencil" @click.stop="manage(s.id)"
>Manage</v-btn>
</div>
</div>
</article>
</div>
<div v-if="store.loading" class="fc-series-browse__sentinel">
<v-progress-circular indeterminate color="accent" size="28" />
</div>
</v-container>
</template>
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
const router = useRouter()
const store = useSeriesBrowseStore()
const sortItems = [
{ title: 'Recently updated', value: 'recent' },
{ title: 'Name', value: 'name' },
{ title: 'Size', value: 'size' }
]
function manage(id) {
router.push({ name: 'series-manage', params: { tagId: id } })
}
function read(id) {
router.push({ name: 'series-read', params: { tagId: id } })
}
onMounted(() => store.load())
</script>
<style scoped>
.fc-series-browse__controls {
display: flex; gap: 12px; margin-bottom: 16px;
}
.fc-series-browse__empty {
padding: 48px; text-align: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-series-browse__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 14px;
}
.fc-sbcard {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px; overflow: hidden; cursor: pointer;
background: rgb(var(--v-theme-surface));
transition: border-color 120ms ease, transform 120ms ease;
}
.fc-sbcard:hover {
border-color: rgb(var(--v-theme-accent), 0.6);
transform: translateY(-2px);
}
.fc-sbcard__cover {
position: relative; aspect-ratio: 3 / 2; background: rgb(var(--v-theme-surface-light));
}
.fc-sbcard__cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-sbcard__cover--empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-sbcard__gap {
position: absolute; top: 6px; right: 6px;
display: inline-flex; align-items: center; gap: 2px;
padding: 1px 6px; border-radius: 999px; font-size: 11px;
background: rgba(20, 23, 26, 0.75);
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-sbcard__body { padding: 8px 10px; }
.fc-sbcard__name {
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
margin: 0 0 4px; color: rgb(var(--v-theme-on-surface));
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-sbcard__meta {
display: flex; justify-content: space-between; gap: 8px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 8px;
}
.fc-sbcard__artist { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fc-sbcard__counts { flex: 0 0 auto; font-variant-numeric: tabular-nums; }
.fc-sbcard__actions { display: flex; gap: 6px; }
</style>