[FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert

This commit is contained in:
gamer147
2025-12-08 18:30:00 -05:00
parent c9d93a4e55
commit 81e4e88ad4
48 changed files with 3298 additions and 329 deletions

View File

@@ -1,10 +1,20 @@
<script lang="ts">
import { onMount } from 'svelte';
import { SvelteMap } from 'svelte/reactivity';
import { client } from '$lib/graphql/client';
import { NovelsDocument, type NovelsQuery } from '$lib/graphql/__generated__/graphql';
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;
@@ -15,16 +25,33 @@
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 result = await client.query(NovelsDocument, { first: PAGE_SIZE, after }).toPromise();
const where = filtersToGraphQLWhere(filters);
const result = await client
.query(NovelsDocument, { first: PAGE_SIZE, after, where })
.toPromise();
if (result.error) {
error = result.error.message;
@@ -36,7 +63,7 @@
// Append for pagination
edges = [...edges, ...result.data.novels.edges];
} else {
// Initial load
// Initial load or filter change
edges = result.data.novels.edges;
}
pageInfo = result.data.novels.pageInfo;
@@ -55,17 +82,48 @@
}
}
onMount(() => {
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>Latest Novels</CardTitle>
<p class="text-sm text-muted-foreground">Novels that have recently been updated.</p>
<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}
@@ -73,7 +131,7 @@
<CardContent>
<div class="flex items-center justify-center py-8">
<div
class="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent"
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading novels"
></div>
</div>
@@ -84,7 +142,7 @@
{#if error}
<Card class="border-destructive/40 bg-destructive/5">
<CardContent>
<p class="py-4 text-sm text-destructive">Could not load novels: {error}</p>
<p class="text-destructive py-4 text-sm">Could not load novels: {error}</p>
</CardContent>
</Card>
{/if}
@@ -92,8 +150,12 @@
{#if !fetching && novels.length === 0 && !error && !initialLoad}
<Card>
<CardContent>
<p class="py-4 text-sm text-muted-foreground">
No novels found yet. Try adding content to the gateway.
<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>