lastwebnovel-app/app/pages/novels/[id].vue
2026-04-11 22:55:16 +02:00

433 lines
14 KiB
Vue

<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()
const toast = useToast()
const { addToLibrary, removeFromLibrary, isInLibrary, getHistory } = useReaderStorage()
const novelId = computed(() => route.params.id as string)
const novel = ref<WebNovel | null>(null)
const chapters = ref<Chapter[]>([])
const isLoading = ref(true)
const error = ref('')
const inLibrary = ref(false)
const lastReadChapter = ref<string | null>(null)
const chapterSearch = ref('')
const sortAscending = ref(false)
const sortedChapters = computed(() => {
let filtered = [...chapters.value]
// Apply search filter
if (chapterSearch.value.trim()) {
const query = chapterSearch.value.toLowerCase()
filtered = filtered.filter(chapter =>
chapter.number.toString().includes(query)
|| chapter.title.toLowerCase().includes(query)
)
}
// Apply sort
return filtered.sort((a, b) =>
sortAscending.value ? a.number - b.number : b.number - a.number
)
})
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return `${(num / 1000000).toFixed(1)}M`
} else if (num >= 1000) {
return `${(num / 1000).toFixed(1)}k`
}
return num.toString()
}
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) {
error.value = 'Novel not found'
return
}
// Map the markdown frontmatter to WebNovel interface
const mappedNovel: WebNovel = {
id: 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 chapters from Nuxt Content
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)
return {
id: uuid, // Use UUID for public URLs
internalId: internalId, // Keep internal ch-0 reference
novelId: novelId.value,
number: item.meta?.number || index + 1,
title: item.title || `Chapter ${index + 1}`,
content: item.body?.text || '',
views: item.meta?.views || 0,
likes: item.meta?.likes || 0,
createdAt: item.meta?.createdAt || new Date().toISOString(),
updatedAt: item.meta?.updatedAt || new Date().toISOString()
}
})
chapters.value = novelChapters
console.log(`Loaded 1 novel and ${novelChapters.length} chapters`)
// Check if novel is in library
inLibrary.value = isInLibrary(novelId.value)
// Check if there's a reading history for this novel
const readingHistory = getHistory()
const historyEntry = readingHistory.find(entry => entry.novelId === novelId.value)
if (historyEntry) {
lastReadChapter.value = historyEntry.chapterId
}
} catch (err) {
console.error('Error loading novel:', err)
error.value = 'Failed to load novel'
} finally {
isLoading.value = false
}
}
const toggleLibrary = () => {
if (!novel.value) return
if (inLibrary.value) {
removeFromLibrary(novelId.value)
inLibrary.value = false
toast.add({
title: 'Removed from Library',
description: `${novel.value.title} has been removed from your library`,
icon: 'i-lucide-bookmark-minus',
color: 'warning'
})
} else {
const authorName = typeof novel.value.author === 'string' ? novel.value.author : novel.value.author?.name || 'Unknown'
addToLibrary({
id: novel.value.id,
title: novel.value.title,
author: authorName,
cover: novel.value.cover
})
inLibrary.value = true
toast.add({
title: 'Added to Library',
description: `${novel.value.title} has been added to your library`,
icon: 'i-lucide-bookmark-plus',
color: 'success'
})
}
}
const startReading = () => {
if (chapters.value.length > 0) {
const firstChapter = chapters.value[0]
router.push(`/novel/${novelId.value}/${firstChapter.id}`)
}
}
const continueReading = () => {
if (lastReadChapter.value) {
// Resume from last read chapter in history
router.push(`/novel/${novelId.value}/${lastReadChapter.value}`)
} else if (chapters.value.length > 0) {
// Fallback to first chapter if no history
const firstChapter = chapters.value[0]
router.push(`/novel/${novelId.value}/${firstChapter.id}`)
}
}
const shareNovel = async () => {
if (!novel.value) return
const shareData = {
title: novel.value.title,
text: `Check out "${novel.value.title}" - ${novel.value.description.slice(0, 100)}...`,
url: window.location.href
}
try {
// Try native Web Share API (mobile-first)
if (navigator.share) {
await navigator.share(shareData)
toast.add({
title: 'Shared successfully',
color: 'success'
})
} else {
// Fallback: Copy to clipboard
await navigator.clipboard.writeText(window.location.href)
toast.add({
title: 'Link copied to clipboard',
description: 'Share the link with your friends!',
color: 'success'
})
}
} catch (error: unknown) {
// User cancelled or error occurred
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Error sharing:', error)
toast.add({
title: 'Failed to share',
color: 'error'
})
}
}
}
onMounted(() => {
loadData()
})
</script>
<template>
<UDashboardPanel v-if="!isLoading && novel" id="novel-detail">
<template #header>
<UDashboardNavbar :title="novel.title" :ui="{ right: 'gap-3' }">
<template #leading>
<UDashboardSidebarCollapse />
<UButton
color="neutral"
variant="ghost"
icon="i-lucide-arrow-left"
square
@click="router.back()"
/>
</template>
<template #right>
<UButton
:icon="inLibrary ? 'i-lucide-bookmark-check' : 'i-lucide-bookmark'"
:color="inLibrary ? 'primary' : 'secondary'"
variant="ghost"
square
@click="toggleLibrary"
/>
<UButton
icon="i-lucide-share-2"
color="secondary"
variant="ghost"
square
@click="shareNovel"
/>
</template>
</UDashboardNavbar>
</template>
<template #body>
<UContainer class="py-8 space-y-8">
<!-- Compact Header Section -->
<div class="grid grid-cols-1 md:grid-cols-5 gap-6">
<!-- Cover Image -->
<div class="md:col-span-1">
<img
:src="novel.cover"
:alt="novel.title"
class="w-full rounded-lg shadow-lg"
>
<div class="flex flex-col gap-2 mt-4">
<UButton
icon="i-lucide-play"
color="primary"
size="lg"
class="w-full"
@click="startReading"
>
Start Reading
</UButton>
<UButton
v-if="lastReadChapter"
icon="i-lucide-bookmark-check"
color="secondary"
variant="soft"
size="lg"
class="w-full"
@click="continueReading"
>
Continue Reading
</UButton>
<UButton
icon="i-lucide-share-2"
color="secondary"
variant="soft"
size="lg"
class="w-full"
@click="shareNovel"
>
Share
</UButton>
</div>
</div>
<!-- Summary & Info -->
<div class="md:col-span-4 space-y-4">
<div>
<div class="flex items-center gap-3 mb-2 flex-wrap">
<h1 class="text-2xl font-bold">
{{ novel.title }}
</h1>
<UBadge
:color="novel.status === 'completed' ? 'success' : novel.status === 'hiatus' ? 'warning' : 'info'"
>
{{ novel.status }}
</UBadge>
</div>
<p class="text-gray-600 dark:text-gray-400">
by {{ typeof novel.author === 'string' ? novel.author : novel.author?.name }}
</p>
</div>
<!-- Quick Stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div class="bg-gray-50 dark:bg-gray-900 p-3 rounded">
<p class="text-xs text-gray-600 dark:text-gray-400">
Rating
</p>
<p class="text-lg font-bold flex items-center gap-1">
<UIcon name="i-lucide-star" class="size-4 text-yellow-500" />
{{ (novel.rating || 0).toFixed(1) }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 p-3 rounded">
<p class="text-xs text-gray-600 dark:text-gray-400">
Chapters
</p>
<p class="text-lg font-bold">
{{ novel.chapters || 0 }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 p-3 rounded">
<p class="text-xs text-gray-600 dark:text-gray-400">
Views
</p>
<p class="text-lg font-bold">
{{ formatNumber(novel.views || 0) }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-900 p-3 rounded">
<p class="text-xs text-gray-600 dark:text-gray-400">
Followers
</p>
<p class="text-lg font-bold">
{{ formatNumber(novel.followers || 0) }}
</p>
</div>
</div>
<!-- Summary -->
<div class="bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<p class="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{{ novel.description }}
</p>
</div>
<!-- Genres -->
<div class="flex flex-wrap gap-2">
<UBadge
v-for="genre in novel.genres"
:key="genre"
size="sm"
variant="outline"
>
{{ genre }}
</UBadge>
</div>
</div>
</div>
<!-- Chapters Section (Main Content) -->
<div class="border-t pt-8">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<h2 class="text-2xl font-bold">
{{ chapters.length }} Chapter{{ chapters.length !== 1 ? 's' : '' }}
</h2>
<div class="flex items-center gap-2">
<UInput
v-model="chapterSearch"
icon="i-lucide-search"
placeholder="Search chapters..."
class="w-full sm:w-64"
/>
<UButton
:icon="sortAscending ? 'i-lucide-arrow-up-1-0' : 'i-lucide-arrow-down-1-0'"
color="secondary"
variant="ghost"
square
@click="sortAscending = !sortAscending"
/>
</div>
</div>
<div v-if="sortedChapters.length > 0" class="space-y-2">
<ChapterList
:chapters="sortedChapters"
:novel-id="novelId"
/>
</div>
<div v-else class="text-center py-12">
<UIcon name="i-lucide-search-x" class="size-12 mx-auto mb-4 text-gray-400" />
<p class="text-gray-600 dark:text-gray-400">
No chapters found matching "{{ chapterSearch }}"
</p>
</div>
</div>
</UContainer>
</template>
</UDashboardPanel>
<!-- Loading State -->
<div v-else-if="isLoading" class="p-8">
<USkeleton class="h-96" />
</div>
<!-- Error State -->
<div v-else class="p-8 text-center">
<UIcon name="i-lucide-alert-circle" class="size-12 mx-auto mb-4 text-red-500" />
<h3 class="text-lg font-semibold mb-2">
{{ error }}
</h3>
<UButton color="gray" @click="router.back()">
Go Back
</UButton>
</div>
</template>