lastwebnovel-app/app/pages/novel/[novelId]/[chapterId].vue
2026-04-17 21:45:12 +02:00

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>