Files
FabledCurator/frontend/src/views/SeriesReaderView.vue
T
2026-05-17 16:30:48 -04:00

308 lines
9.6 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.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>