Files
bookorbit/client/src/features/series/views/SeriesView.vue
T

518 lines
17 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowUpDown, Filter, Search, SlidersHorizontal, X } from 'lucide-vue-next'
import ViewHeader from '@/components/ViewHeader.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { storage } from '@/services/storage'
import SeriesCard from '../components/SeriesCard.vue'
import SeriesFilters from '../components/SeriesFilters.vue'
import { useSeriesList } from '../composables/useSeriesList'
import type { CompletionStatus, SeriesListSort, SortDirection } from '../types/series'
const router = useRouter()
const route = useRoute()
const { viewMode } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const { items, total, loading, error, hasMore, q, sort, order, libraryId, completionStatus, author, load } = useSeriesList()
const SERIES_CARD_WIDTH_STORAGE_KEY = 'projectx:seriesCardWidth'
const SERIES_GRID_GAP_STORAGE_KEY = 'projectx:seriesGridGap'
const SERIES_CARD_WIDTH_DEFAULT = 260
const SERIES_CARD_WIDTH_MIN = 240
const SERIES_CARD_WIDTH_MAX = 420
const SERIES_GRID_GAP_MIN = 8
const SERIES_GRID_GAP_MAX = 48
const hydrating = ref(true)
const suppressAutoReload = ref(false)
const filtersOpen = ref(false)
const mobileControlsExpanded = ref(false)
const mobileSearchOpen = ref(false)
const initialLoadComplete = ref(false)
const INITIAL_SKELETON_COUNT = 18
const sentinel = ref<HTMLElement | null>(null)
const mobileSearchInput = ref<HTMLInputElement | null>(null)
const seriesCardWidth = ref(
Math.min(Math.max(storage.get(SERIES_CARD_WIDTH_STORAGE_KEY, SERIES_CARD_WIDTH_DEFAULT), SERIES_CARD_WIDTH_MIN), SERIES_CARD_WIDTH_MAX),
)
const seriesGridGap = ref(Math.max(storage.get(SERIES_GRID_GAP_STORAGE_KEY, 20), SERIES_GRID_GAP_MIN))
let observer: IntersectionObserver | null = null
let searchTimer: ReturnType<typeof setTimeout> | null = null
const SORT_LABELS: Record<SeriesListSort, string> = {
name: 'Name',
bookCount: 'Book Count',
lastAddedAt: 'Recent Additions',
readProgress: 'Reading Progress',
}
const isDefaultSort = computed(() => sort.value === 'name' && order.value === 'asc')
const sortSummary = computed(() => `${SORT_LABELS[sort.value]} ${order.value === 'asc' ? '↑' : '↓'}`)
const activeFilterCount = computed(() => {
let count = 0
if (q.value.trim()) count += 1
if (libraryId.value !== null) count += 1
if (completionStatus.value !== null) count += 1
if (author.value) count += 1
return count
})
const mobileControlsBadgeCount = computed(() => activeFilterCount.value + (!isDefaultSort.value ? 1 : 0))
function parseSort(value: unknown): SeriesListSort {
return value === 'bookCount' || value === 'lastAddedAt' || value === 'readProgress' || value === 'name' ? value : 'name'
}
function parseOrder(value: unknown): SortDirection {
return value === 'desc' || value === 'asc' ? value : 'asc'
}
function parseLibraryId(value: unknown): number | null {
const raw = typeof value === 'string' ? Number(value) : NaN
return Number.isInteger(raw) && raw > 0 ? raw : null
}
function parseCompletionStatus(value: unknown): CompletionStatus | null {
if (value === 'not_started' || value === 'in_progress' || value === 'complete') return value
return null
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function syncRouteQuery() {
void router.replace({
name: 'series',
query: {
q: q.value.trim() || undefined,
sort: sort.value !== 'name' ? sort.value : undefined,
order: order.value !== 'asc' ? order.value : undefined,
libraryId: libraryId.value ? String(libraryId.value) : undefined,
completionStatus: completionStatus.value ?? undefined,
author: author.value || undefined,
},
})
}
function openSeries(seriesName: string) {
void router.push({ name: 'series-detail', params: { seriesName }, query: { from: route.fullPath } })
}
function setSortField(field: SeriesListSort) {
sort.value = field
order.value = 'asc'
collapseMobileControlsIfNeeded()
}
function setSortOrder(dir: SortDirection) {
order.value = dir
collapseMobileControlsIfNeeded()
}
function resetSort() {
sort.value = 'name'
order.value = 'asc'
collapseMobileControlsIfNeeded()
}
function clearSearchQuery() {
q.value = ''
}
function toggleFiltersOpen() {
filtersOpen.value = !filtersOpen.value
}
function closeFiltersPanel() {
filtersOpen.value = false
}
function isMobileViewport() {
return typeof window !== 'undefined' && window.innerWidth < 640
}
function closeMobileControls() {
mobileControlsExpanded.value = false
mobileSearchOpen.value = false
}
function collapseMobileControlsIfNeeded() {
if (!mobileControlsExpanded.value) return
if (!isMobileViewport()) return
closeMobileControls()
}
function toggleMobileControls() {
if (mobileControlsExpanded.value) {
closeMobileControls()
return
}
mobileControlsExpanded.value = true
}
function toggleMobileSearch() {
mobileSearchOpen.value = !mobileSearchOpen.value
if (!mobileSearchOpen.value) return
void nextTick(() => {
mobileSearchInput.value?.focus()
})
}
function closeMobileSearch() {
mobileSearchOpen.value = false
}
async function clearFilters() {
if (searchTimer) clearTimeout(searchTimer)
suppressAutoReload.value = true
q.value = ''
sort.value = 'name'
order.value = 'asc'
libraryId.value = null
completionStatus.value = null
author.value = null
syncRouteQuery()
await load(true)
filtersOpen.value = false
collapseMobileControlsIfNeeded()
await nextTick()
suppressAutoReload.value = false
}
function handleLibraryIdUpdate(value: number | null) {
libraryId.value = value
}
function handleCompletionStatusUpdate(value: CompletionStatus | null) {
completionStatus.value = value
}
function handleAuthorUpdate(value: string | null) {
author.value = value
}
function loadIfSentinelVisible() {
if (loading.value || !hasMore.value || !sentinel.value) return
if (sentinel.value.getBoundingClientRect().top < window.innerHeight + 250) {
void load()
}
}
onMounted(async () => {
q.value = typeof route.query.q === 'string' ? route.query.q : ''
sort.value = parseSort(route.query.sort)
order.value = parseOrder(route.query.order)
libraryId.value = parseLibraryId(route.query.libraryId)
completionStatus.value = parseCompletionStatus(route.query.completionStatus)
author.value = typeof route.query.author === 'string' ? route.query.author : null
await fetchLibraries()
hydrating.value = false
filtersOpen.value = activeFilterCount.value > 0
await load(true)
initialLoadComplete.value = true
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !loading.value) {
void load()
}
},
{ rootMargin: '280px' },
)
await nextTick()
if (sentinel.value) observer.observe(sentinel.value)
})
onUnmounted(() => {
observer?.disconnect()
if (searchTimer) clearTimeout(searchTimer)
})
watch([sort, order, libraryId, completionStatus, author], () => {
if (hydrating.value || suppressAutoReload.value) return
syncRouteQuery()
void load(true)
collapseMobileControlsIfNeeded()
})
watch(q, () => {
if (hydrating.value || suppressAutoReload.value) return
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
syncRouteQuery()
void load(true)
}, 250)
})
watch(
loading,
(isLoading) => {
if (!isLoading) loadIfSentinelVisible()
},
{ flush: 'post' },
)
watch(seriesCardWidth, (value) => {
const normalized = clamp(value, SERIES_CARD_WIDTH_MIN, SERIES_CARD_WIDTH_MAX)
if (normalized !== value) {
seriesCardWidth.value = normalized
return
}
storage.set(SERIES_CARD_WIDTH_STORAGE_KEY, normalized)
})
watch(seriesGridGap, (value) => {
const normalized = clamp(value, SERIES_GRID_GAP_MIN, SERIES_GRID_GAP_MAX)
if (normalized !== value) {
seriesGridGap.value = normalized
return
}
storage.set(SERIES_GRID_GAP_STORAGE_KEY, normalized)
})
</script>
<template>
<ViewHeader
title="Series"
icon="Library"
:total="total"
:view-mode="viewMode"
:show-selection="false"
:show-view-mode-toggle="false"
v-model:coverSize="seriesCardWidth"
v-model:gridGap="seriesGridGap"
:cover-size-min="SERIES_CARD_WIDTH_MIN"
:cover-size-max="SERIES_CARD_WIDTH_MAX"
:cover-size-step="20"
:grid-gap-min="SERIES_GRID_GAP_MIN"
:grid-gap-max="SERIES_GRID_GAP_MAX"
>
<template #toolbar>
<div class="hidden lg:flex h-8 w-64 items-center rounded-md border border-input bg-background px-2.5">
<Search :size="13" class="mr-1.5 shrink-0 text-muted-foreground/85" />
<input
v-model="q"
type="search"
placeholder="Search series"
class="h-full w-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground/85"
/>
<button v-if="q.trim()" class="ml-1 text-muted-foreground/85 transition-colors hover:text-foreground" @click="clearSearchQuery">
<X :size="12" />
</button>
</div>
<div class="hidden lg:block h-5 w-px shrink-0 bg-border" />
<div class="hidden sm:flex items-center gap-1">
<Popover>
<PopoverTrigger as-child>
<button
class="flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
!isDefaultSort
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
>
<ArrowUpDown :size="13" />
<span class="hidden lg:inline">{{ sortSummary }}</span>
<span class="lg:hidden">Sort</span>
</button>
</PopoverTrigger>
<PopoverContent align="start" class="w-56 p-2">
<div class="mb-2 px-1 text-xs font-medium text-muted-foreground">Sort by</div>
<div class="flex flex-col gap-0.5">
<button
v-for="field in ['name', 'bookCount', 'lastAddedAt', 'readProgress'] as const"
:key="field"
class="flex items-center justify-between rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="sort === field ? 'text-foreground font-medium' : 'text-muted-foreground'"
@click="setSortField(field)"
>
{{ SORT_LABELS[field] }}
<span v-if="sort === field" class="text-xs text-primary">{{ order === 'asc' ? '↑' : '↓' }}</span>
</button>
</div>
<div class="my-2 border-t border-border" />
<div class="flex gap-1">
<button
v-for="dir in ['asc', 'desc'] as const"
:key="dir"
class="flex-1 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-muted"
:class="order === dir ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'"
@click="setSortOrder(dir)"
>
{{ dir === 'asc' ? 'Ascending' : 'Descending' }}
</button>
</div>
</PopoverContent>
</Popover>
<button
v-if="!isDefaultSort"
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-destructive hover:bg-muted"
@click="resetSort"
>
<X :size="13" />
</button>
</div>
<div class="hidden sm:block h-5 w-px shrink-0 bg-border" />
<button
class="hidden sm:flex h-8 items-center gap-1.5 rounded-md border px-3 text-sm transition-colors"
:class="
activeFilterCount > 0
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
@click="toggleFiltersOpen"
>
<Filter :size="13" />
<span>Filters</span>
<span v-if="activeFilterCount > 0" class="text-xs font-semibold">({{ activeFilterCount }})</span>
</button>
<button
v-if="activeFilterCount > 0 || !isDefaultSort"
class="hidden sm:flex h-8 items-center gap-1 rounded-md px-2 text-sm text-muted-foreground transition-colors hover:text-destructive"
@click="clearFilters"
>
<X :size="13" />
Clear
</button>
<button
class="sm:hidden relative flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
:class="
mobileControlsExpanded
? 'border-primary text-primary bg-primary/10'
: 'border-input bg-background text-muted-foreground hover:bg-muted hover:text-foreground'
"
@click="toggleMobileControls"
>
<SlidersHorizontal :size="14" />
<span
v-if="mobileControlsBadgeCount > 0"
class="absolute -right-1 -top-1 inline-flex min-h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-none text-primary-foreground"
>
{{ mobileControlsBadgeCount }}
</span>
</button>
</template>
</ViewHeader>
<!-- Mobile controls -->
<section v-if="mobileControlsExpanded" class="mb-3 space-y-2 rounded-lg border border-border/70 bg-card/70 p-2 sm:hidden">
<div class="flex items-center gap-1">
<button
class="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-md border border-input text-sm text-muted-foreground transition-colors hover:bg-muted"
@click="toggleMobileSearch"
>
<Search :size="13" />
{{ q.trim() ? `"${q.trim()}"` : 'Search' }}
</button>
</div>
<div v-if="mobileSearchOpen" class="flex items-center gap-1 rounded-md border border-input bg-background px-2.5">
<Search :size="13" class="shrink-0 text-muted-foreground/85" />
<input
ref="mobileSearchInput"
v-model="q"
type="search"
placeholder="Search series"
class="h-9 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground/85"
/>
<button v-if="q.trim()" class="text-muted-foreground/85 hover:text-foreground" @click="clearSearchQuery">
<X :size="12" />
</button>
<button class="ml-1 text-xs text-muted-foreground hover:text-foreground" @click="closeMobileSearch">Done</button>
</div>
<SeriesFilters
:library-id="libraryId"
:libraries="libraries"
:completion-status="completionStatus"
:author="author"
:active-count="activeFilterCount"
@update:library-id="handleLibraryIdUpdate"
@update:completion-status="handleCompletionStatusUpdate"
@update:author="handleAuthorUpdate"
@clear="clearFilters"
/>
</section>
<!-- Desktop filters panel -->
<SeriesFilters
v-if="filtersOpen && !mobileControlsExpanded"
:library-id="libraryId"
:libraries="libraries"
:completion-status="completionStatus"
:author="author"
:active-count="activeFilterCount"
closable
@update:library-id="handleLibraryIdUpdate"
@update:completion-status="handleCompletionStatusUpdate"
@update:author="handleAuthorUpdate"
@clear="clearFilters"
@close="closeFiltersPanel"
/>
<!-- Grid -->
<div
class="series-grid grid"
:style="{
'--series-card-width': `${seriesCardWidth}px`,
gap: `${seriesGridGap}px`,
}"
>
<template v-if="!initialLoadComplete">
<div v-for="n in INITIAL_SKELETON_COUNT" :key="n" class="overflow-hidden rounded-xl border border-border/60 bg-card">
<div class="animate-pulse bg-muted/80" style="aspect-ratio: 11 / 8" />
<div class="space-y-2 px-4 py-3">
<div class="h-4 w-2/3 rounded bg-muted/80" />
<div class="h-3 w-1/2 rounded bg-muted/70" />
</div>
<div class="h-1.5 w-full animate-pulse bg-muted/80" />
</div>
</template>
<template v-else>
<SeriesCard v-for="series in items" :key="series.name" :series="series" @open="openSeries" />
</template>
</div>
<!-- Empty state -->
<div v-if="initialLoadComplete && items.length === 0 && !loading" class="mt-12 text-center text-sm text-muted-foreground">
<p v-if="activeFilterCount > 0">No series match your filters.</p>
<p v-else>No series found in your libraries.</p>
</div>
<!-- Error state -->
<div v-if="error" class="mt-8 text-center text-sm text-destructive">{{ error }}</div>
<!-- Infinite scroll sentinel -->
<div ref="sentinel" class="h-px" />
</template>
<style scoped>
.series-grid {
grid-template-columns: repeat(auto-fill, minmax(var(--series-card-width), 1fr));
}
@media (max-width: 639px) {
.series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>