640 lines
20 KiB
Vue
640 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import type { WebNovel, Chapter, ReaderPreferences } from '~/types'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const { addToHistory } = useReaderStorage()
|
|
|
|
const novelId = computed(() => route.params.novelId as string)
|
|
const chapterId = computed(() => route.params.chapterId as string)
|
|
|
|
const novel = ref<WebNovel | null>(null)
|
|
const chapter = ref<Chapter | null>(null)
|
|
const chapters = ref<Chapter[]>([])
|
|
const isLoading = ref(true)
|
|
const showControls = ref(false)
|
|
const showChapterList = ref(false)
|
|
const chapterSearchQuery = ref('')
|
|
const coverImageLoaded = ref(false)
|
|
const showTopBar = ref(true)
|
|
const lastScrollTop = ref(0)
|
|
let hideTopBarTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const preferences = ref<ReaderPreferences>({
|
|
fontSize: 16,
|
|
fontFamily: 'serif',
|
|
lineHeight: 1.8,
|
|
backgroundColor: 'white',
|
|
textColor: 'black',
|
|
theme: 'light',
|
|
textJustify: false
|
|
})
|
|
|
|
const defaultPreferences: ReaderPreferences = {
|
|
fontSize: 16,
|
|
fontFamily: 'serif',
|
|
lineHeight: 1.8,
|
|
backgroundColor: 'white',
|
|
textColor: 'black',
|
|
theme: 'light',
|
|
textJustify: false
|
|
}
|
|
|
|
const currentChapterIndex = computed(() => {
|
|
return chapters.value.findIndex(ch => ch.id === chapterId.value)
|
|
})
|
|
|
|
const filteredChapters = computed(() => {
|
|
if (!chapterSearchQuery.value.trim()) {
|
|
return chapters.value
|
|
}
|
|
const query = chapterSearchQuery.value.toLowerCase()
|
|
return chapters.value.filter(ch =>
|
|
ch.title.toLowerCase().includes(query)
|
|
|| ch.number.toString().includes(query)
|
|
)
|
|
})
|
|
|
|
const previousChapter = computed(() => {
|
|
if (currentChapterIndex.value > 0) {
|
|
return chapters.value[currentChapterIndex.value - 1]
|
|
}
|
|
return null
|
|
})
|
|
|
|
const nextChapter = computed(() => {
|
|
if (currentChapterIndex.value < chapters.value.length - 1) {
|
|
return chapters.value[currentChapterIndex.value + 1]
|
|
}
|
|
return null
|
|
})
|
|
|
|
const getBackgroundColor = () => {
|
|
const colors: { [key: string]: string } = {
|
|
white: '#ffffff',
|
|
cream: '#f5f1e8',
|
|
gray: '#f0f0f0',
|
|
black: '#1a1a1a'
|
|
}
|
|
return colors[preferences.value.backgroundColor] || '#ffffff'
|
|
}
|
|
|
|
const getTextColor = () => {
|
|
const colors: { [key: string]: string } = {
|
|
black: '#000000',
|
|
white: '#ffffff',
|
|
gray: '#666666'
|
|
}
|
|
return colors[preferences.value.textColor] || '#000000'
|
|
}
|
|
|
|
const isDarkMode = computed(() => {
|
|
if (import.meta.client) {
|
|
return document.documentElement.classList.contains('dark')
|
|
}
|
|
return false
|
|
})
|
|
|
|
const applyThemeBasedPreset = () => {
|
|
if (isDarkMode.value) {
|
|
// Dark theme → Dark preset (serif + black background + white text)
|
|
preferences.value = {
|
|
...defaultPreferences,
|
|
fontFamily: 'serif',
|
|
backgroundColor: 'black',
|
|
textColor: 'white'
|
|
}
|
|
} else {
|
|
// Light theme → Modern preset (sans-serif + white background + black text)
|
|
preferences.value = {
|
|
...defaultPreferences,
|
|
fontFamily: 'sans-serif',
|
|
backgroundColor: 'white',
|
|
textColor: 'black'
|
|
}
|
|
}
|
|
}
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
isLoading.value = true
|
|
|
|
// Load novel data from Nuxt Content
|
|
const novelData = await queryCollection('content').path(`/novels/${novelId.value}`).first() as any
|
|
|
|
if (!novelData) {
|
|
throw new Error('Novel not found')
|
|
}
|
|
|
|
// Map novel data
|
|
const mappedNovel: WebNovel = {
|
|
id: novelId.value,
|
|
slug: novelId.value,
|
|
title: novelData.title || 'Unknown',
|
|
author: novelData.meta?.author || 'Unknown',
|
|
description: novelData.description || novelData.body?.text || '',
|
|
cover: `/images/${novelId.value}/cover.png`,
|
|
status: novelData.meta?.status || 'ongoing',
|
|
genres: novelData.meta?.genres || [],
|
|
rating: novelData.meta?.rating || 0,
|
|
views: novelData.meta?.views || 0,
|
|
followers: novelData.meta?.followers || 0,
|
|
chapters: novelData.meta?.chapters || 0,
|
|
language: novelData.meta?.language || 'English',
|
|
tags: novelData.meta?.tags || [],
|
|
createdAt: novelData.meta?.createdAt || new Date().toISOString(),
|
|
updatedAt: novelData.meta?.updatedAt || new Date().toISOString()
|
|
}
|
|
novel.value = mappedNovel
|
|
|
|
// Load all content and filter chapters for this novel
|
|
const allItems = await queryCollection('content').all() as any[]
|
|
const filteredItems = allItems
|
|
.filter((item) => {
|
|
const path = item._path || item.id || ''
|
|
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
|
|
})
|
|
.sort((a, b) => {
|
|
// Sort by number first, before mapping
|
|
const numA = a.meta?.number || 0
|
|
const numB = b.meta?.number || 0
|
|
return numA - numB
|
|
})
|
|
|
|
const novelChapters = filteredItems.map((item) => {
|
|
// Extract slug from various possible locations in Nuxt Content
|
|
let slug = item.slug || item.meta?.slug
|
|
|
|
// Fallback: extract from _path (e.g., /novels/novel-name/ch-2 -> ch-2)
|
|
if (!slug && item._path) {
|
|
slug = item._path.split('/').pop()
|
|
}
|
|
|
|
// Final fallback: extract filename from id if it contains the full path
|
|
if (!slug && item.id) {
|
|
slug = item.id.split('/').pop()?.replace('.md', '')
|
|
}
|
|
|
|
return {
|
|
id: slug || 'unknown', // Use slug for SEO-friendly URLs
|
|
novelId: novelId.value,
|
|
number: item.meta?.number || 0,
|
|
title: item.title || 'Untitled Chapter',
|
|
body: item.body, // Store full body for ContentRenderer
|
|
views: item.meta?.views || 0,
|
|
likes: item.meta?.likes || 0,
|
|
createdAt: item.meta?.createdAt || new Date().toISOString(),
|
|
updatedAt: item.meta?.updatedAt || new Date().toISOString()
|
|
}
|
|
})
|
|
|
|
chapters.value = novelChapters
|
|
|
|
// Find and load the current chapter using slug
|
|
const currentChapter = novelChapters.find(ch => ch.id === chapterId.value)
|
|
|
|
if (currentChapter) {
|
|
chapter.value = currentChapter as any
|
|
} else {
|
|
throw new Error('Chapter not found')
|
|
}
|
|
|
|
// Load preferences from localStorage
|
|
const saved = localStorage.getItem('readerPreferences')
|
|
if (saved) {
|
|
preferences.value = { ...defaultPreferences, ...JSON.parse(saved) }
|
|
} else {
|
|
// Apply preset based on current app theme
|
|
applyThemeBasedPreset()
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading data:', error)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
const updatePreferences = (updates: Partial<ReaderPreferences>) => {
|
|
preferences.value = { ...preferences.value, ...updates }
|
|
}
|
|
|
|
const savePreferences = () => {
|
|
localStorage.setItem('readerPreferences', JSON.stringify(preferences.value))
|
|
}
|
|
|
|
const resetPreferences = () => {
|
|
preferences.value = { ...defaultPreferences }
|
|
}
|
|
|
|
const goToChapter = (ch: any) => {
|
|
router.push(`/novel/${novelId.value}/${ch.id}`)
|
|
}
|
|
|
|
const handleScroll = (event: Event) => {
|
|
const target = event.target as HTMLElement
|
|
const scrollTop = target.scrollTop
|
|
|
|
// Hide top bar when scrolling down
|
|
if (scrollTop > lastScrollTop.value && scrollTop > 50) {
|
|
showTopBar.value = false
|
|
// Clear any pending hide timer
|
|
if (hideTopBarTimer) {
|
|
clearTimeout(hideTopBarTimer)
|
|
hideTopBarTimer = null
|
|
}
|
|
}
|
|
|
|
lastScrollTop.value = scrollTop
|
|
}
|
|
|
|
const handleScreenTap = () => {
|
|
// Toggle top bar on any click
|
|
const newState = !showTopBar.value
|
|
showTopBar.value = newState
|
|
|
|
// Auto-hide after 5 seconds if shown
|
|
if (newState) {
|
|
if (hideTopBarTimer) {
|
|
clearTimeout(hideTopBarTimer)
|
|
}
|
|
hideTopBarTimer = setTimeout(() => {
|
|
showTopBar.value = false
|
|
}, 5000)
|
|
} else {
|
|
// Clear timer if manually hidden
|
|
if (hideTopBarTimer) {
|
|
clearTimeout(hideTopBarTimer)
|
|
hideTopBarTimer = null
|
|
}
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowLeft' && previousChapter.value) {
|
|
goToChapter(previousChapter.value)
|
|
} else if (e.key === 'ArrowRight' && nextChapter.value) {
|
|
goToChapter(nextChapter.value)
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadData()
|
|
window.addEventListener('keydown', handleKeyDown)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
window.removeEventListener('keydown', handleKeyDown)
|
|
if (hideTopBarTimer) {
|
|
clearTimeout(hideTopBarTimer)
|
|
}
|
|
})
|
|
|
|
// Update SEO meta when chapter is loaded
|
|
watch([chapter, novel], ([newChapter, newNovel]) => {
|
|
if (newChapter && newNovel) {
|
|
useSeoMeta({
|
|
title: `${newChapter.title} - ${newNovel.title}`,
|
|
description: newNovel.description,
|
|
ogTitle: `${newChapter.title} - ${newNovel.title}`,
|
|
ogDescription: newNovel.description,
|
|
ogImage: newNovel.cover,
|
|
twitterCard: 'summary_large_image'
|
|
})
|
|
|
|
// Add to reading history
|
|
addToHistory({
|
|
novelId: novelId.value,
|
|
novelTitle: newNovel.title,
|
|
chapterId: newChapter.id,
|
|
chapterTitle: newChapter.title,
|
|
cover: newNovel.cover
|
|
})
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="chapter && novel"
|
|
class="h-screen flex flex-col transition-colors duration-300 w-full relative"
|
|
:style="{
|
|
backgroundColor: getBackgroundColor(),
|
|
color: getTextColor()
|
|
}"
|
|
>
|
|
<!-- Top Bar -->
|
|
<transition
|
|
enter-active-class="transition-transform duration-300 ease-out"
|
|
leave-active-class="transition-transform duration-300 ease-in"
|
|
enter-from-class="-translate-y-full"
|
|
leave-to-class="-translate-y-full"
|
|
>
|
|
<div
|
|
v-show="showTopBar"
|
|
class="sticky top-0 z-20 border-b transition-colors duration-300"
|
|
:style="{ borderColor: getTextColor() + '20' }"
|
|
>
|
|
<div class="flex items-center justify-between p-3 px-4 sm:px-6 lg:px-8 gap-2 sm:gap-4">
|
|
<!-- Left Section: Back Button + Cover -->
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<button
|
|
class="p-2 hover:opacity-60 transition-opacity shrink-0"
|
|
:title="`Back to ${novel.title}`"
|
|
@click="router.push(`/novels/${novelId}`)"
|
|
>
|
|
<UIcon name="i-lucide-arrow-left" class="size-5" />
|
|
</button>
|
|
<!-- Cover Thumbnail -->
|
|
<div class="hidden sm:block relative w-10 h-12">
|
|
<USkeleton
|
|
v-if="!coverImageLoaded"
|
|
class="w-10 h-12 rounded"
|
|
/>
|
|
<img
|
|
:src="novel.cover"
|
|
:alt="novel.title"
|
|
class="w-10 h-12 rounded object-cover"
|
|
:class="{ 'opacity-0 absolute': !coverImageLoaded }"
|
|
@load="coverImageLoaded = true"
|
|
@error="coverImageLoaded = true"
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Title Section -->
|
|
<div class="flex-1 min-w-0 text-center">
|
|
<!-- Novel Title -->
|
|
<h1
|
|
class="text-sm lg:text-base font-semibold line-clamp-1 wrap-break-word"
|
|
:title="novel.title"
|
|
>
|
|
{{ novel.title }}
|
|
</h1>
|
|
|
|
<!-- Chapter Info (compressed on mobile) -->
|
|
<div class="flex items-center justify-center gap-1 text-xs lg:text-xs opacity-60 line-clamp-1 flex-wrap">
|
|
<!-- <span>Ch {{ chapter.number }}</span> -->
|
|
<!-- <span class="hidden sm:inline">·</span> -->
|
|
<p class="line-clamp-1 min-w-0" :title="chapter.title">
|
|
{{ chapter.title }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div class="flex items-center gap-1 sm:gap-2 shrink-0">
|
|
<UTooltip text="Reader Settings" :shortcuts="[]">
|
|
<button
|
|
class="p-2 hover:opacity-60 transition-opacity"
|
|
@click="showControls = !showControls; showChapterList = false"
|
|
>
|
|
<UIcon name="i-lucide-settings" class="size-5" />
|
|
</button>
|
|
</UTooltip>
|
|
|
|
<UTooltip text="Chapter List" :shortcuts="[]">
|
|
<button
|
|
class="p-2 hover:opacity-60 transition-opacity"
|
|
@click="showChapterList = !showChapterList; showControls = false"
|
|
>
|
|
<UIcon name="i-lucide-book-open" class="size-5" />
|
|
</button>
|
|
</UTooltip>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<!-- Floating Bottom Navigation (Mobile Only) -->
|
|
<transition
|
|
enter-active-class="transition-transform duration-300 ease-out"
|
|
leave-active-class="transition-transform duration-300 ease-in"
|
|
enter-from-class="translate-y-full"
|
|
leave-to-class="translate-y-full"
|
|
>
|
|
<div
|
|
v-show="showTopBar"
|
|
class="fixed bottom-0 left-0 right-0 z-20 sm:hidden border-t transition-colors duration-300"
|
|
:style="{
|
|
borderColor: getTextColor() + '20',
|
|
backgroundColor: getBackgroundColor()
|
|
}"
|
|
>
|
|
<div class="flex items-center justify-between p-3 gap-2 safe-bottom">
|
|
<button
|
|
v-if="previousChapter"
|
|
class="flex-1 py-3 px-4 rounded-lg border transition-all active:scale-95"
|
|
:style="{ borderColor: getTextColor() + '40' }"
|
|
@click.stop="goToChapter(previousChapter)"
|
|
>
|
|
<div class="flex items-center justify-center gap-2">
|
|
<UIcon name="i-lucide-chevron-left" class="size-5" />
|
|
<span class="text-sm font-semibold">Previous</span>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
v-if="nextChapter"
|
|
class="flex-1 py-3 px-4 rounded-lg border transition-all active:scale-95"
|
|
:style="{ borderColor: getTextColor() + '40' }"
|
|
@click.stop="goToChapter(nextChapter)"
|
|
>
|
|
<div class="flex items-center justify-center gap-2">
|
|
<span class="text-sm font-semibold">Next</span>
|
|
<UIcon name="i-lucide-chevron-right" class="size-5" />
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
|
|
<div class="flex-1 flex overflow-hidden">
|
|
<!-- Main Content -->
|
|
<div class="flex-1 overflow-y-auto flex flex-col pb-20 sm:pb-0" @scroll="handleScroll" @click="handleScreenTap">
|
|
<div class="flex-1">
|
|
<article
|
|
class="w-full px-8 py-8 prose prose-invert max-w-3xl mx-auto chapter-content"
|
|
:style="{
|
|
'--reader-font-size': `${preferences.fontSize}px`,
|
|
'--reader-line-height': preferences.lineHeight,
|
|
'fontSize': `${preferences.fontSize}px`,
|
|
'fontFamily': preferences.fontFamily === 'serif' ? 'Merriweather, Georgia, serif'
|
|
: preferences.fontFamily === 'sans-serif' ? '\'Open Sans\', system-ui, sans-serif'
|
|
: 'monospace',
|
|
'lineHeight': preferences.lineHeight,
|
|
'textAlign': preferences.textJustify ? 'justify' : 'left'
|
|
}"
|
|
>
|
|
<h1>{{ chapter.title }}</h1>
|
|
<ContentRenderer :value="chapter" />
|
|
</article>
|
|
</div>
|
|
|
|
<!-- Navigation -->
|
|
<div class="border-t transition-colors duration-300 mt-8" :style="{ borderColor: getTextColor() + '20' }">
|
|
<div class="w-full px-4 sm:px-8 py-6 sm:py-8">
|
|
<!-- Chapter Info (Hidden on mobile, shown on desktop) -->
|
|
<div class="hidden sm:flex items-center justify-between mb-4">
|
|
<span class="text-sm opacity-60">
|
|
Chapter {{ chapter.number }} of {{ chapters.length }}
|
|
</span>
|
|
<!-- <span class="text-sm opacity-60">
|
|
{{ chapter.views.toLocaleString() }} views · {{ chapter.likes.toLocaleString() }} likes
|
|
</span> -->
|
|
</div>
|
|
|
|
<!-- Mobile: Buttons side by side -->
|
|
<div class="flex sm:hidden gap-2 pb-6">
|
|
<button
|
|
v-if="previousChapter"
|
|
class="flex-1 p-4 rounded-lg border transition-all active:scale-95"
|
|
:style="{ borderColor: getTextColor() + '40' }"
|
|
@click.stop="goToChapter(previousChapter)"
|
|
>
|
|
<div class="flex flex-col items-center gap-1">
|
|
<UIcon name="i-lucide-chevron-left" class="size-6" />
|
|
<span class="text-sm font-semibold">Previous</span>
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
v-if="nextChapter"
|
|
class="flex-1 p-4 rounded-lg border transition-all active:scale-95"
|
|
:style="{ borderColor: getTextColor() + '40' }"
|
|
@click.stop="goToChapter(nextChapter)"
|
|
>
|
|
<div class="flex flex-col items-center gap-1">
|
|
<UIcon name="i-lucide-chevron-right" class="size-6" />
|
|
<span class="text-sm font-semibold">Next</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Desktop: Side by side buttons -->
|
|
<div class="hidden sm:flex gap-4">
|
|
<button
|
|
v-if="previousChapter"
|
|
class="flex-1 p-3 rounded border transition-opacity hover:opacity-60"
|
|
:style="{ borderColor: getTextColor() + '40' }"
|
|
@click="goToChapter(previousChapter)"
|
|
>
|
|
<div class="text-xs opacity-60 mb-1">
|
|
Previous
|
|
</div>
|
|
<div class="text-sm font-semibold truncate">
|
|
{{ previousChapter.title }}
|
|
</div>
|
|
</button>
|
|
|
|
<button
|
|
v-if="nextChapter"
|
|
class="flex-1 p-3 rounded border transition-opacity hover:opacity-60"
|
|
:style="{ borderColor: getTextColor() + '40' }"
|
|
@click="goToChapter(nextChapter)"
|
|
>
|
|
<div class="text-xs opacity-60 mb-1">
|
|
Next
|
|
</div>
|
|
<div class="text-sm font-semibold truncate">
|
|
{{ nextChapter.title }}
|
|
</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chapter List Sidebar -->
|
|
<div
|
|
v-if="showChapterList"
|
|
class="w-80 border overflow-y-auto transition-colors duration-300"
|
|
:style="{ borderColor: getTextColor() + '20' }"
|
|
>
|
|
<div class="p-8">
|
|
<p class="text-lg font-bold mb-4">
|
|
Table Of Contents
|
|
<span class="text-xs opacity-60 ml-2">
|
|
({{ chapters.length }} Chapter{{ chapters.length !== 1 ? 's' : '' }})
|
|
</span>
|
|
</p>
|
|
<input
|
|
v-model="chapterSearchQuery"
|
|
type="text"
|
|
placeholder="Search chapters..."
|
|
class="w-full px-3 py-2 mb-4 text-sm rounded border transition-colors"
|
|
:style="{
|
|
borderColor: getTextColor() + '20',
|
|
backgroundColor: getBackgroundColor(),
|
|
color: getTextColor()
|
|
}"
|
|
>
|
|
<button
|
|
v-for="ch in filteredChapters"
|
|
:key="ch.id"
|
|
class="w-full text-left p-2 rounded transition-opacity hover:opacity-60"
|
|
:class="{ 'opacity-100 font-semibold': ch.id === chapterId, 'opacity-60': ch.id !== chapterId }"
|
|
@click="goToChapter(ch)"
|
|
>
|
|
<p class="text-xs truncate">
|
|
{{ ch.title }}
|
|
</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls Sidebar -->
|
|
<div
|
|
v-if="showControls"
|
|
class="w-80 border-l overflow-y-auto transition-colors duration-300"
|
|
:style="{ borderColor: getTextColor() + '20' }"
|
|
>
|
|
<div class="p-4">
|
|
<ReaderControls
|
|
:preferences="preferences"
|
|
:background-color="getBackgroundColor()"
|
|
:text-color="getTextColor()"
|
|
@update="updatePreferences"
|
|
@save="savePreferences"
|
|
@reset="resetPreferences"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading skeleton -->
|
|
<div v-else-if="isLoading" class="flex items-center justify-center h-full w-full p-10">
|
|
<USkeleton class="w-full h-full" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
:deep(.prose) {
|
|
color: inherit;
|
|
}
|
|
|
|
:deep(.chapter-content p) {
|
|
margin: 1em 0;
|
|
line-height: var(--reader-line-height, 1.8) !important;
|
|
font-size: var(--reader-font-size, 16px);
|
|
}
|
|
|
|
:deep(.prose h1) {
|
|
margin: 1.5em 0 0.5em 0;
|
|
font-size: 1.5em;
|
|
}
|
|
|
|
/* Mobile safe area support */
|
|
.pb-safe {
|
|
padding-bottom: env(safe-area-inset-bottom);
|
|
}
|
|
|
|
.mb-safe {
|
|
margin-bottom: env(safe-area-inset-bottom);
|
|
}
|
|
|
|
.safe-bottom {
|
|
padding-bottom: max(env(safe-area-inset-bottom), 0.75rem);
|
|
}
|
|
</style>
|