Files
FabledCurator/frontend/src/components/posts/PostSeriesMenu.vue
T
bvandeusen 7bb765b6ed
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m17s
feat(series): pending staging for add-from-post (#789 Phase 2)
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>
2026-06-11 21:47:58 -04:00

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>