feat(reader): SeriesReaderView — IR-faithful immersive series reader
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
<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.page_number"
|
||||
:value="p.page_number"
|
||||
>Page {{ p.page_number }}</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.page_number === currentPage }"
|
||||
@click="jumpTo(p.page_number, true)"
|
||||
>
|
||||
<img :src="p.thumbnail_url" alt="" loading="lazy" />
|
||||
<span class="fc-reader__thumbnum">{{ p.page_number }}</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>
|
||||
<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.page_number)
|
||||
return {
|
||||
page_number: p.page_number,
|
||||
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__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>
|
||||
Reference in New Issue
Block a user