lastwebnovel-app/app/pages/novel/[novelId]/[chapterId].vue
2026-04-11 22:55:16 +02:00

464 lines
14 KiB
Vue

<script setup lang="ts">
import type { WebNovel, Chapter, ReaderPreferences } from '~/types'
import { v5 as uuidv5 } from 'uuid'
// Namespace UUID for generating deterministic chapter UUIDs
const CHAPTER_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
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 chapterUuidMap = ref<Map<string, string>>(new Map()) // UUID -> chapter id mapping
const isLoading = ref(true)
const showControls = ref(false)
const showChapterList = ref(false)
const chapterSearchQuery = ref('')
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 novelChapters = allItems
.filter((item) => {
const path = item._path || item.id || ''
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
})
.map((item, index) => {
const internalId = item.slug || `ch-${index}`
// Generate deterministic UUID from novel ID + internal chapter ID
const uuid = uuidv5(`${novelId.value}/${internalId}`, CHAPTER_NAMESPACE)
// Store UUID -> internal ID mapping
chapterUuidMap.value.set(uuid, internalId)
return {
id: uuid, // Use UUID for public URLs
internalId: internalId, // Keep internal reference
novelId: novelId.value,
number: item.meta?.number || index + 1,
title: item.title || `Chapter ${index + 1}`,
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()
}
})
.sort((a, b) => a.number - b.number)
chapters.value = novelChapters
// Find and load the current chapter using UUID
let currentChapter = novelChapters.find(ch => ch.id === chapterId.value)
// If not found by UUID, try by internal ID (backward compatibility)
if (!currentChapter) {
currentChapter = novelChapters.find(ch => ch.internalId === 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 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)
})
// 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="min-h-screen flex flex-col transition-colors duration-300 w-full"
:style="{
backgroundColor: getBackgroundColor(),
color: getTextColor()
}"
>
<!-- Top Bar -->
<div
class="sticky top-0 z-20 border-b transition-colors duration-300"
:style="{ borderColor: getTextColor() + '20' }"
>
<div class="flex items-center justify-between p-4 px-8">
<!-- <UDashboardSidebarCollapse /> -->
<button class="p-2 hover:opacity-60 transition-opacity" @click="router.push(`/novels/${novelId}`)">
<UIcon name="i-lucide-arrow-left" class="size-5" />
</button>
<div class="flex-1 mx-4">
<h1 class="text-sm font-semibold text-center truncate">
{{ novel.title }}
</h1>
<p class="text-xs opacity-60 text-center truncate">
<!-- Chapter {{ chapter.number }}: -->
{{ chapter.title }}
</p>
</div>
<div class="flex items-center gap-2">
<button
class="p-2 hover:opacity-60 transition-opacity"
:title="showControls ? 'Hide controls' : 'Show controls'"
@click="showControls = !showControls; showChapterList = false"
>
<UIcon name="i-lucide-settings" class="size-5" />
</button>
<button
class="p-2 hover:opacity-60 transition-opacity"
:title="showChapterList ? 'Hide chapters' : 'Show chapters'"
@click="showChapterList = !showChapterList; showControls = false"
>
<UIcon name="i-lucide-book-open" class="size-5" />
</button>
</div>
</div>
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Main Content -->
<div class="flex-1 overflow-y-auto flex flex-col w-full">
<div class="flex-1">
<article
class="w-full px-8 py-8 prose prose-invert max-w-3xl mx-auto"
:style="{
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-8 py-8">
<div class="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>
<div class="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">
<!-- Ch{{ previousChapter.number }}: -->
{{ 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">
<!-- Ch{{ nextChapter.number }}: -->
{{ nextChapter.title }}
</div>
</button>
</div>
</div>
</div>
</div>
<!-- Chapter List Sidebar -->
<div
v-if="showChapterList"
class="w-80 border-1 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(.prose p) {
margin: 1em 0;
}
:deep(.prose h1) {
margin: 1.5em 0 0.5em 0;
text-size: 1.5em;
}
</style>