180 lines
4.8 KiB
Svelte
180 lines
4.8 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { SvelteMap } from 'svelte/reactivity';
|
|
import { client } from '$lib/graphql/client';
|
|
import { NovelsDocument, type NovelsQuery, type NovelTagDto } from '$lib/graphql/__generated__/graphql';
|
|
import NovelCard from './NovelCard.svelte';
|
|
import NovelFilters from './NovelFilters.svelte';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
|
import {
|
|
type NovelFilters as NovelFiltersType,
|
|
parseFiltersFromURL,
|
|
syncFiltersToURL,
|
|
filtersToGraphQLWhere,
|
|
hasActiveFilters,
|
|
EMPTY_FILTERS
|
|
} from '$lib/utils/filterParams';
|
|
|
|
const PAGE_SIZE = 12;
|
|
|
|
type NovelEdge = NonNullable<NovelsQuery['novels']>['edges'][number];
|
|
|
|
let edges: NovelEdge[] = $state([]);
|
|
let pageInfo: NonNullable<NovelsQuery['novels']>['pageInfo'] | null = $state(null);
|
|
let fetching = $state(false);
|
|
let error: string | null = $state(null);
|
|
let initialLoad = $state(true);
|
|
let filters: NovelFiltersType = $state({ ...EMPTY_FILTERS });
|
|
|
|
const hasNextPage = $derived(pageInfo?.hasNextPage ?? false);
|
|
const novels = $derived(edges.map((edge) => edge.node).filter(Boolean));
|
|
|
|
// Extract unique tags from loaded novels for the tag filter dropdown
|
|
const availableTags = $derived(() => {
|
|
const tagMap = new SvelteMap<string, Pick<NovelTagDto, 'key' | 'displayName'>>();
|
|
for (const novel of novels) {
|
|
for (const tag of novel.tags ?? []) {
|
|
if (!tagMap.has(tag.key)) {
|
|
tagMap.set(tag.key, { key: tag.key, displayName: tag.displayName });
|
|
}
|
|
}
|
|
}
|
|
return Array.from(tagMap.values()).sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
});
|
|
|
|
async function fetchNovels(after: string | null = null) {
|
|
fetching = true;
|
|
error = null;
|
|
|
|
try {
|
|
const where = filtersToGraphQLWhere(filters);
|
|
const result = await client
|
|
.query(NovelsDocument, { first: PAGE_SIZE, after, where })
|
|
.toPromise();
|
|
|
|
if (result.error) {
|
|
error = result.error.message;
|
|
return;
|
|
}
|
|
|
|
if (result.data?.novels) {
|
|
if (after) {
|
|
// Append for pagination
|
|
edges = [...edges, ...result.data.novels.edges];
|
|
} else {
|
|
// Initial load or filter change
|
|
edges = result.data.novels.edges;
|
|
}
|
|
pageInfo = result.data.novels.pageInfo;
|
|
}
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : 'Unknown error';
|
|
} finally {
|
|
fetching = false;
|
|
initialLoad = false;
|
|
}
|
|
}
|
|
|
|
function loadMore() {
|
|
if (pageInfo?.endCursor) {
|
|
fetchNovels(pageInfo.endCursor);
|
|
}
|
|
}
|
|
|
|
function handleFilterChange(newFilters: NovelFiltersType) {
|
|
filters = newFilters;
|
|
// Reset pagination and refetch
|
|
edges = [];
|
|
pageInfo = null;
|
|
syncFiltersToURL(filters);
|
|
fetchNovels();
|
|
}
|
|
|
|
onMount(() => {
|
|
// Parse filters from URL on initial load
|
|
filters = parseFiltersFromURL();
|
|
fetchNovels();
|
|
|
|
// Listen for browser back/forward navigation
|
|
const handlePopState = () => {
|
|
filters = parseFiltersFromURL();
|
|
edges = [];
|
|
pageInfo = null;
|
|
fetchNovels();
|
|
};
|
|
|
|
window.addEventListener('popstate', handlePopState);
|
|
return () => window.removeEventListener('popstate', handlePopState);
|
|
});
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<Card class="shadow-md shadow-primary/10">
|
|
<CardHeader>
|
|
<CardTitle>Novels</CardTitle>
|
|
<p class="text-muted-foreground text-sm">
|
|
{#if hasActiveFilters(filters)}
|
|
Showing filtered results
|
|
{:else}
|
|
Browse all novels
|
|
{/if}
|
|
</p>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<NovelFilters {filters} onFilterChange={handleFilterChange} availableTags={availableTags()} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{#if fetching && initialLoad}
|
|
<Card>
|
|
<CardContent>
|
|
<div class="flex items-center justify-center py-8">
|
|
<div
|
|
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
|
|
aria-label="Loading novels"
|
|
></div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<Card class="border-destructive/40 bg-destructive/5">
|
|
<CardContent>
|
|
<p class="text-destructive py-4 text-sm">Could not load novels: {error}</p>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if !fetching && novels.length === 0 && !error && !initialLoad}
|
|
<Card>
|
|
<CardContent>
|
|
<p class="text-muted-foreground py-4 text-sm">
|
|
{#if hasActiveFilters(filters)}
|
|
No novels match your filters. Try adjusting your search criteria.
|
|
{:else}
|
|
No novels found yet. Try adding content to the gateway.
|
|
{/if}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
|
|
{#if novels.length > 0}
|
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each novels as novel (novel.id)}
|
|
<NovelCard {novel} />
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if hasNextPage}
|
|
<div class="flex justify-center">
|
|
<Button onclick={loadMore} variant="outline" disabled={fetching} class="min-w-[160px]">
|
|
{fetching ? 'Loading...' : 'Load more'}
|
|
</Button>
|
|
</div>
|
|
{/if}
|
|
</div>
|