Files
FictionArchive/fictionarchive-web-astro/src/lib/components/JobsTab.svelte
gamer147 4264051d11
All checks were successful
CI / build-backend (pull_request) Successful in 1m55s
CI / build-frontend (pull_request) Successful in 44s
[FA-misc] Fix lint issues
2026-02-01 12:10:18 -05:00

176 lines
4.8 KiB
Svelte

<script lang="ts">
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import { JobsDocument, type JobsQuery } from '$lib/graphql/__generated__/graphql';
import JobsTable from './JobsTable.svelte';
import JobFilters from './JobFilters.svelte';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
import {
type JobFilters as JobFiltersType,
parseJobFiltersFromURL,
syncJobFiltersToURL,
jobFiltersToGraphQLWhere,
jobSortToGraphQLOrder,
hasActiveJobFilters,
EMPTY_JOB_FILTERS,
} from '$lib/utils/jobFilterParams';
const PAGE_SIZE = 20;
type JobsConnection = NonNullable<JobsQuery['jobs']>;
type JobEdge = NonNullable<JobsConnection['edges']>[number];
type PageInfo = JobsConnection['pageInfo'];
let edges: JobEdge[] = $state([]);
let pageInfo: PageInfo | null = $state(null);
let fetching = $state(false);
let error: string | null = $state(null);
let initialLoad = $state(true);
let filters: JobFiltersType = $state({ ...EMPTY_JOB_FILTERS });
// Pagination: stack of "after" cursors used to reach each page
// cursorStack[0] = the "after" cursor used to fetch page 2, etc.
let cursorStack: (string | null)[] = $state([]);
let currentPage = $derived(cursorStack.length + 1);
const jobs = $derived((edges ?? []).map((edge) => edge.node).filter(Boolean));
// Extract unique job types from loaded data for the filter dropdown
const availableJobTypes = $derived(
[...new Set(jobs.map((job) => job.jobType))].sort()
);
async function fetchJobs(after: string | null = null) {
fetching = true;
error = null;
try {
const where = jobFiltersToGraphQLWhere(filters);
const order = jobSortToGraphQLOrder(filters.sort);
const result = await client
.query(JobsDocument, { first: PAGE_SIZE, after, where, order }, { requestPolicy: 'network-only' })
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.jobs) {
edges = result.data.jobs.edges ?? [];
pageInfo = result.data.jobs.pageInfo;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
initialLoad = false;
}
}
function nextPage() {
if (pageInfo?.endCursor && pageInfo.hasNextPage) {
cursorStack = [...cursorStack, pageInfo.endCursor];
fetchJobs(pageInfo.endCursor);
}
}
function prevPage() {
if (cursorStack.length > 0) {
const newStack = [...cursorStack];
newStack.pop(); // remove current page's cursor
cursorStack = newStack;
// Fetch with the previous page's cursor (or null for page 1)
const after = newStack.length > 0 ? newStack[newStack.length - 1] : null;
fetchJobs(after);
}
}
function handleFilterChange(newFilters: JobFiltersType) {
filters = newFilters;
edges = [];
pageInfo = null;
cursorStack = [];
syncJobFiltersToURL(filters);
fetchJobs();
}
onMount(() => {
filters = parseJobFiltersFromURL();
fetchJobs();
const handlePopState = () => {
filters = parseJobFiltersFromURL();
edges = [];
pageInfo = null;
cursorStack = [];
fetchJobs();
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
});
</script>
<div class="space-y-4">
<Card class="shadow-md shadow-primary/10">
<CardHeader>
<CardTitle>Filters</CardTitle>
</CardHeader>
<CardContent>
<JobFilters {filters} onFilterChange={handleFilterChange} availableJobTypes={availableJobTypes} />
</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 jobs"
></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 jobs: {error}</p>
</CardContent>
</Card>
{/if}
{#if !initialLoad && !error && jobs.length === 0}
<Card>
<CardContent>
<p class="text-muted-foreground py-4 text-sm">
{#if hasActiveJobFilters(filters)}
No jobs match your filters. Try adjusting your search criteria.
{:else}
No jobs found.
{/if}
</p>
</CardContent>
</Card>
{/if}
{#if jobs.length > 0}
<JobsTable {jobs} />
<!-- Pagination -->
<div class="flex items-center justify-center gap-4">
<Button variant="outline" size="sm" disabled={currentPage === 1 || fetching} onclick={prevPage}>
Previous
</Button>
<span class="text-sm text-muted-foreground">Page {currentPage}</span>
<Button variant="outline" size="sm" disabled={!pageInfo?.hasNextPage || fetching} onclick={nextPage}>
Next
</Button>
</div>
{/if}
</div>