Improve chapter reading experience
This commit is contained in:
parent
487e01f76f
commit
39ffbc680d
@ -240,6 +240,7 @@ onMounted(() => {
|
||||
variant="ghost"
|
||||
trailing-icon="i-lucide-arrow-right"
|
||||
size="sm"
|
||||
@click="router.push('/titles/search')"
|
||||
>
|
||||
More
|
||||
</UButton>
|
||||
@ -287,6 +288,7 @@ onMounted(() => {
|
||||
variant="ghost"
|
||||
trailing-icon="i-lucide-arrow-right"
|
||||
size="sm"
|
||||
@click="router.push('/titles/search')"
|
||||
>
|
||||
More
|
||||
</UButton>
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
<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()
|
||||
@ -15,11 +11,14 @@ 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 coverImageLoaded = ref(false)
|
||||
const showTopBar = ref(true)
|
||||
const lastScrollTop = ref(0)
|
||||
let hideTopBarTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const preferences = ref<ReaderPreferences>({
|
||||
fontSize: 16,
|
||||
@ -150,25 +149,37 @@ const loadData = async () => {
|
||||
|
||||
// Load all content and filter chapters for this novel
|
||||
const allItems = await queryCollection('content').all() as any[]
|
||||
const novelChapters = allItems
|
||||
const filteredItems = 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)
|
||||
.sort((a, b) => {
|
||||
// Sort by number first, before mapping
|
||||
const numA = a.meta?.number || 0
|
||||
const numB = b.meta?.number || 0
|
||||
return numA - numB
|
||||
})
|
||||
|
||||
// Store UUID -> internal ID mapping
|
||||
chapterUuidMap.value.set(uuid, internalId)
|
||||
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: uuid, // Use UUID for public URLs
|
||||
internalId: internalId, // Keep internal reference
|
||||
id: slug || 'unknown', // Use slug for SEO-friendly URLs
|
||||
novelId: novelId.value,
|
||||
number: item.meta?.number || index + 1,
|
||||
title: item.title || `Chapter ${index + 1}`,
|
||||
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,
|
||||
@ -176,17 +187,11 @@ const loadData = async () => {
|
||||
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)
|
||||
}
|
||||
// Find and load the current chapter using slug
|
||||
const currentChapter = novelChapters.find(ch => ch.id === chapterId.value)
|
||||
|
||||
if (currentChapter) {
|
||||
chapter.value = currentChapter as any
|
||||
@ -225,6 +230,45 @@ 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)
|
||||
@ -240,6 +284,9 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
if (hideTopBarTimer) {
|
||||
clearTimeout(hideTopBarTimer)
|
||||
}
|
||||
})
|
||||
|
||||
// Update SEO meta when chapter is loaded
|
||||
@ -269,66 +316,153 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
||||
<template>
|
||||
<div
|
||||
v-if="chapter && novel"
|
||||
class="min-h-screen flex flex-col transition-colors duration-300 w-full"
|
||||
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-4 px-8">
|
||||
<!-- <UDashboardSidebarCollapse /> -->
|
||||
<button class="p-2 hover:opacity-60 transition-opacity" @click="router.push(`/novels/${novelId}`)">
|
||||
<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>
|
||||
|
||||
<div class="flex-1 mx-4">
|
||||
<h1 class="text-sm font-semibold text-center truncate">
|
||||
<!-- 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>
|
||||
<p class="text-xs opacity-60 text-center truncate">
|
||||
<!-- Chapter {{ chapter.number }}: -->
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 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"
|
||||
:title="showControls ? 'Hide controls' : 'Show controls'"
|
||||
@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"
|
||||
:title="showChapterList ? 'Hide chapters' : 'Show chapters'"
|
||||
@click="showChapterList = !showChapterList; showControls = false"
|
||||
>
|
||||
<UIcon name="i-lucide-book-open" class="size-5" />
|
||||
</button>
|
||||
</UTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="flex flex-1 overflow-hidden">
|
||||
<!-- 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 w-full">
|
||||
<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"
|
||||
class="w-full px-8 py-8 prose prose-invert max-w-3xl mx-auto chapter-content"
|
||||
:style="{
|
||||
fontSize: `${preferences.fontSize}px`,
|
||||
fontFamily: preferences.fontFamily === 'serif' ? 'Merriweather, Georgia, serif'
|
||||
'--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'
|
||||
'lineHeight': preferences.lineHeight,
|
||||
'textAlign': preferences.textJustify ? 'justify' : 'left'
|
||||
}"
|
||||
>
|
||||
<h1>{{ chapter.title }}</h1>
|
||||
@ -338,17 +472,46 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
||||
|
||||
<!-- 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">
|
||||
<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">
|
||||
<!-- <span class="text-sm opacity-60">
|
||||
{{ chapter.views.toLocaleString() }} views · {{ chapter.likes.toLocaleString() }} likes
|
||||
</span>
|
||||
</span> -->
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<!-- 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"
|
||||
@ -359,7 +522,6 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
||||
Previous
|
||||
</div>
|
||||
<div class="text-sm font-semibold truncate">
|
||||
<!-- Ch{{ previousChapter.number }}: -->
|
||||
{{ previousChapter.title }}
|
||||
</div>
|
||||
</button>
|
||||
@ -374,7 +536,6 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
||||
Next
|
||||
</div>
|
||||
<div class="text-sm font-semibold truncate">
|
||||
<!-- Ch{{ nextChapter.number }}: -->
|
||||
{{ nextChapter.title }}
|
||||
</div>
|
||||
</button>
|
||||
@ -386,7 +547,7 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
||||
<!-- Chapter List Sidebar -->
|
||||
<div
|
||||
v-if="showChapterList"
|
||||
class="w-80 border-1 overflow-y-auto transition-colors duration-300"
|
||||
class="w-80 border overflow-y-auto transition-colors duration-300"
|
||||
:style="{ borderColor: getTextColor() + '20' }"
|
||||
>
|
||||
<div class="p-8">
|
||||
@ -452,12 +613,27 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
:deep(.prose p) {
|
||||
: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;
|
||||
text-size: 1.5em;
|
||||
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>
|
||||
|
||||
@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { WebNovel, Chapter } from '~/types'
|
||||
import { v5 as uuidv5 } from 'uuid'
|
||||
|
||||
// Same namespace as the reader page for consistent UUIDs
|
||||
const CHAPTER_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@ -82,22 +78,37 @@ const loadData = async () => {
|
||||
|
||||
// Load chapters from Nuxt Content
|
||||
const allItems = await queryCollection('content').all() as any[]
|
||||
const novelChapters = allItems
|
||||
const filteredItems = 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)
|
||||
.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: uuid, // Use UUID for public URLs
|
||||
internalId: internalId, // Keep internal ch-0 reference
|
||||
id: slug || 'unknown', // Use slug for SEO-friendly URLs
|
||||
novelId: novelId.value,
|
||||
number: item.meta?.number || index + 1,
|
||||
title: item.title || `Chapter ${index + 1}`,
|
||||
number: item.meta?.number || 0,
|
||||
title: item.title || 'Untitled Chapter',
|
||||
content: item.body?.text || '',
|
||||
views: item.meta?.views || 0,
|
||||
likes: item.meta?.likes || 0,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user