feat(series): flat series sequence + cosmetic chapter dividers (#789 Phase 1)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m13s

Reframe a series from "ordered chapters that own pages" to ONE flat,
series-global ordered run of pages with optional cosmetic chapter DIVIDERS
over it. A chapter no longer wraps content — it's a labeled divider anchored
to the page that begins it; a page's chapter is derived as the nearest
preceding divider. This is what lets installments assembled from multiple
sources sit in one continuous, correctly-numbered sequence (operator's
Goblin Juice case).

- migration 0047: flatten each series to a series-global page_number
  (preserving today's reading order); convert each existing chapter to a
  divider anchored at its first page (keeping title/stated_part); drop
  series_page.chapter_id; reshape series_chapter (anchor_page_id UNIQUE FK,
  drop chapter_number/is_placeholder/stated_page_start/end). Loss-safe for
  content; drops empty placeholder chapters + a redundant page-1 divider.
- series_page: page_number is now the series-global order; no chapter_id.
- series_chapter: anchored divider (anchor_page_id, title, stated_part).
- series_service: flat list_pages (one run + derived dividers + per-page
  source_post + part_gaps), series-wide reorder/renumber, divider CRUD
  (create/update/move/delete); retired per-chapter reorder/merge/placement.
- api/tags: drop chapter_id from add; /chapters endpoints are divider
  create/update/delete (removed chapter reorder/merge/page-reorder).
- series_match_service: series "end" reads max(series_page.stated_page);
  accept appends via add_post. tag_service series-merge appends src's pages
  after tgt's max so the merged series stays one clean run.
- frontend: seriesManage store + SeriesManageView → one continuous
  drag-reorder grid with inline divider bars + series-global page numbers;
  reader walks the flat run, headings from dividers; PostSeriesMenu copy.
- tests reworked across the series suite for the divider model.

Phase 2 (pending staging for add-from-post) is separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:30:01 -04:00
parent 3610ba495f
commit 59746d213d
18 changed files with 914 additions and 1141 deletions
+66 -98
View File
@@ -11,16 +11,17 @@ export function moveItem(arr, from, to) {
return next
}
// FC-6.x: a series is ONE flat, series-global ordered run of pages, with
// optional cosmetic chapter DIVIDERS anchored to the page that begins them.
export const useSeriesManageStore = defineStore('seriesManage', () => {
const api = useApi()
const tagId = ref(null)
const series = ref(null)
const chapters = ref([]) // [{id, chapter_number, stated_part, title, is_placeholder, stated_page_start/end, source_post, pages:[...]}]
const gaps = ref([]) // missing-page gaps: [{after_chapter_id, start, end}]
const partGaps = ref([]) // missing-Part gaps: [{after_chapter_id, start, end}]
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 partGaps = ref([]) // [{after_divider_id, start, end}]
const pageCount = ref(0)
const targetChapterId = ref(null) // which chapter the picker adds into
const picker = ref([]) // gallery scroll results
const pickerCursor = ref(null)
const pickerSelection = ref([]) // image ids
@@ -32,15 +33,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
try {
const body = await api.get(`/api/series/${id}/pages`)
series.value = body.series
chapters.value = body.chapters || []
gaps.value = body.gaps || []
pages.value = body.pages || []
dividers.value = body.dividers || []
partGaps.value = body.part_gaps || []
pageCount.value = (body.pages || []).length
// Keep a valid add-target: the selected chapter, else the first one.
const ids = chapters.value.map(c => c.id)
if (!ids.includes(targetChapterId.value)) {
targetChapterId.value = ids.length ? ids[0] : null
}
pageCount.value = pages.value.length
} finally {
loading.value = false
}
@@ -50,79 +46,66 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
if (tagId.value != null) await load(tagId.value)
}
function gapAfter(chapterId) {
return gaps.value.find(g => g.after_chapter_id === chapterId) || null
// The divider anchored at a given page (shown as a chapter bar before it).
function dividerAt(imageId) {
return dividers.value.find(d => d.anchor_image_id === imageId) || null
}
function partGapAfter(chapterId) {
return partGaps.value.find(g => g.after_chapter_id === chapterId) || null
function partGapAfter(dividerId) {
return partGaps.value.find(g => g.after_divider_id === dividerId) || null
}
// ---- chapters ----
async function createChapter({ title = null, isPlaceholder = false } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
body: { title, is_placeholder: isPlaceholder }
})
await refresh()
}
async function renameChapter(chapterId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { title }
})
await refresh()
}
async function setChapterPart(chapterId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_part: part }
})
await refresh()
}
async function setChapterStated(chapterId, start, end) {
await api.patch(`/api/series/${tagId.value}/chapters/${chapterId}`, {
body: { stated_page_start: start, stated_page_end: end }
})
await refresh()
}
async function reorderChapters(orderedChapterIds) {
await api.post(`/api/series/${tagId.value}/chapters/reorder`, {
body: { chapter_ids: orderedChapterIds }
})
await refresh()
}
async function moveChapter(chapterId, dir) {
const ids = chapters.value.map(c => c.id)
const from = ids.indexOf(chapterId)
const to = from + dir
if (from === -1 || to < 0 || to >= ids.length) return
await reorderChapters(moveItem(ids, from, to))
}
async function deleteChapter(chapterId) {
await api.delete(`/api/series/${tagId.value}/chapters/${chapterId}`)
await refresh()
}
async function mergeChapter(sourceId, targetId) {
await api.post(`/api/series/${tagId.value}/chapters/${sourceId}/merge`, {
body: { target_chapter_id: targetId }
})
await refresh()
toast({ text: 'Chapters merged', type: 'success' })
}
async function reorderPages(chapterId, orderedImageIds) {
await api.post(`/api/series/${tagId.value}/chapters/${chapterId}/reorder`, {
// ---- pages (series-wide) ----
async function reorder(orderedImageIds) {
await api.post(`/api/series/${tagId.value}/reorder`, {
body: { image_ids: orderedImageIds }
})
await refresh()
}
// ---- picker / pages ----
async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
})
await refresh()
}
// ---- chapter dividers ----
async function createDivider(anchorImageId, { title = null, statedPart = null } = {}) {
await api.post(`/api/series/${tagId.value}/chapters`, {
body: { anchor_image_id: anchorImageId, title, stated_part: statedPart }
})
await refresh()
}
async function renameDivider(dividerId, title) {
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
body: { title }
})
await refresh()
}
async function setDividerPart(dividerId, part) {
await api.patch(`/api/series/${tagId.value}/chapters/${dividerId}`, {
body: { stated_part: part }
})
await refresh()
}
async function deleteDivider(dividerId) {
await api.delete(`/api/series/${tagId.value}/chapters/${dividerId}`)
await refresh()
toast({ text: 'Chapter divider removed', type: 'success' })
}
// ---- picker / add pages ----
async function loadPicker(reset = false) {
if (reset) { picker.value = []; pickerCursor.value = null }
const params = { limit: 50 }
@@ -140,35 +123,20 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
async function addSelected() {
if (pickerSelection.value.length === 0) return
if (targetChapterId.value == null) await createChapter()
await api.post(`/api/series/${tagId.value}/pages`, {
body: { image_ids: pickerSelection.value, chapter_id: targetChapterId.value }
body: { image_ids: pickerSelection.value }
})
pickerSelection.value = []
await refresh()
toast({ text: 'Added to chapter', type: 'success' })
}
async function remove(imageId) {
await api.post(`/api/series/${tagId.value}/pages/remove`, {
body: { image_ids: [imageId] }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
})
await refresh()
toast({ text: 'Added to series', type: 'success' })
}
return {
tagId, series, chapters, gaps, partGaps, pageCount, targetChapterId,
tagId, series, pages, dividers, partGaps, pageCount,
picker, pickerCursor, pickerSelection, loading,
load, refresh, gapAfter, partGapAfter,
createChapter, renameChapter, setChapterPart, setChapterStated,
reorderChapters, moveChapter, deleteChapter, mergeChapter, reorderPages,
loadPicker, togglePick, addSelected, remove, setCover
load, refresh, dividerAt, partGapAfter,
reorder, remove, setCover,
createDivider, renameDivider, setDividerPart, deleteDivider,
loadPicker, togglePick, addSelected
}
})
+16 -20
View File
@@ -42,27 +42,23 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series
// page_number is now WITHIN a chapter, so it can't anchor scroll/jump
// (two chapters both have a page 1). Decorate each page with a global
// `seq` (reading-order position) for anchors, plus chapter-divider info
// so the reader can mark where each chapter begins.
const chapters = body.chapters || []
const labelById = {}
for (const c of chapters) {
labelById[c.id] = c.title || `Chapter ${c.chapter_number}`
// FC-6.x: page_number is already the series-global reading order, so it
// anchors scroll/jump directly (seq = page_number). Chapters are cosmetic
// dividers anchored to a page; mark the page each divider begins on so the
// reader can render a chapter heading there.
const dividers = body.dividers || []
const anchorIds = new Set(dividers.map(d => d.anchor_image_id))
const labelByAnchor = {}
for (const d of dividers) {
labelByAnchor[d.anchor_image_id] =
d.title || (d.stated_part != null ? `Part ${d.stated_part}` : 'Chapter')
}
let prevChapter = null
pages.value = (body.pages || []).map((p, i) => {
const isChapterStart =
chapters.length > 1 && p.chapter_id !== prevChapter
prevChapter = p.chapter_id
return {
...p,
seq: i + 1,
isChapterStart,
chapterLabel: labelById[p.chapter_id] || null
}
})
pages.value = (body.pages || []).map((p) => ({
...p,
seq: p.page_number,
isChapterStart: anchorIds.has(p.image_id),
chapterLabel: labelByAnchor[p.image_id] || 'Chapter'
}))
})
}