Improve chapter reading experience
This commit is contained in:
parent
487e01f76f
commit
39ffbc680d
@ -240,6 +240,7 @@ onMounted(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
trailing-icon="i-lucide-arrow-right"
|
trailing-icon="i-lucide-arrow-right"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@click="router.push('/titles/search')"
|
||||||
>
|
>
|
||||||
More
|
More
|
||||||
</UButton>
|
</UButton>
|
||||||
@ -287,6 +288,7 @@ onMounted(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
trailing-icon="i-lucide-arrow-right"
|
trailing-icon="i-lucide-arrow-right"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
@click="router.push('/titles/search')"
|
||||||
>
|
>
|
||||||
More
|
More
|
||||||
</UButton>
|
</UButton>
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WebNovel, Chapter, ReaderPreferences } from '~/types'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -15,11 +11,14 @@ const chapterId = computed(() => route.params.chapterId as string)
|
|||||||
const novel = ref<WebNovel | null>(null)
|
const novel = ref<WebNovel | null>(null)
|
||||||
const chapter = ref<Chapter | null>(null)
|
const chapter = ref<Chapter | null>(null)
|
||||||
const chapters = ref<Chapter[]>([])
|
const chapters = ref<Chapter[]>([])
|
||||||
const chapterUuidMap = ref<Map<string, string>>(new Map()) // UUID -> chapter id mapping
|
|
||||||
const isLoading = ref(true)
|
const isLoading = ref(true)
|
||||||
const showControls = ref(false)
|
const showControls = ref(false)
|
||||||
const showChapterList = ref(false)
|
const showChapterList = ref(false)
|
||||||
const chapterSearchQuery = ref('')
|
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>({
|
const preferences = ref<ReaderPreferences>({
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
@ -150,25 +149,37 @@ const loadData = async () => {
|
|||||||
|
|
||||||
// Load all content and filter chapters for this novel
|
// Load all content and filter chapters for this novel
|
||||||
const allItems = await queryCollection('content').all() as any[]
|
const allItems = await queryCollection('content').all() as any[]
|
||||||
const novelChapters = allItems
|
const filteredItems = allItems
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const path = item._path || item.id || ''
|
const path = item._path || item.id || ''
|
||||||
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
|
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
|
||||||
})
|
})
|
||||||
.map((item, index) => {
|
.sort((a, b) => {
|
||||||
const internalId = item.slug || `ch-${index}`
|
// Sort by number first, before mapping
|
||||||
// Generate deterministic UUID from novel ID + internal chapter ID
|
const numA = a.meta?.number || 0
|
||||||
const uuid = uuidv5(`${novelId.value}/${internalId}`, CHAPTER_NAMESPACE)
|
const numB = b.meta?.number || 0
|
||||||
|
return numA - numB
|
||||||
|
})
|
||||||
|
|
||||||
// Store UUID -> internal ID mapping
|
const novelChapters = filteredItems.map((item) => {
|
||||||
chapterUuidMap.value.set(uuid, internalId)
|
// 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 {
|
return {
|
||||||
id: uuid, // Use UUID for public URLs
|
id: slug || 'unknown', // Use slug for SEO-friendly URLs
|
||||||
internalId: internalId, // Keep internal reference
|
|
||||||
novelId: novelId.value,
|
novelId: novelId.value,
|
||||||
number: item.meta?.number || index + 1,
|
number: item.meta?.number || 0,
|
||||||
title: item.title || `Chapter ${index + 1}`,
|
title: item.title || 'Untitled Chapter',
|
||||||
body: item.body, // Store full body for ContentRenderer
|
body: item.body, // Store full body for ContentRenderer
|
||||||
views: item.meta?.views || 0,
|
views: item.meta?.views || 0,
|
||||||
likes: item.meta?.likes || 0,
|
likes: item.meta?.likes || 0,
|
||||||
@ -176,17 +187,11 @@ const loadData = async () => {
|
|||||||
updatedAt: item.meta?.updatedAt || new Date().toISOString()
|
updatedAt: item.meta?.updatedAt || new Date().toISOString()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.number - b.number)
|
|
||||||
|
|
||||||
chapters.value = novelChapters
|
chapters.value = novelChapters
|
||||||
|
|
||||||
// Find and load the current chapter using UUID
|
// Find and load the current chapter using slug
|
||||||
let currentChapter = novelChapters.find(ch => ch.id === chapterId.value)
|
const 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) {
|
if (currentChapter) {
|
||||||
chapter.value = currentChapter as any
|
chapter.value = currentChapter as any
|
||||||
@ -225,6 +230,45 @@ const goToChapter = (ch: any) => {
|
|||||||
router.push(`/novel/${novelId.value}/${ch.id}`)
|
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) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'ArrowLeft' && previousChapter.value) {
|
if (e.key === 'ArrowLeft' && previousChapter.value) {
|
||||||
goToChapter(previousChapter.value)
|
goToChapter(previousChapter.value)
|
||||||
@ -240,6 +284,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
if (hideTopBarTimer) {
|
||||||
|
clearTimeout(hideTopBarTimer)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Update SEO meta when chapter is loaded
|
// Update SEO meta when chapter is loaded
|
||||||
@ -269,66 +316,153 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="chapter && novel"
|
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="{
|
:style="{
|
||||||
backgroundColor: getBackgroundColor(),
|
backgroundColor: getBackgroundColor(),
|
||||||
color: getTextColor()
|
color: getTextColor()
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<!-- Top Bar -->
|
<!-- 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
|
<div
|
||||||
|
v-show="showTopBar"
|
||||||
class="sticky top-0 z-20 border-b transition-colors duration-300"
|
class="sticky top-0 z-20 border-b transition-colors duration-300"
|
||||||
:style="{ borderColor: getTextColor() + '20' }"
|
:style="{ borderColor: getTextColor() + '20' }"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between p-4 px-8">
|
<div class="flex items-center justify-between p-3 px-4 sm:px-6 lg:px-8 gap-2 sm:gap-4">
|
||||||
<!-- <UDashboardSidebarCollapse /> -->
|
<!-- Left Section: Back Button + Cover -->
|
||||||
<button class="p-2 hover:opacity-60 transition-opacity" @click="router.push(`/novels/${novelId}`)">
|
<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" />
|
<UIcon name="i-lucide-arrow-left" class="size-5" />
|
||||||
</button>
|
</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">
|
<!-- Title Section -->
|
||||||
<h1 class="text-sm font-semibold text-center truncate">
|
<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 }}
|
{{ novel.title }}
|
||||||
</h1>
|
</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 }}
|
{{ chapter.title }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<button
|
||||||
class="p-2 hover:opacity-60 transition-opacity"
|
class="p-2 hover:opacity-60 transition-opacity"
|
||||||
:title="showControls ? 'Hide controls' : 'Show controls'"
|
|
||||||
@click="showControls = !showControls; showChapterList = false"
|
@click="showControls = !showControls; showChapterList = false"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-settings" class="size-5" />
|
<UIcon name="i-lucide-settings" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
|
<UTooltip text="Chapter List" :shortcuts="[]">
|
||||||
<button
|
<button
|
||||||
class="p-2 hover:opacity-60 transition-opacity"
|
class="p-2 hover:opacity-60 transition-opacity"
|
||||||
:title="showChapterList ? 'Hide chapters' : 'Show chapters'"
|
|
||||||
@click="showChapterList = !showChapterList; showControls = false"
|
@click="showChapterList = !showChapterList; showControls = false"
|
||||||
>
|
>
|
||||||
<UIcon name="i-lucide-book-open" class="size-5" />
|
<UIcon name="i-lucide-book-open" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
</UTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- 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">
|
<div class="flex-1">
|
||||||
<article
|
<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="{
|
:style="{
|
||||||
fontSize: `${preferences.fontSize}px`,
|
'--reader-font-size': `${preferences.fontSize}px`,
|
||||||
fontFamily: preferences.fontFamily === 'serif' ? 'Merriweather, Georgia, serif'
|
'--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'
|
: preferences.fontFamily === 'sans-serif' ? '\'Open Sans\', system-ui, sans-serif'
|
||||||
: 'monospace',
|
: 'monospace',
|
||||||
lineHeight: preferences.lineHeight,
|
'lineHeight': preferences.lineHeight,
|
||||||
textAlign: preferences.textJustify ? 'justify' : 'left'
|
'textAlign': preferences.textJustify ? 'justify' : 'left'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<h1>{{ chapter.title }}</h1>
|
<h1>{{ chapter.title }}</h1>
|
||||||
@ -338,17 +472,46 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
|||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<div class="border-t transition-colors duration-300 mt-8" :style="{ borderColor: getTextColor() + '20' }">
|
<div class="border-t transition-colors duration-300 mt-8" :style="{ borderColor: getTextColor() + '20' }">
|
||||||
<div class="w-full px-8 py-8">
|
<div class="w-full px-4 sm:px-8 py-6 sm:py-8">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<!-- 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">
|
<span class="text-sm opacity-60">
|
||||||
Chapter {{ chapter.number }} of {{ chapters.length }}
|
Chapter {{ chapter.number }} of {{ chapters.length }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm opacity-60">
|
<!-- <span class="text-sm opacity-60">
|
||||||
{{ chapter.views.toLocaleString() }} views · {{ chapter.likes.toLocaleString() }} likes
|
{{ chapter.views.toLocaleString() }} views · {{ chapter.likes.toLocaleString() }} likes
|
||||||
</span>
|
</span> -->
|
||||||
</div>
|
</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
|
<button
|
||||||
v-if="previousChapter"
|
v-if="previousChapter"
|
||||||
class="flex-1 p-3 rounded border transition-opacity hover:opacity-60"
|
class="flex-1 p-3 rounded border transition-opacity hover:opacity-60"
|
||||||
@ -359,7 +522,6 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
|||||||
Previous
|
Previous
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold truncate">
|
<div class="text-sm font-semibold truncate">
|
||||||
<!-- Ch{{ previousChapter.number }}: -->
|
|
||||||
{{ previousChapter.title }}
|
{{ previousChapter.title }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -374,7 +536,6 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
|||||||
Next
|
Next
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-semibold truncate">
|
<div class="text-sm font-semibold truncate">
|
||||||
<!-- Ch{{ nextChapter.number }}: -->
|
|
||||||
{{ nextChapter.title }}
|
{{ nextChapter.title }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -386,7 +547,7 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
|||||||
<!-- Chapter List Sidebar -->
|
<!-- Chapter List Sidebar -->
|
||||||
<div
|
<div
|
||||||
v-if="showChapterList"
|
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' }"
|
:style="{ borderColor: getTextColor() + '20' }"
|
||||||
>
|
>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
@ -452,12 +613,27 @@ watch([chapter, novel], ([newChapter, newNovel]) => {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.prose p) {
|
:deep(.chapter-content p) {
|
||||||
margin: 1em 0;
|
margin: 1em 0;
|
||||||
|
line-height: var(--reader-line-height, 1.8) !important;
|
||||||
|
font-size: var(--reader-font-size, 16px);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.prose h1) {
|
:deep(.prose h1) {
|
||||||
margin: 1.5em 0 0.5em 0;
|
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>
|
</style>
|
||||||
|
|||||||
@ -1,9 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { WebNovel, Chapter } from '~/types'
|
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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -82,22 +78,37 @@ const loadData = async () => {
|
|||||||
|
|
||||||
// Load chapters from Nuxt Content
|
// Load chapters from Nuxt Content
|
||||||
const allItems = await queryCollection('content').all() as any[]
|
const allItems = await queryCollection('content').all() as any[]
|
||||||
const novelChapters = allItems
|
const filteredItems = allItems
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const path = item._path || item.id || ''
|
const path = item._path || item.id || ''
|
||||||
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
|
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
|
||||||
})
|
})
|
||||||
.map((item, index) => {
|
.sort((a, b) => {
|
||||||
const internalId = item.slug || `ch-${index}`
|
// Sort by number first, before mapping
|
||||||
// Generate deterministic UUID from novel ID + internal chapter ID
|
const numA = a.meta?.number || 0
|
||||||
const uuid = uuidv5(`${novelId.value}/${internalId}`, CHAPTER_NAMESPACE)
|
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 {
|
return {
|
||||||
id: uuid, // Use UUID for public URLs
|
id: slug || 'unknown', // Use slug for SEO-friendly URLs
|
||||||
internalId: internalId, // Keep internal ch-0 reference
|
|
||||||
novelId: novelId.value,
|
novelId: novelId.value,
|
||||||
number: item.meta?.number || index + 1,
|
number: item.meta?.number || 0,
|
||||||
title: item.title || `Chapter ${index + 1}`,
|
title: item.title || 'Untitled Chapter',
|
||||||
content: item.body?.text || '',
|
content: item.body?.text || '',
|
||||||
views: item.meta?.views || 0,
|
views: item.meta?.views || 0,
|
||||||
likes: item.meta?.likes || 0,
|
likes: item.meta?.likes || 0,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user