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>
This commit is contained in:
@@ -123,7 +123,9 @@ async function onAddExisting() {
|
||||
body: { post_id: props.post.id }
|
||||
})
|
||||
pickerOpen.value = false
|
||||
toast({ text: 'Added to series', type: 'success' })
|
||||
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 {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
const series = ref(null)
|
||||
const pages = ref([]) // flat, ordered: [{image_id, page_number, stated_page, thumbnail_url, image_url, source_post}]
|
||||
const dividers = ref([]) // [{id, anchor_image_id, page_number, title, stated_part}]
|
||||
const pending = ref([]) // staged-from-post: [{post:{id,title}|null, pages:[{image_id, stated_page, thumbnail_url, image_url}]}]
|
||||
const partGaps = ref([]) // [{after_divider_id, start, end}]
|
||||
const pageCount = ref(0)
|
||||
const picker = ref([]) // gallery scroll results
|
||||
@@ -35,6 +36,7 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
series.value = body.series
|
||||
pages.value = body.pages || []
|
||||
dividers.value = body.dividers || []
|
||||
pending.value = body.pending || []
|
||||
partGaps.value = body.part_gaps || []
|
||||
pageCount.value = pages.value.length
|
||||
} finally {
|
||||
@@ -105,6 +107,15 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
toast({ text: 'Chapter divider removed', type: 'success' })
|
||||
}
|
||||
|
||||
// ---- pending staging (add-from-post) ----
|
||||
async function placePending(imageIds, beforeImageId = null) {
|
||||
await api.post(`/api/series/${tagId.value}/pending/place`, {
|
||||
body: { image_ids: imageIds, before_image_id: beforeImageId }
|
||||
})
|
||||
await refresh()
|
||||
toast({ text: 'Pages placed into the series', type: 'success' })
|
||||
}
|
||||
|
||||
// ---- picker / add pages ----
|
||||
async function loadPicker(reset = false) {
|
||||
if (reset) { picker.value = []; pickerCursor.value = null }
|
||||
@@ -132,10 +143,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
tagId, series, pages, dividers, partGaps, pageCount,
|
||||
tagId, series, pages, dividers, pending, partGaps, pageCount,
|
||||
picker, pickerCursor, pickerSelection, loading,
|
||||
load, refresh, dividerAt, partGapAfter,
|
||||
reorder, remove, setCover,
|
||||
reorder, remove, setCover, placePending,
|
||||
createDivider, renameDivider, setDividerPart, deleteDivider,
|
||||
loadPicker, togglePick, addSelected
|
||||
}
|
||||
|
||||
@@ -32,6 +32,51 @@
|
||||
be dragged into place.
|
||||
</p>
|
||||
|
||||
<!-- Pending tray: pages staged from a post, grouped by source post, for the
|
||||
operator to drop junk and place into the run. -->
|
||||
<div v-if="store.pending.length" class="fc-pending">
|
||||
<div class="fc-pending__head">
|
||||
<v-icon size="small">mdi-tray-arrow-down</v-icon>
|
||||
<span>Pending — sort into the series</span>
|
||||
</div>
|
||||
<p class="fc-pending__hint">
|
||||
New pages from a post land here. Drop the ones that don't belong
|
||||
(text-free alternates, bumpers), then place the rest into the run.
|
||||
</p>
|
||||
<section v-for="(grp, gi) in store.pending" :key="gi" class="fc-pgroup">
|
||||
<header class="fc-pgroup__head">
|
||||
<span class="fc-pgroup__title" :title="grp.post?.title">
|
||||
<v-icon size="x-small">mdi-link-variant</v-icon>
|
||||
{{ grp.post?.title || 'Unknown post' }}
|
||||
</span>
|
||||
<span class="fc-pgroup__count">{{ grp.pages.length }} pending</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-playlist-plus"
|
||||
@click="store.placePending(grp.pages.map(p => p.image_id))"
|
||||
>Place all into series</v-btn>
|
||||
</header>
|
||||
<div class="fc-pgroup__pages">
|
||||
<div v-for="p in grp.pages" :key="p.image_id" class="fc-ppage">
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<div class="fc-ppage__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="flat" icon="mdi-playlist-plus"
|
||||
title="Place this page into the series"
|
||||
@click="store.placePending([p.image_id])"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="flat" icon="mdi-close"
|
||||
title="Drop — doesn't belong in the series"
|
||||
@click="store.remove(p.image_id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- One continuous page run; chapter dividers render inline before their
|
||||
anchor page. -->
|
||||
<div v-if="store.pages.length" class="fc-run">
|
||||
@@ -337,6 +382,57 @@ onMounted(async () => {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
/* Pending tray */
|
||||
.fc-pending {
|
||||
max-width: 1100px; margin-bottom: 18px;
|
||||
border: 1px solid rgb(var(--v-theme-accent), 0.4);
|
||||
border-radius: 10px; padding: 10px 14px;
|
||||
background: rgb(var(--v-theme-accent), 0.06);
|
||||
}
|
||||
.fc-pending__head {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 16px;
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-pending__hint {
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
.fc-pgroup { margin-bottom: 12px; }
|
||||
.fc-pgroup__head {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 6px;
|
||||
}
|
||||
.fc-pgroup__title {
|
||||
display: inline-flex; align-items: center; gap: 4px; max-width: 360px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.fc-pgroup__count {
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-pgroup__pages {
|
||||
display: grid; gap: 8px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
}
|
||||
.fc-ppage {
|
||||
position: relative; border-radius: 6px; overflow: hidden;
|
||||
background: rgb(var(--v-theme-surface-light));
|
||||
border: 1px dashed rgb(var(--v-theme-accent), 0.5);
|
||||
}
|
||||
.fc-ppage img {
|
||||
width: 100%; aspect-ratio: 3 / 4; object-fit: contain; display: block;
|
||||
background: rgb(var(--v-theme-background));
|
||||
}
|
||||
.fc-ppage__actions {
|
||||
position: absolute; top: 4px; right: 4px; display: flex; gap: 2px;
|
||||
opacity: 0; transition: opacity 0.12s;
|
||||
}
|
||||
.fc-ppage:hover .fc-ppage__actions,
|
||||
.fc-ppage:focus-within .fc-ppage__actions { opacity: 1; }
|
||||
.fc-ppage__actions :deep(.v-btn) {
|
||||
background: rgba(0, 0, 0, 0.55); color: #fff;
|
||||
}
|
||||
|
||||
/* Picker slide-over */
|
||||
.fc-picker__head {
|
||||
position: sticky; top: 0; z-index: 1; padding: 12px 14px;
|
||||
|
||||
Reference in New Issue
Block a user