Improve chapter reading experience

This commit is contained in:
Ahmed DCHAR 2026-04-17 21:45:12 +02:00
parent 487e01f76f
commit 39ffbc680d
3 changed files with 289 additions and 100 deletions

View File

@ -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>

View File

@ -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>

View File

@ -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,