feat(series): chapter-aware manage view + reader — frontend (FC-6.1)
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:
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user