feat(series): flat series sequence + cosmetic chapter dividers (#789 Phase 1)
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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user