summaryrefslogtreecommitdiffstatshomepage
path: root/frontend/src/routes
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/routes')
-rw-r--r--frontend/src/routes/+layout.svelte95
-rw-r--r--frontend/src/routes/+layout.ts1
-rw-r--r--frontend/src/routes/+page.svelte66
-rw-r--r--frontend/src/routes/archives/+page.svelte119
-rw-r--r--frontend/src/routes/archives/+page.ts12
-rw-r--r--frontend/src/routes/archives/[id]/+page.svelte99
-rw-r--r--frontend/src/routes/archives/[id]/+page.ts5
-rw-r--r--frontend/src/routes/artists/+page.svelte101
-rw-r--r--frontend/src/routes/artists/+page.ts12
-rw-r--r--frontend/src/routes/characters/+page.svelte101
-rw-r--r--frontend/src/routes/characters/+page.ts12
-rw-r--r--frontend/src/routes/circles/+page.svelte101
-rw-r--r--frontend/src/routes/circles/+page.ts12
-rw-r--r--frontend/src/routes/comics/+page.svelte116
-rw-r--r--frontend/src/routes/comics/+page.ts12
-rw-r--r--frontend/src/routes/comics/[id]/+page.svelte176
-rw-r--r--frontend/src/routes/comics/[id]/+page.ts5
-rw-r--r--frontend/src/routes/namespaces/+page.svelte101
-rw-r--r--frontend/src/routes/namespaces/+page.ts12
-rw-r--r--frontend/src/routes/tags/+page.svelte109
-rw-r--r--frontend/src/routes/tags/+page.ts12
-rw-r--r--frontend/src/routes/worlds/+page.svelte102
-rw-r--r--frontend/src/routes/worlds/+page.ts12
23 files changed, 1393 insertions, 0 deletions
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
new file mode 100644
index 0000000..0eefed1
--- /dev/null
+++ b/frontend/src/routes/+layout.svelte
@@ -0,0 +1,95 @@
+<script lang="ts">
+ import { addShortcut, handleShortcuts } from '$lib/Shortcuts';
+ import { fadeDefault } from '$lib/Transitions';
+ import AddArtist from '$lib/dialogs/AddArtist.svelte';
+ import AddCharacter from '$lib/dialogs/AddCharacter.svelte';
+ import AddCircle from '$lib/dialogs/AddCircle.svelte';
+ import AddNamespace from '$lib/dialogs/AddNamespace.svelte';
+ import AddTag from '$lib/dialogs/AddTag.svelte';
+ import AddWorld from '$lib/dialogs/AddWorld.svelte';
+ import Link from '$lib/navigation/Link.svelte';
+ import Navigation from '$lib/navigation/Navigation.svelte';
+ import { cacheExchange, fetchExchange, initContextClient } from '@urql/svelte';
+ import { SvelteToast } from '@zerodevx/svelte-toast';
+ import { Modals, closeModal, openModal } from 'svelte-modals';
+ import { fade } from 'svelte/transition';
+ import '../app.css';
+
+ initContextClient({
+ url: import.meta.env.VITE_GQL_ENDPOINT ?? '/graphql',
+ exchanges: [cacheExchange, fetchExchange]
+ });
+
+ addShortcut('na', () => openModal(AddArtist));
+ addShortcut('nh', () => openModal(AddCharacter));
+ addShortcut('ni', () => openModal(AddCircle));
+ addShortcut('nn', () => openModal(AddNamespace));
+ addShortcut('nt', () => openModal(AddTag));
+ addShortcut('nw', () => openModal(AddWorld));
+
+ function keydown(event: KeyboardEvent) {
+ handleShortcuts(event);
+ }
+</script>
+
+<svelte:document on:keydown={keydown} />
+
+<Navigation>
+ <Link matchExact href="/" title="Home" accel="go">
+ <span class="icon-base icon-[material-symbols--home]" />
+ </Link>
+ <Link href="/comics/" title="Comics" accel="gc">
+ <span class="icon-base icon-[material-symbols--menu-book]" />
+ </Link>
+ <Link href="/namespaces/" title="Namespaces" accel="gn">
+ <span class="icon-base icon-[material-symbols--inbox]" />
+ </Link>
+ <Link href="/tags/" title="Tags" accel="gt">
+ <span class="icon-base icon-[material-symbols--label]" />
+ </Link>
+ <Link href="/artists/" title="Artists" accel="ga">
+ <span class="icon-base icon-[material-symbols--person]" />
+ </Link>
+ <Link href="/circles/" title="Circles" accel="gi">
+ <span class="icon-base icon-[material-symbols--group]" />
+ </Link>
+ <Link href="/characters/" title="Characters" accel="gh">
+ <span class="icon-base icon-[material-symbols--face]" />
+ </Link>
+ <Link href="/worlds/" title="Worlds" accel="gw">
+ <span class="icon-base icon-[material-symbols--public]" />
+ </Link>
+ <Link href="/archives/" title="Archives" accel="gz">
+ <span class="icon-base icon-[material-symbols--folder-zip]" />
+ </Link>
+ <div class="mb-auto" />
+ <Link href="/help/" title="Help" accel="?" target="_blank">
+ <span class="icon-base icon-[material-symbols--help]" />
+ </Link>
+</Navigation>
+
+<div class="min-w-[360px] overflow-auto p-4">
+ <slot />
+</div>
+
+<Modals>
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
+ <!-- svelte-ignore a11y-click-events-have-key-events -->
+ <div
+ slot="backdrop"
+ on:click={closeModal}
+ transition:fade={fadeDefault}
+ class="fixed bottom-0 left-0 right-0 top-0 z-20 bg-stone-800/80"
+ />
+</Modals>
+
+<SvelteToast options={{ reversed: true, intro: { y: 192 } }} />
+
+<style>
+ :root {
+ --toastBarHeight: 0;
+ --toastContainerTop: auto;
+ --toastContainerLeft: 4rem;
+ --toastContainerBottom: 1rem;
+ }
+</style>
diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts
new file mode 100644
index 0000000..a3d1578
--- /dev/null
+++ b/frontend/src/routes/+layout.ts
@@ -0,0 +1 @@
+export const ssr = false;
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte
new file mode 100644
index 0000000..97a7a60
--- /dev/null
+++ b/frontend/src/routes/+page.svelte
@@ -0,0 +1,66 @@
+<script lang="ts">
+ import { version } from '$app/environment';
+ import { frontpageQuery } from '$gql/Queries';
+ import { ComicSort, SortDirection } from '$gql/graphql';
+ import { codename } from '$lib/Meta';
+ import { href } from '$lib/Navigation';
+ import { fadeDefault } from '$lib/Transitions';
+ import logo from '$lib/assets/logo.webp';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Carousel from '$lib/containers/Carousel.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { fade } from 'svelte/transition';
+
+ const bookmarkLink = href('comics', { filter: { include: { bookmarked: true } } });
+ const recentLink = href('comics', {
+ sort: { on: ComicSort.CreatedAt, direction: SortDirection.Descending }
+ });
+ const favouriteLink = href('comics', { filter: { include: { favourite: true } } });
+
+ $: query = frontpageQuery(getContextClient());
+ $: recent = $query.data?.recent;
+ $: favourites = $query.data?.favourites;
+ $: bookmarked = $query.data?.bookmarked;
+</script>
+
+<Head section="Home" />
+
+<div class="flex flex-col justify-center gap-16 xl:flex-row">
+ {#if $query.data}
+ <div class="flex flex-col items-center gap-1">
+ <img src={logo} width="512" height="512" class="min-w-[400px]" alt="" />
+ <h1 class="text-4xl font-medium">
+ <span>hircine</span>
+ <span>{version}</span>
+ </h1>
+ <h2 class="text-2xl font-light text-zinc-400">{codename}</h2>
+ </div>
+ <div class="flex flex-col gap-8" in:fade={fadeDefault}>
+ {#if recent && recent.count > 0}
+ <Carousel title="Recently added" href={recentLink}>
+ {#each recent.edges as comic}
+ <Card coverOnly {...comicCard(comic)} />
+ {/each}
+ </Carousel>
+ {/if}
+ {#if favourites && favourites.count > 0}
+ <Carousel title="Favourites" href={favouriteLink}>
+ {#each favourites.edges as comic}
+ <Card coverOnly {...comicCard(comic)} />
+ {/each}
+ </Carousel>
+ {/if}
+ {#if bookmarked && bookmarked.count > 0}
+ <Carousel title="Bookmarks" href={bookmarkLink}>
+ {#each bookmarked.edges as comic}
+ <Card coverOnly {...comicCard(comic)} />
+ {/each}
+ </Carousel>
+ {/if}
+ </div>
+ {:else}
+ <Guard result={query} />
+ {/if}
+</div>
diff --git a/frontend/src/routes/archives/+page.svelte b/frontend/src/routes/archives/+page.svelte
new file mode 100644
index 0000000..545058a
--- /dev/null
+++ b/frontend/src/routes/archives/+page.svelte
@@ -0,0 +1,119 @@
+<script lang="ts">
+ import { deleteArchives, updateArchives } from '$gql/Mutations';
+ import { archivesQuery } from '$gql/Queries';
+ import type { ArchiveFragment } from '$gql/graphql';
+ import { ArchiveSortLabel } from '$lib/Enums';
+ import { ArchiveFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import Card from '$lib/components/Card.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import RefreshButton from '$lib/components/RefreshButton.svelte';
+ import Cards from '$lib/containers/Cards.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Pill from '$lib/pills/Pill.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte';
+ import MarkOrganized from '$lib/toolbar/MarkOrganized.svelte';
+ import MarkSelection from '$lib/toolbar/MarkSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { filesize } from 'filesize';
+ import type { PageData } from './$types';
+
+ let client = getContextClient();
+
+ export let data: PageData;
+
+ $: result = archivesQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: archives = $result.data?.archives;
+
+ const selection = initSelectionContext<ArchiveFragment>('Archive', (a) => a.name);
+ $: if (archives) {
+ $selection.view = archives.edges;
+ $pagination.total = archives.count;
+ }
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const filter = initFilterContext<ArchiveFilterContext>();
+ $: $filter = new ArchiveFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, ArchiveSortLabel);
+ $: $sort.update = data.sort;
+
+ function refresh() {
+ result.reexecute({ requestPolicy: 'network-only' });
+ }
+</script>
+
+<Head section="Archives" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <MarkSelection>
+ <MarkOrganized mutation={updateArchives} />
+ </MarkSelection>
+ <DeleteSelection
+ mutation={deleteArchives}
+ warning="Deleting an archive will also delete its archive file on disk as well as all comics that belong to it."
+ />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Archives" bind:field={$filter.include.controls.path.contains} />
+ <FilterOrganized />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <RefreshButton slot="end" on:click={refresh} />
+ </Toolbar>
+ {#if archives}
+ <Pagination />
+ <main>
+ <Cards>
+ {#each archives.edges as { id, name, cover, size, pageCount }, index (id)}
+ <Selectable {index} {id} let:handle let:selected>
+ <Card
+ ellipsis={false}
+ href={id.toString()}
+ details={{ title: name, cover: cover }}
+ on:click={handle}
+ >
+ <SelectionOverlay position="left" {selected} slot="overlay" />
+ <div class="flex gap-1 text-xs">
+ <Pill name={`${pageCount} pages`}>
+ <span class="icon-[material-symbols--note] mr-0.5" slot="icon" />
+ </Pill>
+ <Pill name={filesize(size, { base: 2 })}>
+ <span class="icon-[material-symbols--hard-drive] mr-0.5" slot="icon" />
+ </Pill>
+ </div>
+ </Card>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cards>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/archives/+page.ts b/frontend/src/routes/archives/+page.ts
new file mode 100644
index 0000000..88acade
--- /dev/null
+++ b/frontend/src/routes/archives/+page.ts
@@ -0,0 +1,12 @@
+import { ArchiveSort, type ArchiveFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, ArchiveSort.Path),
+ filter: parseFilter<ArchiveFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams, 24)
+ };
+}
diff --git a/frontend/src/routes/archives/[id]/+page.svelte b/frontend/src/routes/archives/[id]/+page.svelte
new file mode 100644
index 0000000..50a2940
--- /dev/null
+++ b/frontend/src/routes/archives/[id]/+page.svelte
@@ -0,0 +1,99 @@
+<script lang="ts">
+ import { updateArchives } from '$gql/Mutations';
+ import { archiveQuery } from '$gql/Queries';
+ import { Direction, Layout, type FullArchiveFragment, type PageFragment } from '$gql/graphql';
+ import { initReaderContext } from '$lib/Reader';
+ import { initSelectionContext } from '$lib/Selection';
+ import { setTabContext } from '$lib/Tabs';
+ import { toastFinally } from '$lib/Toasts';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Titlebar from '$lib/components/Titlebar.svelte';
+ import Grid from '$lib/containers/Grid.svelte';
+ import Gallery from '$lib/gallery/Gallery.svelte';
+ import PageView from '$lib/reader/PageView.svelte';
+ import Reader from '$lib/reader/Reader.svelte';
+ import ArchiveDelete from '$lib/tabs/ArchiveDelete.svelte';
+ import ArchiveDetails from '$lib/tabs/ArchiveDetails.svelte';
+ import ArchiveEdit from '$lib/tabs/ArchiveEdit.svelte';
+ import Tab from '$lib/tabs/Tab.svelte';
+ import Tabs from '$lib/tabs/Tabs.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import type { PageData } from './$types';
+
+ export let data: PageData;
+ const client = getContextClient();
+ const reader = initReaderContext();
+ setTabContext({
+ tabs: {
+ details: { title: 'Details' },
+ edit: { title: 'Edit' },
+ deletion: { title: 'Delete' }
+ },
+ current: 'details'
+ });
+
+ $: result = archiveQuery(client, { id: data.id });
+
+ function updateCover(event: CustomEvent<number>) {
+ updateArchives(client, { ids: archive.id, input: { cover: { id: event.detail } } }).catch(
+ toastFinally
+ );
+ }
+
+ let archive: FullArchiveFragment;
+
+ $: $result, update();
+ function update() {
+ if (!$result.stale && $result.data?.archive.__typename === 'FullArchive') {
+ archive = structuredClone($result.data.archive);
+
+ $reader.pages = archive.pages;
+ }
+ }
+
+ const selection = initSelectionContext<PageFragment>('Page', (p) => p.path);
+ $selection.selectable = (p) => p.comicId === null;
+
+ $: if (archive) {
+ $selection.view = archive.pages;
+ }
+</script>
+
+<Head section="Archive" title={archive?.name} />
+
+{#if archive}
+ <Grid>
+ <header>
+ <Titlebar title={archive.name} />
+ </header>
+
+ <aside>
+ <Tabs>
+ <Tab id="details">
+ <ArchiveDetails {archive} />
+ </Tab>
+ <Tab id="edit">
+ <ArchiveEdit {archive} />
+ </Tab>
+ <Tab id="deletion">
+ <ArchiveDelete {archive} />
+ </Tab>
+ </Tabs>
+ </aside>
+
+ <main class="overflow-auto">
+ <Gallery
+ pages={archive.pages}
+ on:open={(e) => ($reader = $reader.open(e.detail))}
+ on:cover={updateCover}
+ />
+ </main>
+ </Grid>
+{:else}
+ <Guard {result} />
+{/if}
+
+<Reader>
+ <PageView layout={Layout.Single} direction={Direction.LeftToRight} />
+</Reader>
diff --git a/frontend/src/routes/archives/[id]/+page.ts b/frontend/src/routes/archives/[id]/+page.ts
new file mode 100644
index 0000000..d872ba2
--- /dev/null
+++ b/frontend/src/routes/archives/[id]/+page.ts
@@ -0,0 +1,5 @@
+export function load({ params }: { params: Record<string, string> }) {
+ return {
+ id: +params.id
+ };
+}
diff --git a/frontend/src/routes/artists/+page.svelte b/frontend/src/routes/artists/+page.svelte
new file mode 100644
index 0000000..e07338c
--- /dev/null
+++ b/frontend/src/routes/artists/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteArtists } from '$gql/Mutations';
+ import { artistsQuery, fetchArtist } from '$gql/Queries';
+ import type { Artist } from '$gql/graphql';
+ import { ArtistSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddArtist from '$lib/dialogs/AddArtist.svelte';
+ import EditArtist from '$lib/dialogs/EditArtist.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = artistsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: artists = $result.data?.artists;
+
+ const selection = initSelectionContext<Artist>('Artist', (a) => a.name);
+ $: if (artists) {
+ $selection.view = artists.edges;
+ $pagination.total = artists.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, ArtistSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchArtist(client, id)
+ .then((artist) => openModal(EditArtist, { artist }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="artists" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteArtists} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Artists" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Artist" on:click={() => openModal(AddArtist)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if artists}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each artists.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="artists" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/artists/+page.ts b/frontend/src/routes/artists/+page.ts
new file mode 100644
index 0000000..5a76550
--- /dev/null
+++ b/frontend/src/routes/artists/+page.ts
@@ -0,0 +1,12 @@
+import { ArtistSort, type ArtistFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, ArtistSort.Name),
+ filter: parseFilter<ArtistFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/characters/+page.svelte b/frontend/src/routes/characters/+page.svelte
new file mode 100644
index 0000000..0934bab
--- /dev/null
+++ b/frontend/src/routes/characters/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteCharacters } from '$gql/Mutations';
+ import { charactersQuery, fetchCharacter } from '$gql/Queries';
+ import type { Character } from '$gql/graphql';
+ import { CharacterSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddCharacter from '$lib/dialogs/AddCharacter.svelte';
+ import EditCharacter from '$lib/dialogs/EditCharacter.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = charactersQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: characters = $result.data?.characters;
+
+ const selection = initSelectionContext<Character>('Character', (c) => c.name);
+ $: if (characters) {
+ $selection.view = characters.edges;
+ $pagination.total = characters.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, CharacterSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchCharacter(client, id)
+ .then((character) => openModal(EditCharacter, { character }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="characters" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteCharacters} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Characters" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Character" on:click={() => openModal(AddCharacter)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if characters}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each characters.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="characters" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/characters/+page.ts b/frontend/src/routes/characters/+page.ts
new file mode 100644
index 0000000..4f7a3cf
--- /dev/null
+++ b/frontend/src/routes/characters/+page.ts
@@ -0,0 +1,12 @@
+import { CharacterSort, type CharacterFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, CharacterSort.Name),
+ filter: parseFilter<CharacterFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/circles/+page.svelte b/frontend/src/routes/circles/+page.svelte
new file mode 100644
index 0000000..14b0866
--- /dev/null
+++ b/frontend/src/routes/circles/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteCircles } from '$gql/Mutations';
+ import { circlesQuery, fetchCircle } from '$gql/Queries';
+ import type { Circle } from '$gql/graphql';
+ import { CircleSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddCircle from '$lib/dialogs/AddCircle.svelte';
+ import EditCircle from '$lib/dialogs/EditCircle.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = circlesQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: circles = $result.data?.circles;
+
+ const selection = initSelectionContext<Circle>('Circle', (c) => c.name);
+ $: if (circles) {
+ $selection.view = circles.edges;
+ $pagination.total = circles.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, CircleSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchCircle(client, id)
+ .then((circle) => openModal(EditCircle, { circle }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="circles" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteCircles} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Circles" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Circle" on:click={() => openModal(AddCircle)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if circles}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each circles.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="circles" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/circles/+page.ts b/frontend/src/routes/circles/+page.ts
new file mode 100644
index 0000000..ea5c3df
--- /dev/null
+++ b/frontend/src/routes/circles/+page.ts
@@ -0,0 +1,12 @@
+import { CircleSort, type CircleFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, CircleSort.Name),
+ filter: parseFilter<CircleFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/comics/+page.svelte b/frontend/src/routes/comics/+page.svelte
new file mode 100644
index 0000000..353d69c
--- /dev/null
+++ b/frontend/src/routes/comics/+page.svelte
@@ -0,0 +1,116 @@
+<script lang="ts">
+ import { deleteComics, updateComics } from '$gql/Mutations';
+ import { comicsQuery } from '$gql/Queries';
+ import { type ComicFragment } from '$gql/graphql';
+ import { ComicSortLabel } from '$lib/Enums';
+ import { ComicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cards from '$lib/containers/Cards.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import UpdateComicsDialog from '$lib/dialogs/UpdateComics.svelte';
+ import ComicFilterForm from '$lib/filter/ComicFilterForm.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import ComicPills from '$lib/pills/ComicPills.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import EditSelection from '$lib/toolbar/EditSelection.svelte';
+ import FilterBookmarked from '$lib/toolbar/FilterBookmarked.svelte';
+ import FilterFavourites from '$lib/toolbar/FilterFavourites.svelte';
+ import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte';
+ import MarkBookmark from '$lib/toolbar/MarkBookmark.svelte';
+ import MarkFavourite from '$lib/toolbar/MarkFavourite.svelte';
+ import MarkOrganized from '$lib/toolbar/MarkOrganized.svelte';
+ import MarkSelection from '$lib/toolbar/MarkSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import type { PageData } from './$types';
+
+ export let data: PageData;
+
+ const client = getContextClient();
+
+ $: result = comicsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: comics = $result.data?.comics;
+
+ const selection = initSelectionContext<ComicFragment>('Comic', (c) => c.title);
+ $: if (comics) {
+ $selection.view = comics.edges;
+ $pagination.total = comics.count;
+ }
+
+ const filter = initFilterContext<ComicFilterContext>();
+ $: $filter = new ComicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, ComicSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+</script>
+
+<Head section="Comics" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <MarkSelection>
+ <MarkFavourite mutation={updateComics} />
+ <hr class="col-span-2 border-slate-600" />
+ <MarkBookmark mutation={updateComics} />
+ <hr class="col-span-2 border-slate-600" />
+ <MarkOrganized mutation={updateComics} />
+ </MarkSelection>
+ <EditSelection dialog={UpdateComicsDialog} />
+ <DeleteSelection mutation={deleteComics} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Comics" bind:field={$filter.include.controls.title.contains} />
+ <ToggleAdvancedFilters />
+ <div class="rounded-group flex">
+ <FilterFavourites />
+ <FilterBookmarked />
+ <FilterOrganized />
+ </div>
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <ComicFilterForm />
+ </Toolbar>
+ {#if comics}
+ <Pagination />
+ <main>
+ <Cards>
+ {#each comics.edges as comic, index (comic.id)}
+ <Selectable {index} id={comic.id} let:handle let:selected>
+ <Card {...comicCard(comic)} on:click={handle}>
+ <SelectionOverlay position="left" {selected} slot="overlay" />
+ <ComicPills {comic} />
+ </Card>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cards>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/comics/+page.ts b/frontend/src/routes/comics/+page.ts
new file mode 100644
index 0000000..4558804
--- /dev/null
+++ b/frontend/src/routes/comics/+page.ts
@@ -0,0 +1,12 @@
+import { ComicSort, type ComicFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, ComicSort.Title),
+ filter: parseFilter<ComicFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams, 24)
+ };
+}
diff --git a/frontend/src/routes/comics/[id]/+page.svelte b/frontend/src/routes/comics/[id]/+page.svelte
new file mode 100644
index 0000000..cfc5840
--- /dev/null
+++ b/frontend/src/routes/comics/[id]/+page.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import { beforeNavigate } from '$app/navigation';
+ import { updateComics } from '$gql/Mutations';
+ import { comicQuery } from '$gql/Queries';
+ import { comicEquals } from '$gql/Utils';
+ import { UpdateMode, type FullComicFragment, type UpdateComicInput } from '$gql/graphql';
+ import { initReaderContext } from '$lib/Reader';
+ import { initScraperContext } from '$lib/Scraper';
+ import { initSelectionContext } from '$lib/Selection';
+ import { setTabContext } from '$lib/Tabs';
+ import { toastFinally } from '$lib/Toasts';
+ import { preventOnPending } from '$lib/Utils';
+ import BookmarkButton from '$lib/components/BookmarkButton.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import OrganizedButton from '$lib/components/OrganizedButton.svelte';
+ import RemovePageButton from '$lib/components/RemovePageButton.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import Titlebar from '$lib/components/Titlebar.svelte';
+ import Grid from '$lib/containers/Grid.svelte';
+ import ComicForm from '$lib/forms/ComicForm.svelte';
+ import Gallery from '$lib/gallery/Gallery.svelte';
+ import PageView from '$lib/reader/PageView.svelte';
+ import Reader from '$lib/reader/Reader.svelte';
+ import ComicScrapeForm from '$lib/scraper/ComicScrapeForm.svelte';
+ import ComicDelete from '$lib/tabs/ComicDelete.svelte';
+ import ComicDetails from '$lib/tabs/ComicDetails.svelte';
+ import Tab from '$lib/tabs/Tab.svelte';
+ import Tabs from '$lib/tabs/Tabs.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ const reader = initReaderContext();
+ const selection = initSelectionContext();
+ const scraper = initScraperContext();
+ const tabContext = setTabContext({
+ tabs: {
+ details: { title: 'Details' },
+ edit: { title: 'Edit' },
+ scrape: { title: 'Scrape' },
+ deletion: { title: 'Delete' }
+ },
+ current: 'details'
+ });
+
+ export let data: PageData;
+ $: result = comicQuery(client, { id: data.id });
+
+ let comic: FullComicFragment;
+ let original: Readonly<FullComicFragment>;
+ let updatePartial = false;
+
+ $: $result, update();
+ $: pending = !comicEquals(comic, original);
+ $: $tabContext.tabs.edit.badge = pending;
+
+ function update() {
+ if (!$result.stale && $result.data?.comic.__typename === 'FullComic') {
+ original = $result.data.comic;
+ if (updatePartial) {
+ comic.pages = structuredClone(original.pages);
+ comic.favourite = original.favourite;
+ comic.bookmarked = original.bookmarked;
+ comic.organized = original.organized;
+ comic.updatedAt = original.updatedAt;
+ updatePartial = false;
+ } else {
+ comic = structuredClone(original);
+ }
+
+ $reader.pages = original.pages;
+ $selection.view = comic.pages;
+ $scraper.selector = undefined;
+ }
+ }
+
+ function toggle(field: keyof Omit<UpdateComicInput, 'cover'>) {
+ updateComics(client, { ids: comic.id, input: { [field]: !comic[field] } })
+ .then(() => (updatePartial = true))
+ .catch(toastFinally);
+ }
+
+ function updateComic(event: CustomEvent<UpdateComicInput>) {
+ updateComics(client, { ids: comic.id, input: event.detail }).catch(toastFinally);
+ }
+
+ function updateCover(event: CustomEvent<number>) {
+ updateComics(client, { ids: comic.id, input: { cover: { id: event.detail } } })
+ .then(() => (updatePartial = true))
+ .catch(toastFinally);
+ }
+
+ function removePages() {
+ updateComics(client, {
+ ids: comic.id,
+ input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Remove } } }
+ })
+ .then(() => {
+ updatePartial = true;
+ $selection = $selection.clear();
+ })
+ .catch(toastFinally);
+ }
+
+ beforeNavigate((navigation) => preventOnPending(navigation, pending));
+</script>
+
+<Head section="Comic" title={original?.title} />
+
+{#if comic}
+ <Grid>
+ <header>
+ <Titlebar
+ title={original.title}
+ subtitle={original.originalTitle}
+ bind:favourite={comic.favourite}
+ on:favourite={() => toggle('favourite')}
+ />
+ </header>
+
+ <aside>
+ <Tabs>
+ <Tab id="details">
+ <ComicDetails comic={original} />
+ </Tab>
+ <Tab id="edit">
+ <div class="flex flex-col gap-4">
+ <div class="flex gap-2 text-sm">
+ <SelectionControls page>
+ <RemovePageButton on:click={removePages} />
+ </SelectionControls>
+ <div class="grow" />
+ <BookmarkButton bookmarked={comic.bookmarked} on:click={() => toggle('bookmarked')} />
+ <OrganizedButton organized={comic.organized} on:click={() => toggle('organized')} />
+ </div>
+ <ComicForm bind:comic on:submit={updateComic}>
+ <div class="flex gap-2">
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </ComicForm>
+ </div>
+ </Tab>
+ <Tab id="scrape">
+ <ComicScrapeForm {comic} />
+ </Tab>
+ <Tab id="deletion">
+ <ComicDelete {comic} />
+ </Tab>
+ </Tabs>
+ </aside>
+
+ <main class="overflow-auto">
+ <Gallery
+ pages={comic.pages}
+ on:open={(e) => ($reader = $reader.open(e.detail))}
+ on:cover={updateCover}
+ />
+ </main>
+ </Grid>
+
+ <Reader>
+ <PageView layout={comic.layout} direction={comic.direction} />
+ <svelte:fragment slot="sidebar">
+ <ComicForm bind:comic on:submit={updateComic}>
+ <div class="flex justify-end gap-2">
+ <SubmitButton active={pending} />
+ </div>
+ </ComicForm>
+ </svelte:fragment>
+ </Reader>
+{:else}
+ <Guard {result} />
+{/if}
diff --git a/frontend/src/routes/comics/[id]/+page.ts b/frontend/src/routes/comics/[id]/+page.ts
new file mode 100644
index 0000000..d872ba2
--- /dev/null
+++ b/frontend/src/routes/comics/[id]/+page.ts
@@ -0,0 +1,5 @@
+export function load({ params }: { params: Record<string, string> }) {
+ return {
+ id: +params.id
+ };
+}
diff --git a/frontend/src/routes/namespaces/+page.svelte b/frontend/src/routes/namespaces/+page.svelte
new file mode 100644
index 0000000..f6568f9
--- /dev/null
+++ b/frontend/src/routes/namespaces/+page.svelte
@@ -0,0 +1,101 @@
+<script lang="ts">
+ import { deleteNamespaces } from '$gql/Mutations';
+ import { fetchNamespace, namespacesQuery } from '$gql/Queries';
+ import type { Namespace } from '$gql/graphql';
+ import { NamespaceSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddNamespace from '$lib/dialogs/AddNamespace.svelte';
+ import EditNamespace from '$lib/dialogs/EditNamespace.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+ export let data: PageData;
+
+ $: result = namespacesQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: namespaces = $result.data?.namespaces;
+
+ const selection = initSelectionContext<Namespace>('Namespace', (n) => n.name);
+ $: if (namespaces) {
+ $selection.view = namespaces.edges;
+ $pagination.total = namespaces.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, NamespaceSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchNamespace(client, id)
+ .then((namespace) => openModal(EditNamespace, { namespace }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="Namespaces" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteNamespaces} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Namespaces" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Namespace" on:click={() => openModal(AddNamespace)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if namespaces}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each namespaces.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="tags" id={`${id}:`}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/namespaces/+page.ts b/frontend/src/routes/namespaces/+page.ts
new file mode 100644
index 0000000..893b540
--- /dev/null
+++ b/frontend/src/routes/namespaces/+page.ts
@@ -0,0 +1,12 @@
+import { NamespaceSort, type NamespaceFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, NamespaceSort.Name),
+ filter: parseFilter<NamespaceFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/tags/+page.svelte b/frontend/src/routes/tags/+page.svelte
new file mode 100644
index 0000000..e0909ad
--- /dev/null
+++ b/frontend/src/routes/tags/+page.svelte
@@ -0,0 +1,109 @@
+<script lang="ts">
+ import { deleteTags } from '$gql/Mutations';
+ import { fetchTag, tagsQuery } from '$gql/Queries';
+ import { type Tag } from '$gql/graphql';
+ import { TagSortLabel } from '$lib/Enums';
+ import { TagFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddTag from '$lib/dialogs/AddTag.svelte';
+ import EditTag from '$lib/dialogs/EditTag.svelte';
+ import UpdateTagsDialog from '$lib/dialogs/UpdateTags.svelte';
+ import TagFilterForm from '$lib/filter/TagFilterForm.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import EditSelection from '$lib/toolbar/EditSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+
+ export let data: PageData;
+
+ $: result = tagsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: tags = $result.data?.tags;
+
+ const selection = initSelectionContext<Tag>('Tag', (t) => t.name);
+ $: if (tags) {
+ $selection.view = tags.edges;
+ $pagination.total = tags.count;
+ }
+
+ const filter = initFilterContext<TagFilterContext>();
+ $: $filter = new TagFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, TagSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchTag(client, id)
+ .then((tag) => openModal(EditTag, { tag }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="Tags" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <EditSelection dialog={UpdateTagsDialog} />
+ <DeleteSelection mutation={deleteTags} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Tags" bind:field={$filter.include.controls.name.contains} />
+ <ToggleAdvancedFilters />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add Tag" on:click={() => openModal(AddTag)} />
+ </svelte:fragment>
+ <TagFilterForm />
+ </Toolbar>
+ {#if tags}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each tags.edges as { id, name, description }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} title={description} on:click={handle} filter="tags" id={`:${id}`}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/tags/+page.ts b/frontend/src/routes/tags/+page.ts
new file mode 100644
index 0000000..f584b6f
--- /dev/null
+++ b/frontend/src/routes/tags/+page.ts
@@ -0,0 +1,12 @@
+import { TagSort, type TagFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, TagSort.Name),
+ filter: parseFilter<TagFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}
diff --git a/frontend/src/routes/worlds/+page.svelte b/frontend/src/routes/worlds/+page.svelte
new file mode 100644
index 0000000..e0366e9
--- /dev/null
+++ b/frontend/src/routes/worlds/+page.svelte
@@ -0,0 +1,102 @@
+<script lang="ts">
+ import { deleteWorlds } from '$gql/Mutations';
+ import { fetchWorld, worldsQuery } from '$gql/Queries';
+ import type { World } from '$gql/graphql';
+ import { WorldSortLabel } from '$lib/Enums';
+ import { BasicFilterContext, initFilterContext } from '$lib/Filter';
+ import { initPaginationContext } from '$lib/Pagination';
+ import { initSelectionContext } from '$lib/Selection';
+ import { initSortContext } from '$lib/Sort';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Cardlet from '$lib/components/Cardlet.svelte';
+ import Empty from '$lib/components/Empty.svelte';
+ import Guard from '$lib/components/Guard.svelte';
+ import Head from '$lib/components/Head.svelte';
+ import Cardlets from '$lib/containers/Cardlets.svelte';
+ import Column from '$lib/containers/Column.svelte';
+ import AddWorld from '$lib/dialogs/AddWorld.svelte';
+ import EditWorld from '$lib/dialogs/EditWorld.svelte';
+ import Pagination from '$lib/pagination/Pagination.svelte';
+ import Selectable from '$lib/selection/Selectable.svelte';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte';
+ import Search from '$lib/toolbar/Search.svelte';
+ import SelectItems from '$lib/toolbar/SelectItems.svelte';
+ import SelectSort from '$lib/toolbar/SelectSort.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import Toolbar from '$lib/toolbar/Toolbar.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { openModal } from 'svelte-modals';
+ import type { PageData } from './$types';
+
+ const client = getContextClient();
+
+ export let data: PageData;
+
+ $: result = worldsQuery(client, {
+ pagination: data.pagination,
+ filter: data.filter,
+ sort: data.sort
+ });
+
+ $: worlds = $result.data?.worlds;
+
+ const selection = initSelectionContext<World>('World', (w) => w.name);
+ $: if (worlds) {
+ $selection.view = worlds.edges;
+ $pagination.total = worlds.count;
+ }
+
+ const filter = initFilterContext<BasicFilterContext>();
+ $: $filter = new BasicFilterContext(data.filter);
+
+ const sort = initSortContext(data.sort, WorldSortLabel);
+ $: $sort.update = data.sort;
+
+ const pagination = initPaginationContext();
+ $: $pagination.update = data.pagination;
+
+ const edit = (id: number) => {
+ fetchWorld(client, id)
+ .then((world) => openModal(EditWorld, { world }))
+ .catch(toastFinally);
+ };
+</script>
+
+<Head section="Worlds" />
+
+<Column>
+ <Toolbar>
+ <SelectionControls slot="start">
+ <DeleteSelection mutation={deleteWorlds} />
+ </SelectionControls>
+ <svelte:fragment slot="center">
+ <Search name="Worlds" bind:field={$filter.include.controls.name.contains} />
+ <SelectSort />
+ <SelectItems />
+ </svelte:fragment>
+ <svelte:fragment slot="end">
+ <AddButton title="Add World" on:click={() => openModal(AddWorld)} />
+ </svelte:fragment>
+ </Toolbar>
+ {#if worlds}
+ <Pagination />
+ <main>
+ <Cardlets>
+ {#each worlds.edges as { id, name }, index (id)}
+ <Selectable {index} {id} {edit} let:handle let:selected>
+ <Cardlet {name} on:click={handle} filter="worlds" {id}>
+ <SelectionOverlay slot="overlay" position="right" centered {selected} />
+ </Cardlet>
+ </Selectable>
+ {:else}
+ <Empty />
+ {/each}
+ </Cardlets>
+ </main>
+ <Pagination />
+ {:else}
+ <Guard {result} />
+ {/if}
+</Column>
diff --git a/frontend/src/routes/worlds/+page.ts b/frontend/src/routes/worlds/+page.ts
new file mode 100644
index 0000000..3b85f4c
--- /dev/null
+++ b/frontend/src/routes/worlds/+page.ts
@@ -0,0 +1,12 @@
+import { WorldSort, type WorldFilterInput } from '$gql/graphql';
+import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation';
+
+export const trailingSlash = 'always';
+
+export function load({ url }: { url: URL; params: Record<string, string> }) {
+ return {
+ sort: parseSortData(url.searchParams, WorldSort.Name),
+ filter: parseFilter<WorldFilterInput>(url.searchParams),
+ pagination: parsePaginationData(url.searchParams)
+ };
+}