464 lines
14 KiB
Vue
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>
|