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
@@ -28,7 +28,8 @@
<v-card-title>Add to series</v-card-title>
<v-card-text>
<p class="text-caption mb-2">
Appends this post as the next chapter of the chosen series.
Adds this post's pages to the end of the chosen series, ready to drag
into place.
</p>
<v-autocomplete
v-model="picked"
+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'
}))
})
}
+148 -258
View File
@@ -9,9 +9,13 @@
@click="renameOpen = true"
/>
<span class="fc-series__count">
{{ store.chapters.length }} part(s) · {{ store.pageCount }} page(s)
{{ store.dividers.length }} chapter(s) · {{ store.pageCount }} page(s)
</span>
<v-spacer />
<v-btn
size="small" variant="tonal" prepend-icon="mdi-image-plus"
@click="pickerOpen = true"
>Add pages</v-btn>
<v-btn
v-if="store.pageCount > 0"
size="small" variant="tonal" color="accent"
@@ -21,161 +25,103 @@
</div>
<p class="fc-series__hint">
Each part is one installment drag pages to set their order, and set the
<strong>Part #</strong> to mark where it falls in the story. Use
<strong>Add pages</strong> to pull images from the gallery into the part
you're working on.
One continuous series. <strong>Drag any page</strong> to set its order
the numbers are the page's place in the whole series. Use a page's
<strong> Start a chapter here</strong> to drop a labeled divider before
it. New pages from <strong>Add pages</strong> append to the end, ready to
be dragged into place.
</p>
<!-- Parts, full width -->
<div class="fc-parts">
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
<section class="fc-part">
<header class="fc-part__head">
<label class="fc-part__partfield" :title="'Part number for this installment'">
<span class="fc-part__partlabel">Part</span>
<!-- One continuous page run; chapter dividers render inline before their
anchor page. -->
<div v-if="store.pages.length" class="fc-run">
<template v-for="(p, pi) in store.pages" :key="p.image_id">
<!-- chapter divider bar, if one is anchored at this page -->
<template v-if="store.dividerAt(p.image_id)">
<div class="fc-divider">
<v-icon size="small" class="fc-divider__icon">mdi-format-section</v-icon>
<label class="fc-divider__partfield" title="Part number for this chapter">
<span class="fc-divider__partlabel">Part</span>
<input
class="fc-part__partnum" type="number" min="1"
:value="partDraft[ch.id] ?? ''"
:placeholder="String(ch.chapter_number)"
@input="partDraft[ch.id] = $event.target.value"
@keydown.enter.prevent="commitPart(ch)"
@blur="commitPart(ch)"
class="fc-divider__partnum" type="number" min="1"
:value="partDraft[store.dividerAt(p.image_id).id] ?? ''"
placeholder="—"
@input="partDraft[store.dividerAt(p.image_id).id] = $event.target.value"
@keydown.enter.prevent="commitPart(store.dividerAt(p.image_id))"
@blur="commitPart(store.dividerAt(p.image_id))"
>
</label>
<v-text-field
v-model="titleDraft[ch.id]"
:placeholder="`Untitled — Part ${ch.stated_part ?? ch.chapter_number}`"
:model-value="titleDraft[store.dividerAt(p.image_id).id]"
placeholder="Untitled chapter"
density="compact" variant="plain" hide-details
class="fc-part__title"
@keydown.enter="commitTitle(ch)"
@blur="commitTitle(ch)"
class="fc-divider__title"
@update:model-value="titleDraft[store.dividerAt(p.image_id).id] = $event"
@keydown.enter="commitTitle(store.dividerAt(p.image_id))"
@blur="commitTitle(store.dividerAt(p.image_id))"
/>
<span
v-if="ch.source_post?.title" class="fc-part__src"
:title="ch.source_post.title"
>
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ ch.source_post.title }}
</span>
<span v-if="ch.is_placeholder" class="fc-part__badge">placeholder</span>
<span v-else class="fc-part__pc">{{ ch.pages.length }} pg</span>
<v-spacer />
<v-btn
v-if="!ch.is_placeholder"
size="small" variant="tonal" color="accent"
prepend-icon="mdi-image-plus"
@click="openPicker(ch.id)"
>Add pages</v-btn>
<KebabMenu size="small" label="Part actions">
<v-list-item
prepend-icon="mdi-chevron-up" title="Move up"
:disabled="ci === 0"
@click="store.moveChapter(ch.id, -1)"
/>
<v-list-item
prepend-icon="mdi-chevron-down" title="Move down"
:disabled="ci === store.chapters.length - 1"
@click="store.moveChapter(ch.id, 1)"
/>
<v-list-item
prepend-icon="mdi-arrow-collapse-up" title="Merge into previous"
:disabled="ci === 0"
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
/>
<v-divider />
<v-list-item
prepend-icon="mdi-delete-outline" title="Delete part"
base-color="error"
@click="confirmDelete(ch)"
/>
</KebabMenu>
</header>
<div class="fc-part__statedrow">
<span class="fc-part__statedlabel">Printed pages</span>
<input
class="fc-part__statedin" type="number" min="0"
:value="ch.stated_page_start ?? ''" placeholder="start"
@change="onStated(ch, 'start', $event)"
>
<span class="fc-part__dash"></span>
<input
class="fc-part__statedin" type="number" min="0"
:value="ch.stated_page_end ?? ''" placeholder="end"
@change="onStated(ch, 'end', $event)"
>
<span class="fc-part__statedhelp">
optional — the page numbers printed in this installment
</span>
size="x-small" variant="text" icon="mdi-close"
title="Remove this chapter divider"
@click="store.deleteDivider(store.dividerAt(p.image_id).id)"
/>
</div>
<div v-if="ch.is_placeholder" class="fc-part__reserved">
Reserved slot — a part you don't have yet.
<div
v-if="store.partGapAfter(store.dividerAt(p.image_id).id)"
class="fc-gap"
>
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="gapText(p.image_id).single">
Part {{ gapText(p.image_id).start }}
</template>
<template v-else>
Parts {{ gapText(p.image_id).start }}{{ gapText(p.image_id).end }}
</template>
</div>
<div v-else-if="ch.pages.length === 0" class="fc-part__empty">
No pages yet.
</template>
<!-- the page -->
<div
class="fc-page" draggable="true"
:class="{ 'fc-page--dragging': drag === pi }"
@dragstart="drag = pi"
@dragend="drag = null"
@dragover.prevent
@drop="onDrop(pi)"
>
<span class="fc-page__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<span
v-if="p.source_post?.title" class="fc-page__src"
:title="`From: ${p.source_post.title}`"
>
<v-icon size="x-small">mdi-link-variant</v-icon>
{{ p.source_post.title }}
</span>
<div class="fc-page__actions">
<v-btn
size="small" variant="text" color="accent"
prepend-icon="mdi-image-plus" @click="openPicker(ch.id)"
>Add pages</v-btn>
size="x-small" variant="flat" icon="mdi-image-frame"
title="Make series cover" @click="store.setCover(p.image_id)"
/>
<v-btn
v-if="!store.dividerAt(p.image_id)"
size="x-small" variant="flat" icon="mdi-format-section"
title="Start a chapter here" @click="store.createDivider(p.image_id)"
/>
<v-btn
size="x-small" variant="flat" icon="mdi-close"
title="Remove from series" @click="store.remove(p.image_id)"
/>
</div>
<div v-else class="fc-part__pages">
<div
v-for="(p, pi) in ch.pages" :key="p.image_id"
class="fc-page" draggable="true"
:class="{ 'fc-page--dragging': drag && drag.chapterId === ch.id && drag.idx === pi }"
@dragstart="drag = { chapterId: ch.id, idx: pi }"
@dragend="drag = null"
@dragover.prevent
@drop="onPageDrop(ch, pi)"
>
<span class="fc-page__pn">{{ p.page_number }}</span>
<img :src="p.thumbnail_url" alt="" loading="lazy" />
<div class="fc-page__actions">
<v-btn size="x-small" variant="flat" icon="mdi-image-frame"
title="Make series cover" @click="store.setCover(p.image_id)" />
<v-btn size="x-small" variant="flat" icon="mdi-close"
title="Remove from series" @click="store.remove(p.image_id)" />
</div>
</div>
</div>
</section>
<div v-if="store.partGapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Missing
<template v-if="store.partGapAfter(ch.id).start === store.partGapAfter(ch.id).end">
Part {{ store.partGapAfter(ch.id).start }}
</template>
<template v-else>
Parts {{ store.partGapAfter(ch.id).start }}{{ store.partGapAfter(ch.id).end }}
</template>
</div>
<div v-if="store.gapAfter(ch.id)" class="fc-gap">
<v-icon size="x-small">mdi-alert-outline</v-icon>
Gap: printed pages {{ store.gapAfter(ch.id).start }}{{ store.gapAfter(ch.id).end }} missing
</div>
</template>
</div>
<div v-if="store.chapters.length === 0" class="fc-series__empty">
No parts yet add one, then add pages from the gallery.
</div>
<div class="fc-parts__add">
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
@click="store.createChapter()">Add part</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
@click="store.createChapter({ isPlaceholder: true })">
Add placeholder
</v-btn>
</div>
<div v-else class="fc-series__empty">
No pages yet use <strong>Add pages</strong> to pull images from the
gallery into this series.
</div>
<!-- Picker slide-over -->
@@ -189,19 +135,13 @@
<v-btn size="x-small" variant="text" icon="mdi-close"
@click="pickerOpen = false" />
</div>
<v-select
v-model="store.targetChapterId"
:items="chapterItems" item-title="label" item-value="id"
density="compact" variant="outlined" hide-details
label="Add to part" class="mt-2"
/>
<div class="fc-picker__bar">
<span>{{ store.pickerSelection.length }} selected</span>
<v-btn
size="small" color="accent" variant="flat"
:disabled="store.pickerSelection.length === 0"
@click="store.addSelected()"
>Add to part</v-btn>
>Add to series</v-btn>
</div>
</div>
<div class="fc-picker__grid">
@@ -232,19 +172,18 @@
</template>
<script setup>
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
import KebabMenu from '../components/common/KebabMenu.vue'
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
const route = useRoute()
const store = useSeriesManageStore()
const sentinel = ref(null)
const drag = ref(null) // { chapterId, idx }
const titleDraft = reactive({}) // chapterId -> draft title
const partDraft = reactive({}) // chapterId -> draft stated_part (string)
const drag = ref(null) // index in the flat pages run being dragged
const titleDraft = reactive({}) // dividerId -> draft title
const partDraft = reactive({}) // dividerId -> draft stated_part (string)
const pickerOpen = ref(false)
const renameOpen = ref(false)
@@ -255,73 +194,47 @@ function onRenamed(updated) {
if (store.series && updated?.name) store.series.name = updated.name
}
// Keep local drafts in sync with loaded chapters. Edits commit on blur/Enter,
// which refreshes and resets the draft to the saved value.
watch(() => store.chapters, (chs) => {
// Keep local divider drafts in sync with loaded dividers; edits commit on
// blur/Enter, which refreshes and resets the draft to the saved value.
watch(() => store.dividers, (divs) => {
for (const k of Object.keys(titleDraft)) delete titleDraft[k]
for (const k of Object.keys(partDraft)) delete partDraft[k]
for (const c of chs) {
titleDraft[c.id] = c.title || ''
partDraft[c.id] = c.stated_part == null ? '' : String(c.stated_part)
for (const d of divs) {
titleDraft[d.id] = d.title || ''
partDraft[d.id] = d.stated_part == null ? '' : String(d.stated_part)
}
}, { immediate: true })
const chapterItems = computed(() =>
store.chapters.map(c => ({
id: c.id,
label: `Part ${c.stated_part ?? c.chapter_number}` +
(c.title ? `${c.title}` : '') +
(c.is_placeholder ? ' (placeholder)' : ` · ${c.pages.length} pg`),
}))
)
function commitTitle(ch) {
const v = (titleDraft[ch.id] || '').trim()
if (v === (ch.title || '')) return
store.renameChapter(ch.id, v || null)
function commitTitle(d) {
const v = (titleDraft[d.id] || '').trim()
if (v === (d.title || '')) return
store.renameDivider(d.id, v || null)
}
function commitPart(ch) {
const raw = (partDraft[ch.id] ?? '').trim()
function commitPart(d) {
const raw = (partDraft[d.id] ?? '').trim()
const next = raw === '' ? null : parseInt(raw, 10)
if (raw !== '' && (Number.isNaN(next) || next < 1)) {
partDraft[ch.id] = ch.stated_part == null ? '' : String(ch.stated_part)
partDraft[d.id] = d.stated_part == null ? '' : String(d.stated_part)
return
}
if (next === (ch.stated_part ?? null)) return
store.setChapterPart(ch.id, next)
if (next === (d.stated_part ?? null)) return
store.setDividerPart(d.id, next)
}
function onStated(ch, which, ev) {
const raw = ev.target.value
const n = raw === '' ? null : parseInt(raw, 10)
const start = which === 'start' ? n : ch.stated_page_start
const end = which === 'end' ? n : ch.stated_page_end
store.setChapterStated(ch.id, start, end)
function gapText(anchorImageId) {
const d = store.dividerAt(anchorImageId)
const g = d ? store.partGapAfter(d.id) : null
return { start: g?.start, end: g?.end, single: g && g.start === g.end }
}
function onPageDrop(chapter, toIdx) {
if (!drag.value || drag.value.chapterId !== chapter.id) { drag.value = null; return }
if (drag.value.idx === toIdx) { drag.value = null; return }
function onDrop(toIdx) {
if (drag.value === null || drag.value === toIdx) { drag.value = null; return }
const ordered = moveItem(
chapter.pages.map(p => p.image_id), drag.value.idx, toIdx
store.pages.map(p => p.image_id), drag.value, toIdx
)
drag.value = null
store.reorderPages(chapter.id, ordered)
}
function confirmDelete(ch) {
const label = `Part ${ch.stated_part ?? ch.chapter_number}`
const n = ch.pages.length
const msg = n
? `Delete ${label} and remove its ${n} page(s) from the series?`
: `Delete ${label}?`
if (window.confirm(msg)) store.deleteChapter(ch.id)
}
function openPicker(chapterId) {
store.targetChapterId = chapterId
pickerOpen.value = true
store.reorder(ordered)
}
useInfiniteScroll(sentinel, () => store.loadPicker())
@@ -342,76 +255,46 @@ onMounted(async () => {
}
.fc-series__hint {
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
margin-bottom: 18px; max-width: 760px;
margin-bottom: 18px; max-width: 820px;
}
/* Parts — full width, stacked */
.fc-parts { display: flex; flex-direction: column; gap: 14px; max-width: 1100px; }
.fc-part {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 10px; padding: 12px 14px;
background: rgb(var(--v-theme-surface));
/* One continuous page run with inline divider bars. */
.fc-run {
display: grid; gap: 10px; max-width: 1100px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.fc-part__head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.fc-part__partfield {
.fc-divider {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 12px;
margin-top: 6px; padding: 6px 12px;
border-left: 3px solid rgb(var(--v-theme-accent));
background: rgb(var(--v-theme-accent), 0.10);
border-radius: 0 8px 8px 0;
}
.fc-divider__icon { color: rgb(var(--v-theme-accent)); }
.fc-divider__partfield {
display: inline-flex; align-items: center; gap: 6px;
background: rgb(var(--v-theme-accent), 0.12);
background: rgb(var(--v-theme-accent), 0.14);
border: 1px solid rgb(var(--v-theme-accent), 0.35);
border-radius: 8px; padding: 4px 8px;
border-radius: 8px; padding: 2px 8px;
}
.fc-part__partlabel {
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
.fc-divider__partlabel {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
color: rgb(var(--v-theme-accent));
}
.fc-part__partnum {
width: 46px; text-align: center; font-size: 18px; font-weight: 600;
.fc-divider__partnum {
width: 42px; text-align: center; font-size: 16px; font-weight: 600;
font-variant-numeric: tabular-nums;
background: transparent; border: none; color: rgb(var(--v-theme-on-surface));
}
.fc-part__partnum:focus { outline: none; }
.fc-part__partnum::placeholder { color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6; }
.fc-divider__partnum:focus { outline: none; }
.fc-divider__partnum::placeholder {
color: rgb(var(--v-theme-on-surface-variant)); opacity: 0.6;
}
.fc-divider__title { flex: 1 1 200px; min-width: 120px; font-size: 15px; }
.fc-part__title { flex: 1 1 180px; min-width: 120px; font-size: 15px; }
.fc-part__src {
display: inline-flex; align-items: center; gap: 4px; max-width: 240px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__badge {
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__pc {
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-part__statedrow {
display: flex; align-items: center; gap: 6px; margin: 8px 0 2px;
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
}
.fc-part__statedlabel { text-transform: uppercase; letter-spacing: 0.04em; font-size: 11px; }
.fc-part__statedin {
width: 56px; text-align: center; font-size: 12px;
background: rgb(var(--v-theme-surface-light));
border: 1px solid transparent; border-radius: 4px; padding: 2px 4px;
color: rgb(var(--v-theme-on-surface));
}
.fc-part__statedin:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
.fc-part__dash { opacity: 0.6; }
.fc-part__statedhelp { font-size: 11px; opacity: 0.7; }
.fc-part__reserved, .fc-part__empty {
padding: 18px; text-align: center; font-size: 13px;
color: rgb(var(--v-theme-on-surface-variant));
}
/* Big page grid */
.fc-part__pages {
display: grid; gap: 10px; margin-top: 10px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
/* Big page tiles */
.fc-page {
position: relative; border-radius: 8px; overflow: hidden; cursor: grab;
background: rgb(var(--v-theme-surface-light));
@@ -429,6 +312,12 @@ onMounted(async () => {
font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums;
background: rgb(var(--v-theme-accent)); color: rgb(var(--v-theme-on-accent, 0 0 0));
}
.fc-page__src {
position: absolute; bottom: 0; left: 0; right: 0; z-index: 2;
display: flex; align-items: center; gap: 4px; padding: 3px 6px;
font-size: 11px; color: #fff; background: rgba(0, 0, 0, 0.55);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.fc-page__actions {
position: absolute; top: 4px; right: 4px; z-index: 2;
display: flex; gap: 2px; opacity: 0; transition: opacity 0.12s;
@@ -439,12 +328,13 @@ onMounted(async () => {
}
.fc-gap {
grid-column: 1 / -1;
display: flex; align-items: center; gap: 6px; padding: 2px 10px;
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
}
.fc-parts__add { display: flex; gap: 8px; margin-top: 4px; }
.fc-series__empty {
padding: 40px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
max-width: 1100px;
}
/* Picker slide-over */
+34 -29
View File
@@ -13,17 +13,14 @@ function stubFetch(handler) {
})
}
// FC-6.x: a flat page run + cosmetic chapter dividers.
const SERIES_BODY = {
series: { id: 7, name: 'V' },
chapters: [
{
id: 1, chapter_number: 1, title: null, is_placeholder: false,
stated_page_start: null, stated_page_end: null,
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
}
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't', source_post: null }],
dividers: [
{ id: 1, anchor_image_id: 1, page_number: 1, title: null, stated_part: null }
],
gaps: [],
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
part_gaps: []
}
describe('seriesManage', () => {
@@ -35,17 +32,17 @@ describe('seriesManage', () => {
expect(moveItem([1, 2, 3], 0, 2)).toEqual([2, 3, 1])
})
it('load fetches chapters + series and picks a default target', async () => {
it('load fetches the flat pages + dividers + series', async () => {
const s = useSeriesManageStore()
stubFetch(() => ({ status: 200, body: SERIES_BODY }))
await s.load(7)
expect(s.series).toEqual({ id: 7, name: 'V' })
expect(s.chapters.map(c => c.id)).toEqual([1])
expect(s.pages.map(p => p.image_id)).toEqual([1])
expect(s.dividers.map(d => d.id)).toEqual([1])
expect(s.pageCount).toBe(1)
expect(s.targetChapterId).toBe(1)
})
it('reorderPages posts ordered ids to the chapter reorder route', async () => {
it('reorder posts ordered ids to the series reorder route', async () => {
const s = useSeriesManageStore()
s.tagId = 7
const calls = []
@@ -54,28 +51,29 @@ describe('seriesManage', () => {
if (url.includes('/reorder')) return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY }
})
await s.reorderPages(3, [30, 10, 20])
const r = calls.find(c => c.url.includes('/chapters/3/reorder'))
expect(r.url).toContain('/api/series/7/chapters/3/reorder')
await s.reorder([30, 10, 20])
const r = calls.find(c => c.url.includes('/reorder'))
expect(r.url).toContain('/api/series/7/reorder')
expect(r.body).toEqual({ image_ids: [30, 10, 20] })
})
it('moveChapter reorders via the chapter id list', async () => {
it('createDivider posts the anchor image + labels', async () => {
const s = useSeriesManageStore()
s.tagId = 7
s.chapters = [{ id: 1 }, { id: 2 }, { id: 3 }]
const calls = []
stubFetch((url, init) => {
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
if (url.includes('/chapters/reorder')) return { status: 200, body: { ok: true } }
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : null })
if (init.method === 'POST' && url.endsWith('/chapters'))
return { status: 200, body: { id: 2, anchor_image_id: 5 } }
return { status: 200, body: SERIES_BODY }
})
await s.moveChapter(2, -1)
const r = calls.find(c => c.url.includes('/chapters/reorder'))
expect(r.body).toEqual({ chapter_ids: [2, 1, 3] })
await s.createDivider(5, { title: 'X', statedPart: 2 })
const c = calls.find(x => x.method === 'POST' && x.url.endsWith('/chapters'))
expect(c.url).toContain('/api/series/7/chapters')
expect(c.body).toEqual({ anchor_image_id: 5, title: 'X', stated_part: 2 })
})
it('setChapterPart patches stated_part on the chapter', async () => {
it('setDividerPart patches stated_part on the divider', async () => {
const s = useSeriesManageStore()
s.tagId = 7
const calls = []
@@ -84,7 +82,7 @@ describe('seriesManage', () => {
if (init.method === 'PATCH') return { status: 200, body: { ok: true } }
return { status: 200, body: SERIES_BODY }
})
await s.setChapterPart(1, 2)
await s.setDividerPart(1, 2)
const p = calls.find(c => c.method === 'PATCH')
expect(p.url).toContain('/api/series/7/chapters/1')
expect(p.body).toEqual({ stated_part: 2 })
@@ -94,18 +92,25 @@ describe('seriesManage', () => {
const s = useSeriesManageStore()
stubFetch(() => ({
status: 200,
body: { ...SERIES_BODY, part_gaps: [{ after_chapter_id: 1, start: 2, end: 2 }] }
body: { ...SERIES_BODY, part_gaps: [{ after_divider_id: 1, start: 2, end: 2 }] }
}))
await s.load(7)
expect(s.partGaps).toHaveLength(1)
expect(s.partGapAfter(1)).toEqual({ after_chapter_id: 1, start: 2, end: 2 })
expect(s.partGapAfter(1)).toEqual({ after_divider_id: 1, start: 2, end: 2 })
expect(s.partGapAfter(99)).toBeNull()
})
it('addSelected posts selection + target chapter then clears', async () => {
it('dividerAt finds a divider by its anchor image', async () => {
const s = useSeriesManageStore()
stubFetch(() => ({ status: 200, body: SERIES_BODY }))
await s.load(7)
expect(s.dividerAt(1)?.id).toBe(1)
expect(s.dividerAt(99)).toBeNull()
})
it('addSelected posts the selection (no chapter) then clears', async () => {
const s = useSeriesManageStore()
s.tagId = 7
s.targetChapterId = 5
s.pickerSelection = [9, 10]
const calls = []
stubFetch((url, init) => {
@@ -116,7 +121,7 @@ describe('seriesManage', () => {
})
await s.addSelected()
const add = calls.find(c => c.url.endsWith('/api/series/7/pages'))
expect(add.body).toEqual({ image_ids: [9, 10], chapter_id: 5 })
expect(add.body).toEqual({ image_ids: [9, 10] })
expect(s.pickerSelection).toEqual([])
})
})