feat(series): chapter-aware manage view + reader — frontend (FC-6.1)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 3m5s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 26s

Completes FC-6.1: the series management UI now works in chapters.

- SeriesManageView: chapters as cards (inline-rename, stated-page range inputs,
  move up/down, merge-into-previous, delete, pick-as-add-target), pages
  drag-reorder WITHIN a chapter, a "gap: N-M missing" badge between chapters
  with a stated-page hole, and Add chapter / Add placeholder. The picker adds
  the selection into the targeted chapter.
- seriesManage store: chapter CRUD + reorderChapters/moveChapter/mergeChapter/
  reorderPages actions; consumes chapters[]/gaps[]; addSelected targets a chapter.
- Reader: page_number is now within-chapter, so anchors switched to a global
  `seq` (reading-order position) — fixes scroll/jump/active collisions across
  chapters — plus chapter-title dividers at each chapter boundary.
- Updated seriesManage.spec to the chaptered store shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:43:10 -04:00
parent 1804a2c622
commit 8ad40da145
5 changed files with 385 additions and 93 deletions
+83 -14
View File
@@ -16,7 +16,10 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
const tagId = ref(null)
const series = ref(null)
const pages = ref([]) // [{image_id, page_number, thumbnail_url}]
const chapters = ref([]) // [{id, chapter_number, title, is_placeholder, stated_page_start/end, pages:[...]}]
const gaps = ref([]) // [{after_chapter_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
@@ -28,7 +31,14 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
try {
const body = await api.get(`/api/series/${id}/pages`)
series.value = body.series
pages.value = body.pages
chapters.value = body.chapters || []
gaps.value = body.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
}
} finally {
loading.value = false
}
@@ -38,6 +48,68 @@ 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
}
// ---- 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 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`, {
body: { image_ids: orderedImageIds }
})
await refresh()
}
// ---- picker / pages ----
async function loadPicker(reset = false) {
if (reset) { picker.value = []; pickerCursor.value = null }
const params = { limit: 50 }
@@ -55,12 +127,13 @@ 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 }
body: { image_ids: pickerSelection.value, chapter_id: targetChapterId.value }
})
pickerSelection.value = []
await refresh()
toast({ text: 'Added to series', type: 'success' })
toast({ text: 'Added to chapter', type: 'success' })
}
async function remove(imageId) {
@@ -70,13 +143,6 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
await refresh()
}
async function reorder(orderedImageIds) {
await api.post(`/api/series/${tagId.value}/reorder`, {
body: { image_ids: orderedImageIds }
})
await refresh()
}
async function setCover(imageId) {
await api.post(`/api/series/${tagId.value}/cover`, {
body: { image_id: imageId }
@@ -85,8 +151,11 @@ export const useSeriesManageStore = defineStore('seriesManage', () => {
}
return {
tagId, series, pages, picker, pickerCursor, pickerSelection, loading,
load, refresh, loadPicker, togglePick, addSelected, remove,
reorder, setCover
tagId, series, chapters, gaps, pageCount, targetChapterId,
picker, pickerCursor, pickerSelection, loading,
load, refresh, gapAfter,
createChapter, renameChapter, setChapterStated, reorderChapters,
moveChapter, deleteChapter, mergeChapter, reorderPages,
loadPicker, togglePick, addSelected, remove, setCover
}
})
+21 -1
View File
@@ -42,7 +42,27 @@ export const useSeriesReaderStore = defineStore('seriesReader', () => {
await run(async () => {
const body = await api.get(`/api/series/${tagId}/pages`)
series.value = body.series
pages.value = body.pages
// 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}`
}
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
}
})
})
}