322 lines
9.7 KiB
Vue
322 lines
9.7 KiB
Vue
<script setup lang="ts">
|
|
import type { WebNovel, NovelGenre, NovelStatus } from '~/types'
|
|
|
|
const router = useRouter()
|
|
|
|
const novels = ref<WebNovel[]>([])
|
|
const isLoading = ref(true)
|
|
const viewMode = ref<'grid' | 'list'>('grid')
|
|
const showFilters = ref(false)
|
|
|
|
const filters = reactive({
|
|
genre: null as NovelGenre | null,
|
|
status: null as NovelStatus | null,
|
|
search: ''
|
|
})
|
|
|
|
const genres = ref<NovelGenre[]>([
|
|
'fantasy', 'romance', 'sci-fi', 'mystery', 'slice-of-life',
|
|
'action', 'adventure', 'horror', 'comedy', 'drama'
|
|
])
|
|
|
|
const statuses = ref<NovelStatus[]>(['ongoing', 'completed', 'hiatus'])
|
|
|
|
const filteredNovels = computed(() => {
|
|
let result = novels.value
|
|
|
|
if (filters.search) {
|
|
result = result.filter(n =>
|
|
(n.title || '').toLowerCase().includes(filters.search.toLowerCase())
|
|
|| (n.author || '').toLowerCase().includes(filters.search.toLowerCase())
|
|
)
|
|
}
|
|
|
|
if (filters.genre) {
|
|
result = result.filter(n => (n.genres || []).includes(filters.genre!))
|
|
}
|
|
|
|
if (filters.status) {
|
|
result = result.filter(n => n.status === filters.status)
|
|
}
|
|
|
|
return result
|
|
})
|
|
|
|
const sortOptions = [
|
|
{ value: 'rating', label: 'Highest Rated' },
|
|
{ value: 'views', label: 'Most Viewed' },
|
|
{ value: 'chapters', label: 'Most Chapters' },
|
|
{ value: 'recent', label: 'Recently Updated' }
|
|
]
|
|
|
|
const currentSort = ref('rating')
|
|
|
|
const sortedNovels = computed(() => {
|
|
const sorted = [...filteredNovels.value]
|
|
|
|
switch (currentSort.value) {
|
|
case 'rating':
|
|
return sorted.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
|
case 'views':
|
|
return sorted.sort((a, b) => (b.views || 0) - (a.views || 0))
|
|
case 'chapters':
|
|
return sorted.sort((a, b) => (b.chapters || 0) - (a.chapters || 0))
|
|
case 'recent':
|
|
return sorted.sort((a, b) => new Date(b.updatedAt || 0).getTime() - new Date(a.updatedAt || 0).getTime())
|
|
default:
|
|
return sorted
|
|
}
|
|
})
|
|
|
|
const hasActiveFilters = computed(() => filters.genre || filters.status || filters.search)
|
|
|
|
const clearFilters = () => {
|
|
Object.assign(filters, { genre: null, status: null, search: '' })
|
|
showFilters.value = false
|
|
}
|
|
|
|
const loadNovels = async () => {
|
|
try {
|
|
// Fetch all novels from Nuxt Content using queryCollection
|
|
const content = await queryCollection('content').all() as WebNovel[]
|
|
|
|
// Filter to only index files (no chapters)
|
|
novels.value = content.filter(n => !n._path?.includes('/ch-')) as WebNovel[]
|
|
} catch (error) {
|
|
console.error('Error loading novels:', error)
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadNovels()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UDashboardPanel id="novels">
|
|
<template #header>
|
|
<UDashboardNavbar title="Browse Novels" :ui="{ right: 'gap-2 sm:gap-3' }">
|
|
<template #leading>
|
|
<UDashboardSidebarCollapse />
|
|
</template>
|
|
|
|
<template #right>
|
|
<UInputMenu
|
|
v-model="filters.search"
|
|
icon="i-lucide-search"
|
|
placeholder="Search..."
|
|
:popper="{ placement: 'bottom-end' }"
|
|
class="w-full sm:w-64"
|
|
size="sm"
|
|
/>
|
|
</template>
|
|
</UDashboardNavbar>
|
|
|
|
<UDashboardToolbar>
|
|
<template #left>
|
|
<div class="flex items-center gap-2 sm:gap-3 w-full sm:w-auto">
|
|
<!-- Sort dropdown - responsive -->
|
|
<USelect
|
|
v-model="currentSort"
|
|
:options="sortOptions"
|
|
color="white"
|
|
variant="none"
|
|
class="w-full sm:w-40 text-sm"
|
|
size="sm"
|
|
/>
|
|
|
|
<UDivider orientation="vertical" class="hidden sm:block h-6" />
|
|
|
|
<!-- View mode toggle -->
|
|
<UButton
|
|
:icon="`i-lucide-${viewMode === 'grid' ? 'layout-list' : 'grid-2x2'}`"
|
|
size="sm"
|
|
color="gray"
|
|
variant="ghost"
|
|
@click="viewMode = viewMode === 'grid' ? 'list' : 'grid'"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<template #right>
|
|
<div class="flex items-center gap-2">
|
|
<!-- Filter button with badge -->
|
|
<UButton
|
|
icon="i-lucide-filter"
|
|
size="sm"
|
|
:color="hasActiveFilters ? 'primary' : 'gray'"
|
|
:variant="hasActiveFilters ? 'soft' : 'ghost'"
|
|
@click="showFilters = true"
|
|
>
|
|
<template v-if="hasActiveFilters" #trailing>
|
|
<UBadge color="primary" variant="subtle" class="w-5 h-5 flex items-center justify-center p-0 text-xs">
|
|
{{ (filters.genre ? 1 : 0) + (filters.status ? 1 : 0) }}
|
|
</UBadge>
|
|
</template>
|
|
</UButton>
|
|
|
|
<!-- Clear filters button -->
|
|
<UButton
|
|
v-if="hasActiveFilters"
|
|
size="sm"
|
|
color="gray"
|
|
variant="outline"
|
|
label="Clear"
|
|
class="hidden sm:flex"
|
|
@click="clearFilters"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</UDashboardToolbar>
|
|
</template>
|
|
|
|
<template #body>
|
|
<UContainer class="py-4 sm:py-8 space-y-4 sm:space-y-6">
|
|
<!-- Mobile Filters Drawer -->
|
|
<UDrawer v-model="showFilters" title="Filters" side="right">
|
|
<template #header>
|
|
<div class="flex items-center justify-between gap-2">
|
|
<h2 class="text-lg font-semibold">
|
|
Filters
|
|
</h2>
|
|
<UButton
|
|
color="gray"
|
|
variant="ghost"
|
|
size="sm"
|
|
icon="i-lucide-x"
|
|
@click="showFilters = false"
|
|
/>
|
|
</div>
|
|
</template>
|
|
|
|
<div class="space-y-4 p-4">
|
|
<!-- Genre Filter -->
|
|
<div>
|
|
<label class="text-sm font-semibold mb-2 block">Genre</label>
|
|
<USelect
|
|
v-model="filters.genre"
|
|
:options="genres"
|
|
placeholder="All genres"
|
|
class="w-full"
|
|
@update:model-value="v => filters.genre = v || null"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Status Filter -->
|
|
<div>
|
|
<label class="text-sm font-semibold mb-2 block">Status</label>
|
|
<USelect
|
|
v-model="filters.status"
|
|
:options="statuses"
|
|
placeholder="All statuses"
|
|
class="w-full"
|
|
@update:model-value="v => filters.status = v || null"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Clear button in drawer -->
|
|
<UButton
|
|
v-if="hasActiveFilters"
|
|
block
|
|
color="gray"
|
|
variant="outline"
|
|
@click="clearFilters"
|
|
>
|
|
Clear All Filters
|
|
</UButton>
|
|
</div>
|
|
</UDrawer>
|
|
|
|
<!-- Desktop Filters (hidden on mobile) -->
|
|
<div class="hidden sm:flex gap-4 items-end flex-wrap">
|
|
<div class="w-48">
|
|
<label class="text-sm font-semibold mb-2 block">Genre</label>
|
|
<USelect
|
|
v-model="filters.genre"
|
|
:options="genres"
|
|
placeholder="All genres"
|
|
@update:model-value="v => filters.genre = v || null"
|
|
/>
|
|
</div>
|
|
|
|
<div class="w-48">
|
|
<label class="text-sm font-semibold mb-2 block">Status</label>
|
|
<USelect
|
|
v-model="filters.status"
|
|
:options="statuses"
|
|
placeholder="All statuses"
|
|
@update:model-value="v => filters.status = v || null"
|
|
/>
|
|
</div>
|
|
|
|
<div class="flex-1">
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
Found <span class="font-semibold">{{ sortedNovels.length }}</span> novel{{ sortedNovels.length !== 1 ? 's' : '' }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Mobile Results Summary -->
|
|
<div class="sm:hidden px-4">
|
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
|
Found <span class="font-semibold">{{ sortedNovels.length }}</span> novel{{ sortedNovels.length !== 1 ? 's' : '' }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="grid gap-3 sm:gap-4 px-4 sm:px-0" :class="viewMode === 'grid' ? 'grid-cols-2 sm:grid-cols-2 lg:grid-cols-3' : ''">
|
|
<div v-for="i in 6" :key="i">
|
|
<USkeleton class="h-64 sm:h-80" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="sortedNovels.length === 0" class="text-center py-12">
|
|
<UIcon name="i-lucide-inbox" 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-sm sm:text-base text-gray-600 dark:text-gray-400 mb-4 px-4">
|
|
Try adjusting your filters or search term
|
|
</p>
|
|
<UButton
|
|
size="sm"
|
|
color="gray"
|
|
@click="clearFilters"
|
|
>
|
|
Clear Filters
|
|
</UButton>
|
|
</div>
|
|
|
|
<!-- Novels Grid/List -->
|
|
<div
|
|
v-else
|
|
class="px-4 sm:px-0"
|
|
:class="viewMode === 'grid'
|
|
? 'grid gap-3 sm:gap-4 grid-cols-2 sm:grid-cols-2 lg:grid-cols-3'
|
|
: 'space-y-2 sm:space-y-4'
|
|
"
|
|
>
|
|
<template v-if="viewMode === 'grid'">
|
|
<NovelCard
|
|
v-for="novel in sortedNovels"
|
|
:key="novel.id"
|
|
:novel="novel"
|
|
/>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<NovelCardList
|
|
v-for="novel in sortedNovels"
|
|
:key="novel.id"
|
|
:novel="novel"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</UContainer>
|
|
</template>
|
|
</UDashboardPanel>
|
|
</template>
|