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
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<div class="fc-series__head">
|
||||
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
||||
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
||||
<span class="fc-series__count">
|
||||
{{ store.chapters.length }} chapter(s) · {{ store.pageCount }} page(s)
|
||||
</span>
|
||||
<v-btn
|
||||
v-if="store.pages.length > 0"
|
||||
v-if="store.pageCount > 0"
|
||||
size="small" variant="tonal" color="accent"
|
||||
prepend-icon="mdi-book-open"
|
||||
class="fc-series__read"
|
||||
@@ -13,28 +15,123 @@
|
||||
</div>
|
||||
|
||||
<div class="fc-series__body">
|
||||
<div class="fc-series__pages">
|
||||
<div
|
||||
v-for="(p, idx) in store.pages" :key="p.image_id"
|
||||
class="fc-series__page" draggable="true"
|
||||
@dragstart="dragFrom = idx"
|
||||
@dragover.prevent
|
||||
@drop="onDrop(idx)"
|
||||
>
|
||||
<span class="fc-series__pn">{{ p.page_number }}</span>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<div class="fc-series__pageactions">
|
||||
<v-btn size="x-small" variant="text" icon="mdi-image-frame"
|
||||
title="Make cover" @click="store.setCover(p.image_id)" />
|
||||
<v-btn size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove" @click="store.remove(p.image_id)" />
|
||||
<!-- Chapters column -->
|
||||
<div class="fc-series__chapters">
|
||||
<template v-for="(ch, ci) in store.chapters" :key="ch.id">
|
||||
<section
|
||||
class="fc-chapter"
|
||||
:class="{ 'fc-chapter--target': ch.id === store.targetChapterId }"
|
||||
>
|
||||
<header class="fc-chapter__head">
|
||||
<span class="fc-chapter__num">{{ ch.chapter_number }}</span>
|
||||
<v-text-field
|
||||
v-model="titleDraft[ch.id]"
|
||||
:placeholder="`Chapter ${ch.chapter_number}`"
|
||||
density="compact" variant="plain" hide-details
|
||||
class="fc-chapter__title"
|
||||
@keydown.enter="commitTitle(ch)"
|
||||
@blur="commitTitle(ch)"
|
||||
/>
|
||||
<span v-if="ch.is_placeholder" class="fc-chapter__ph">placeholder</span>
|
||||
<span v-else class="fc-chapter__pc">{{ ch.pages.length }} pg</span>
|
||||
|
||||
<div class="fc-chapter__stated">
|
||||
<input
|
||||
class="fc-chapter__num-in" type="number" min="0"
|
||||
:value="ch.stated_page_start ?? ''" placeholder="–"
|
||||
title="Stated first page"
|
||||
@change="onStated(ch, 'start', $event)"
|
||||
>
|
||||
<span>–</span>
|
||||
<input
|
||||
class="fc-chapter__num-in" type="number" min="0"
|
||||
:value="ch.stated_page_end ?? ''" placeholder="–"
|
||||
title="Stated last page"
|
||||
@change="onStated(ch, 'end', $event)"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="fc-chapter__actions">
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-chevron-up"
|
||||
title="Move chapter up" :disabled="ci === 0"
|
||||
@click="store.moveChapter(ch.id, -1)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-chevron-down"
|
||||
title="Move chapter down"
|
||||
:disabled="ci === store.chapters.length - 1"
|
||||
@click="store.moveChapter(ch.id, 1)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-arrow-collapse-up"
|
||||
title="Merge into previous chapter" :disabled="ci === 0"
|
||||
@click="store.mergeChapter(ch.id, store.chapters[ci - 1].id)"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-target"
|
||||
:color="ch.id === store.targetChapterId ? 'accent' : undefined"
|
||||
title="Add picked images here"
|
||||
@click="store.targetChapterId = ch.id"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small" variant="text" icon="mdi-delete-outline"
|
||||
title="Delete chapter (removes its pages from the series)"
|
||||
@click="store.deleteChapter(ch.id)"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="ch.is_placeholder" class="fc-chapter__reserved">
|
||||
Reserved slot — a section you don't have yet.
|
||||
</div>
|
||||
<div v-else-if="ch.pages.length === 0" class="fc-chapter__empty">
|
||||
No pages — pick this chapter (◎) then add from the right.
|
||||
</div>
|
||||
<div v-else class="fc-chapter__pages">
|
||||
<div
|
||||
v-for="(p, pi) in ch.pages" :key="p.image_id"
|
||||
class="fc-page" draggable="true"
|
||||
@dragstart="drag = { chapterId: ch.id, idx: pi }"
|
||||
@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="text" icon="mdi-image-frame"
|
||||
title="Make cover" @click="store.setCover(p.image_id)" />
|
||||
<v-btn size="x-small" variant="text" icon="mdi-close"
|
||||
title="Remove" @click="store.remove(p.image_id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="store.gapAfter(ch.id)"
|
||||
class="fc-gap"
|
||||
>
|
||||
<v-icon size="x-small">mdi-alert-outline</v-icon>
|
||||
Gap: pages {{ store.gapAfter(ch.id).start }}–{{ store.gapAfter(ch.id).end }} missing
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="store.chapters.length === 0" class="fc-series__empty">
|
||||
No chapters yet — add one, then add images from the right.
|
||||
</div>
|
||||
<div v-if="store.pages.length === 0" class="fc-series__empty">
|
||||
No pages yet — add images from the right.
|
||||
|
||||
<div class="fc-series__chapter-add">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-plus"
|
||||
@click="store.createChapter()">Add chapter</v-btn>
|
||||
<v-btn size="small" variant="text" prepend-icon="mdi-bookmark-outline"
|
||||
@click="store.createChapter({ isPlaceholder: true })">
|
||||
Add placeholder
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Picker column -->
|
||||
<div class="fc-series__picker">
|
||||
<div class="fc-series__pickerhead">
|
||||
<span>{{ store.pickerSelection.length }} selected</span>
|
||||
@@ -42,7 +139,7 @@
|
||||
size="small" color="accent" variant="flat"
|
||||
:disabled="store.pickerSelection.length === 0"
|
||||
@click="store.addSelected()"
|
||||
>Add to series</v-btn>
|
||||
>Add to {{ targetLabel }}</v-btn>
|
||||
</div>
|
||||
<div class="fc-series__pickergrid">
|
||||
<div
|
||||
@@ -61,23 +158,52 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSeriesManageStore, moveItem } from '../stores/seriesManage.js'
|
||||
import { useInfiniteScroll } from '../composables/useInfiniteScroll.js'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useSeriesManageStore()
|
||||
const dragFrom = ref(null)
|
||||
const sentinel = ref(null)
|
||||
const drag = ref(null) // { chapterId, idx }
|
||||
const titleDraft = reactive({}) // chapterId -> draft string
|
||||
|
||||
function onDrop(toIdx) {
|
||||
if (dragFrom.value === null || dragFrom.value === toIdx) return
|
||||
// Keep local title drafts in sync with the loaded chapters. Edits commit on
|
||||
// blur/Enter, which refreshes and resets the draft to the saved value.
|
||||
watch(() => store.chapters, (chs) => {
|
||||
Object.keys(titleDraft).forEach(k => delete titleDraft[k])
|
||||
for (const c of chs) titleDraft[c.id] = c.title || ''
|
||||
}, { immediate: true })
|
||||
|
||||
const targetLabel = computed(() => {
|
||||
const ch = store.chapters.find(c => c.id === store.targetChapterId)
|
||||
if (!ch) return 'chapter'
|
||||
return ch.title || `Chapter ${ch.chapter_number}`
|
||||
})
|
||||
|
||||
function commitTitle(ch) {
|
||||
const v = (titleDraft[ch.id] || '').trim()
|
||||
if (v === (ch.title || '')) return
|
||||
store.renameChapter(ch.id, v || null)
|
||||
}
|
||||
|
||||
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 onPageDrop(chapter, toIdx) {
|
||||
if (!drag.value || drag.value.chapterId !== chapter.id) { drag.value = null; return }
|
||||
if (drag.value.idx === toIdx) { drag.value = null; return }
|
||||
const ordered = moveItem(
|
||||
store.pages.map(p => p.image_id), dragFrom.value, toIdx
|
||||
chapter.pages.map(p => p.image_id), drag.value.idx, toIdx
|
||||
)
|
||||
dragFrom.value = null
|
||||
store.reorder(ordered)
|
||||
drag.value = null
|
||||
store.reorderPages(chapter.id, ordered)
|
||||
}
|
||||
|
||||
useInfiniteScroll(sentinel, () => store.loadPicker())
|
||||
@@ -92,33 +218,71 @@ onMounted(async () => {
|
||||
.fc-series__head {
|
||||
display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.fc-series__name {
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 22px;
|
||||
}
|
||||
.fc-series__name { font-family: 'Fraunces', Georgia, serif; font-size: 22px; }
|
||||
.fc-series__count {
|
||||
font-size: 13px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-series__body { display: flex; gap: 16px; align-items: flex-start; }
|
||||
.fc-series__pages { flex: 1; min-width: 0; display: flex;
|
||||
flex-direction: column; gap: 6px; }
|
||||
.fc-series__page {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px;
|
||||
background: rgb(var(--v-theme-surface)); border-radius: 6px;
|
||||
cursor: grab;
|
||||
.fc-series__chapters {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.fc-series__page img {
|
||||
width: 64px; height: 64px; object-fit: cover; border-radius: 4px;
|
||||
|
||||
.fc-chapter {
|
||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||
border-radius: 8px; padding: 8px 10px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
}
|
||||
.fc-series__pn {
|
||||
width: 28px; text-align: center;
|
||||
.fc-chapter--target { border-color: rgb(var(--v-theme-accent), 0.7); }
|
||||
.fc-chapter__head { display: flex; align-items: center; gap: 8px; }
|
||||
.fc-chapter__num {
|
||||
flex: 0 0 auto; min-width: 22px; height: 22px; border-radius: 4px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-variant-numeric: tabular-nums;
|
||||
background: rgb(var(--v-theme-accent), 0.16);
|
||||
color: rgb(var(--v-theme-accent));
|
||||
}
|
||||
.fc-chapter__title { flex: 1; min-width: 0; }
|
||||
.fc-chapter__ph {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-chapter__pc {
|
||||
font-size: 12px; color: rgb(var(--v-theme-on-surface-variant));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.fc-series__pageactions { margin-left: auto; }
|
||||
.fc-series__empty {
|
||||
padding: 32px; text-align: center;
|
||||
.fc-chapter__stated { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.fc-chapter__num-in {
|
||||
width: 42px; 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-chapter__num-in:focus { outline: none; border-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-chapter__actions { flex: 0 0 auto; display: inline-flex; }
|
||||
.fc-chapter__reserved, .fc-chapter__empty {
|
||||
padding: 14px; text-align: center; font-size: 13px;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-chapter__pages { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.fc-page {
|
||||
display: flex; align-items: center; gap: 10px; padding: 6px;
|
||||
background: rgb(var(--v-theme-surface-light)); border-radius: 6px; cursor: grab;
|
||||
}
|
||||
.fc-page img { width: 56px; height: 56px; object-fit: cover; border-radius: 4px; }
|
||||
.fc-page__pn {
|
||||
width: 26px; text-align: center; font-variant-numeric: tabular-nums;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
.fc-page__actions { margin-left: auto; }
|
||||
.fc-gap {
|
||||
display: flex; align-items: center; gap: 6px; padding: 4px 10px;
|
||||
font-size: 12px; color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
||||
}
|
||||
.fc-series__chapter-add { display: flex; gap: 8px; margin-top: 4px; }
|
||||
.fc-series__empty {
|
||||
padding: 32px; text-align: center; color: rgb(var(--v-theme-on-surface-variant));
|
||||
}
|
||||
|
||||
.fc-series__picker { flex: 1; min-width: 0; }
|
||||
.fc-series__pickerhead {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
@@ -128,8 +292,10 @@ onMounted(async () => {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.fc-series__pick { cursor: pointer; aspect-ratio: 1; overflow: hidden;
|
||||
border-radius: 4px; outline: 2px solid transparent; }
|
||||
.fc-series__pick {
|
||||
cursor: pointer; aspect-ratio: 1; overflow: hidden;
|
||||
border-radius: 4px; outline: 2px solid transparent;
|
||||
}
|
||||
.fc-series__pick img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.fc-series__pick.on { outline-color: rgb(var(--v-theme-accent)); }
|
||||
.fc-series__sentinel { height: 40px; }
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
:value="currentPage" @change="jumpTo($event.target.value)"
|
||||
>
|
||||
<option
|
||||
v-for="p in store.pages" :key="p.page_number"
|
||||
:value="p.page_number"
|
||||
>Page {{ p.page_number }}</option>
|
||||
v-for="p in store.pages" :key="p.seq"
|
||||
:value="p.seq"
|
||||
>Page {{ p.seq }}</option>
|
||||
</select>
|
||||
<v-btn
|
||||
icon="mdi-menu" variant="text" size="small"
|
||||
@@ -43,23 +43,27 @@
|
||||
<div
|
||||
v-for="p in store.pages" :key="p.image_id"
|
||||
class="fc-reader__thumb"
|
||||
:class="{ active: p.page_number === currentPage }"
|
||||
@click="jumpTo(p.page_number, true)"
|
||||
:class="{ active: p.seq === currentPage }"
|
||||
@click="jumpTo(p.seq, true)"
|
||||
>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<span class="fc-reader__thumbnum">{{ p.page_number }}</span>
|
||||
<span class="fc-reader__thumbnum">{{ p.seq }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div ref="scrollEl" class="fc-reader__content" @scroll="onScroll">
|
||||
<div
|
||||
v-for="p in store.pages" :key="p.image_id"
|
||||
class="fc-reader__page" :id="'fc-page-' + p.page_number"
|
||||
:data-page="p.page_number"
|
||||
>
|
||||
<img :src="p.image_url" :alt="'Page ' + p.page_number" loading="lazy" />
|
||||
</div>
|
||||
<template v-for="p in store.pages" :key="p.image_id">
|
||||
<div v-if="p.isChapterStart" class="fc-reader__chdiv">
|
||||
{{ p.chapterLabel }}
|
||||
</div>
|
||||
<div
|
||||
class="fc-reader__page" :id="'fc-page-' + p.seq"
|
||||
:data-page="p.seq"
|
||||
>
|
||||
<img :src="p.image_url" :alt="'Page ' + p.seq" loading="lazy" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="store.error" class="fc-reader__empty">
|
||||
{{ store.error }}
|
||||
<v-btn variant="text" color="accent" @click="goBack">Back</v-btn>
|
||||
@@ -115,9 +119,9 @@ function pageMetrics() {
|
||||
const root = scrollEl.value
|
||||
if (!root) return []
|
||||
return store.pages.map(p => {
|
||||
const el = root.querySelector('#fc-page-' + p.page_number)
|
||||
const el = root.querySelector('#fc-page-' + p.seq)
|
||||
return {
|
||||
page_number: p.page_number,
|
||||
page_number: p.seq,
|
||||
top: el ? el.offsetTop : 0,
|
||||
height: el ? el.offsetHeight : 0
|
||||
}
|
||||
@@ -280,6 +284,13 @@ onUnmounted(() => {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
gap: 2px; padding: 0.25rem;
|
||||
}
|
||||
.fc-reader__chdiv {
|
||||
width: 100%; max-width: 1200px; margin: 0.5rem auto 0;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 0.95rem;
|
||||
color: rgb(var(--v-theme-on-surface-variant));
|
||||
border-top: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-reader__page { width: 100%; display: flex; justify-content: center; }
|
||||
.fc-reader__page img {
|
||||
max-width: min(100%, 1200px); height: auto; display: block;
|
||||
|
||||
@@ -13,6 +13,19 @@ function stubFetch(handler) {
|
||||
})
|
||||
}
|
||||
|
||||
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' }]
|
||||
}
|
||||
],
|
||||
gaps: [],
|
||||
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }]
|
||||
}
|
||||
|
||||
describe('seriesManage', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
@@ -22,48 +35,61 @@ describe('seriesManage', () => {
|
||||
expect(moveItem([1, 2, 3], 0, 2)).toEqual([2, 3, 1])
|
||||
})
|
||||
|
||||
it('load fetches pages + series', async () => {
|
||||
it('load fetches chapters + series and picks a default target', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
stubFetch(() => ({
|
||||
status: 200,
|
||||
body: { series: { id: 7, name: 'V' },
|
||||
pages: [{ image_id: 1, page_number: 1, thumbnail_url: 't' }] }
|
||||
}))
|
||||
stubFetch(() => ({ status: 200, body: SERIES_BODY }))
|
||||
await s.load(7)
|
||||
expect(s.series).toEqual({ id: 7, name: 'V' })
|
||||
expect(s.pages.map(p => p.image_id)).toEqual([1])
|
||||
expect(s.chapters.map(c => c.id)).toEqual([1])
|
||||
expect(s.pageCount).toBe(1)
|
||||
expect(s.targetChapterId).toBe(1)
|
||||
})
|
||||
|
||||
it('reorder posts the full ordered id list', async () => {
|
||||
it('reorderPages posts ordered ids to the chapter reorder route', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
s.tagId = 7
|
||||
s.pages = [{ image_id: 1 }, { image_id: 2 }, { image_id: 3 }]
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
|
||||
if (url.includes('/reorder')) return { status: 200, body: { ok: true } }
|
||||
return { status: 200, body: { series: { id: 7, name: 'V' }, pages: [] } }
|
||||
return { status: 200, body: SERIES_BODY }
|
||||
})
|
||||
await s.reorder([3, 1, 2])
|
||||
const r = calls.find(c => c.url.includes('/reorder'))
|
||||
expect(r.url).toContain('/api/series/7/reorder')
|
||||
expect(r.body).toEqual({ image_ids: [3, 1, 2] })
|
||||
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')
|
||||
expect(r.body).toEqual({ image_ids: [30, 10, 20] })
|
||||
})
|
||||
|
||||
it('addSelected posts picker selection then reloads', async () => {
|
||||
it('moveChapter reorders via the chapter id list', 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 } }
|
||||
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] })
|
||||
})
|
||||
|
||||
it('addSelected posts selection + target chapter then clears', async () => {
|
||||
const s = useSeriesManageStore()
|
||||
s.tagId = 7
|
||||
s.targetChapterId = 5
|
||||
s.pickerSelection = [9, 10]
|
||||
const calls = []
|
||||
stubFetch((url, init) => {
|
||||
calls.push({ url, body: init.body ? JSON.parse(init.body) : null })
|
||||
if (url.includes('/pages') && init.method === 'POST')
|
||||
if (url.endsWith('/pages') && init.method === 'POST')
|
||||
return { status: 200, body: { added_count: 2 } }
|
||||
return { status: 200, body: { series: { id: 7, name: 'V' }, pages: [] } }
|
||||
return { status: 200, body: SERIES_BODY }
|
||||
})
|
||||
await s.addSelected()
|
||||
const add = calls.find(c => c.url.endsWith('/api/series/7/pages'))
|
||||
expect(add.body).toEqual({ image_ids: [9, 10] })
|
||||
expect(add.body).toEqual({ image_ids: [9, 10], chapter_id: 5 })
|
||||
expect(s.pickerSelection).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user