249 lines
8.5 KiB
Vue
249 lines
8.5 KiB
Vue
<script setup lang="ts">
|
|
import type { WebNovel } from '~/types'
|
|
|
|
useSeoMeta({
|
|
title: 'Latest Updates - LastWebNovel',
|
|
description: 'Novels with the latest chapter updates'
|
|
})
|
|
|
|
const router = useRouter()
|
|
const isLoading = ref(true)
|
|
const updatedNovels = ref<WebNovel[]>([])
|
|
const displayLimit = ref(5)
|
|
|
|
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 formatDate = (dateString: string): string => {
|
|
const date = new Date(dateString)
|
|
const now = new Date()
|
|
const diffTime = Math.abs(now.getTime() - date.getTime())
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
|
|
|
if (diffDays === 0) {
|
|
return 'Updated today'
|
|
} else if (diffDays === 1) {
|
|
return 'Updated yesterday'
|
|
} else if (diffDays < 7) {
|
|
return `Updated ${diffDays} days ago`
|
|
} else if (diffDays < 30) {
|
|
return `Updated ${Math.floor(diffDays / 7)} weeks ago`
|
|
} else if (diffDays < 365) {
|
|
return `Updated ${Math.floor(diffDays / 30)} months ago`
|
|
} else {
|
|
return `Updated ${Math.floor(diffDays / 365)} years ago`
|
|
}
|
|
}
|
|
|
|
const goToNovel = (novelId: string) => {
|
|
router.push(`/novels/${novelId}`)
|
|
}
|
|
|
|
const loadMoreNovels = () => {
|
|
displayLimit.value += 1
|
|
}
|
|
|
|
const displayedNovels = computed(() => {
|
|
return updatedNovels.value.slice(0, displayLimit.value)
|
|
})
|
|
|
|
const hasMoreNovels = computed(() => {
|
|
return displayLimit.value < updatedNovels.value.length
|
|
})
|
|
|
|
// Load novels data
|
|
const loadNovels = async () => {
|
|
try {
|
|
isLoading.value = true
|
|
|
|
const allItems = await queryCollection('content').all() as any[]
|
|
|
|
const novels: WebNovel[] = allItems
|
|
.filter((item) => {
|
|
const path = item._path || item.id || ''
|
|
return path.includes('/novels/') && path.endsWith('/index.md')
|
|
})
|
|
.map((item) => {
|
|
const pathParts = (item._path || item.id || '').split('/')
|
|
const novelId = pathParts[pathParts.length - 2] || pathParts[pathParts.length - 1]
|
|
|
|
return {
|
|
id: novelId,
|
|
title: item.title || 'Unknown',
|
|
slug: item.meta?.slug || 'no-slug',
|
|
author: item.meta?.author || item.author || 'Unknown',
|
|
description: item.description || item.body?.text || '',
|
|
cover: `/images/${novelId}/cover.png`,
|
|
status: item.meta?.status || item.status || 'ongoing',
|
|
genres: item.meta?.genres || item.genres || [],
|
|
rating: item.meta?.rating || item.rating || 0,
|
|
views: item.meta?.views || item.views || 0,
|
|
followers: item.meta?.followers || item.followers || 0,
|
|
chapters: item.meta?.chapters || item.chapters || 0,
|
|
language: item.meta?.language || item.language || 'English',
|
|
tags: item.meta?.tags || item.tags || [],
|
|
createdAt: item.meta?.createdAt || item.createdAt || new Date().toISOString(),
|
|
updatedAt: item.meta?.updatedAt || item.updatedAt || new Date().toISOString()
|
|
} as WebNovel
|
|
})
|
|
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
|
|
updatedNovels.value = novels
|
|
console.log(`Loaded ${novels.length} recently updated novels`)
|
|
} catch (err) {
|
|
console.error('Error loading novels:', err)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadNovels()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="latest-updates">
|
|
<template #header>
|
|
<UDashboardNavbar title="Latest Updates" :ui="{ right: 'gap-3' }">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
</template>
|
|
|
|
<template #body>
|
|
<UContainer class="py-6">
|
|
<!-- Header Info -->
|
|
<div class="flex flex-row items-center justify-between gap-4 mb-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold mb-1">
|
|
Recently Updated
|
|
</h2>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ updatedNovels.length }} novel{{ updatedNovels.length !== 1 ? 's' : '' }} with recent updates
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="space-y-4">
|
|
<USkeleton v-for="i in 10" :key="i" class="h-32" />
|
|
</div>
|
|
|
|
<!-- Novels List -->
|
|
<div
|
|
v-else-if="displayedNovels.length > 0"
|
|
class="space-y-6"
|
|
>
|
|
<!-- Mobile-First List View -->
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="novel in displayedNovels"
|
|
:key="novel.id"
|
|
class="flex gap-3 sm:gap-4 p-3 sm:p-4 bg-gray-50 dark:bg-gray-900 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
|
@click="goToNovel(novel.id)"
|
|
>
|
|
<!-- Cover Image -->
|
|
<div class="flex-shrink-0 w-20 sm:w-24 md:w-28">
|
|
<div class="relative overflow-hidden rounded aspect-2/3">
|
|
<img
|
|
:src="`/images/${novel.slug || novel.id}/cover.png`"
|
|
:alt="novel.title"
|
|
class="w-full h-full object-cover"
|
|
>
|
|
<UBadge
|
|
:color="novel.status === 'completed' ? 'success' : novel.status === 'hiatus' ? 'warning' : 'info'"
|
|
size="xs"
|
|
class="absolute top-1 left-1"
|
|
>
|
|
{{ novel.status }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Novel Info -->
|
|
<div class="flex-1 min-w-0 flex flex-col justify-between">
|
|
<div>
|
|
<h3 class="font-semibold text-sm sm:text-base line-clamp-2 mb-1">
|
|
{{ novel.title }}
|
|
</h3>
|
|
<p class="text-xs text-gray-600 dark:text-gray-400 line-clamp-1 mb-2">
|
|
{{ typeof novel.author === 'string' ? novel.author : novel.author?.name }}
|
|
</p>
|
|
|
|
<!-- Genres (Desktop) -->
|
|
<div class="hidden sm:flex flex-wrap gap-1 mb-2">
|
|
<UBadge
|
|
v-for="genre in novel.genres?.slice(0, 3)"
|
|
:key="genre"
|
|
size="xs"
|
|
variant="soft"
|
|
class="capitalize"
|
|
>
|
|
{{ genre }}
|
|
</UBadge>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Stats Row -->
|
|
<div class="flex flex-wrap items-center gap-2 sm:gap-4 text-xs text-gray-600 dark:text-gray-400">
|
|
<div class="flex items-center gap-1">
|
|
<UIcon name="i-lucide-book-open" class="size-3" />
|
|
<span>{{ novel.chapters || 0 }} ch</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<UIcon name="i-lucide-star" class="size-3 text-yellow-400" />
|
|
<span>{{ (novel.rating || 0).toFixed(1) }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1">
|
|
<UIcon name="i-lucide-eye" class="size-3" />
|
|
<span>{{ formatNumber(novel.views || 0) }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-1 text-primary">
|
|
<UIcon name="i-lucide-clock" class="size-3" />
|
|
<span>{{ formatDate(novel.updatedAt) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Arrow Icon (Desktop) -->
|
|
<div class="hidden md:flex items-center">
|
|
<UIcon name="i-lucide-chevron-right" class="size-5 text-gray-400" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Load More Button -->
|
|
<div v-if="hasMoreNovels" class="flex justify-center pt-4">
|
|
<UButton
|
|
size="lg"
|
|
variant="soft"
|
|
@click="loadMoreNovels"
|
|
>
|
|
Load More Novels
|
|
</UButton>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else class="text-center py-12">
|
|
<UIcon name="i-lucide-zap" class="size-12 mx-auto mb-4 text-gray-400" />
|
|
<h3 class="text-lg font-semibold mb-2">
|
|
No novels found
|
|
</h3>
|
|
<p class="text-gray-600 dark:text-gray-400">
|
|
Check back later for updated novels
|
|
</p>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
</UDashboardPanel>
|
|
</template>
|