345 lines
11 KiB
Vue
345 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import type { WebNovel } from '~/types'
|
|
|
|
useSeoMeta({
|
|
title: 'Advanced Search - LastWebNovel',
|
|
description: 'Search and filter novels by genre, rating, and more'
|
|
})
|
|
|
|
const router = useRouter()
|
|
const isLoading = ref(true)
|
|
const allNovels = ref<WebNovel[]>([])
|
|
|
|
// Search and filter states
|
|
const searchQuery = ref('')
|
|
const selectedGenres = ref<string[]>([])
|
|
const selectedStatus = ref<string>('all')
|
|
const minRating = ref<number>(0)
|
|
const sortBy = ref<string>('relevance')
|
|
|
|
// Available options
|
|
const availableGenres = computed(() => {
|
|
const genresSet = new Set<string>()
|
|
allNovels.value.forEach((novel) => {
|
|
novel.genres?.forEach(genre => genresSet.add(genre))
|
|
})
|
|
return Array.from(genresSet).sort()
|
|
})
|
|
|
|
const statusOptions = [
|
|
{ value: 'all', label: 'All Status' },
|
|
{ value: 'ongoing', label: 'Ongoing' },
|
|
{ value: 'completed', label: 'Completed' },
|
|
{ value: 'hiatus', label: 'Hiatus' }
|
|
]
|
|
|
|
const ratingOptions = [
|
|
{ value: 0, label: 'All Ratings' },
|
|
{ value: 4.0, label: '4.0+' },
|
|
{ value: 4.5, label: '4.5+' },
|
|
{ value: 4.8, label: '4.8+' }
|
|
]
|
|
|
|
const sortOptions = [
|
|
{ value: 'relevance', label: 'Relevance' },
|
|
{ value: 'rating', label: 'Highest Rating' },
|
|
{ value: 'views', label: 'Most Views' },
|
|
{ value: 'followers', label: 'Most Followers' },
|
|
{ value: 'updated', label: 'Recently Updated' },
|
|
{ value: 'title', label: 'Title A-Z' }
|
|
]
|
|
|
|
// Genre options computed from available genres
|
|
const genreOptions = computed(() =>
|
|
availableGenres.value.map(genre => ({ value: genre, label: genre.charAt(0).toUpperCase() + genre.slice(1) }))
|
|
)
|
|
|
|
// Filtered and sorted results
|
|
const filteredNovels = computed(() => {
|
|
let results = [...allNovels.value]
|
|
|
|
// Text search
|
|
if (searchQuery.value.trim()) {
|
|
const query = searchQuery.value.toLowerCase()
|
|
results = results.filter(novel =>
|
|
novel.title.toLowerCase().includes(query)
|
|
|| (typeof novel.author === 'string' ? novel.author : novel.author?.name || '').toLowerCase().includes(query)
|
|
|| novel.description.toLowerCase().includes(query)
|
|
)
|
|
}
|
|
|
|
// Genre filter
|
|
if (selectedGenres.value.length > 0) {
|
|
results = results.filter(novel =>
|
|
selectedGenres.value.some(genre => novel.genres?.includes(genre))
|
|
)
|
|
}
|
|
|
|
// Status filter
|
|
if (selectedStatus.value !== 'all') {
|
|
results = results.filter(novel => novel.status === selectedStatus.value)
|
|
}
|
|
|
|
// Rating filter
|
|
if (minRating.value > 0) {
|
|
results = results.filter(novel => (novel.rating || 0) >= minRating.value)
|
|
}
|
|
|
|
// Sorting
|
|
switch (sortBy.value) {
|
|
case 'rating':
|
|
results.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
|
break
|
|
case 'views':
|
|
results.sort((a, b) => (b.views || 0) - (a.views || 0))
|
|
break
|
|
case 'followers':
|
|
results.sort((a, b) => (b.followers || 0) - (a.followers || 0))
|
|
break
|
|
case 'updated':
|
|
results.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
break
|
|
case 'title':
|
|
results.sort((a, b) => a.title.localeCompare(b.title))
|
|
break
|
|
// 'relevance' keeps the filtered order
|
|
}
|
|
|
|
return results
|
|
})
|
|
|
|
const hasActiveFilters = computed(() => {
|
|
return selectedGenres.value.length > 0
|
|
|| selectedStatus.value !== 'all'
|
|
|| minRating.value > 0
|
|
})
|
|
|
|
const clearFilters = () => {
|
|
selectedGenres.value = []
|
|
selectedStatus.value = 'all'
|
|
minRating.value = 0
|
|
sortBy.value = 'relevance'
|
|
}
|
|
|
|
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 goToNovel = (novelId: string) => {
|
|
router.push(`/novels/${novelId}`)
|
|
}
|
|
|
|
// Load novels data
|
|
const loadNovels = async () => {
|
|
try {
|
|
isLoading.value = true
|
|
|
|
const allItems = await queryCollection('content').all() as any[]
|
|
console.log(`Total content items found: ${allItems.length}`)
|
|
|
|
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
|
|
})
|
|
|
|
allNovels.value = novels
|
|
console.log(`Loaded ${novels.length} novels for search`)
|
|
} catch (err) {
|
|
console.error('Error loading novels:', err)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadNovels()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="search">
|
|
<template #header>
|
|
<UDashboardNavbar title="Advanced Search" :ui="{ right: 'gap-3' }">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
</UDashboardNavbar>
|
|
</template>
|
|
|
|
<template #body>
|
|
<UContainer class="py-6">
|
|
<!-- Search Bar -->
|
|
<div class="mb-6">
|
|
<UInput
|
|
v-model="searchQuery"
|
|
icon="i-lucide-search"
|
|
size="xl"
|
|
placeholder="Search by title, author, or description..."
|
|
class="w-full"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Filters Bar -->
|
|
<div class="flex flex-row items-center justify-between gap-4 mb-6">
|
|
<span class="text-sm text-gray-600 dark:text-gray-400">
|
|
{{ filteredNovels.length }} novel{{ filteredNovels.length !== 1 ? 's' : '' }}
|
|
</span>
|
|
<!-- Clear Filters Button -->
|
|
<UButton
|
|
:disabled="!hasActiveFilters"
|
|
icon="i-lucide-x"
|
|
color="error"
|
|
variant="subtle"
|
|
@click="clearFilters"
|
|
>
|
|
Clear Filters
|
|
</UButton>
|
|
</div>
|
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
|
|
<div class="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
|
<!-- Sort Select -->
|
|
<USelect
|
|
v-model="sortBy"
|
|
:items="sortOptions"
|
|
option-attribute="label"
|
|
value-attribute="value"
|
|
placeholder="Sort by..."
|
|
class="w-full sm:w-40"
|
|
/>
|
|
|
|
<!-- Genres Select -->
|
|
<USelect
|
|
v-model="selectedGenres"
|
|
:items="genreOptions"
|
|
multiple
|
|
option-attribute="label"
|
|
value-attribute="value"
|
|
placeholder="Genres"
|
|
class="w-full sm:w-40"
|
|
/>
|
|
|
|
<!-- Status Select -->
|
|
<USelect
|
|
v-model="selectedStatus"
|
|
:items="statusOptions"
|
|
option-attribute="label"
|
|
value-attribute="value"
|
|
placeholder="Status"
|
|
class="w-full sm:w-40"
|
|
/>
|
|
|
|
<!-- Rating Select -->
|
|
<USelect
|
|
v-model="minRating"
|
|
:items="ratingOptions"
|
|
option-attribute="label"
|
|
value-attribute="value"
|
|
placeholder="Rating"
|
|
class="w-full sm:w-40"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Results Grid -->
|
|
<div v-if="isLoading" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
<USkeleton v-for="i in 10" :key="i" class="h-80" />
|
|
</div>
|
|
|
|
<div
|
|
v-else-if="filteredNovels.length > 0"
|
|
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"
|
|
>
|
|
<div
|
|
v-for="novel in filteredNovels"
|
|
:key="novel.id"
|
|
class="group cursor-pointer"
|
|
@click="goToNovel(novel.id)"
|
|
>
|
|
<div class="relative overflow-hidden rounded-lg mb-2 aspect-2/3">
|
|
<img
|
|
:src="`/images/${novel.slug || novel.id}/cover.png`"
|
|
:alt="novel.title"
|
|
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-110"
|
|
>
|
|
<div class="absolute inset-0 bg-linear-to-t from-black/90 via-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
|
<div class="absolute bottom-0 left-0 right-0 p-3 space-y-1">
|
|
<div class="flex items-center gap-1 text-xs text-white/90">
|
|
<UIcon name="i-lucide-star" class="size-3 text-yellow-400" />
|
|
<span>{{ (novel.rating || 0).toFixed(1) }}</span>
|
|
<span class="mx-1">•</span>
|
|
<span>{{ novel.chapters || 0 }} ch</span>
|
|
</div>
|
|
<div class="flex items-center gap-1 text-xs text-white/80">
|
|
<UIcon name="i-lucide-eye" class="size-3" />
|
|
<span>{{ formatNumber(novel.views || 0) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<UBadge
|
|
:color="novel.status === 'completed' ? 'success' : novel.status === 'hiatus' ? 'warning' : 'info'"
|
|
size="xs"
|
|
class="absolute top-2 right-2"
|
|
>
|
|
{{ novel.status }}
|
|
</UBadge>
|
|
</div>
|
|
<h3 class="font-semibold text-sm line-clamp-2 mb-1">
|
|
{{ novel.title }}
|
|
</h3>
|
|
<p class="text-xs text-gray-600 dark:text-gray-400 line-clamp-1">
|
|
{{ typeof novel.author === 'string' ? novel.author : novel.author?.name }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else class="text-center py-12">
|
|
<UIcon name="i-lucide-search-x" 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 mb-4">
|
|
Try adjusting your filters or search query
|
|
</p>
|
|
<UButton
|
|
v-if="hasActiveFilters || searchQuery"
|
|
color="secondary"
|
|
@click="clearFilters(); searchQuery = ''"
|
|
>
|
|
Clear all filters
|
|
</UButton>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
</UDashboardPanel>
|
|
</template>
|