444 lines
14 KiB
Vue
444 lines
14 KiB
Vue
<script setup lang="ts">
|
|
import type { WebNovel, Chapter } from '~/types'
|
|
|
|
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 filteredItems = allItems
|
|
.filter((item) => {
|
|
const path = item._path || item.id || ''
|
|
return path.includes(`/novels/${novelId.value}/`) && path.includes('/ch-')
|
|
})
|
|
.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: slug || 'unknown', // Use slug for SEO-friendly URLs
|
|
novelId: novelId.value,
|
|
number: item.meta?.number || 0,
|
|
title: item.title || 'Untitled Chapter',
|
|
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>
|