feat(series): Add-to-series control + Series browse view + nav (FC-6.2)
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:
@@ -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>
|
||||
Reference in New Issue
Block a user