7bb765b6ed
Add-from-post no longer appends straight into the run — it STAGES the post's
pages as pending (per-page status; page_number NULL), grouped by source post,
so the operator drops junk (text-free alts, bumpers) and places the keepers
into the sequence with clean series-global numbering.
- migration 0048: series_page.status ('placed' default | 'pending') + nullable
page_number.
- series_service: placed/pending split everywhere (list_pages returns the
placed run + a `pending` section grouped by source post; reorder/cover/
list_series operate on placed only); add_post stages pending; new
place_pending(image_ids, before_image_id=None) flips pending→placed spliced
before a page (or appended) and renumbers; junk removal reuses remove_images.
- api/tags: /add-post now returns staged count; new POST /series/<id>/pending/
place.
- frontend: PostSeriesMenu navigates to the series after staging; seriesManage
store surfaces `pending` + placePending; SeriesManageView gains a pending
tray (per-post groups, place-all / place-one / drop-junk).
- tests: pending staging, place (append + insert-before), ignore-already-
placed, drop-junk, route guard; updated add_post + match-accept expectations.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
136 lines
4.4 KiB
Vue
136 lines
4.4 KiB
Vue
<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">
|
|
Adds this post's pages to the end of the chosen series, ready to drag
|
|
into place.
|
|
</p>
|
|
<v-autocomplete
|
|
v-model="picked"
|
|
v-model:menu="pickerMenuOpen"
|
|
:items="hits"
|
|
:item-title="(s) => s.name"
|
|
:item-value="(s) => s.id"
|
|
:loading="searching"
|
|
label="Series" density="compact" autofocus clearable
|
|
@keydown.enter.capture="onEnter"
|
|
/>
|
|
</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 { useAcceptOnEnter } from '../../composables/useAcceptOnEnter.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)
|
|
|
|
// Enter on the closed dropdown adds the chosen series instead of re-opening it.
|
|
// (aliased — `menuOpen` above is the outer "Series ▾" v-menu's state.)
|
|
const { menuOpen: pickerMenuOpen, onEnter } = useAcceptOnEnter(() => {
|
|
if (picked.value != null && !busy.value) onAddExisting()
|
|
})
|
|
|
|
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: 'Staged — sort the new pages into the series', type: 'success' })
|
|
// Take the operator to the series so they can drop junk + place the pages.
|
|
router.push({ name: 'series-manage', params: { tagId: picked.value } })
|
|
} catch (e) {
|
|
toast({ text: `Could not add to series: ${e.message}`, type: 'error' })
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
</script>
|