8ad40da145
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>
319 lines
9.9 KiB
Vue
319 lines
9.9 KiB
Vue
<template>
|
|
<div class="fc-reader">
|
|
<div class="fc-reader__progress" :style="{ width: progress + '%' }" />
|
|
|
|
<div class="fc-reader__header" :class="{ hidden: headerHidden }">
|
|
<div class="fc-reader__hleft">
|
|
<v-btn
|
|
icon="mdi-arrow-left" variant="text" size="small"
|
|
title="Back to series" @click="goBack"
|
|
/>
|
|
<span class="fc-reader__title">
|
|
{{ store.series?.name || 'Series' }}
|
|
</span>
|
|
<span class="fc-reader__count">{{ store.pages.length }} pages</span>
|
|
</div>
|
|
<div class="fc-reader__hright">
|
|
<label class="fc-reader__jlabel" for="fc-reader-jump">Jump</label>
|
|
<select
|
|
id="fc-reader-jump" class="fc-reader__jump"
|
|
:value="currentPage" @change="jumpTo($event.target.value)"
|
|
>
|
|
<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"
|
|
title="Toggle pages" @click="navOpen = !navOpen"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<aside class="fc-reader__nav" :class="{ open: navOpen }">
|
|
<div class="fc-reader__navhead">
|
|
<span>Pages</span>
|
|
<v-btn
|
|
icon="mdi-close" variant="text" size="x-small"
|
|
@click="navOpen = false"
|
|
/>
|
|
</div>
|
|
<div class="fc-reader__thumbs">
|
|
<div
|
|
v-for="p in store.pages" :key="p.image_id"
|
|
class="fc-reader__thumb"
|
|
:class="{ active: p.seq === currentPage }"
|
|
@click="jumpTo(p.seq, true)"
|
|
>
|
|
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
|
<span class="fc-reader__thumbnum">{{ p.seq }}</span>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<div ref="scrollEl" class="fc-reader__content" @scroll="onScroll">
|
|
<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>
|
|
</div>
|
|
<div
|
|
v-else-if="!store.loading && store.pages.length === 0"
|
|
class="fc-reader__empty"
|
|
>
|
|
No pages in this series yet.
|
|
<v-btn variant="text" color="accent" @click="goBack">Back</v-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="fc-reader__indicator">
|
|
{{ currentPage }} / {{ store.pages.length }}
|
|
</div>
|
|
<div class="fc-reader__quick">
|
|
<v-btn size="small" variant="tonal" @click="scrollEnd(false)">↑ First</v-btn>
|
|
<v-btn size="small" variant="tonal" @click="scrollEnd(true)">Last ↓</v-btn>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import {
|
|
useSeriesReaderStore,
|
|
currentPageFromScroll,
|
|
progressPct,
|
|
clampPage
|
|
} from '../stores/seriesReader.js'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const store = useSeriesReaderStore()
|
|
|
|
const scrollEl = ref(null)
|
|
const currentPage = ref(1)
|
|
const progress = ref(0)
|
|
const navOpen = ref(false)
|
|
const headerHidden = ref(false)
|
|
|
|
let hideTimer = null
|
|
let scrollGate = null
|
|
const tagId = parseInt(route.params.tagId, 10)
|
|
|
|
function goBack() {
|
|
router.push({ name: 'series-manage', params: { tagId } })
|
|
}
|
|
|
|
function pageMetrics() {
|
|
const root = scrollEl.value
|
|
if (!root) return []
|
|
return store.pages.map(p => {
|
|
const el = root.querySelector('#fc-page-' + p.seq)
|
|
return {
|
|
page_number: p.seq,
|
|
top: el ? el.offsetTop : 0,
|
|
height: el ? el.offsetHeight : 0
|
|
}
|
|
})
|
|
}
|
|
|
|
function showHeader() {
|
|
headerHidden.value = false
|
|
clearTimeout(hideTimer)
|
|
hideTimer = setTimeout(() => { headerHidden.value = true }, 2500)
|
|
}
|
|
|
|
function syncFromScroll() {
|
|
const root = scrollEl.value
|
|
if (!root) return
|
|
progress.value = progressPct(
|
|
root.scrollTop, root.scrollHeight, root.clientHeight
|
|
)
|
|
const pg = currentPageFromScroll(
|
|
pageMetrics(), root.scrollTop, root.clientHeight
|
|
)
|
|
if (pg !== currentPage.value) {
|
|
currentPage.value = pg
|
|
const url = new URL(window.location)
|
|
url.searchParams.set('page', String(pg))
|
|
history.replaceState({}, '', url)
|
|
}
|
|
}
|
|
|
|
function onScroll() {
|
|
showHeader()
|
|
if (scrollGate) return
|
|
scrollGate = setTimeout(() => { syncFromScroll(); scrollGate = null }, 50)
|
|
}
|
|
|
|
function scrollToPage(n, smooth = true) {
|
|
const el = scrollEl.value?.querySelector('#fc-page-' + n)
|
|
if (el) el.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' })
|
|
}
|
|
|
|
function jumpTo(n, fromThumb = false) {
|
|
scrollToPage(parseInt(n, 10), true)
|
|
if (fromThumb && window.innerWidth < 768) navOpen.value = false
|
|
}
|
|
|
|
function scrollEnd(toBottom) {
|
|
const root = scrollEl.value
|
|
if (!root) return
|
|
root.scrollTo({
|
|
top: toBottom ? root.scrollHeight : 0, behavior: 'smooth'
|
|
})
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return
|
|
const root = scrollEl.value
|
|
if (!root) return
|
|
showHeader()
|
|
switch (e.key) {
|
|
case 'Home': e.preventDefault(); root.scrollTo({ top: 0, behavior: 'smooth' }); break
|
|
case 'End': e.preventDefault(); root.scrollTo({ top: root.scrollHeight, behavior: 'smooth' }); break
|
|
case 'PageUp': e.preventDefault(); root.scrollBy({ top: -root.clientHeight * 0.9, behavior: 'smooth' }); break
|
|
case 'PageDown':
|
|
case ' ': e.preventDefault(); root.scrollBy({ top: root.clientHeight * 0.9, behavior: 'smooth' }); break
|
|
case 'n': navOpen.value = !navOpen.value; break
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await store.load(tagId)
|
|
await nextTick()
|
|
const want = clampPage(
|
|
parseInt(route.query.page, 10), store.pages.length
|
|
)
|
|
if (want > 1) scrollToPage(want, false)
|
|
currentPage.value = want
|
|
syncFromScroll()
|
|
showHeader()
|
|
document.addEventListener('keydown', onKey)
|
|
document.addEventListener('mousemove', showHeader)
|
|
})
|
|
onUnmounted(() => {
|
|
clearTimeout(hideTimer)
|
|
clearTimeout(scrollGate)
|
|
document.removeEventListener('keydown', onKey)
|
|
document.removeEventListener('mousemove', showHeader)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-reader {
|
|
position: fixed; inset: 0;
|
|
display: flex; flex-direction: column;
|
|
background: rgb(var(--v-theme-background));
|
|
}
|
|
.fc-reader__progress {
|
|
position: fixed; top: 0; left: 0; height: 2px;
|
|
background: rgb(var(--v-theme-accent));
|
|
z-index: 1100; transition: width 0.1s linear; pointer-events: none;
|
|
}
|
|
.fc-reader__header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 0.5rem 0.75rem;
|
|
background: rgb(var(--v-theme-surface));
|
|
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
|
flex-shrink: 0;
|
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
}
|
|
.fc-reader__header.hidden {
|
|
opacity: 0; pointer-events: none; transform: translateY(-4px);
|
|
}
|
|
.fc-reader__hleft, .fc-reader__hright {
|
|
display: flex; align-items: center; gap: 0.6rem;
|
|
}
|
|
.fc-reader__title {
|
|
font-family: 'Fraunces', Georgia, serif; font-size: 1.05rem;
|
|
}
|
|
.fc-reader__count, .fc-reader__jlabel {
|
|
font-size: 0.8rem; color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-reader__jump {
|
|
padding: 0.3rem 0.5rem; border-radius: 6px;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
color: rgb(var(--v-theme-on-surface));
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-reader__nav {
|
|
position: fixed; top: 0; left: 0; width: 200px; height: 100vh;
|
|
background: rgb(var(--v-theme-surface));
|
|
border-right: 1px solid rgb(var(--v-theme-surface-light));
|
|
transform: translateX(-100%); transition: transform 0.3s ease;
|
|
z-index: 1000; display: flex; flex-direction: column;
|
|
}
|
|
.fc-reader__nav.open { transform: translateX(0); }
|
|
.fc-reader__navhead {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 0.75rem; border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-reader__thumbs {
|
|
flex: 1; overflow-y: auto; padding: 0.5rem;
|
|
display: flex; flex-direction: column; gap: 0.5rem;
|
|
}
|
|
.fc-reader__thumb {
|
|
position: relative; border-radius: 6px; overflow: hidden;
|
|
cursor: pointer; opacity: 0.5; transition: opacity 0.2s ease;
|
|
outline: 2px solid transparent;
|
|
}
|
|
.fc-reader__thumb:hover { opacity: 1; }
|
|
.fc-reader__thumb.active {
|
|
opacity: 1; outline-color: rgb(var(--v-theme-accent));
|
|
}
|
|
.fc-reader__thumb img { width: 100%; height: auto; display: block; }
|
|
.fc-reader__thumbnum {
|
|
position: absolute; bottom: 4px; right: 4px;
|
|
background: rgba(20, 23, 26, 0.8); color: rgb(var(--v-theme-on-surface));
|
|
padding: 1px 5px; border-radius: 4px; font-size: 0.7rem;
|
|
}
|
|
.fc-reader__content {
|
|
flex: 1; overflow-y: auto; scroll-behavior: smooth;
|
|
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;
|
|
}
|
|
.fc-reader__empty {
|
|
padding: 4rem 1rem; text-align: center;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
}
|
|
.fc-reader__indicator {
|
|
position: fixed; bottom: 1.5rem; right: 1.5rem;
|
|
padding: 4px 10px; border-radius: 999px;
|
|
background: rgba(20, 23, 26, 0.6);
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
font-size: 0.8rem; color: rgb(var(--v-theme-on-surface-variant));
|
|
z-index: 100; pointer-events: none;
|
|
}
|
|
.fc-reader__quick {
|
|
position: fixed; bottom: 1.5rem; left: 1.5rem;
|
|
display: flex; gap: 0.5rem; z-index: 50;
|
|
}
|
|
@media (max-width: 768px) {
|
|
.fc-reader__nav { width: 150px; }
|
|
.fc-reader__quick { flex-direction: column; }
|
|
}
|
|
</style>
|