summaryrefslogtreecommitdiffstatshomepage
path: root/frontend/src
diff options
context:
space:
mode:
authorWolfgang Müller2024-03-05 18:08:09 +0100
committerWolfgang Müller2024-03-05 19:25:59 +0100
commitd1d654ebac2d51e3841675faeb56480e440f622f (patch)
tree56ef123c1a15a10dfd90836e4038e27efde950c6 /frontend/src
downloadhircine-d1d654ebac2d51e3841675faeb56480e440f622f.tar.gz
Initial commit0.1.0
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/app.css180
-rw-r--r--frontend/src/app.d.ts12
-rw-r--r--frontend/src/app.html13
-rw-r--r--frontend/src/gql/Mutations.ts244
-rw-r--r--frontend/src/gql/Queries.ts243
-rw-r--r--frontend/src/gql/Utils.ts74
-rw-r--r--frontend/src/gql/graphql.ts1764
-rw-r--r--frontend/src/lib/Actions.ts109
-rw-r--r--frontend/src/lib/Enums.ts325
-rw-r--r--frontend/src/lib/Filter.ts365
-rw-r--r--frontend/src/lib/Meta.ts1
-rw-r--r--frontend/src/lib/Navigation.ts114
-rw-r--r--frontend/src/lib/Pagination.ts31
-rw-r--r--frontend/src/lib/Reader.ts62
-rw-r--r--frontend/src/lib/Scraper.ts156
-rw-r--r--frontend/src/lib/Selection.ts141
-rw-r--r--frontend/src/lib/Shortcuts.ts153
-rw-r--r--frontend/src/lib/Sort.ts42
-rw-r--r--frontend/src/lib/Tabs.ts18
-rw-r--r--frontend/src/lib/Toasts.ts19
-rw-r--r--frontend/src/lib/Transitions.ts10
-rw-r--r--frontend/src/lib/Update.ts97
-rw-r--r--frontend/src/lib/Utils.ts108
-rw-r--r--frontend/src/lib/assets/logo.webpbin0 -> 89322 bytes
-rw-r--r--frontend/src/lib/components/AddButton.svelte7
-rw-r--r--frontend/src/lib/components/Badge.svelte15
-rw-r--r--frontend/src/lib/components/BookmarkButton.svelte9
-rw-r--r--frontend/src/lib/components/Card.svelte106
-rw-r--r--frontend/src/lib/components/Cardlet.svelte37
-rw-r--r--frontend/src/lib/components/DeleteButton.svelte15
-rw-r--r--frontend/src/lib/components/Dialog.svelte36
-rw-r--r--frontend/src/lib/components/Dropdown.svelte18
-rw-r--r--frontend/src/lib/components/Empty.svelte10
-rw-r--r--frontend/src/lib/components/Expander.svelte17
-rw-r--r--frontend/src/lib/components/Guard.svelte13
-rw-r--r--frontend/src/lib/components/Head.svelte12
-rw-r--r--frontend/src/lib/components/Labelled.svelte10
-rw-r--r--frontend/src/lib/components/LabelledBlock.svelte18
-rw-r--r--frontend/src/lib/components/OrganizedButton.svelte9
-rw-r--r--frontend/src/lib/components/RefreshButton.svelte3
-rw-r--r--frontend/src/lib/components/RemovePageButton.svelte13
-rw-r--r--frontend/src/lib/components/Select.svelte55
-rw-r--r--frontend/src/lib/components/Spinner.svelte36
-rw-r--r--frontend/src/lib/components/SubmitButton.svelte7
-rw-r--r--frontend/src/lib/components/Titlebar.svelte32
-rw-r--r--frontend/src/lib/containers/Cardlets.svelte11
-rw-r--r--frontend/src/lib/containers/Cards.svelte8
-rw-r--r--frontend/src/lib/containers/Carousel.svelte15
-rw-r--r--frontend/src/lib/containers/Column.svelte3
-rw-r--r--frontend/src/lib/containers/Grid.svelte23
-rw-r--r--frontend/src/lib/dialogs/AddArtist.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddCharacter.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddCircle.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddNamespace.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddTag.svelte30
-rw-r--r--frontend/src/lib/dialogs/AddWorld.svelte30
-rw-r--r--frontend/src/lib/dialogs/ConfirmDeletion.svelte51
-rw-r--r--frontend/src/lib/dialogs/EditArtist.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditCharacter.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditCircle.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditNamespace.svelte46
-rw-r--r--frontend/src/lib/dialogs/EditTag.svelte44
-rw-r--r--frontend/src/lib/dialogs/EditWorld.svelte46
-rw-r--r--frontend/src/lib/dialogs/UpdateComics.svelte96
-rw-r--r--frontend/src/lib/dialogs/UpdateTags.svelte45
-rw-r--r--frontend/src/lib/dialogs/components/UpdateModeSelector.svelte24
-rw-r--r--frontend/src/lib/filter/ComicFilterForm.svelte48
-rw-r--r--frontend/src/lib/filter/TagFilterForm.svelte31
-rw-r--r--frontend/src/lib/filter/components/ComicFilterGroup.svelte27
-rw-r--r--frontend/src/lib/filter/components/Filter.svelte77
-rw-r--r--frontend/src/lib/filter/components/FilterForm.svelte47
-rw-r--r--frontend/src/lib/filter/components/TagFilterGroup.svelte14
-rw-r--r--frontend/src/lib/forms/ArtistForm.svelte25
-rw-r--r--frontend/src/lib/forms/CharacterForm.svelte25
-rw-r--r--frontend/src/lib/forms/CircleForm.svelte25
-rw-r--r--frontend/src/lib/forms/ComicForm.svelte100
-rw-r--r--frontend/src/lib/forms/NamespaceForm.svelte28
-rw-r--r--frontend/src/lib/forms/TagForm.svelte42
-rw-r--r--frontend/src/lib/forms/WorldForm.svelte25
-rw-r--r--frontend/src/lib/gallery/Gallery.svelte42
-rw-r--r--frontend/src/lib/gallery/GalleryPage.svelte93
-rw-r--r--frontend/src/lib/icons/Bookmark.svelte10
-rw-r--r--frontend/src/lib/icons/Female.svelte1
-rw-r--r--frontend/src/lib/icons/Location.svelte1
-rw-r--r--frontend/src/lib/icons/Male.svelte1
-rw-r--r--frontend/src/lib/icons/Organized.svelte21
-rw-r--r--frontend/src/lib/icons/Star.svelte25
-rw-r--r--frontend/src/lib/icons/Transgender.svelte1
-rw-r--r--frontend/src/lib/navigation/Link.svelte20
-rw-r--r--frontend/src/lib/navigation/Navigation.svelte5
-rw-r--r--frontend/src/lib/pagination/Pagination.svelte45
-rw-r--r--frontend/src/lib/pagination/Target.svelte21
-rw-r--r--frontend/src/lib/pills/AssociationPill.svelte30
-rw-r--r--frontend/src/lib/pills/ComicPills.svelte37
-rw-r--r--frontend/src/lib/pills/Pill.svelte40
-rw-r--r--frontend/src/lib/pills/TagPill.svelte40
-rw-r--r--frontend/src/lib/reader/PageView.svelte67
-rw-r--r--frontend/src/lib/reader/Reader.svelte39
-rw-r--r--frontend/src/lib/reader/ReaderPage.svelte24
-rw-r--r--frontend/src/lib/reader/components/CloseReaderButton.svelte19
-rw-r--r--frontend/src/lib/reader/components/ReaderMenuButton.svelte16
-rw-r--r--frontend/src/lib/scraper/ComicScrapeForm.svelte138
-rw-r--r--frontend/src/lib/scraper/components/SelectorButton.svelte22
-rw-r--r--frontend/src/lib/scraper/components/SelectorGroup.svelte35
-rw-r--r--frontend/src/lib/scraper/components/SelectorItem.svelte24
-rw-r--r--frontend/src/lib/selection/Selectable.svelte24
-rw-r--r--frontend/src/lib/selection/SelectionOverlay.svelte34
-rw-r--r--frontend/src/lib/tabs/AddOverlay.svelte36
-rw-r--r--frontend/src/lib/tabs/ArchiveDelete.svelte42
-rw-r--r--frontend/src/lib/tabs/ArchiveDetails.svelte50
-rw-r--r--frontend/src/lib/tabs/ArchiveEdit.svelte68
-rw-r--r--frontend/src/lib/tabs/ComicDelete.svelte34
-rw-r--r--frontend/src/lib/tabs/ComicDetails.svelte121
-rw-r--r--frontend/src/lib/tabs/DetailsHeader.svelte11
-rw-r--r--frontend/src/lib/tabs/DetailsSection.svelte10
-rw-r--r--frontend/src/lib/tabs/Tab.svelte14
-rw-r--r--frontend/src/lib/tabs/Tabs.svelte40
-rw-r--r--frontend/src/lib/toolbar/DeleteSelection.svelte26
-rw-r--r--frontend/src/lib/toolbar/EditSelection.svelte29
-rw-r--r--frontend/src/lib/toolbar/FilterBookmarked.svelte24
-rw-r--r--frontend/src/lib/toolbar/FilterFavourites.svelte24
-rw-r--r--frontend/src/lib/toolbar/FilterOrganized.svelte30
-rw-r--r--frontend/src/lib/toolbar/MarkBookmark.svelte27
-rw-r--r--frontend/src/lib/toolbar/MarkFavourite.svelte27
-rw-r--r--frontend/src/lib/toolbar/MarkOrganized.svelte27
-rw-r--r--frontend/src/lib/toolbar/MarkSelection.svelte24
-rw-r--r--frontend/src/lib/toolbar/Search.svelte21
-rw-r--r--frontend/src/lib/toolbar/SelectItems.svelte19
-rw-r--r--frontend/src/lib/toolbar/SelectSort.svelte61
-rw-r--r--frontend/src/lib/toolbar/SelectionControls.svelte57
-rw-r--r--frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte40
-rw-r--r--frontend/src/lib/toolbar/Toolbar.svelte42
-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
155 files changed, 9175 insertions, 0 deletions
diff --git a/frontend/src/app.css b/frontend/src/app.css
new file mode 100644
index 0000000..13a7883
--- /dev/null
+++ b/frontend/src/app.css
@@ -0,0 +1,180 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@layer base {
+ body {
+ display: grid;
+ grid-template-columns: 3rem 1fr;
+ scrollbar-color: theme('colors.gray.500') rgba(0, 0, 0, 0);
+ font-family: 'Noto Sans', sans-serif;
+ }
+
+ input,
+ textarea {
+ @apply w-full rounded bg-slate-900 p-[.4rem] focus:outline focus:outline-1 focus:outline-slate-500;
+ }
+
+ label {
+ @apply mb-0.5 inline-block;
+ }
+
+ form {
+ @apply flex flex-col gap-4 p-px text-sm;
+ }
+
+ .rounded-group > * {
+ @apply rounded-none first:rounded-l-sm last:rounded-r-sm !important;
+ }
+
+ .rounded-group-start > * {
+ @apply rounded-none first:rounded-l-sm !important;
+ }
+
+ .rounded-group-end > * {
+ @apply rounded-none last:rounded-r-sm !important;
+ }
+
+ .grid-labels {
+ @apply grid grid-cols-[1fr_5fr] gap-2;
+ }
+
+ header {
+ grid-area: header;
+ }
+
+ aside {
+ overflow: auto;
+ grid-area: sidebar;
+ @apply lg:w-[28rem] xl:w-[32rem] min-[1920px]:w-[36rem];
+ }
+
+ main {
+ grid-area: main;
+ }
+}
+
+@layer components {
+ .btn {
+ @apply flex items-center justify-center rounded-sm p-2 text-white transition-colors disabled:opacity-40;
+ }
+
+ .btn-xs {
+ @apply btn rounded-sm p-0.5 py-0;
+ }
+
+ .btn-blue {
+ @apply btn bg-blue-700 hover:bg-blue-600 disabled:bg-blue-900;
+ }
+
+ .btn-rose {
+ @apply btn bg-rose-700 hover:bg-rose-600 disabled:bg-rose-900;
+ }
+
+ .btn-slate {
+ @apply btn bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800;
+ }
+
+ .btn-indigo {
+ @apply btn bg-indigo-700 hover:bg-indigo-600 disabled:bg-indigo-800;
+ }
+
+ .icon-xs {
+ @apply text-[18px];
+ }
+
+ .icon-base {
+ @apply text-[24px];
+ }
+
+ .icon-lg {
+ @apply text-[28px];
+ }
+
+ .icon-2xl {
+ @apply text-[48px];
+ }
+
+ .icon-gray.hoverable:hover {
+ @apply text-gray-200/80;
+ }
+
+ .icon-gray.dim {
+ @apply text-gray-100/40;
+ }
+
+ .icon-yellow {
+ @apply text-yellow-300;
+ }
+
+ .icon-yellow.hoverable:hover {
+ @apply text-yellow-300/80;
+ }
+
+ .icon-yellow.dim {
+ @apply text-slate-100/40;
+ }
+}
+
+@layer utilities {
+ .toggled {
+ @apply bg-indigo-700 hover:bg-indigo-600;
+ }
+
+ .floating {
+ @apply rounded-full bg-black/50 p-1 text-white/80 backdrop-blur-sm hover:bg-black/50 hover:text-white;
+ }
+
+ .ellipsis-nowrap {
+ @apply overflow-hidden text-ellipsis whitespace-nowrap;
+ }
+
+ .rounded-inherit {
+ border-radius: inherit;
+ }
+
+ .grid-card-h {
+ grid-template-columns: 210px 1fr;
+ grid-template-rows: 300px;
+ }
+
+ .grid-card-cover-only {
+ @apply !grid-card-h;
+ }
+
+ .grid-card-v {
+ grid-template-columns: 1fr;
+ grid-template-rows: 500px 1fr;
+ }
+}
+
+.svelecte-control {
+ --sv-item-color: inherit !important;
+ --sv-bg: theme(colors.slate.900) !important;
+ --sv-disabled-bg: theme(colors.slate.900) !important;
+ --sv-border: 1px solid rgba(0, 0, 0, 0) !important;
+ --sv-disabled-border-color: rgba(0, 0, 0, 0) !important;
+ --sv-border-color: theme(colors.slate.600) !important;
+ --sv-active-border: 1px solid theme(colors.slate.500) !important;
+ --sv-item-selected-bg: theme(colors.indigo.800) !important;
+ --sv-item-active-bg: theme(colors.indigo.800) !important;
+ --sv-highlight-bg: none !important;
+ --sv-item-btn-bg-hover: theme(colors.rose.900) !important;
+ --sv-placeholder-color: theme(colors.gray.500) !important;
+}
+
+.svelecte-control input {
+ @apply !h-8;
+}
+
+.exclude .svelecte-control {
+ --sv-border: 1px solid theme('colors.red.900') !important;
+ --sv-active-border: 1px solid theme('colors.red.700') !important;
+
+ --sv-item-selected-bg: theme(colors.rose.800) !important;
+ --sv-item-active-bg: theme(colors.rose.800) !important;
+}
+
+.sv-dropdown {
+ @apply my-1 !bg-slate-950;
+}
diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
new file mode 100644
index 0000000..41634fe
--- /dev/null
+++ b/frontend/src/app.d.ts
@@ -0,0 +1,12 @@
+/// <reference types="@sveltejs/kit" />
+/// <reference types="unplugin-icons/types/svelte" />
+
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+// and what to do when importing types
+declare namespace App {
+ // interface Locals {}
+ // interface PageData {}
+ // interface Error {}
+ // interface Platform {}
+}
diff --git a/frontend/src/app.html b/frontend/src/app.html
new file mode 100644
index 0000000..6303945
--- /dev/null
+++ b/frontend/src/app.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width" />
+ <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
+ <title>hircine</title>
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-preload-data="hover" class="h-screen bg-slate-800 text-gray-200">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
diff --git a/frontend/src/gql/Mutations.ts b/frontend/src/gql/Mutations.ts
new file mode 100644
index 0000000..d669b25
--- /dev/null
+++ b/frontend/src/gql/Mutations.ts
@@ -0,0 +1,244 @@
+import { toastSuccess } from '$lib/Toasts';
+import {
+ Client,
+ type AnyVariables,
+ type OperationResult,
+ type OperationResultSource
+} from '@urql/svelte';
+import { isError, isSuccess, type RequiredName } from './Utils';
+import * as gql from './graphql';
+
+export type DeleteMutation = (client: Client, args: { ids: number | number[] }) => Promise<unknown>;
+
+const comicTypes = ['Comic'];
+const comicUpsertTypes = comicTypes.concat([
+ 'Artist',
+ 'Character',
+ 'Circle',
+ 'Namespace',
+ 'Collection',
+ 'Tag',
+ 'World'
+]);
+
+function handleResult<D, I extends AnyVariables>(result: OperationResult<D, I>): Promise<D> {
+ return new Promise((resolve, reject) => {
+ if (result.error) {
+ return reject(`${result.error.name}: ${result.error.message}`);
+ }
+
+ if (!result.data) {
+ return reject('Mutation resolved, but result contains no data.');
+ }
+
+ const obj = Object.values(result.data)[0];
+
+ if (isError(obj)) {
+ reject(obj.message);
+ } else if (isSuccess(obj)) {
+ toastSuccess(obj.message);
+ resolve(result.data);
+ }
+
+ reject('This should not happen.');
+ });
+}
+
+async function handleMutation<D, V extends AnyVariables>(
+ mutation: OperationResultSource<OperationResult<D, V>>
+) {
+ return await mutation.toPromise().then(handleResult);
+}
+
+export async function addComic(client: Client, args: gql.AddComicMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddComicDocument, args, {
+ additionalTypenames: comicTypes.concat('Archive', 'Page')
+ })
+ );
+}
+
+export async function updateComics(client: Client, args: gql.UpdateComicsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateComicsDocument, args, {
+ additionalTypenames: comicTypes
+ })
+ );
+}
+
+export async function upsertComics(client: Client, args: gql.UpsertComicsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpsertComicsDocument, args, {
+ additionalTypenames: comicUpsertTypes
+ })
+ );
+}
+
+export async function updateArchives(client: Client, args: gql.UpdateArchivesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateArchivesDocument, args, {
+ additionalTypenames: ['Archive']
+ })
+ );
+}
+
+export async function deleteArchives(client: Client, args: gql.DeleteArchivesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteArchivesDocument, args, {
+ additionalTypenames: comicTypes.concat('Archive')
+ })
+ );
+}
+
+export async function deleteComics(client: Client, args: gql.DeleteComicsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteComicsDocument, args, { additionalTypenames: comicTypes })
+ );
+}
+
+export async function addArtist(client: Client, args: gql.AddArtistMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddArtistDocument, args, { additionalTypenames: ['ArtistFilterResult'] })
+ );
+}
+
+export async function updateArtists(client: Client, args: gql.UpdateArtistsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateArtistsDocument, args, { additionalTypenames: ['Artist'] })
+ );
+}
+
+export async function deleteArtists(client: Client, args: gql.DeleteArtistsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteArtistsDocument, args, {
+ additionalTypenames: ['Artist']
+ })
+ );
+}
+
+export async function addCharacter(client: Client, args: gql.AddCharacterMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddCharacterDocument, args, {
+ additionalTypenames: ['CharacterFilterResult']
+ })
+ );
+}
+
+export async function updateCharacters(
+ client: Client,
+ args: gql.UpdateCharactersMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.UpdateCharactersDocument, args, { additionalTypenames: ['Character'] })
+ );
+}
+
+export async function deleteCharacters(
+ client: Client,
+ args: gql.DeleteCharactersMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.DeleteCharactersDocument, args, {
+ additionalTypenames: ['Character']
+ })
+ );
+}
+
+export async function addCircle(client: Client, args: gql.AddCircleMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddCircleDocument, args, { additionalTypenames: ['CircleFilterResult'] })
+ );
+}
+
+export async function updateCircles(client: Client, args: gql.UpdateCirclesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateCirclesDocument, args, { additionalTypenames: ['Circle'] })
+ );
+}
+
+export async function deleteCircles(client: Client, args: gql.DeleteCirclesMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteCirclesDocument, args, {
+ additionalTypenames: ['Circle']
+ })
+ );
+}
+
+export async function addNamespace(client: Client, args: gql.AddNamespaceMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddNamespaceDocument, args, {
+ additionalTypenames: ['NamespaceFilterResult', 'ComicTagFilterResult']
+ })
+ );
+}
+
+export async function updateNamespaces(
+ client: Client,
+ args: gql.UpdateNamespacesMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.UpdateNamespacesDocument, args, {
+ additionalTypenames: ['Namespace', 'ComicTag']
+ })
+ );
+}
+
+export async function deleteNamespaces(
+ client: Client,
+ args: gql.DeleteNamespacesMutationVariables
+) {
+ return await handleMutation(
+ client.mutation(gql.DeleteNamespacesDocument, args, {
+ additionalTypenames: ['NamespaceFilterResult', 'ComicTag']
+ })
+ );
+}
+
+export async function addTag(client: Client, args: gql.AddTagMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddTagDocument, args, {
+ additionalTypenames: ['TagFilterResult', 'ComicTagFilterResult']
+ })
+ );
+}
+
+export async function updateTags(client: Client, args: gql.UpdateTagsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateTagsDocument, args, {
+ additionalTypenames: ['Tag', 'ComicTag']
+ })
+ );
+}
+
+export async function deleteTags(client: Client, args: gql.DeleteTagsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteTagsDocument, args, {
+ additionalTypenames: ['TagFilterResult', 'ComicTag']
+ })
+ );
+}
+
+export async function addWorld(client: Client, args: gql.AddWorldMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.AddWorldDocument, args, { additionalTypenames: ['WorldFilterResult'] })
+ );
+}
+
+export async function updateWorlds(client: Client, args: gql.UpdateWorldsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.UpdateWorldsDocument, args, { additionalTypenames: ['World'] })
+ );
+}
+
+export async function deleteWorlds(client: Client, args: gql.DeleteWorldsMutationVariables) {
+ return await handleMutation(
+ client.mutation(gql.DeleteWorldsDocument, args, { additionalTypenames: ['World'] })
+ );
+}
+
+export type ArtistInput = RequiredName<gql.UpdateArtistInput>;
+export type CharacterInput = RequiredName<gql.UpdateCharacterInput>;
+export type CircleInput = RequiredName<gql.UpdateCircleInput>;
+export type NamespaceInput = RequiredName<gql.UpdateNamespaceInput>;
+export type TagInput = RequiredName<gql.UpdateTagInput>;
+export type WorldInput = RequiredName<gql.UpdateWorldInput>;
diff --git a/frontend/src/gql/Queries.ts b/frontend/src/gql/Queries.ts
new file mode 100644
index 0000000..cc9dd4c
--- /dev/null
+++ b/frontend/src/gql/Queries.ts
@@ -0,0 +1,243 @@
+import { Client, queryStore, type OperationResult } from '@urql/svelte';
+import { isError } from './Utils';
+import * as gql from './graphql';
+
+function handleResult<D, T>(result: OperationResult<D>): Promise<T> {
+ return new Promise((resolve, reject) => {
+ if (result.error) {
+ return reject(`${result.error.name}: ${result.error.message}`);
+ }
+
+ if (!result.data) {
+ return reject('Query resolved, but result contains no data.');
+ }
+
+ const data = Object.values<T>(result.data)[0];
+
+ if (isError(data)) {
+ reject(data.message);
+ } else {
+ resolve(data);
+ }
+
+ reject('This should not happen.');
+ });
+}
+
+export function comicTagList(client: Client, args?: gql.ComicTagListQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicTagListDocument,
+ context: {
+ additionalTypenames: ['Namespace', 'Tag']
+ },
+ variables: args
+ });
+}
+
+export function artistList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.ArtistListDocument,
+ context: {
+ additionalTypenames: ['Artist']
+ }
+ });
+}
+
+export function characterList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.CharacterListDocument,
+ context: {
+ additionalTypenames: ['Character']
+ }
+ });
+}
+
+export function circleList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.CircleListDocument,
+ context: {
+ additionalTypenames: ['Circle']
+ }
+ });
+}
+
+export function worldList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.WorldListDocument,
+ context: {
+ additionalTypenames: ['World']
+ }
+ });
+}
+
+export function namespaceList(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.NamespaceListDocument,
+ context: {
+ additionalTypenames: ['Namespace']
+ }
+ });
+}
+
+export function comicScrapersQuery(client: Client, args: gql.ComicScrapersQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicScrapersDocument,
+ variables: args,
+ context: {
+ additionalTypenames: ['Comic']
+ }
+ });
+}
+
+export async function scrapeComic(client: Client, args: gql.ScrapeComicQueryVariables) {
+ return await client
+ .query(gql.ScrapeComicDocument, args, { additionalTypenames: ['Comic'] })
+ .toPromise();
+}
+
+export function archiveQuery(client: Client, args: gql.ArchiveQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ArchiveDocument,
+ variables: args,
+ context: { additionalTypenames: ['Archive'] }
+ });
+}
+
+export function archivesQuery(client: Client, args: gql.ArchivesQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ArchivesDocument,
+ variables: args,
+ context: { additionalTypenames: ['Archive'] }
+ });
+}
+
+export function artistsQuery(client: Client, args?: gql.ArtistsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ArtistsDocument,
+ variables: args,
+ context: { additionalTypenames: ['Artist'] }
+ });
+}
+
+export function charactersQuery(client: Client, args?: gql.CharactersQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.CharactersDocument,
+ variables: args,
+ context: { additionalTypenames: ['Character'] }
+ });
+}
+
+export function circlesQuery(client: Client, args?: gql.CirclesQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.CirclesDocument,
+ variables: args,
+ context: { additionalTypenames: ['Circle'] }
+ });
+}
+
+export function comicQuery(client: Client, args: gql.ComicQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicDocument,
+ variables: args,
+ context: { additionalTypenames: ['Comic'] }
+ });
+}
+
+export function comicsQuery(client: Client, args?: gql.ComicsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.ComicsDocument,
+ variables: args,
+ context: { additionalTypenames: ['Comic'] }
+ });
+}
+
+export function namespacesQuery(client: Client, args?: gql.NamespacesQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.NamespacesDocument,
+ variables: args,
+ context: { additionalTypenames: ['Namespace'] }
+ });
+}
+
+export function tagsQuery(client: Client, args?: gql.TagsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.TagsDocument,
+ variables: args,
+ context: { additionalTypenames: ['Tag'] }
+ });
+}
+
+export function worldsQuery(client: Client, args?: gql.WorldsQueryVariables) {
+ return queryStore({
+ client: client,
+ query: gql.WorldsDocument,
+ variables: args,
+ context: { additionalTypenames: ['World'] }
+ });
+}
+
+export function frontpageQuery(client: Client) {
+ return queryStore({
+ client: client,
+ query: gql.FrontpageDocument,
+ requestPolicy: 'network-only'
+ });
+}
+
+export function fetchArtist(client: Client, id: number) {
+ return client
+ .query(gql.ArtistDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.ArtistQuery, gql.Artist>);
+}
+
+export function fetchCharacter(client: Client, id: number) {
+ return client
+ .query(gql.CharacterDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.CharacterQuery, gql.Character>);
+}
+
+export function fetchCircle(client: Client, id: number) {
+ return client
+ .query(gql.CircleDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.CircleQuery, gql.Circle>);
+}
+
+export function fetchNamespace(client: Client, id: number) {
+ return client
+ .query(gql.NamespaceDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.NamespaceQuery, gql.Namespace>);
+}
+
+export function fetchTag(client: Client, id: number) {
+ return client
+ .query(gql.TagDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.TagQuery, gql.FullTag>);
+}
+
+export function fetchWorld(client: Client, id: number) {
+ return client
+ .query(gql.WorldDocument, { id }, { requestPolicy: 'cache-and-network' })
+ .toPromise()
+ .then(handleResult<gql.WorldQuery, gql.World>);
+}
diff --git a/frontend/src/gql/Utils.ts b/frontend/src/gql/Utils.ts
new file mode 100644
index 0000000..dd21bbe
--- /dev/null
+++ b/frontend/src/gql/Utils.ts
@@ -0,0 +1,74 @@
+import equal from 'fast-deep-equal';
+import * as gql from './graphql';
+
+export type OmitIdentifiers<T> = Omit<T, 'id' | '__typename'>;
+export type RequiredName<T> = T & { name: string };
+
+export function isSuccess(object: any): object is gql.Success {
+ if (object.__typename === undefined) {
+ return false;
+ }
+
+ return object.__typename.endsWith('Success') && (object as gql.Success).message !== undefined;
+}
+
+export function isError(object: any): object is gql.Error {
+ if (object.__typename === undefined) {
+ return false;
+ }
+ return object.__typename.endsWith('Error') && (object as gql.Error).message !== undefined;
+}
+
+type Item = {
+ id: number | string;
+ name: string;
+};
+
+export function itemEquals(a: Item, b: Item) {
+ return a.name == b.name;
+}
+
+function assocEquals(as: Item[], bs: Item[]) {
+ return equal(
+ as.map((a) => a.id),
+ bs.map((b) => b.id)
+ );
+}
+
+function stringEquals(a: string | null | undefined, b: string | null | undefined) {
+ return (a ? a : null) == (b ? b : null);
+}
+
+export function tagEquals(a: gql.FullTag, b: gql.FullTag) {
+ return (
+ itemEquals(a, b) &&
+ stringEquals(a.description, b.description) &&
+ assocEquals(a.namespaces, b.namespaces)
+ );
+}
+
+export function comicEquals(
+ a: gql.FullComicFragment | undefined,
+ b: gql.FullComicFragment | undefined
+) {
+ if (a === undefined) return b === undefined;
+ if (b === undefined) return a === undefined;
+
+ return (
+ stringEquals(a.title, b.title) &&
+ stringEquals(a.originalTitle, b.originalTitle) &&
+ stringEquals(a.url, b.url) &&
+ stringEquals(a.date, b.date) &&
+ a.category == b.category &&
+ a.rating == b.rating &&
+ a.censorship == b.censorship &&
+ a.language == b.language &&
+ a.direction == b.direction &&
+ a.layout == b.layout &&
+ assocEquals(a.artists, b.artists) &&
+ assocEquals(a.circles, b.circles) &&
+ assocEquals(a.characters, b.characters) &&
+ assocEquals(a.tags, b.tags) &&
+ assocEquals(a.worlds, b.worlds)
+ );
+}
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
new file mode 100644
index 0000000..139068c
--- /dev/null
+++ b/frontend/src/gql/graphql.ts
@@ -0,0 +1,1764 @@
+import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
+export type Maybe<T> = T | null;
+export type InputMaybe<T> = Maybe<T>;
+export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
+export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
+export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
+export type MakeEmpty<T extends { [key: string]: unknown }, K extends keyof T> = { [_ in K]?: never };
+export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
+/** All built-in and custom scalars, mapped to their actual values */
+export type Scalars = {
+ ID: { input: string; output: string; }
+ String: { input: string; output: string; }
+ Boolean: { input: boolean; output: boolean; }
+ Int: { input: number; output: number; }
+ Float: { input: number; output: number; }
+ Date: { input: string; output: string; }
+ DateTime: { input: string; output: string; }
+};
+
+export type AddArtistInput = {
+ name: Scalars['String']['input'];
+};
+
+export type AddCharacterInput = {
+ name: Scalars['String']['input'];
+};
+
+export type AddCircleInput = {
+ name: Scalars['String']['input'];
+};
+
+export type AddComicInput = {
+ archive: ArchiveInput;
+ cover: CoverInput;
+ pages: UniquePagesInput;
+ title: Scalars['String']['input'];
+};
+
+export type AddComicResponse = AddComicSuccess | IdNotFoundError | InvalidParameterError | PageClaimedError | PageRemoteError;
+
+export type AddComicSuccess = Success & {
+ __typename?: 'AddComicSuccess';
+ archivePagesRemaining: Scalars['Boolean']['output'];
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type AddNamespaceInput = {
+ name: Scalars['String']['input'];
+ sortName?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type AddResponse = AddSuccess | IdNotFoundError | InvalidParameterError | NameExistsError;
+
+export type AddSuccess = Success & {
+ __typename?: 'AddSuccess';
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type AddTagInput = {
+ description?: InputMaybe<Scalars['String']['input']>;
+ name: Scalars['String']['input'];
+ namespaces?: InputMaybe<NamespacesInput>;
+};
+
+export type AddWorldInput = {
+ name: Scalars['String']['input'];
+};
+
+export type Archive = {
+ __typename?: 'Archive';
+ cover: Image;
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+ organized: Scalars['Boolean']['output'];
+ pageCount: Scalars['Int']['output'];
+ path: Scalars['String']['output'];
+ size: Scalars['Int']['output'];
+};
+
+export type ArchiveFilter = {
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ path?: InputMaybe<StringFilter>;
+};
+
+export type ArchiveFilterInput = {
+ exclude?: InputMaybe<ArchiveFilter>;
+ include?: InputMaybe<ArchiveFilter>;
+};
+
+export type ArchiveFilterResult = {
+ __typename?: 'ArchiveFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Archive>;
+};
+
+export type ArchiveInput = {
+ id: Scalars['Int']['input'];
+};
+
+export type ArchiveResponse = FullArchive | IdNotFoundError;
+
+export enum ArchiveSort {
+ CreatedAt = 'CREATED_AT',
+ PageCount = 'PAGE_COUNT',
+ Path = 'PATH',
+ Random = 'RANDOM',
+ Size = 'SIZE'
+}
+
+export type ArchiveSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: ArchiveSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type Artist = {
+ __typename?: 'Artist';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type ArtistFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type ArtistFilterInput = {
+ exclude?: InputMaybe<ArtistFilter>;
+ include?: InputMaybe<ArtistFilter>;
+};
+
+export type ArtistFilterResult = {
+ __typename?: 'ArtistFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Artist>;
+};
+
+export type ArtistResponse = Artist | IdNotFoundError;
+
+export enum ArtistSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type ArtistSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: ArtistSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ArtistsUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type ArtistsUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type AssociationFilter = {
+ all?: InputMaybe<Array<Scalars['Int']['input']>>;
+ any?: InputMaybe<Array<Scalars['Int']['input']>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+ exact?: InputMaybe<Array<Scalars['Int']['input']>>;
+};
+
+export enum Category {
+ Artbook = 'ARTBOOK',
+ Comic = 'COMIC',
+ Doujinshi = 'DOUJINSHI',
+ GameCg = 'GAME_CG',
+ ImageSet = 'IMAGE_SET',
+ Manga = 'MANGA',
+ VariantSet = 'VARIANT_SET',
+ Webtoon = 'WEBTOON'
+}
+
+export type CategoryFilter = {
+ any?: InputMaybe<Array<Category>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export enum Censorship {
+ Bar = 'BAR',
+ Full = 'FULL',
+ Mosaic = 'MOSAIC',
+ None = 'NONE'
+}
+
+export type CensorshipFilter = {
+ any?: InputMaybe<Array<Censorship>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export type Character = {
+ __typename?: 'Character';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type CharacterFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type CharacterFilterInput = {
+ exclude?: InputMaybe<CharacterFilter>;
+ include?: InputMaybe<CharacterFilter>;
+};
+
+export type CharacterFilterResult = {
+ __typename?: 'CharacterFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Character>;
+};
+
+export type CharacterResponse = Character | IdNotFoundError;
+
+export enum CharacterSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type CharacterSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: CharacterSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type CharactersUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type CharactersUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type Circle = {
+ __typename?: 'Circle';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type CircleFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type CircleFilterInput = {
+ exclude?: InputMaybe<CircleFilter>;
+ include?: InputMaybe<CircleFilter>;
+};
+
+export type CircleFilterResult = {
+ __typename?: 'CircleFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Circle>;
+};
+
+export type CircleResponse = Circle | IdNotFoundError;
+
+export enum CircleSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type CircleSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: CircleSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type CirclesUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type CirclesUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type Comic = {
+ __typename?: 'Comic';
+ artists: Array<Artist>;
+ bookmarked: Scalars['Boolean']['output'];
+ category?: Maybe<Category>;
+ censorship?: Maybe<Censorship>;
+ characters: Array<Character>;
+ circles: Array<Circle>;
+ cover: Image;
+ date?: Maybe<Scalars['Date']['output']>;
+ favourite: Scalars['Boolean']['output'];
+ id: Scalars['Int']['output'];
+ language?: Maybe<Language>;
+ organized: Scalars['Boolean']['output'];
+ originalTitle?: Maybe<Scalars['String']['output']>;
+ pageCount: Scalars['Int']['output'];
+ rating?: Maybe<Rating>;
+ tags: Array<ComicTag>;
+ title: Scalars['String']['output'];
+ worlds: Array<World>;
+};
+
+export type ComicFilter = {
+ artists?: InputMaybe<AssociationFilter>;
+ bookmarked?: InputMaybe<Scalars['Boolean']['input']>;
+ category?: InputMaybe<CategoryFilter>;
+ censorship?: InputMaybe<CensorshipFilter>;
+ characters?: InputMaybe<AssociationFilter>;
+ circles?: InputMaybe<AssociationFilter>;
+ favourite?: InputMaybe<Scalars['Boolean']['input']>;
+ language?: InputMaybe<LanguageFilter>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ originalTitle?: InputMaybe<StringFilter>;
+ rating?: InputMaybe<RatingFilter>;
+ tags?: InputMaybe<TagAssociationFilter>;
+ title?: InputMaybe<StringFilter>;
+ url?: InputMaybe<StringFilter>;
+ worlds?: InputMaybe<AssociationFilter>;
+};
+
+export type ComicFilterInput = {
+ exclude?: InputMaybe<ComicFilter>;
+ include?: InputMaybe<ComicFilter>;
+};
+
+export type ComicFilterResult = {
+ __typename?: 'ComicFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Comic>;
+};
+
+export type ComicResponse = FullComic | IdNotFoundError;
+
+export type ComicScraper = {
+ __typename?: 'ComicScraper';
+ id: Scalars['String']['output'];
+ name: Scalars['String']['output'];
+};
+
+export enum ComicSort {
+ CreatedAt = 'CREATED_AT',
+ Date = 'DATE',
+ OriginalTitle = 'ORIGINAL_TITLE',
+ PageCount = 'PAGE_COUNT',
+ Random = 'RANDOM',
+ TagCount = 'TAG_COUNT',
+ Title = 'TITLE',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type ComicSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: ComicSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type ComicTag = {
+ __typename?: 'ComicTag';
+ description?: Maybe<Scalars['String']['output']>;
+ id: Scalars['String']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type ComicTagFilterResult = {
+ __typename?: 'ComicTagFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<ComicTag>;
+};
+
+export type ComicTagsUpdateInput = {
+ ids?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type ComicTagsUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type CoverInput = {
+ id: Scalars['Int']['input'];
+};
+
+export type CoverUpdateInput = {
+ id: Scalars['Int']['input'];
+};
+
+export type DeleteResponse = DeleteSuccess | IdNotFoundError;
+
+export type DeleteSuccess = Success & {
+ __typename?: 'DeleteSuccess';
+ message: Scalars['String']['output'];
+};
+
+export enum Direction {
+ LeftToRight = 'LEFT_TO_RIGHT',
+ RightToLeft = 'RIGHT_TO_LEFT'
+}
+
+export type Error = {
+ message: Scalars['String']['output'];
+};
+
+export type FullArchive = {
+ __typename?: 'FullArchive';
+ comics: Array<Comic>;
+ cover: Image;
+ createdAt: Scalars['DateTime']['output'];
+ id: Scalars['Int']['output'];
+ mtime: Scalars['DateTime']['output'];
+ name: Scalars['String']['output'];
+ organized: Scalars['Boolean']['output'];
+ pageCount: Scalars['Int']['output'];
+ pages: Array<Page>;
+ path: Scalars['String']['output'];
+ size: Scalars['Int']['output'];
+};
+
+export type FullComic = {
+ __typename?: 'FullComic';
+ archive: Archive;
+ artists: Array<Artist>;
+ bookmarked: Scalars['Boolean']['output'];
+ category?: Maybe<Category>;
+ censorship?: Maybe<Censorship>;
+ characters: Array<Character>;
+ circles: Array<Circle>;
+ cover: Image;
+ createdAt: Scalars['DateTime']['output'];
+ date?: Maybe<Scalars['Date']['output']>;
+ direction: Direction;
+ favourite: Scalars['Boolean']['output'];
+ id: Scalars['Int']['output'];
+ language?: Maybe<Language>;
+ layout: Layout;
+ organized: Scalars['Boolean']['output'];
+ originalTitle?: Maybe<Scalars['String']['output']>;
+ pageCount: Scalars['Int']['output'];
+ pages: Array<Page>;
+ rating?: Maybe<Rating>;
+ tags: Array<ComicTag>;
+ title: Scalars['String']['output'];
+ updatedAt: Scalars['DateTime']['output'];
+ url?: Maybe<Scalars['String']['output']>;
+ worlds: Array<World>;
+};
+
+export type FullTag = {
+ __typename?: 'FullTag';
+ description?: Maybe<Scalars['String']['output']>;
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+ namespaces: Array<Namespace>;
+};
+
+export type IdNotFoundError = Error & {
+ __typename?: 'IDNotFoundError';
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type Image = {
+ __typename?: 'Image';
+ aspectRatio: Scalars['Float']['output'];
+ hash: Scalars['String']['output'];
+ height: Scalars['Int']['output'];
+ id: Scalars['Int']['output'];
+ width: Scalars['Int']['output'];
+};
+
+export type InvalidParameterError = Error & {
+ __typename?: 'InvalidParameterError';
+ message: Scalars['String']['output'];
+ parameter: Scalars['String']['output'];
+};
+
+export enum Language {
+ Aa = 'AA',
+ Ab = 'AB',
+ Ae = 'AE',
+ Af = 'AF',
+ Ak = 'AK',
+ Am = 'AM',
+ An = 'AN',
+ Ar = 'AR',
+ As = 'AS',
+ Av = 'AV',
+ Ay = 'AY',
+ Az = 'AZ',
+ Ba = 'BA',
+ Be = 'BE',
+ Bg = 'BG',
+ Bh = 'BH',
+ Bi = 'BI',
+ Bm = 'BM',
+ Bn = 'BN',
+ Bo = 'BO',
+ Br = 'BR',
+ Bs = 'BS',
+ Ca = 'CA',
+ Ce = 'CE',
+ Ch = 'CH',
+ Co = 'CO',
+ Cr = 'CR',
+ Cs = 'CS',
+ Cu = 'CU',
+ Cv = 'CV',
+ Cy = 'CY',
+ Da = 'DA',
+ De = 'DE',
+ Dv = 'DV',
+ Dz = 'DZ',
+ Ee = 'EE',
+ El = 'EL',
+ En = 'EN',
+ Eo = 'EO',
+ Es = 'ES',
+ Et = 'ET',
+ Eu = 'EU',
+ Fa = 'FA',
+ Ff = 'FF',
+ Fi = 'FI',
+ Fj = 'FJ',
+ Fo = 'FO',
+ Fr = 'FR',
+ Fy = 'FY',
+ Ga = 'GA',
+ Gd = 'GD',
+ Gl = 'GL',
+ Gn = 'GN',
+ Gu = 'GU',
+ Gv = 'GV',
+ Ha = 'HA',
+ He = 'HE',
+ Hi = 'HI',
+ Ho = 'HO',
+ Hr = 'HR',
+ Ht = 'HT',
+ Hu = 'HU',
+ Hy = 'HY',
+ Hz = 'HZ',
+ Ia = 'IA',
+ Id = 'ID',
+ Ie = 'IE',
+ Ig = 'IG',
+ Ii = 'II',
+ Ik = 'IK',
+ Io = 'IO',
+ Is = 'IS',
+ It = 'IT',
+ Iu = 'IU',
+ Ja = 'JA',
+ Jv = 'JV',
+ Ka = 'KA',
+ Kg = 'KG',
+ Ki = 'KI',
+ Kj = 'KJ',
+ Kk = 'KK',
+ Kl = 'KL',
+ Km = 'KM',
+ Kn = 'KN',
+ Ko = 'KO',
+ Kr = 'KR',
+ Ks = 'KS',
+ Ku = 'KU',
+ Kv = 'KV',
+ Kw = 'KW',
+ Ky = 'KY',
+ La = 'LA',
+ Lb = 'LB',
+ Lg = 'LG',
+ Li = 'LI',
+ Ln = 'LN',
+ Lo = 'LO',
+ Lt = 'LT',
+ Lu = 'LU',
+ Lv = 'LV',
+ Mg = 'MG',
+ Mh = 'MH',
+ Mi = 'MI',
+ Mk = 'MK',
+ Ml = 'ML',
+ Mn = 'MN',
+ Mr = 'MR',
+ Ms = 'MS',
+ Mt = 'MT',
+ My = 'MY',
+ Na = 'NA',
+ Nb = 'NB',
+ Nd = 'ND',
+ Ne = 'NE',
+ Ng = 'NG',
+ Nl = 'NL',
+ Nn = 'NN',
+ No = 'NO',
+ Nr = 'NR',
+ Nv = 'NV',
+ Ny = 'NY',
+ Oc = 'OC',
+ Oj = 'OJ',
+ Om = 'OM',
+ Or = 'OR',
+ Os = 'OS',
+ Pa = 'PA',
+ Pi = 'PI',
+ Pl = 'PL',
+ Ps = 'PS',
+ Pt = 'PT',
+ Qu = 'QU',
+ Rm = 'RM',
+ Rn = 'RN',
+ Ro = 'RO',
+ Ru = 'RU',
+ Rw = 'RW',
+ Sa = 'SA',
+ Sc = 'SC',
+ Sd = 'SD',
+ Se = 'SE',
+ Sg = 'SG',
+ Si = 'SI',
+ Sk = 'SK',
+ Sl = 'SL',
+ Sm = 'SM',
+ Sn = 'SN',
+ So = 'SO',
+ Sq = 'SQ',
+ Sr = 'SR',
+ Ss = 'SS',
+ St = 'ST',
+ Su = 'SU',
+ Sv = 'SV',
+ Sw = 'SW',
+ Ta = 'TA',
+ Te = 'TE',
+ Tg = 'TG',
+ Th = 'TH',
+ Ti = 'TI',
+ Tk = 'TK',
+ Tl = 'TL',
+ Tn = 'TN',
+ To = 'TO',
+ Tr = 'TR',
+ Ts = 'TS',
+ Tt = 'TT',
+ Tw = 'TW',
+ Ty = 'TY',
+ Ug = 'UG',
+ Uk = 'UK',
+ Ur = 'UR',
+ Uz = 'UZ',
+ Ve = 'VE',
+ Vi = 'VI',
+ Vo = 'VO',
+ Wa = 'WA',
+ Wo = 'WO',
+ Xh = 'XH',
+ Yi = 'YI',
+ Yo = 'YO',
+ Za = 'ZA',
+ Zh = 'ZH',
+ Zu = 'ZU'
+}
+
+export type LanguageFilter = {
+ any?: InputMaybe<Array<Language>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export enum Layout {
+ Double = 'DOUBLE',
+ DoubleOffset = 'DOUBLE_OFFSET',
+ Single = 'SINGLE'
+}
+
+export type Mutation = {
+ __typename?: 'Mutation';
+ addArtist: AddResponse;
+ addCharacter: AddResponse;
+ addCircle: AddResponse;
+ addComic: AddComicResponse;
+ addNamespace: AddResponse;
+ addTag: AddResponse;
+ addWorld: AddResponse;
+ deleteArchives: DeleteResponse;
+ deleteArtists: DeleteResponse;
+ deleteCharacters: DeleteResponse;
+ deleteCircles: DeleteResponse;
+ deleteComics: DeleteResponse;
+ deleteNamespaces: DeleteResponse;
+ deleteTags: DeleteResponse;
+ deleteWorlds: DeleteResponse;
+ updateArchives: UpdateResponse;
+ updateArtists: UpdateResponse;
+ updateCharacters: UpdateResponse;
+ updateCircles: UpdateResponse;
+ updateComics: UpdateResponse;
+ updateNamespaces: UpdateResponse;
+ updateTags: UpdateResponse;
+ updateWorlds: UpdateResponse;
+ upsertComics: UpsertResponse;
+};
+
+
+export type MutationAddArtistArgs = {
+ input: AddArtistInput;
+};
+
+
+export type MutationAddCharacterArgs = {
+ input: AddCharacterInput;
+};
+
+
+export type MutationAddCircleArgs = {
+ input: AddCircleInput;
+};
+
+
+export type MutationAddComicArgs = {
+ input: AddComicInput;
+};
+
+
+export type MutationAddNamespaceArgs = {
+ input: AddNamespaceInput;
+};
+
+
+export type MutationAddTagArgs = {
+ input: AddTagInput;
+};
+
+
+export type MutationAddWorldArgs = {
+ input: AddWorldInput;
+};
+
+
+export type MutationDeleteArchivesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteArtistsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteCharactersArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteCirclesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteComicsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteNamespacesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteTagsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationDeleteWorldsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+
+export type MutationUpdateArchivesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateArchiveInput;
+};
+
+
+export type MutationUpdateArtistsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateArtistInput;
+};
+
+
+export type MutationUpdateCharactersArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateCharacterInput;
+};
+
+
+export type MutationUpdateCirclesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateCircleInput;
+};
+
+
+export type MutationUpdateComicsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateComicInput;
+};
+
+
+export type MutationUpdateNamespacesArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateNamespaceInput;
+};
+
+
+export type MutationUpdateTagsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateTagInput;
+};
+
+
+export type MutationUpdateWorldsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpdateWorldInput;
+};
+
+
+export type MutationUpsertComicsArgs = {
+ ids: Array<Scalars['Int']['input']>;
+ input: UpsertComicInput;
+};
+
+export type NameExistsError = Error & {
+ __typename?: 'NameExistsError';
+ message: Scalars['String']['output'];
+};
+
+export type Namespace = {
+ __typename?: 'Namespace';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+ sortName?: Maybe<Scalars['String']['output']>;
+};
+
+export type NamespaceFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type NamespaceFilterInput = {
+ exclude?: InputMaybe<NamespaceFilter>;
+ include?: InputMaybe<NamespaceFilter>;
+};
+
+export type NamespaceFilterResult = {
+ __typename?: 'NamespaceFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Namespace>;
+};
+
+export type NamespaceResponse = IdNotFoundError | Namespace;
+
+export enum NamespaceSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ SortName = 'SORT_NAME',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type NamespaceSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: NamespaceSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type NamespacesInput = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+export type NamespacesUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export enum OnMissing {
+ Create = 'CREATE',
+ Ignore = 'IGNORE'
+}
+
+export type Page = {
+ __typename?: 'Page';
+ comicId?: Maybe<Scalars['Int']['output']>;
+ id: Scalars['Int']['output'];
+ image: Image;
+ path: Scalars['String']['output'];
+};
+
+export type PageClaimedError = Error & {
+ __typename?: 'PageClaimedError';
+ comicId: Scalars['Int']['output'];
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type PageRemoteError = Error & {
+ __typename?: 'PageRemoteError';
+ archiveId: Scalars['Int']['output'];
+ id: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type Pagination = {
+ items?: Scalars['Int']['input'];
+ page?: Scalars['Int']['input'];
+};
+
+export type Query = {
+ __typename?: 'Query';
+ archive: ArchiveResponse;
+ archives: ArchiveFilterResult;
+ artist: ArtistResponse;
+ artists: ArtistFilterResult;
+ character: CharacterResponse;
+ characters: CharacterFilterResult;
+ circle: CircleResponse;
+ circles: CircleFilterResult;
+ comic: ComicResponse;
+ comicScrapers: Array<ComicScraper>;
+ comicTags: ComicTagFilterResult;
+ comics: ComicFilterResult;
+ namespace: NamespaceResponse;
+ namespaces: NamespaceFilterResult;
+ scrapeComic: ScrapeComicResponse;
+ tag: TagResponse;
+ tags: TagFilterResult;
+ world: WorldResponse;
+ worlds: WorldFilterResult;
+};
+
+
+export type QueryArchiveArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryArchivesArgs = {
+ filter?: InputMaybe<ArchiveFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<ArchiveSortInput>;
+};
+
+
+export type QueryArtistArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryArtistsArgs = {
+ filter?: InputMaybe<ArtistFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<ArtistSortInput>;
+};
+
+
+export type QueryCharacterArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryCharactersArgs = {
+ filter?: InputMaybe<CharacterFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<CharacterSortInput>;
+};
+
+
+export type QueryCircleArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryCirclesArgs = {
+ filter?: InputMaybe<CircleFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<CircleSortInput>;
+};
+
+
+export type QueryComicArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryComicScrapersArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryComicTagsArgs = {
+ forFilter?: Scalars['Boolean']['input'];
+};
+
+
+export type QueryComicsArgs = {
+ filter?: InputMaybe<ComicFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<ComicSortInput>;
+};
+
+
+export type QueryNamespaceArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryNamespacesArgs = {
+ filter?: InputMaybe<NamespaceFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<NamespaceSortInput>;
+};
+
+
+export type QueryScrapeComicArgs = {
+ id: Scalars['Int']['input'];
+ scraper: Scalars['String']['input'];
+};
+
+
+export type QueryTagArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryTagsArgs = {
+ filter?: InputMaybe<TagFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<TagSortInput>;
+};
+
+
+export type QueryWorldArgs = {
+ id: Scalars['Int']['input'];
+};
+
+
+export type QueryWorldsArgs = {
+ filter?: InputMaybe<WorldFilterInput>;
+ pagination?: InputMaybe<Pagination>;
+ sort?: InputMaybe<WorldSortInput>;
+};
+
+export enum Rating {
+ Explicit = 'EXPLICIT',
+ Questionable = 'QUESTIONABLE',
+ Safe = 'SAFE'
+}
+
+export type RatingFilter = {
+ any?: InputMaybe<Array<Rating>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export type ScrapeComicResponse = IdNotFoundError | ScrapeComicResult | ScraperError | ScraperNotAvailableError | ScraperNotFoundError;
+
+export type ScrapeComicResult = {
+ __typename?: 'ScrapeComicResult';
+ data: ScrapedComic;
+ warnings: Array<Scalars['String']['output']>;
+};
+
+export type ScrapedComic = {
+ __typename?: 'ScrapedComic';
+ artists: Array<Scalars['String']['output']>;
+ category?: Maybe<Category>;
+ censorship?: Maybe<Censorship>;
+ characters: Array<Scalars['String']['output']>;
+ circles: Array<Scalars['String']['output']>;
+ date?: Maybe<Scalars['Date']['output']>;
+ direction?: Maybe<Direction>;
+ language?: Maybe<Language>;
+ layout?: Maybe<Layout>;
+ originalTitle?: Maybe<Scalars['String']['output']>;
+ rating?: Maybe<Rating>;
+ tags: Array<Scalars['String']['output']>;
+ title?: Maybe<Scalars['String']['output']>;
+ url?: Maybe<Scalars['String']['output']>;
+ worlds: Array<Scalars['String']['output']>;
+};
+
+export type ScraperError = Error & {
+ __typename?: 'ScraperError';
+ error: Scalars['String']['output'];
+ message: Scalars['String']['output'];
+};
+
+export type ScraperNotAvailableError = Error & {
+ __typename?: 'ScraperNotAvailableError';
+ comicId: Scalars['Int']['output'];
+ message: Scalars['String']['output'];
+ scraper: Scalars['String']['output'];
+};
+
+export type ScraperNotFoundError = Error & {
+ __typename?: 'ScraperNotFoundError';
+ message: Scalars['String']['output'];
+ name: Scalars['String']['output'];
+};
+
+export enum SortDirection {
+ Ascending = 'ASCENDING',
+ Descending = 'DESCENDING'
+}
+
+export type StringFilter = {
+ contains?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type Success = {
+ message: Scalars['String']['output'];
+};
+
+export type Tag = {
+ __typename?: 'Tag';
+ description?: Maybe<Scalars['String']['output']>;
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type TagAssociationFilter = {
+ all?: InputMaybe<Array<Scalars['String']['input']>>;
+ any?: InputMaybe<Array<Scalars['String']['input']>>;
+ empty?: InputMaybe<Scalars['Boolean']['input']>;
+ exact?: InputMaybe<Array<Scalars['String']['input']>>;
+};
+
+export type TagFilter = {
+ name?: InputMaybe<StringFilter>;
+ namespaces?: InputMaybe<AssociationFilter>;
+};
+
+export type TagFilterInput = {
+ exclude?: InputMaybe<TagFilter>;
+ include?: InputMaybe<TagFilter>;
+};
+
+export type TagFilterResult = {
+ __typename?: 'TagFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<Tag>;
+};
+
+export type TagResponse = FullTag | IdNotFoundError;
+
+export enum TagSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type TagSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: TagSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type UniquePagesInput = {
+ ids: Array<Scalars['Int']['input']>;
+};
+
+export type UniquePagesUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type UpdateArchiveInput = {
+ cover?: InputMaybe<CoverUpdateInput>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+};
+
+export type UpdateArtistInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateCharacterInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateCircleInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateComicInput = {
+ artists?: InputMaybe<ArtistsUpdateInput>;
+ bookmarked?: InputMaybe<Scalars['Boolean']['input']>;
+ category?: InputMaybe<Category>;
+ censorship?: InputMaybe<Censorship>;
+ characters?: InputMaybe<CharactersUpdateInput>;
+ circles?: InputMaybe<CirclesUpdateInput>;
+ cover?: InputMaybe<CoverUpdateInput>;
+ date?: InputMaybe<Scalars['Date']['input']>;
+ direction?: InputMaybe<Direction>;
+ favourite?: InputMaybe<Scalars['Boolean']['input']>;
+ language?: InputMaybe<Language>;
+ layout?: InputMaybe<Layout>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ originalTitle?: InputMaybe<Scalars['String']['input']>;
+ pages?: InputMaybe<UniquePagesUpdateInput>;
+ rating?: InputMaybe<Rating>;
+ tags?: InputMaybe<ComicTagsUpdateInput>;
+ title?: InputMaybe<Scalars['String']['input']>;
+ url?: InputMaybe<Scalars['String']['input']>;
+ worlds?: InputMaybe<WorldsUpdateInput>;
+};
+
+export enum UpdateMode {
+ Add = 'ADD',
+ Remove = 'REMOVE',
+ Replace = 'REPLACE'
+}
+
+export type UpdateNamespaceInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+ sortName?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpdateOptions = {
+ mode?: UpdateMode;
+};
+
+export type UpdateResponse = IdNotFoundError | InvalidParameterError | NameExistsError | PageClaimedError | PageRemoteError | UpdateSuccess;
+
+export type UpdateSuccess = Success & {
+ __typename?: 'UpdateSuccess';
+ message: Scalars['String']['output'];
+};
+
+export type UpdateTagInput = {
+ description?: InputMaybe<Scalars['String']['input']>;
+ name?: InputMaybe<Scalars['String']['input']>;
+ namespaces?: InputMaybe<NamespacesUpdateInput>;
+};
+
+export type UpdateWorldInput = {
+ name?: InputMaybe<Scalars['String']['input']>;
+};
+
+export type UpsertComicInput = {
+ artists?: InputMaybe<ArtistsUpsertInput>;
+ bookmarked?: InputMaybe<Scalars['Boolean']['input']>;
+ category?: InputMaybe<Category>;
+ censorship?: InputMaybe<Censorship>;
+ characters?: InputMaybe<CharactersUpsertInput>;
+ circles?: InputMaybe<CirclesUpsertInput>;
+ date?: InputMaybe<Scalars['Date']['input']>;
+ direction?: InputMaybe<Direction>;
+ favourite?: InputMaybe<Scalars['Boolean']['input']>;
+ language?: InputMaybe<Language>;
+ layout?: InputMaybe<Layout>;
+ organized?: InputMaybe<Scalars['Boolean']['input']>;
+ originalTitle?: InputMaybe<Scalars['String']['input']>;
+ rating?: InputMaybe<Rating>;
+ tags?: InputMaybe<ComicTagsUpsertInput>;
+ title?: InputMaybe<Scalars['String']['input']>;
+ url?: InputMaybe<Scalars['String']['input']>;
+ worlds?: InputMaybe<WorldsUpsertInput>;
+};
+
+export type UpsertOptions = {
+ onMissing?: OnMissing;
+};
+
+export type UpsertResponse = InvalidParameterError | NameExistsError | UpsertSuccess;
+
+export type UpsertSuccess = Success & {
+ __typename?: 'UpsertSuccess';
+ message: Scalars['String']['output'];
+};
+
+export type World = {
+ __typename?: 'World';
+ id: Scalars['Int']['output'];
+ name: Scalars['String']['output'];
+};
+
+export type WorldFilter = {
+ name?: InputMaybe<StringFilter>;
+};
+
+export type WorldFilterInput = {
+ exclude?: InputMaybe<WorldFilter>;
+ include?: InputMaybe<WorldFilter>;
+};
+
+export type WorldFilterResult = {
+ __typename?: 'WorldFilterResult';
+ count: Scalars['Int']['output'];
+ edges: Array<World>;
+};
+
+export type WorldResponse = IdNotFoundError | World;
+
+export enum WorldSort {
+ CreatedAt = 'CREATED_AT',
+ Name = 'NAME',
+ Random = 'RANDOM',
+ UpdatedAt = 'UPDATED_AT'
+}
+
+export type WorldSortInput = {
+ direction?: InputMaybe<SortDirection>;
+ on: WorldSort;
+ seed?: InputMaybe<Scalars['Int']['input']>;
+};
+
+export type WorldsUpdateInput = {
+ ids: Array<Scalars['Int']['input']>;
+ options?: InputMaybe<UpdateOptions>;
+};
+
+export type WorldsUpsertInput = {
+ names?: Array<Scalars['String']['input']>;
+ options?: InputMaybe<UpsertOptions>;
+};
+
+export type ImageFragment = { __typename?: 'Image', hash: string, width: number, height: number };
+
+export type PageFragment = { __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } };
+
+export type ComicFragment = { __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> };
+
+export type FullArchiveFragment = { __typename?: 'FullArchive', id: number, name: string, path: string, size: number, createdAt: string, mtime: string, organized: boolean, pageCount: number, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, comics: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> };
+
+export type ArchiveFragment = { __typename?: 'Archive', id: number, name: string, size: number, pageCount: number, cover: { __typename?: 'Image', hash: string, width: number, height: number } };
+
+export type FullComicFragment = { __typename?: 'FullComic', id: number, title: string, originalTitle?: string | null, url?: string | null, language?: Language | null, direction: Direction, date?: string | null, layout: Layout, rating?: Rating | null, category?: Category | null, censorship?: Censorship | null, favourite: boolean, createdAt: string, updatedAt: string, organized: boolean, bookmarked: boolean, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, archive: { __typename?: 'Archive', id: number }, tags: Array<{ __typename?: 'ComicTag', id: string, name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', id: number, name: string }>, characters: Array<{ __typename?: 'Character', id: number, name: string }>, worlds: Array<{ __typename?: 'World', id: number, name: string }>, circles: Array<{ __typename?: 'Circle', id: number, name: string }> };
+
+export type ComicScraperFragment = { __typename?: 'ComicScraper', id: string, name: string };
+
+export type ScrapeComicResultFragment = { __typename?: 'ScrapeComicResult', warnings: Array<string>, data: { __typename?: 'ScrapedComic', artists: Array<string>, category?: Category | null, censorship?: Censorship | null, characters: Array<string>, circles: Array<string>, date?: string | null, direction?: Direction | null, language?: Language | null, layout?: Layout | null, originalTitle?: string | null, url?: string | null, rating?: Rating | null, tags: Array<string>, title?: string | null, worlds: Array<string> } };
+
+export type ComicsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<ComicFilterInput>;
+ sort?: InputMaybe<ComicSortInput>;
+}>;
+
+
+export type ComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } };
+
+export type ArchivesQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<ArchiveFilterInput>;
+ sort?: InputMaybe<ArchiveSortInput>;
+}>;
+
+
+export type ArchivesQuery = { __typename?: 'Query', archives: { __typename?: 'ArchiveFilterResult', count: number, edges: Array<{ __typename?: 'Archive', id: number, name: string, size: number, pageCount: number, cover: { __typename?: 'Image', hash: string, width: number, height: number } }> } };
+
+export type ArchiveQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ArchiveQuery = { __typename?: 'Query', archive: { __typename?: 'FullArchive', id: number, name: string, path: string, size: number, createdAt: string, mtime: string, organized: boolean, pageCount: number, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, comics: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type ComicQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ComicQuery = { __typename?: 'Query', comic: { __typename?: 'FullComic', id: number, title: string, originalTitle?: string | null, url?: string | null, language?: Language | null, direction: Direction, date?: string | null, layout: Layout, rating?: Rating | null, category?: Category | null, censorship?: Censorship | null, favourite: boolean, createdAt: string, updatedAt: string, organized: boolean, bookmarked: boolean, pages: Array<{ __typename?: 'Page', id: number, path: string, comicId?: number | null, image: { __typename?: 'Image', id: number, hash: string, aspectRatio: number, width: number, height: number } }>, archive: { __typename?: 'Archive', id: number }, tags: Array<{ __typename?: 'ComicTag', id: string, name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', id: number, name: string }>, characters: Array<{ __typename?: 'Character', id: number, name: string }>, worlds: Array<{ __typename?: 'World', id: number, name: string }>, circles: Array<{ __typename?: 'Circle', id: number, name: string }> } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type TagQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type TagQuery = { __typename?: 'Query', tag: { __typename?: 'FullTag', id: number, name: string, description?: string | null, namespaces: Array<{ __typename?: 'Namespace', id: number, name: string }> } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type TagsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<TagFilterInput>;
+ sort?: InputMaybe<TagSortInput>;
+}>;
+
+
+export type TagsQuery = { __typename?: 'Query', tags: { __typename?: 'TagFilterResult', count: number, edges: Array<{ __typename?: 'Tag', id: number, name: string, description?: string | null }> } };
+
+export type NamespaceQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type NamespaceQuery = { __typename?: 'Query', namespace: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'Namespace', id: number, name: string, sortName?: string | null } };
+
+export type NamespacesQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<NamespaceFilterInput>;
+ sort?: InputMaybe<NamespaceSortInput>;
+}>;
+
+
+export type NamespacesQuery = { __typename?: 'Query', namespaces: { __typename?: 'NamespaceFilterResult', count: number, edges: Array<{ __typename?: 'Namespace', id: number, name: string }> } };
+
+export type ComicTagListQueryVariables = Exact<{
+ forFilter?: InputMaybe<Scalars['Boolean']['input']>;
+}>;
+
+
+export type ComicTagListQuery = { __typename?: 'Query', comicTags: { __typename?: 'ComicTagFilterResult', edges: Array<{ __typename?: 'ComicTag', id: string, name: string }> } };
+
+export type ArtistListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type ArtistListQuery = { __typename?: 'Query', artists: { __typename?: 'ArtistFilterResult', edges: Array<{ __typename?: 'Artist', id: number, name: string }> } };
+
+export type CharacterListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type CharacterListQuery = { __typename?: 'Query', characters: { __typename?: 'CharacterFilterResult', edges: Array<{ __typename?: 'Character', id: number, name: string }> } };
+
+export type CircleListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type CircleListQuery = { __typename?: 'Query', circles: { __typename?: 'CircleFilterResult', edges: Array<{ __typename?: 'Circle', id: number, name: string }> } };
+
+export type WorldListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type WorldListQuery = { __typename?: 'Query', worlds: { __typename?: 'WorldFilterResult', edges: Array<{ __typename?: 'World', id: number, name: string }> } };
+
+export type NamespaceListQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type NamespaceListQuery = { __typename?: 'Query', namespaces: { __typename?: 'NamespaceFilterResult', edges: Array<{ __typename?: 'Namespace', id: number, name: string }> } };
+
+export type ArtistsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<ArtistFilterInput>;
+ sort?: InputMaybe<ArtistSortInput>;
+}>;
+
+
+export type ArtistsQuery = { __typename?: 'Query', artists: { __typename?: 'ArtistFilterResult', count: number, edges: Array<{ __typename?: 'Artist', id: number, name: string }> } };
+
+export type ArtistQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ArtistQuery = { __typename?: 'Query', artist: { __typename?: 'Artist', id: number, name: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type CharactersQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<CharacterFilterInput>;
+ sort?: InputMaybe<CharacterSortInput>;
+}>;
+
+
+export type CharactersQuery = { __typename?: 'Query', characters: { __typename?: 'CharacterFilterResult', count: number, edges: Array<{ __typename?: 'Character', id: number, name: string }> } };
+
+export type CharacterQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type CharacterQuery = { __typename?: 'Query', character: { __typename?: 'Character', id: number, name: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type CirclesQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<CircleFilterInput>;
+ sort?: InputMaybe<CircleSortInput>;
+}>;
+
+
+export type CirclesQuery = { __typename?: 'Query', circles: { __typename?: 'CircleFilterResult', count: number, edges: Array<{ __typename?: 'Circle', id: number, name: string }> } };
+
+export type CircleQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type CircleQuery = { __typename?: 'Query', circle: { __typename?: 'Circle', id: number, name: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type WorldsQueryVariables = Exact<{
+ pagination: Pagination;
+ filter?: InputMaybe<WorldFilterInput>;
+ sort?: InputMaybe<WorldSortInput>;
+}>;
+
+
+export type WorldsQuery = { __typename?: 'Query', worlds: { __typename?: 'WorldFilterResult', count: number, edges: Array<{ __typename?: 'World', id: number, name: string }> } };
+
+export type WorldQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type WorldQuery = { __typename?: 'Query', world: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'World', id: number, name: string } };
+
+export type ComicScrapersQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+}>;
+
+
+export type ComicScrapersQuery = { __typename?: 'Query', comicScrapers: Array<{ __typename?: 'ComicScraper', id: string, name: string }> };
+
+export type ScrapeComicQueryVariables = Exact<{
+ id: Scalars['Int']['input'];
+ scraper: Scalars['String']['input'];
+}>;
+
+
+export type ScrapeComicQuery = { __typename?: 'Query', scrapeComic: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'ScrapeComicResult', warnings: Array<string>, data: { __typename?: 'ScrapedComic', artists: Array<string>, category?: Category | null, censorship?: Censorship | null, characters: Array<string>, circles: Array<string>, date?: string | null, direction?: Direction | null, language?: Language | null, layout?: Layout | null, originalTitle?: string | null, url?: string | null, rating?: Rating | null, tags: Array<string>, title?: string | null, worlds: Array<string> } } | { __typename?: 'ScraperError', message: string } | { __typename?: 'ScraperNotAvailableError', message: string } | { __typename?: 'ScraperNotFoundError', message: string } };
+
+export type FrontpageQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type FrontpageQuery = { __typename?: 'Query', recent: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> }, favourites: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> }, bookmarked: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } };
+
+export type AddComicMutationVariables = Exact<{
+ input: AddComicInput;
+}>;
+
+
+export type AddComicMutation = { __typename?: 'Mutation', addComic: { __typename?: 'AddComicSuccess', message: string, archivePagesRemaining: boolean } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } };
+
+export type UpdateArchivesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateArchiveInput;
+}>;
+
+
+export type UpdateArchivesMutation = { __typename?: 'Mutation', updateArchives: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type UpdateComicsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateComicInput;
+}>;
+
+
+export type UpdateComicsMutation = { __typename?: 'Mutation', updateComics: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type UpsertComicsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpsertComicInput;
+}>;
+
+
+export type UpsertComicsMutation = { __typename?: 'Mutation', upsertComics: { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'UpsertSuccess', message: string } };
+
+export type DeleteArchivesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteArchivesMutation = { __typename?: 'Mutation', deleteArchives: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type DeleteComicsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteComicsMutation = { __typename?: 'Mutation', deleteComics: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddTagMutationVariables = Exact<{
+ input: AddTagInput;
+}>;
+
+
+export type AddTagMutation = { __typename?: 'Mutation', addTag: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateTagsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateTagInput;
+}>;
+
+
+export type UpdateTagsMutation = { __typename?: 'Mutation', updateTags: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteTagsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteTagsMutation = { __typename?: 'Mutation', deleteTags: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddNamespaceMutationVariables = Exact<{
+ input: AddNamespaceInput;
+}>;
+
+
+export type AddNamespaceMutation = { __typename?: 'Mutation', addNamespace: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateNamespacesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateNamespaceInput;
+}>;
+
+
+export type UpdateNamespacesMutation = { __typename?: 'Mutation', updateNamespaces: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteNamespacesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteNamespacesMutation = { __typename?: 'Mutation', deleteNamespaces: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddArtistMutationVariables = Exact<{
+ input: AddArtistInput;
+}>;
+
+
+export type AddArtistMutation = { __typename?: 'Mutation', addArtist: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateArtistsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateArtistInput;
+}>;
+
+
+export type UpdateArtistsMutation = { __typename?: 'Mutation', updateArtists: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteArtistsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteArtistsMutation = { __typename?: 'Mutation', deleteArtists: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddCharacterMutationVariables = Exact<{
+ input: AddCharacterInput;
+}>;
+
+
+export type AddCharacterMutation = { __typename?: 'Mutation', addCharacter: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateCharactersMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateCharacterInput;
+}>;
+
+
+export type UpdateCharactersMutation = { __typename?: 'Mutation', updateCharacters: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteCharactersMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteCharactersMutation = { __typename?: 'Mutation', deleteCharacters: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddCircleMutationVariables = Exact<{
+ input: AddCircleInput;
+}>;
+
+
+export type AddCircleMutation = { __typename?: 'Mutation', addCircle: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateCirclesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateCircleInput;
+}>;
+
+
+export type UpdateCirclesMutation = { __typename?: 'Mutation', updateCircles: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteCirclesMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteCirclesMutation = { __typename?: 'Mutation', deleteCircles: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export type AddWorldMutationVariables = Exact<{
+ input: AddWorldInput;
+}>;
+
+
+export type AddWorldMutation = { __typename?: 'Mutation', addWorld: { __typename?: 'AddSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } };
+
+export type UpdateWorldsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+ input: UpdateWorldInput;
+}>;
+
+
+export type UpdateWorldsMutation = { __typename?: 'Mutation', updateWorlds: { __typename?: 'IDNotFoundError', message: string } | { __typename?: 'InvalidParameterError', message: string } | { __typename?: 'NameExistsError', message: string } | { __typename?: 'PageClaimedError', message: string } | { __typename?: 'PageRemoteError', message: string } | { __typename?: 'UpdateSuccess', message: string } };
+
+export type DeleteWorldsMutationVariables = Exact<{
+ ids: Array<Scalars['Int']['input']> | Scalars['Int']['input'];
+}>;
+
+
+export type DeleteWorldsMutation = { __typename?: 'Mutation', deleteWorlds: { __typename?: 'DeleteSuccess', message: string } | { __typename?: 'IDNotFoundError', message: string } };
+
+export const PageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}}]} as unknown as DocumentNode<PageFragment, unknown>;
+export const ImageFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]} as unknown as DocumentNode<ImageFragment, unknown>;
+export const ComicFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]} as unknown as DocumentNode<ComicFragment, unknown>;
+export const FullArchiveFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullArchive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullArchive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"mtime"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<FullArchiveFragment, unknown>;
+export const ArchiveFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Archive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Archive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}}]} as unknown as DocumentNode<ArchiveFragment, unknown>;
+export const FullComicFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullComic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullComic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"bookmarked"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}}]} as unknown as DocumentNode<FullComicFragment, unknown>;
+export const ComicScraperFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ComicScraper"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ComicScraper"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]} as unknown as DocumentNode<ComicScraperFragment, unknown>;
+export const ScrapeComicResultFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ScrapeComicResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}}]} as unknown as DocumentNode<ScrapeComicResultFragment, unknown>;
+export const ComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ComicFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ComicSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicsQuery, ComicsQueryVariables>;
+export const ArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"archives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArchiveFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArchiveSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Archive"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Archive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Archive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}}]}}]} as unknown as DocumentNode<ArchivesQuery, ArchivesQueryVariables>;
+export const ArchiveDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"archive"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archive"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullArchive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullArchive"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullArchive"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullArchive"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"mtime"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"pageCount"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}}]}}]} as unknown as DocumentNode<ArchiveQuery, ArchiveQueryVariables>;
+export const ComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullComic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"FullComic"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Page"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Page"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"image"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"aspectRatio"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comicId"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"FullComic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullComic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organized"}},{"kind":"Field","name":{"kind":"Name","value":"bookmarked"}},{"kind":"Field","name":{"kind":"Name","value":"pages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Page"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicQuery, ComicQueryVariables>;
+export const TagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"tag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"FullTag"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"namespaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<TagQuery, TagQueryVariables>;
+export const TagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"tags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"TagFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"TagSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"tags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}}]} as unknown as DocumentNode<TagsQuery, TagsQueryVariables>;
+export const NamespaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"namespace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"namespace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Namespace"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sortName"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<NamespaceQuery, NamespaceQueryVariables>;
+export const NamespacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"namespaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NamespaceFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NamespaceSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"namespaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<NamespacesQuery, NamespacesQueryVariables>;
+export const ComicTagListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comicTagList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"forFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Boolean"}},"defaultValue":{"kind":"BooleanValue","value":false}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comicTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"forFilter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"forFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<ComicTagListQuery, ComicTagListQueryVariables>;
+export const ArtistListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"artistList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<ArtistListQuery, ArtistListQueryVariables>;
+export const CharacterListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"characterList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CharacterListQuery, CharacterListQueryVariables>;
+export const CircleListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"circleList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CircleListQuery, CircleListQueryVariables>;
+export const WorldListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"worldList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<WorldListQuery, WorldListQueryVariables>;
+export const NamespaceListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"namespaceList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"namespaces"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<NamespaceListQuery, NamespaceListQueryVariables>;
+export const ArtistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"artists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArtistFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"ArtistSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<ArtistsQuery, ArtistsQueryVariables>;
+export const ArtistDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"artist"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artist"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Artist"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<ArtistQuery, ArtistQueryVariables>;
+export const CharactersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"characters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CharacterFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CharacterSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"characters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CharactersQuery, CharactersQueryVariables>;
+export const CharacterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"character"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"character"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Character"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<CharacterQuery, CharacterQueryVariables>;
+export const CirclesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"circles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CircleFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"CircleSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"circles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<CirclesQuery, CirclesQueryVariables>;
+export const CircleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"circle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"circle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Circle"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<CircleQuery, CircleQueryVariables>;
+export const WorldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"worlds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Pagination"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorldFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"sort"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"WorldSortInput"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"worlds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"Variable","name":{"kind":"Name","value":"pagination"}}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"Variable","name":{"kind":"Name","value":"sort"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"count"}},{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]} as unknown as DocumentNode<WorldsQuery, WorldsQueryVariables>;
+export const WorldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"world"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"world"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"World"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<WorldQuery, WorldQueryVariables>;
+export const ComicScrapersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comicScrapers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comicScrapers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicScrapersQuery, ComicScrapersQueryVariables>;
+export const ScrapeComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"scrapeComic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"scraper"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scrapeComic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"scraper"},"value":{"kind":"Variable","name":{"kind":"Name","value":"scraper"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ScrapeComicResult"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ScrapeComicResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}}]} as unknown as DocumentNode<ScrapeComicQuery, ScrapeComicQueryVariables>;
+export const FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"recent"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"CREATED_AT"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"DESCENDING"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"favourites"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"include"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"favourite"},"value":{"kind":"BooleanValue","value":true}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"RANDOM"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"bookmarked"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"include"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"bookmarked"},"value":{"kind":"BooleanValue","value":true}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"RANDOM"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<FrontpageQuery, FrontpageQueryVariables>;
+export const AddComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addComic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addComic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AddComicSuccess"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"archivePagesRemaining"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddComicMutation, AddComicMutationVariables>;
+export const UpdateArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateArchives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateArchiveInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateArchives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateArchivesMutation, UpdateArchivesMutationVariables>;
+export const UpdateComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateComicsMutation, UpdateComicsMutationVariables>;
+export const UpsertComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"upsertComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpsertComicsMutation, UpsertComicsMutationVariables>;
+export const DeleteArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteArchives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteArchives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteArchivesMutation, DeleteArchivesMutationVariables>;
+export const DeleteComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteComicsMutation, DeleteComicsMutationVariables>;
+export const AddTagDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addTag"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddTagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addTag"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddTagMutation, AddTagMutationVariables>;
+export const UpdateTagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateTags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateTagInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateTagsMutation, UpdateTagsMutationVariables>;
+export const DeleteTagsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteTags"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteTags"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteTagsMutation, DeleteTagsMutationVariables>;
+export const AddNamespaceDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addNamespace"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddNamespaceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addNamespace"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddNamespaceMutation, AddNamespaceMutationVariables>;
+export const UpdateNamespacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateNamespaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateNamespaceInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateNamespaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateNamespacesMutation, UpdateNamespacesMutationVariables>;
+export const DeleteNamespacesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteNamespaces"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNamespaces"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteNamespacesMutation, DeleteNamespacesMutationVariables>;
+export const AddArtistDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addArtist"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddArtistInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addArtist"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddArtistMutation, AddArtistMutationVariables>;
+export const UpdateArtistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateArtists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateArtistInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateArtists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateArtistsMutation, UpdateArtistsMutationVariables>;
+export const DeleteArtistsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteArtists"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteArtists"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteArtistsMutation, DeleteArtistsMutationVariables>;
+export const AddCharacterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addCharacter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddCharacterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addCharacter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddCharacterMutation, AddCharacterMutationVariables>;
+export const UpdateCharactersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateCharacters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCharacterInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCharacters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateCharactersMutation, UpdateCharactersMutationVariables>;
+export const DeleteCharactersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteCharacters"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteCharacters"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteCharactersMutation, DeleteCharactersMutationVariables>;
+export const AddCircleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addCircle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddCircleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addCircle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddCircleMutation, AddCircleMutationVariables>;
+export const UpdateCirclesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateCircles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateCircleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateCircles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateCirclesMutation, UpdateCirclesMutationVariables>;
+export const DeleteCirclesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteCircles"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteCircles"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteCirclesMutation, DeleteCirclesMutationVariables>;
+export const AddWorldDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addWorld"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddWorldInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addWorld"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddWorldMutation, AddWorldMutationVariables>;
+export const UpdateWorldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateWorlds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorldInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorlds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateWorldsMutation, UpdateWorldsMutationVariables>;
+export const DeleteWorldsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteWorlds"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteWorlds"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<DeleteWorldsMutation, DeleteWorldsMutationVariables>; \ No newline at end of file
diff --git a/frontend/src/lib/Actions.ts b/frontend/src/lib/Actions.ts
new file mode 100644
index 0000000..7231c2f
--- /dev/null
+++ b/frontend/src/lib/Actions.ts
@@ -0,0 +1,109 @@
+export function debounce(
+ node: HTMLInputElement,
+ { callback, timeout = 500 }: { callback: () => void; timeout?: number }
+) {
+ let timer: NodeJS.Timeout;
+
+ function trigger(event: KeyboardEvent) {
+ clearTimeout(timer);
+ if (event.key !== 'Enter') {
+ timer = setTimeout(callback, timeout);
+ } else {
+ callback();
+ }
+ }
+
+ node.addEventListener('keyup', trigger);
+
+ return {
+ destroy() {
+ clearTimeout(timer);
+ node.removeEventListener('keyup', trigger);
+ }
+ };
+}
+
+export function clickOutside(
+ node: HTMLElement,
+ { handler, ignore }: { handler: () => void; ignore?: HTMLElement }
+) {
+ const handle = (event: Event) => {
+ const target = event.target as HTMLElement;
+ if (!target || target === ignore) return;
+
+ if (node && !node.contains(target) && !event.defaultPrevented) {
+ handler();
+ }
+ };
+
+ document.addEventListener('click', handle, true);
+
+ return {
+ destroy() {
+ document.removeEventListener('click', handle, true);
+ }
+ };
+}
+
+export const focusableElements = [
+ 'a[href]',
+ 'area[href]',
+ 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
+ 'select:not([disabled]):not([aria-hidden])',
+ 'textarea:not([disabled]):not([aria-hidden])',
+ 'button:not([disabled]):not([aria-hidden])',
+ 'iframe',
+ 'object',
+ 'embed',
+ '[contenteditable]',
+ '[tabindex]:not([tabindex^="-"])'
+];
+
+let trapped: HTMLElement[] = [];
+
+export function trapFocus(node: HTMLElement) {
+ function handler(event: KeyboardEvent) {
+ if (event.target === window) return;
+
+ // return if we're not the topmost node to handle
+ if (trapped.at(0) !== node) return;
+
+ const focusable = node.querySelectorAll<HTMLElement>(focusableElements.join());
+
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+
+ if (event.key === 'Tab') {
+ if (!node.contains(document.activeElement)) {
+ first.focus();
+ event.preventDefault();
+ }
+
+ if (event.shiftKey && event.target === first) {
+ last.focus();
+ event.preventDefault();
+ } else if (!event.shiftKey && event.target === last) {
+ first.focus();
+ event.preventDefault();
+ }
+ }
+ }
+
+ if (document.activeElement instanceof HTMLElement) {
+ // if we trap focus, make sure to blur any previously selected external
+ // item such that focus does not remain outside of the node
+ if (!node.contains(document.activeElement)) {
+ document.activeElement.blur();
+ }
+ }
+
+ document.addEventListener('keydown', handler);
+ trapped.unshift(node);
+
+ return {
+ destroy() {
+ document.removeEventListener('keydown', handler);
+ trapped = trapped.filter((i) => i !== node);
+ }
+ };
+}
diff --git a/frontend/src/lib/Enums.ts b/frontend/src/lib/Enums.ts
new file mode 100644
index 0000000..876aec8
--- /dev/null
+++ b/frontend/src/lib/Enums.ts
@@ -0,0 +1,325 @@
+import {
+ ArchiveSort,
+ ArtistSort,
+ Category,
+ Censorship,
+ CharacterSort,
+ CircleSort,
+ ComicSort,
+ Direction,
+ Language,
+ Layout,
+ NamespaceSort,
+ Rating,
+ TagSort,
+ UpdateMode,
+ WorldSort
+} from '$gql/graphql';
+
+export interface EnumOption<T> {
+ id: T;
+ name: string;
+}
+
+export const DirectionLabel: Record<Direction, string> = {
+ [Direction.LeftToRight]: 'Left to Right',
+ [Direction.RightToLeft]: 'Right to Left'
+};
+
+export const LayoutLabel: Record<Layout, string> = {
+ [Layout.Single]: 'Single Page',
+ [Layout.Double]: 'Double Page',
+ [Layout.DoubleOffset]: 'Double Page, offset'
+};
+
+export const RatingLabel: Record<Rating, string> = {
+ [Rating.Safe]: 'Safe',
+ [Rating.Questionable]: 'Questionable',
+ [Rating.Explicit]: 'Explicit'
+};
+
+export const CensorshipLabel: Record<Censorship, string> = {
+ [Censorship.None]: 'None',
+ [Censorship.Bar]: 'Bars',
+ [Censorship.Mosaic]: 'Mosaic',
+ [Censorship.Full]: 'Full'
+};
+
+export const CategoryLabel: Record<Category, string> = {
+ [Category.Manga]: 'Manga',
+ [Category.Doujinshi]: 'Doujinshi',
+ [Category.Comic]: 'Comic',
+ [Category.Artbook]: 'Artbook',
+ [Category.GameCg]: 'Game CG',
+ [Category.ImageSet]: 'Image Set',
+ [Category.VariantSet]: 'Variant Set',
+ [Category.Webtoon]: 'Webtoon'
+};
+
+export const ArchiveSortLabel: Record<ArchiveSort, string> = {
+ [ArchiveSort.Path]: 'Path',
+ [ArchiveSort.Size]: 'File Size',
+ [ArchiveSort.CreatedAt]: 'Created At',
+ [ArchiveSort.PageCount]: 'Page Count',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const ComicSortLabel: Record<ComicSort, string> = {
+ [ComicSort.Title]: 'Title',
+ [ComicSort.OriginalTitle]: 'Original Title',
+ [ComicSort.Date]: 'Date',
+ [ComicSort.CreatedAt]: 'Created At',
+ [ComicSort.UpdatedAt]: 'Updated At',
+ [ComicSort.TagCount]: 'Tag Count',
+ [ComicSort.PageCount]: 'Page Count',
+ [ComicSort.Random]: 'Random'
+};
+
+export const ArtistSortLabel: Record<ArtistSort, string> = {
+ [ArtistSort.Name]: 'Name',
+ [ArtistSort.CreatedAt]: 'Created At',
+ [ArtistSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const CharacterSortLabel: Record<CharacterSort, string> = {
+ [CharacterSort.Name]: 'Name',
+ [CharacterSort.CreatedAt]: 'Created At',
+ [CharacterSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const CircleSortLabel: Record<CircleSort, string> = {
+ [CircleSort.Name]: 'Name',
+ [CircleSort.CreatedAt]: 'Created At',
+ [CircleSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const NamespaceSortLabel: Record<NamespaceSort, string> = {
+ [NamespaceSort.Name]: 'Name',
+ [NamespaceSort.SortName]: 'Sort Name',
+ [NamespaceSort.CreatedAt]: 'Created At',
+ [NamespaceSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const TagSortLabel: Record<TagSort, string> = {
+ [TagSort.Name]: 'Name',
+ [TagSort.CreatedAt]: 'Created At',
+ [TagSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const WorldSortLabel: Record<WorldSort, string> = {
+ [WorldSort.Name]: 'Name',
+ [WorldSort.CreatedAt]: 'Created At',
+ [WorldSort.UpdatedAt]: 'Updated At',
+ [ArchiveSort.Random]: 'Random'
+};
+
+export const UpdateModeLabel: Record<UpdateMode, string> = {
+ [UpdateMode.Add]: 'Add',
+ [UpdateMode.Remove]: 'Remove',
+ [UpdateMode.Replace]: 'Replace'
+};
+
+export const LanguageLabel: Record<Language, string> = {
+ [Language.Ab]: 'Abkhazian',
+ [Language.Aa]: 'Afar',
+ [Language.Af]: 'Afrikaans',
+ [Language.Ak]: 'Akan',
+ [Language.Sq]: 'Albanian',
+ [Language.Am]: 'Amharic',
+ [Language.Ar]: 'Arabic',
+ [Language.An]: 'Aragonese',
+ [Language.Hy]: 'Armenian',
+ [Language.As]: 'Assamese',
+ [Language.Av]: 'Avaric',
+ [Language.Ae]: 'Avestan',
+ [Language.Ay]: 'Aymara',
+ [Language.Az]: 'Azerbaijani',
+ [Language.Bm]: 'Bambara',
+ [Language.Ba]: 'Bashkir',
+ [Language.Eu]: 'Basque',
+ [Language.Be]: 'Belarusian',
+ [Language.Bn]: 'Bengali',
+ [Language.Bh]: 'Bihari languages',
+ [Language.Bi]: 'Bislama',
+ [Language.Bs]: 'Bosnian',
+ [Language.Br]: 'Breton',
+ [Language.Bg]: 'Bulgarian',
+ [Language.My]: 'Burmese',
+ [Language.Ca]: 'Catalan',
+ [Language.Km]: 'Central Khmer',
+ [Language.Ch]: 'Chamorro',
+ [Language.Ce]: 'Chechen',
+ [Language.Ny]: 'Chichewa',
+ [Language.Zh]: 'Chinese',
+ [Language.Cu]: 'Church Slavic',
+ [Language.Cv]: 'Chuvash',
+ [Language.Kw]: 'Cornish',
+ [Language.Co]: 'Corsican',
+ [Language.Cr]: 'Cree',
+ [Language.Hr]: 'Croatian',
+ [Language.Cs]: 'Czech',
+ [Language.Da]: 'Danish',
+ [Language.Dv]: 'Divehi',
+ [Language.Nl]: 'Dutch',
+ [Language.Dz]: 'Dzongkha',
+ [Language.En]: 'English',
+ [Language.Eo]: 'Esperanto',
+ [Language.Et]: 'Estonian',
+ [Language.Ee]: 'Ewe',
+ [Language.Fo]: 'Faroese',
+ [Language.Fj]: 'Fijian',
+ [Language.Fi]: 'Finnish',
+ [Language.Fr]: 'French',
+ [Language.Ff]: 'Fulah',
+ [Language.Gd]: 'Gaelic',
+ [Language.Gl]: 'Galician',
+ [Language.Lg]: 'Ganda',
+ [Language.Ka]: 'Georgian',
+ [Language.De]: 'German',
+ [Language.Gn]: 'Guarani',
+ [Language.Gu]: 'Gujarati',
+ [Language.Ht]: 'Haitian',
+ [Language.Ha]: 'Hausa',
+ [Language.He]: 'Hebrew',
+ [Language.Hz]: 'Herero',
+ [Language.Hi]: 'Hindi',
+ [Language.Ho]: 'Hiri Motu',
+ [Language.Hu]: 'Hungarian',
+ [Language.Is]: 'Icelandic',
+ [Language.Io]: 'Ido',
+ [Language.Ig]: 'Igbo',
+ [Language.Id]: 'Indonesian',
+ [Language.Ia]: 'Interlingua',
+ [Language.Ie]: 'Interlingue',
+ [Language.Iu]: 'Inuktitut',
+ [Language.Ik]: 'Inupiaq',
+ [Language.Ga]: 'Irish',
+ [Language.It]: 'Italian',
+ [Language.Ja]: 'Japanese',
+ [Language.Jv]: 'Javanese',
+ [Language.Kl]: 'Kalaallisut',
+ [Language.Kn]: 'Kannada',
+ [Language.Kr]: 'Kanuri',
+ [Language.Ks]: 'Kashmiri',
+ [Language.Kk]: 'Kazakh',
+ [Language.Ki]: 'Kikuyu',
+ [Language.Rw]: 'Kinyarwanda',
+ [Language.Ky]: 'Kirghiz',
+ [Language.Kv]: 'Komi',
+ [Language.Kg]: 'Kongo',
+ [Language.Ko]: 'Korean',
+ [Language.Kj]: 'Kuanyama',
+ [Language.Ku]: 'Kurdish',
+ [Language.Lo]: 'Lao',
+ [Language.La]: 'Latin',
+ [Language.Lv]: 'Latvian',
+ [Language.Li]: 'Limburgan',
+ [Language.Ln]: 'Lingala',
+ [Language.Lt]: 'Lithuanian',
+ [Language.Lu]: 'Luba-Katanga',
+ [Language.Lb]: 'Luxembourgish',
+ [Language.Mk]: 'Macedonian',
+ [Language.Mg]: 'Malagasy',
+ [Language.Ms]: 'Malay',
+ [Language.Ml]: 'Malayalam',
+ [Language.Mt]: 'Maltese',
+ [Language.Gv]: 'Manx',
+ [Language.Mi]: 'Maori',
+ [Language.Mr]: 'Marathi',
+ [Language.Mh]: 'Marshallese',
+ [Language.El]: 'Modern Greek',
+ [Language.Mn]: 'Mongolian',
+ [Language.Na]: 'Nauru',
+ [Language.Nv]: 'Navajo',
+ [Language.Ng]: 'Ndonga',
+ [Language.Ne]: 'Nepali',
+ [Language.Se]: 'Northern Sami',
+ [Language.Nd]: 'North Ndebele',
+ [Language.No]: 'Norwegian',
+ [Language.Nb]: 'Norwegian Bokmål',
+ [Language.Nn]: 'Norwegian Nynorsk',
+ [Language.Oc]: 'Occitan',
+ [Language.Oj]: 'Ojibwa',
+ [Language.Or]: 'Oriya',
+ [Language.Om]: 'Oromo',
+ [Language.Os]: 'Ossetian',
+ [Language.Pi]: 'Pali',
+ [Language.Pa]: 'Panjabi',
+ [Language.Fa]: 'Persian',
+ [Language.Pl]: 'Polish',
+ [Language.Pt]: 'Portuguese',
+ [Language.Ps]: 'Pushto',
+ [Language.Qu]: 'Quechua',
+ [Language.Ro]: 'Romanian',
+ [Language.Rm]: 'Romansh',
+ [Language.Rn]: 'Rundi',
+ [Language.Ru]: 'Russian',
+ [Language.Sm]: 'Samoan',
+ [Language.Sg]: 'Sango',
+ [Language.Sa]: 'Sanskrit',
+ [Language.Sc]: 'Sardinian',
+ [Language.Sr]: 'Serbian',
+ [Language.Sn]: 'Shona',
+ [Language.Ii]: 'Sichuan Yi',
+ [Language.Sd]: 'Sindhi',
+ [Language.Si]: 'Sinhala',
+ [Language.Sk]: 'Slovak',
+ [Language.Sl]: 'Slovenian',
+ [Language.So]: 'Somali',
+ [Language.St]: 'Southern Sotho',
+ [Language.Nr]: 'South Ndebele',
+ [Language.Es]: 'Spanish',
+ [Language.Su]: 'Sundanese',
+ [Language.Sw]: 'Swahili',
+ [Language.Ss]: 'Swati',
+ [Language.Sv]: 'Swedish',
+ [Language.Tl]: 'Tagalog',
+ [Language.Ty]: 'Tahitian',
+ [Language.Tg]: 'Tajik',
+ [Language.Ta]: 'Tamil',
+ [Language.Tt]: 'Tatar',
+ [Language.Te]: 'Telugu',
+ [Language.Th]: 'Thai',
+ [Language.Bo]: 'Tibetan',
+ [Language.Ti]: 'Tigrinya',
+ [Language.To]: 'Tonga',
+ [Language.Ts]: 'Tsonga',
+ [Language.Tn]: 'Tswana',
+ [Language.Tr]: 'Turkish',
+ [Language.Tk]: 'Turkmen',
+ [Language.Tw]: 'Twi',
+ [Language.Ug]: 'Uighur',
+ [Language.Uk]: 'Ukrainian',
+ [Language.Ur]: 'Urdu',
+ [Language.Uz]: 'Uzbek',
+ [Language.Ve]: 'Venda',
+ [Language.Vi]: 'Vietnamese',
+ [Language.Vo]: 'Volapük',
+ [Language.Wa]: 'Walloon',
+ [Language.Cy]: 'Welsh',
+ [Language.Fy]: 'Western Frisian',
+ [Language.Wo]: 'Wolof',
+ [Language.Xh]: 'Xhosa',
+ [Language.Yi]: 'Yiddish',
+ [Language.Yo]: 'Yoruba',
+ [Language.Za]: 'Zhuang',
+ [Language.Zu]: 'Zulu'
+};
+
+export const directions: EnumOption<Direction>[] = optionsFromLabel(DirectionLabel);
+export const layouts: EnumOption<Layout>[] = optionsFromLabel(LayoutLabel);
+export const ratings: EnumOption<Rating>[] = optionsFromLabel(RatingLabel);
+export const censorships: EnumOption<Censorship>[] = optionsFromLabel(CensorshipLabel);
+export const categories: EnumOption<Category>[] = optionsFromLabel(CategoryLabel);
+export const languages: EnumOption<Language>[] = optionsFromLabel(LanguageLabel);
+
+function optionsFromLabel<T extends string | number | symbol>(
+ labels: Record<T, string>
+): EnumOption<T>[] {
+ return Object.entries(labels).map(([k, v]) => ({ id: k as T, name: v as string }));
+}
diff --git a/frontend/src/lib/Filter.ts b/frontend/src/lib/Filter.ts
new file mode 100644
index 0000000..8e419f3
--- /dev/null
+++ b/frontend/src/lib/Filter.ts
@@ -0,0 +1,365 @@
+import {
+ type ArchiveFilter,
+ type ArchiveFilterInput,
+ type ComicFilter,
+ type ComicFilterInput,
+ type StringFilter,
+ type TagFilter,
+ type TagFilterInput
+} from '$gql/graphql';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { navigate } from './Navigation';
+import { numKeys } from './Utils';
+
+interface FilterInput<T> {
+ include?: T | null;
+ exclude?: T | null;
+}
+
+interface BasicFilter {
+ name?: { contains?: string | null } | null;
+}
+
+type FilterMode = 'any' | 'all' | 'exact';
+
+type Key = string | number | symbol;
+
+type Filter<T, K extends Key> = {
+ [Property in K]?: T | null;
+};
+
+type AssocFilter<T, K extends Key> = Filter<
+ {
+ any?: T[] | null;
+ all?: T[] | null;
+ exact?: T[] | null;
+ empty?: boolean | null;
+ },
+ K
+>;
+
+type EnumFilter<K extends Key> = Filter<
+ {
+ any?: string[] | null;
+ empty?: boolean | null;
+ },
+ K
+>;
+
+interface Integrateable<F> {
+ integrate(filter: F): void;
+}
+
+class ComplexMember<K extends Key> {
+ values: unknown[] = [];
+ key: K;
+ mode: FilterMode;
+ empty?: boolean | null;
+
+ constructor(key: K, mode: FilterMode) {
+ this.key = key;
+ this.mode = mode;
+ }
+
+ integrate(filter: AssocFilter<unknown, K>) {
+ if (this.values.length > 0) {
+ filter[this.key] = { [this.mode]: this.values };
+ }
+
+ if (this.empty) {
+ filter[this.key] = { ...filter[this.key], empty: this.empty };
+ }
+ }
+}
+
+export class Association<K extends Key> extends ComplexMember<K> {
+ values: (string | number)[] = [];
+
+ constructor(key: K, mode: FilterMode, filter?: AssocFilter<string | number, K> | null) {
+ super(key, mode);
+
+ if (!filter) {
+ return;
+ }
+
+ const prop = filter[key];
+ this.empty = prop?.empty;
+
+ if (prop?.all && prop.all.length > 0) {
+ this.mode = 'all';
+ this.values = prop.all;
+ } else if (prop?.any && prop.any.length > 0) {
+ this.mode = 'any';
+ this.values = prop.any;
+ } else if (prop?.exact && prop.exact.length > 0) {
+ this.mode = 'exact';
+ this.values = prop.exact;
+ }
+ }
+}
+
+export class Enum<K extends Key> extends ComplexMember<K> {
+ values: string[] = [];
+
+ constructor(key: K, filter?: EnumFilter<K> | null) {
+ super(key, 'any');
+
+ if (!filter) {
+ return;
+ }
+
+ this.empty = filter[key]?.empty;
+
+ const prop = filter[key];
+ if (prop?.any) {
+ this.values = prop.any;
+ }
+ }
+}
+
+class Bool<K extends Key> {
+ key: K;
+ value?: boolean = undefined;
+
+ constructor(key: K, filter?: Filter<boolean, K> | null) {
+ this.key = key;
+
+ if (filter) {
+ this.value = filter[key] ?? undefined;
+ }
+ }
+
+ integrate(filter: Filter<boolean, K>) {
+ if (this.value !== undefined) {
+ filter[this.key] = this.value;
+ }
+ }
+}
+
+class Str<K extends Key> {
+ key: K;
+ contains = '';
+
+ constructor(key: K, filter?: Filter<StringFilter, K> | null) {
+ this.key = key;
+
+ if (filter) {
+ this.contains = filter[key]?.contains ?? '';
+ }
+ }
+
+ integrate(filter: Filter<StringFilter, K>) {
+ if (this.contains) {
+ filter[this.key] = { contains: this.contains };
+ }
+ }
+}
+
+abstract class Controls<F> {
+ buildFilter() {
+ const filter = {} as F;
+ Object.values(this).forEach((v: Integrateable<F>) => v.integrate(filter));
+ return filter;
+ }
+}
+
+export class ArchiveFilterControls extends Controls<ArchiveFilter> {
+ path: Str<'path'>;
+ organized: Bool<'organized'>;
+
+ constructor(filter: ArchiveFilter | null | undefined) {
+ super();
+
+ this.path = new Str('path', filter);
+ this.organized = new Bool('organized', filter);
+ }
+}
+
+export class ComicFilterControls extends Controls<ComicFilter> {
+ title: Str<'title'>;
+ categories: Enum<'category'>;
+ censorships: Enum<'censorship'>;
+ ratings: Enum<'rating'>;
+ tags: Association<'tags'>;
+ languages: Enum<'language'>;
+ artists: Association<'artists'>;
+ circles: Association<'circles'>;
+ characters: Association<'characters'>;
+ worlds: Association<'worlds'>;
+ favourite: Bool<'favourite'>;
+ organized: Bool<'organized'>;
+ bookmarked: Bool<'bookmarked'>;
+
+ constructor(filter: ComicFilter | null | undefined, mode: FilterMode);
+ constructor(filter: ComicFilter | null | undefined, mode: FilterMode);
+ constructor(filter: ComicFilter | null | undefined, mode: FilterMode) {
+ super();
+
+ this.title = new Str('title', filter);
+ this.favourite = new Bool('favourite', filter);
+ this.organized = new Bool('organized', filter);
+ this.bookmarked = new Bool('bookmarked', filter);
+ this.tags = new Association('tags', mode, filter);
+ this.languages = new Enum('language', filter);
+ this.categories = new Enum('category', filter);
+ this.censorships = new Enum('censorship', filter);
+ this.ratings = new Enum('rating', filter);
+ this.artists = new Association('artists', mode, filter);
+ this.circles = new Association('circles', mode, filter);
+ this.characters = new Association('characters', mode, filter);
+ this.worlds = new Association('worlds', mode, filter);
+ }
+}
+
+export class BasicFilterControls extends Controls<BasicFilter> {
+ name: Str<'name'>;
+
+ constructor(filter?: BasicFilter | null) {
+ super();
+
+ this.name = new Str('name', filter);
+ }
+}
+
+export class TagFilterControls extends BasicFilterControls {
+ namespaces: Association<'namespaces'>;
+
+ constructor(filter: TagFilter | null | undefined, mode: FilterMode) {
+ super(filter);
+
+ this.namespaces = new Association('namespaces', mode, filter);
+ }
+}
+
+function buildFilterInput<F>(include?: F, exclude?: F) {
+ const input: FilterInput<F> = {};
+
+ if (include && Object.keys(include).length > 0) {
+ input.include = include;
+ }
+
+ if (exclude && Object.keys(exclude).length > 0) {
+ input.exclude = exclude;
+ }
+
+ return input;
+}
+
+abstract class FilterContext<F> {
+ include!: { controls: Controls<F>; size: number };
+ exclude!: { controls: Controls<F>; size: number };
+
+ apply(params: URLSearchParams) {
+ navigate(
+ {
+ filter: buildFilterInput(
+ this.include.controls.buildFilter(),
+ this.exclude.controls.buildFilter()
+ )
+ },
+ params
+ );
+ }
+}
+
+export class ArchiveFilterContext extends FilterContext<ArchiveFilter> {
+ include: { controls: ArchiveFilterControls; size: number };
+ exclude: { controls: ArchiveFilterControls; size: number };
+ private static ignore = ['organized'];
+
+ constructor(filter: ArchiveFilterInput) {
+ super();
+
+ this.include = {
+ controls: new ArchiveFilterControls(filter.include),
+ size: numKeys(filter.include, ArchiveFilterContext.ignore)
+ };
+ this.exclude = {
+ controls: new ArchiveFilterControls(filter.exclude),
+ size: numKeys(filter.exclude, ArchiveFilterContext.ignore)
+ };
+ }
+}
+
+export class ComicFilterContext extends FilterContext<ComicFilter> {
+ include: { controls: ComicFilterControls; size: number };
+ exclude: { controls: ComicFilterControls; size: number };
+ private static ignore = ['title', 'favourite', 'organized', 'bookmarked'];
+
+ constructor(filter: ComicFilterInput) {
+ super();
+
+ this.include = {
+ controls: new ComicFilterControls(filter.include, 'all'),
+ size: numKeys(filter.include, ComicFilterContext.ignore)
+ };
+ this.exclude = {
+ controls: new ComicFilterControls(filter.exclude, 'any'),
+ size: numKeys(filter.exclude, ComicFilterContext.ignore)
+ };
+ }
+}
+
+export class BasicFilterContext extends FilterContext<BasicFilter> {
+ include: { controls: BasicFilterControls; size: number };
+ exclude: { controls: BasicFilterControls; size: number };
+
+ constructor(filter: FilterInput<BasicFilter>) {
+ super();
+
+ this.include = {
+ controls: new BasicFilterControls(filter.include),
+ size: numKeys(filter.include)
+ };
+ this.exclude = {
+ controls: new BasicFilterControls(),
+ size: 0
+ };
+ }
+}
+
+export class TagFilterContext extends FilterContext<TagFilter> {
+ include: { controls: TagFilterControls; size: number };
+ exclude: { controls: TagFilterControls; size: number };
+ private static ignore = ['name'];
+
+ constructor(filter: TagFilterInput) {
+ super();
+
+ this.include = {
+ controls: new TagFilterControls(filter.include, 'all'),
+ size: numKeys(filter.include, TagFilterContext.ignore)
+ };
+ this.exclude = {
+ controls: new TagFilterControls(filter.exclude, 'any'),
+ size: numKeys(filter.exclude, TagFilterContext.ignore)
+ };
+ }
+}
+
+export function initFilterContext<F extends FilterContext<unknown>>() {
+ return setContext<Writable<F>>('filter', writable());
+}
+
+export function getFilterContext<F extends FilterContext<unknown>>() {
+ return getContext<Writable<F>>('filter');
+}
+
+export function cycleBooleanFilter(value: boolean | undefined, tristate = true) {
+ if (tristate) {
+ if (value === undefined) {
+ return true;
+ } else if (value) {
+ return false;
+ } else {
+ return undefined;
+ }
+ } else {
+ if (value) {
+ return undefined;
+ } else {
+ return true;
+ }
+ }
+}
diff --git a/frontend/src/lib/Meta.ts b/frontend/src/lib/Meta.ts
new file mode 100644
index 0000000..8cfad6b
--- /dev/null
+++ b/frontend/src/lib/Meta.ts
@@ -0,0 +1 @@
+export const codename = 'Satanic Satyr';
diff --git a/frontend/src/lib/Navigation.ts b/frontend/src/lib/Navigation.ts
new file mode 100644
index 0000000..e6b17cd
--- /dev/null
+++ b/frontend/src/lib/Navigation.ts
@@ -0,0 +1,114 @@
+import { goto as svelteGoto } from '$app/navigation';
+import { SortDirection } from '$gql/graphql';
+import JsonURL from '@jsonurl/jsonurl';
+import { type PaginationData } from './Pagination';
+import { type SortData } from './Sort';
+import { toastError } from './Toasts';
+
+function paramToNum<T>(value: string | null, fallback: T) {
+ if (value) {
+ const number = +value;
+
+ if (Number.isNaN(number) || number < 0) {
+ return fallback;
+ }
+
+ return number;
+ }
+
+ return fallback;
+}
+
+export function parseSortData<T>(params: URLSearchParams, fallback: T): SortData<T> {
+ return {
+ on: (params.get('s') as T) || fallback,
+ direction: (params.get('d') as SortDirection) || SortDirection.Ascending,
+ seed: paramToNum(params.get('r'), undefined)
+ };
+}
+
+export function parsePaginationData(params: URLSearchParams, defaultItems = 120): PaginationData {
+ return {
+ page: paramToNum(params.get('p'), 1),
+ items: paramToNum(params.get('i'), defaultItems)
+ };
+}
+
+export function parseFilter<T>(params: URLSearchParams): T {
+ const param = params.get('f');
+
+ if (!param) return {} as T;
+
+ try {
+ return JsonURL.parse(param, { AQF: true, impliedObject: {} }) as T;
+ } catch (e) {
+ return {} as T;
+ }
+}
+
+interface NavigationOptions {
+ to?: string;
+ params: URLSearchParams;
+ options?: Parameters<typeof svelteGoto>[1];
+}
+
+export function goto({ to = '', params, options }: NavigationOptions) {
+ svelteGoto(`${to}?${params.toString()}`, options).catch(() => toastError('Navigation failed'));
+}
+
+interface NavigationParameters<T> {
+ filter?: T;
+ sort?: Partial<SortData<string>>;
+ pagination?: Partial<PaginationData>;
+}
+
+function paramsFrom<T>(
+ { pagination, filter, sort }: NavigationParameters<T>,
+ current?: URLSearchParams
+) {
+ const params = new URLSearchParams(current);
+
+ if (filter !== undefined) {
+ const json = JsonURL.stringify(filter, { AQF: true, impliedObject: true });
+ if (json) {
+ params.set('f', json);
+ } else {
+ params.delete('f');
+ }
+ }
+
+ if (sort !== undefined) {
+ if (sort.on !== undefined) {
+ params.set('s', sort.on);
+ }
+ if (sort.direction !== undefined) {
+ params.set('d', sort.direction);
+ }
+ if (sort.seed !== undefined) {
+ params.set('r', sort.seed.toString());
+ }
+ }
+
+ params.delete('p');
+
+ if (pagination?.items) {
+ params.set('i', pagination.items.toString());
+ }
+
+ if (pagination?.page) {
+ params.set('p', pagination.page.toString());
+ }
+
+ return params;
+}
+
+export function navigate(parameters: NavigationParameters<object>, current?: URLSearchParams) {
+ goto({
+ params: paramsFrom(parameters, current),
+ options: { noScroll: false, keepFocus: true, replaceState: true }
+ });
+}
+
+export function href<T>(base: string, params: NavigationParameters<T>) {
+ return `/${base}/?${paramsFrom(params).toString()}`;
+}
diff --git a/frontend/src/lib/Pagination.ts b/frontend/src/lib/Pagination.ts
new file mode 100644
index 0000000..f05492b
--- /dev/null
+++ b/frontend/src/lib/Pagination.ts
@@ -0,0 +1,31 @@
+import { navigate } from '$lib/Navigation';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+export interface PaginationData {
+ page: number;
+ items: number;
+}
+
+export class PaginationContext {
+ page = 0;
+ items = 0;
+ total = 0;
+
+ set update({ page, items }: PaginationData) {
+ this.page = page;
+ this.items = items;
+ }
+
+ apply(params: URLSearchParams) {
+ navigate({ pagination: { items: this.items } }, params);
+ }
+}
+
+export function initPaginationContext() {
+ return setContext<Writable<PaginationContext>>('pagination', writable(new PaginationContext()));
+}
+
+export function getPaginationContext() {
+ return getContext<Writable<PaginationContext>>('pagination');
+}
diff --git a/frontend/src/lib/Reader.ts b/frontend/src/lib/Reader.ts
new file mode 100644
index 0000000..8777b9b
--- /dev/null
+++ b/frontend/src/lib/Reader.ts
@@ -0,0 +1,62 @@
+import { Layout, type PageFragment } from '$gql/graphql';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+export interface Chunk {
+ main: PageFragment;
+ secondary?: PageFragment;
+ index: number;
+}
+
+class ReaderContext {
+ visible = false;
+ sidebar = false;
+ pages: PageFragment[] = [];
+ page = 0;
+
+ open(page: number) {
+ this.page = page;
+ this.visible = true;
+
+ return this;
+ }
+}
+
+export function initReaderContext() {
+ return setContext<Writable<ReaderContext>>('reader', writable(new ReaderContext()));
+}
+
+export function getReaderContext() {
+ return getContext<Writable<ReaderContext>>('reader');
+}
+
+export function partition(pages: PageFragment[], layout: Layout): [Chunk[], number[]] {
+ const single = layout === Layout.Single;
+ const offset = layout === Layout.DoubleOffset;
+
+ const chunks: Chunk[] = [];
+ const lookup: number[] = Array<number>(pages.length);
+
+ for (let chunkIndex = 0, pageIndex = 0; pageIndex < pages.length; chunkIndex++) {
+ const wide = () => pages[pageIndex].image.aspectRatio > 1;
+
+ const nextPage = () => {
+ lookup[pageIndex] = chunkIndex;
+ return pages[pageIndex++];
+ };
+
+ const offsetFirst = pageIndex === 0 && offset;
+ const full = single || wide() || offsetFirst;
+
+ const chunk: Chunk = { index: pageIndex, main: nextPage() };
+
+ if (!full && pageIndex < pages.length) {
+ if (!wide()) {
+ chunk.secondary = nextPage();
+ }
+ }
+
+ chunks.push(chunk);
+ }
+ return [chunks, lookup];
+}
diff --git a/frontend/src/lib/Scraper.ts b/frontend/src/lib/Scraper.ts
new file mode 100644
index 0000000..4baf370
--- /dev/null
+++ b/frontend/src/lib/Scraper.ts
@@ -0,0 +1,156 @@
+import {
+ Category,
+ Censorship,
+ Direction,
+ Language,
+ Layout,
+ OnMissing,
+ Rating,
+ type FullComicFragment,
+ type ScrapedComic,
+ type UpsertComicInput,
+ type UpsertOptions
+} from '$gql/graphql';
+import {
+ CategoryLabel,
+ CensorshipLabel,
+ DirectionLabel,
+ LanguageLabel,
+ LayoutLabel,
+ RatingLabel
+} from '$lib/Enums';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+interface ScraperContext {
+ scraper: string;
+ warnings: string[];
+ selector?: ScrapedComicSelector;
+}
+
+export function initScraperContext() {
+ return setContext<Writable<ScraperContext>>('scraper', writable({ scraper: '', warnings: [] }));
+}
+
+export function getScraperContext() {
+ return getContext<Writable<ScraperContext>>('scraper');
+}
+
+export class Selector<T extends string> {
+ keep = true;
+ value: T;
+ display: string | undefined;
+
+ constructor(value: T, display?: string) {
+ this.value = value;
+ this.display = display;
+ }
+
+ toString() {
+ return this.display ?? this.value;
+ }
+
+ static from<T extends string>(
+ scraped: T | undefined | null,
+ have: string | undefined | null,
+ label?: Record<string, string>
+ ) {
+ if (scraped && have !== scraped) {
+ return new Selector(scraped, label ? label[scraped] : undefined);
+ }
+ return undefined;
+ }
+
+ static fromList(scraped: string[], have: { name: string }[]) {
+ const haves = new Set(have.map((i) => i.name));
+
+ return scraped.filter((i) => !haves.has(i)).map((i) => new Selector(i));
+ }
+}
+
+function keepItem<T extends string>(selector?: Selector<T>): T | undefined | null {
+ if (selector?.keep) {
+ return selector.value;
+ }
+ return undefined;
+}
+
+function keepList<T extends string>(
+ selectorList: Selector<T>[],
+ onMissing: OnMissing
+): { names: T[]; options: UpsertOptions } {
+ return {
+ names: selectorList.filter((v) => v.keep).map((v) => v.value),
+ options: { onMissing }
+ };
+}
+
+export class ScrapedComicSelector {
+ title?: Selector<string>;
+ originalTitle?: Selector<string>;
+ url?: Selector<string>;
+ date?: Selector<string>;
+ category?: Selector<Category>;
+ censorship?: Selector<Censorship>;
+ rating?: Selector<Rating>;
+ language?: Selector<Language>;
+ direction?: Selector<Direction>;
+ layout?: Selector<Layout>;
+ artists: Selector<string>[];
+ circles: Selector<string>[];
+ characters: Selector<string>[];
+ worlds: Selector<string>[];
+ tags: Selector<string>[];
+
+ constructor(scraped: ScrapedComic, comic: FullComicFragment) {
+ this.title = Selector.from(scraped.title, comic.title);
+ this.originalTitle = Selector.from(scraped.originalTitle, comic.originalTitle);
+ this.url = Selector.from(scraped.url, comic.url);
+ this.date = Selector.from(scraped.date, comic.date);
+ this.category = Selector.from(scraped.category, comic.category, CategoryLabel);
+ this.censorship = Selector.from(scraped.censorship, comic.censorship, CensorshipLabel);
+ this.rating = Selector.from(scraped.rating, comic.rating, RatingLabel);
+ this.language = Selector.from(scraped.language, comic.language, LanguageLabel);
+ this.direction = Selector.from(scraped.direction, comic.direction, DirectionLabel);
+ this.layout = Selector.from(scraped.layout, comic.layout, LayoutLabel);
+
+ this.artists = Selector.fromList(scraped.artists, comic.artists);
+ this.circles = Selector.fromList(scraped.circles, comic.circles);
+ this.characters = Selector.fromList(scraped.characters, comic.characters);
+ this.tags = Selector.fromList(scraped.tags, comic.tags);
+ this.worlds = Selector.fromList(scraped.worlds, comic.worlds);
+ }
+
+ hasData() {
+ return (
+ Object.values(this).filter((i) => {
+ if (i === undefined) {
+ return false;
+ } else if (Array.isArray(i) && i.length === 0) {
+ return false;
+ }
+ return true;
+ }).length > 0
+ );
+ }
+
+ toInput(onMissing: OnMissing): UpsertComicInput {
+ return {
+ title: keepItem(this.title),
+ originalTitle: keepItem(this.originalTitle),
+ url: keepItem(this.url),
+ date: keepItem(this.date),
+ category: keepItem(this.category),
+ censorship: keepItem(this.censorship),
+ rating: keepItem(this.rating),
+ language: keepItem(this.language),
+ direction: keepItem(this.direction),
+ layout: keepItem(this.layout),
+ artists: keepList(this.artists, onMissing),
+ circles: keepList(this.circles, onMissing),
+ characters: keepList(this.characters, onMissing),
+ worlds: keepList(this.worlds, onMissing),
+ tags: keepList(this.tags, onMissing)
+ };
+ }
+}
diff --git a/frontend/src/lib/Selection.ts b/frontend/src/lib/Selection.ts
new file mode 100644
index 0000000..0ea85cc
--- /dev/null
+++ b/frontend/src/lib/Selection.ts
@@ -0,0 +1,141 @@
+import { getContext, hasContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { range } from './Utils';
+
+interface Item {
+ id: number;
+}
+
+export const hasSelectionContext = () => hasContext('selection');
+
+export function getSelectionContext<T extends Item>() {
+ return getContext<Writable<ItemSelection<T>>>('selection');
+}
+
+export function initSelectionContext<T extends Item>(
+ typename?: string,
+ toName?: (item: T) => string
+) {
+ return setContext<Writable<ItemSelection<T>>>(
+ 'selection',
+ writable(new ItemSelection(typename, toName))
+ );
+}
+
+export class ItemSelection<T extends Item> {
+ active = false;
+ typename: string;
+ #toName: (item: T) => string;
+
+ #view: T[] = [];
+ selectable: (item: T) => boolean = () => true;
+
+ #ids = new Set<number>();
+ #masked = new Set<number>();
+
+ constructor(typename?: string, toName?: (item: T) => string) {
+ this.typename = typename ?? 'unknown';
+ this.#toName = toName ?? (() => 'unknown');
+ }
+
+ set view(view: T[]) {
+ this.#view = view;
+ this.#updateMasked();
+ }
+
+ #indexOf = (id: number) => this.#view.findIndex((v) => v.id === id);
+
+ update(index: number, shift: boolean) {
+ const id = this.#view[index].id;
+
+ const selectableRange = (first: number, last: number) =>
+ range(first, last)
+ .filter((i) => this.selectable(this.#view[i]))
+ .map((i) => this.#view[i].id);
+
+ if (shift) {
+ const indices = this.indices;
+
+ const first = indices.at(0);
+ const last = indices.at(-1);
+
+ if (first === undefined || last === undefined) {
+ this.#ids.add(id);
+ } else if (index === first || index === last) {
+ this.#ids.clear();
+ } else if (index > last) {
+ this.#ids = new Set([...this.#ids, ...selectableRange(last, index)]);
+ } else if (index < last) {
+ this.#ids = new Set([...this.#ids, ...selectableRange(index, last)]);
+ }
+ } else {
+ if (this.#ids.has(id)) {
+ this.#ids.delete(id);
+ } else {
+ this.#ids.add(id);
+ }
+ }
+
+ this.#updateMasked();
+
+ return this;
+ }
+
+ toggle() {
+ this.active = !this.active;
+
+ if (!this.active) {
+ return this.none();
+ }
+
+ return this;
+ }
+
+ all() {
+ this.#ids = new Set(this.#view.filter(this.selectable).map((i) => i.id));
+ this.#updateMasked();
+
+ return this;
+ }
+
+ none() {
+ this.#ids.clear();
+ this.#masked.clear();
+
+ return this;
+ }
+
+ clear() {
+ this.active = false;
+
+ return this.none();
+ }
+
+ contains(id: number) {
+ return this.#masked.has(id);
+ }
+
+ #updateMasked() {
+ this.#masked = new Set([...this.#ids].filter((i) => this.#indexOf(i) >= 0));
+ }
+
+ get ids() {
+ return [...this.#masked];
+ }
+
+ get size() {
+ return this.#masked.size;
+ }
+
+ get indices() {
+ return [...this.#ids].map(this.#indexOf).filter((i) => i >= 0);
+ }
+
+ get items() {
+ return this.indices.map((i) => this.#view[i]);
+ }
+
+ get names() {
+ return this.items.map(this.#toName);
+ }
+}
diff --git a/frontend/src/lib/Shortcuts.ts b/frontend/src/lib/Shortcuts.ts
new file mode 100644
index 0000000..063bd40
--- /dev/null
+++ b/frontend/src/lib/Shortcuts.ts
@@ -0,0 +1,153 @@
+import { closeModal, modals } from 'svelte-modals';
+import { get } from 'svelte/store';
+
+type LowercaseLetter =
+ | 'a'
+ | 'b'
+ | 'c'
+ | 'd'
+ | 'e'
+ | 'f'
+ | 'g'
+ | 'h'
+ | 'i'
+ | 'j'
+ | 'l'
+ | 'm'
+ | 'n'
+ | 'o'
+ | 'p'
+ | 'q'
+ | 'r'
+ | 's'
+ | 't'
+ | 'u'
+ | 'v'
+ | 'w'
+ | 'x'
+ | 'y'
+ | 'z';
+
+type UppercaseLetter = Uppercase<LowercaseLetter>;
+type Letter = LowercaseLetter | UppercaseLetter;
+type Special = '?' | 'Enter' | 'Escape' | 'Delete';
+
+const modeSwitches = ['n', 'g', 'i'] as const;
+type ModeSwitch = (typeof modeSwitches)[number];
+
+function isModeSwitch(s: string): s is ModeSwitch {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
+ return modeSwitches.indexOf(s as any) !== -1;
+}
+
+type Key = Letter | Special;
+type KeyCombo = `${ModeSwitch}${Letter}`;
+export type Shortcut = Key | KeyCombo;
+
+type EventAction = (event: KeyboardEvent) => void;
+type FocusAction = HTMLInputElement;
+type ClickAction = HTMLElement;
+
+type Action = EventAction | FocusAction | ClickAction;
+
+const handlers = new Map<string, Action>();
+let mode: ModeSwitch | undefined;
+
+export function handleShortcuts(event: KeyboardEvent) {
+ if (isInputElement(event.target)) {
+ if (event.key === 'Escape') {
+ event.target.blur();
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ }
+ return;
+ }
+
+ if (event.ctrlKey) {
+ return;
+ }
+
+ if (event.key === 'Escape') {
+ if (get(modals).length > 0) {
+ closeModal();
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ return;
+ }
+ }
+
+ if (isModeSwitch(event.key) && mode === undefined) {
+ mode = event.key;
+ event.preventDefault();
+ return;
+ }
+
+ const handler = handlers.get(mode === undefined ? event.key : `${mode}${event.key}`);
+
+ if (!handler || get(modals).length > 0) {
+ mode = undefined;
+ return;
+ }
+
+ if (handler instanceof HTMLInputElement) {
+ handler.focus();
+ } else if (handler instanceof HTMLElement) {
+ handler.click();
+ } else {
+ handler(event);
+ }
+
+ mode = undefined;
+ event.preventDefault();
+}
+
+export function accelerator(node: HTMLElement | HTMLInputElement, sc: Shortcut) {
+ handlers.set(sc, node);
+
+ return {
+ destroy() {
+ handlers.delete(sc);
+ }
+ };
+}
+
+export function binds(node: Document, scs: [string, EventAction][]) {
+ const handlers = new Map<string, EventAction>();
+
+ for (const [k, a] of scs) {
+ handlers.set(k, a);
+ }
+
+ function keydown(event: KeyboardEvent) {
+ if (isInputElement(event.target)) return;
+
+ const handler = handlers.get(event.key);
+
+ if (!handler) return;
+
+ handler(event);
+ event.preventDefault();
+ }
+
+ node.addEventListener('keydown', keydown);
+
+ return {
+ destroy() {
+ node.removeEventListener('keydown', keydown);
+ }
+ };
+}
+
+export function addShortcut(sc: Shortcut, action: EventAction) {
+ handlers.set(sc, action);
+}
+
+function isInputElement(target: EventTarget | null): target is HTMLElement {
+ return (
+ target instanceof HTMLElement &&
+ (target instanceof HTMLInputElement ||
+ target instanceof HTMLSelectElement ||
+ target instanceof HTMLTextAreaElement ||
+ target.isContentEditable)
+ );
+}
diff --git a/frontend/src/lib/Sort.ts b/frontend/src/lib/Sort.ts
new file mode 100644
index 0000000..4c9a353
--- /dev/null
+++ b/frontend/src/lib/Sort.ts
@@ -0,0 +1,42 @@
+import { SortDirection } from '$gql/graphql';
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { navigate } from './Navigation';
+
+export interface SortData<T> {
+ on: T;
+ direction: SortDirection;
+ seed: number | undefined;
+}
+
+export class SortContext<T extends string> {
+ on: T;
+ direction: SortDirection;
+ seed: number | undefined;
+ labels: Record<T, string>;
+
+ constructor({ on, direction, seed }: SortData<T>, labels: Record<T, string>) {
+ this.on = on;
+ this.direction = direction;
+ this.seed = seed;
+ this.labels = labels;
+ }
+
+ set update({ on, direction, seed }: SortData<T>) {
+ this.on = on;
+ this.direction = direction;
+ this.seed = seed;
+ }
+
+ apply(params: URLSearchParams) {
+ navigate({ sort: { on: this.on, direction: this.direction, seed: this.seed } }, params);
+ }
+}
+
+export function initSortContext<T extends string>(sort: SortData<T>, labels: Record<T, string>) {
+ return setContext<Writable<SortContext<T>>>('sort', writable(new SortContext(sort, labels)));
+}
+
+export function getSortContext<T extends string>() {
+ return getContext<Writable<SortContext<T>>>('sort');
+}
diff --git a/frontend/src/lib/Tabs.ts b/frontend/src/lib/Tabs.ts
new file mode 100644
index 0000000..1c43068
--- /dev/null
+++ b/frontend/src/lib/Tabs.ts
@@ -0,0 +1,18 @@
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+
+type Tab = string;
+type Tabs = Record<Tab, { title: string; badge?: boolean }>;
+
+interface TabContext {
+ tabs: Tabs;
+ current: Tab;
+}
+
+export function setTabContext(context: TabContext) {
+ return setContext<Writable<TabContext>>('tabs', writable(context));
+}
+
+export function getTabContext() {
+ return getContext<Writable<TabContext>>('tabs');
+}
diff --git a/frontend/src/lib/Toasts.ts b/frontend/src/lib/Toasts.ts
new file mode 100644
index 0000000..abc9a7d
--- /dev/null
+++ b/frontend/src/lib/Toasts.ts
@@ -0,0 +1,19 @@
+import { toast } from '@zerodevx/svelte-toast';
+
+export function toastSuccess(message: string) {
+ toast.push(message, {
+ theme: { '--toastBackground': 'rgba(72, 187, 120, 0.9)', '--toastColor': 'mintcream' },
+ duration: 1000
+ });
+}
+
+export function toastError(message: string) {
+ toast.push(message, {
+ theme: { '--toastBackground': 'rgba(187, 72, 72, 0.9)', '--toastColor': 'lavenderblush' },
+ duration: 5000,
+ pausable: true
+ });
+}
+
+// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
+export const toastFinally = (reason: any) => toastError(reason);
diff --git a/frontend/src/lib/Transitions.ts b/frontend/src/lib/Transitions.ts
new file mode 100644
index 0000000..59ebaf2
--- /dev/null
+++ b/frontend/src/lib/Transitions.ts
@@ -0,0 +1,10 @@
+import { quartInOut } from 'svelte/easing';
+import type { FadeParams, SlideParams } from 'svelte/transition';
+
+export const fadeFast: FadeParams = { duration: 60 };
+export const fadeDefault: FadeParams = { duration: 100 };
+
+export const slideYDefault: SlideParams = { axis: 'y', duration: 300, easing: quartInOut };
+
+export const slideXDefault: SlideParams = { axis: 'x', duration: 300, easing: quartInOut };
+export const slideXFast: SlideParams = { axis: 'x', duration: 200 };
diff --git a/frontend/src/lib/Update.ts b/frontend/src/lib/Update.ts
new file mode 100644
index 0000000..507dd52
--- /dev/null
+++ b/frontend/src/lib/Update.ts
@@ -0,0 +1,97 @@
+import {
+ UpdateMode,
+ type UpdateComicInput,
+ type UpdateOptions,
+ type UpdateTagInput
+} from '$gql/graphql';
+
+type Key = string | number | symbol;
+
+interface AssociationUpdate {
+ ids?: number[] | string[] | null;
+ options?: UpdateOptions | null;
+}
+
+type Input<T, K extends Key> = {
+ [Property in K]?: T | null;
+};
+
+abstract class Entry<K extends Key> {
+ key: K;
+
+ constructor(key: K) {
+ this.key = key;
+ }
+
+ abstract integrate(input: Input<unknown, K>): void;
+ abstract hasInput(): boolean;
+}
+
+class Association<K extends Key> extends Entry<K> {
+ ids = [];
+ options = {
+ mode: UpdateMode.Add
+ };
+
+ constructor(key: K) {
+ super(key);
+ }
+
+ integrate(input: Input<AssociationUpdate, K>) {
+ if (this.hasInput()) {
+ input[this.key] = { ids: this.ids, options: this.options };
+ }
+ }
+
+ hasInput() {
+ return this.ids.length > 0;
+ }
+}
+
+class Enum<K extends Key> extends Entry<K> {
+ value?: string = undefined;
+
+ constructor(key: K) {
+ super(key);
+ }
+
+ integrate(input: Input<string, K>): void {
+ if (this.hasInput()) {
+ input[this.key] = this.value;
+ }
+ }
+
+ hasInput() {
+ return this.value !== undefined && this.value !== null;
+ }
+}
+
+abstract class Controls<I> {
+ toInput() {
+ const input = {} as I;
+ Object.values(this).forEach((v: Entry<keyof I>) => v.integrate(input));
+ return input;
+ }
+
+ hasInput() {
+ return Object.values(this).some((i: Entry<keyof I>) => i.hasInput());
+ }
+}
+
+export class UpdateTagsControls extends Controls<UpdateTagInput> {
+ namespaces = new Association('namespaces');
+}
+
+export class UpdateComicsControls extends Controls<UpdateComicInput> {
+ artists = new Association('artists');
+ category = new Enum('category');
+ censorship = new Enum('censorship');
+ direction = new Enum('direction');
+ layout = new Enum('layout');
+ characters = new Association('characters');
+ circles = new Association('circles');
+ language = new Enum('language');
+ rating = new Enum('rating');
+ tags = new Association('tags');
+ worlds = new Association('worlds');
+}
diff --git a/frontend/src/lib/Utils.ts b/frontend/src/lib/Utils.ts
new file mode 100644
index 0000000..1a07be1
--- /dev/null
+++ b/frontend/src/lib/Utils.ts
@@ -0,0 +1,108 @@
+import { isError } from '$gql/Utils';
+import type { ImageFragment } from '$gql/graphql';
+import type { BeforeNavigate } from '@sveltejs/kit';
+import type { OperationResultState } from '@urql/svelte';
+import { openModal } from 'svelte-modals';
+import ConfirmDeletion from './dialogs/ConfirmDeletion.svelte';
+
+export function range(from: number, to: number) {
+ return Array.from({ length: to - from + 1 }, (_, k) => k + from);
+}
+
+export function getRandomInt(min: number, max: number) {
+ const minCeiled = Math.ceil(min);
+ const maxFloored = Math.floor(max);
+
+ return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
+}
+
+export interface ListItem {
+ id: number | string;
+ name: string;
+}
+
+export interface ResultState {
+ fetching: boolean;
+ message?: string;
+}
+
+export function getResultState(state: OperationResultState): ResultState {
+ let message: string | undefined;
+
+ if (state.error) {
+ message = `${state.error.name}: ${state.error.message}`;
+ } else if (state.data) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ const obj = Object.values(state.data)[0];
+ if (isError(obj)) {
+ message = obj.message;
+ }
+ }
+
+ return { fetching: state.fetching, message: message };
+}
+
+export function src(image: ImageFragment, type: 'full' | 'thumb' = 'thumb') {
+ const dir = image.hash.slice(0, 2);
+ const file = image.hash.slice(2);
+
+ return `/objects/${dir}/${file}_${type}.webp`;
+}
+
+export function numKeys(obj?: object | null, ignore: string[] = []) {
+ if (!obj) return 0;
+
+ const len = Object.keys(obj).length;
+ let ignored = 0;
+
+ for (const i of ignore) {
+ if (Object.hasOwn(obj, i)) ignored++;
+ }
+
+ return len - ignored;
+}
+
+export function confirmDeletion(
+ typename: string,
+ names: string | string[],
+ callback: () => void,
+ warning?: string
+) {
+ openModal(
+ ConfirmDeletion,
+ { names: Array.isArray(names) ? names : [names], typename, callback: callback, warning },
+ { replace: true }
+ );
+}
+
+export function idFromLabel(label: string) {
+ return label.toLowerCase().replaceAll(' ', '-');
+}
+
+export function pluralize(singular: string, size: number) {
+ return `${singular}${size > 1 ? 's' : ''}`;
+}
+
+export function formatListSize(word: string, size: number) {
+ return `${size} ${pluralize(word, size)}`;
+}
+
+export function joinText(items: string[], separator = ', ') {
+ return items.filter((i) => i).join(separator);
+}
+
+export function confirmPending() {
+ return confirm('There are pending changes. Click Cancel to keep editing or OK to dismiss them.');
+}
+
+export function preventOnPending({ to, cancel }: BeforeNavigate, pending: boolean) {
+ if (!pending) return;
+
+ if (to) {
+ if (confirmPending()) {
+ return;
+ }
+ }
+
+ cancel();
+}
diff --git a/frontend/src/lib/assets/logo.webp b/frontend/src/lib/assets/logo.webp
new file mode 100644
index 0000000..e41cbb0
--- /dev/null
+++ b/frontend/src/lib/assets/logo.webp
Binary files differ
diff --git a/frontend/src/lib/components/AddButton.svelte b/frontend/src/lib/components/AddButton.svelte
new file mode 100644
index 0000000..9c0ab29
--- /dev/null
+++ b/frontend/src/lib/components/AddButton.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ export let title: string;
+</script>
+
+<button class="btn-blue" {title} on:click>
+ <span class="icon-base icon-[material-symbols--add]" />
+</button>
diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte
new file mode 100644
index 0000000..7ad3173
--- /dev/null
+++ b/frontend/src/lib/components/Badge.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ export let number: number;
+</script>
+
+{#if number > 0}
+ <span
+ class="absolute -right-[3px] -top-[6px] z-[1] rounded-lg bg-teal-600 px-1 text-xs"
+ transition:fade={fadeDefault}
+ >
+ {number}
+ </span>
+{/if}
diff --git a/frontend/src/lib/components/BookmarkButton.svelte b/frontend/src/lib/components/BookmarkButton.svelte
new file mode 100644
index 0000000..89570e6
--- /dev/null
+++ b/frontend/src/lib/components/BookmarkButton.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+ import Bookmark from '$lib/icons/Bookmark.svelte';
+
+ export let bookmarked: boolean;
+</script>
+
+<button type="button" title="Toggle bookmark" class="flex text-base" on:click>
+ <Bookmark hoverable {bookmarked} />
+</button>
diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte
new file mode 100644
index 0000000..2384799
--- /dev/null
+++ b/frontend/src/lib/components/Card.svelte
@@ -0,0 +1,106 @@
+<script lang="ts" context="module">
+ import type { ComicFragment, ImageFragment } from '$gql/graphql';
+
+ interface CardDetails {
+ title: string;
+ favourite?: boolean;
+ subtitle?: string | null;
+ cover?: ImageFragment;
+ }
+
+ export function comicCard(comic: ComicFragment) {
+ return {
+ href: `/comics/${comic.id.toString()}`,
+ details: {
+ title: comic.title,
+ subtitle: comic.originalTitle,
+ favourite: comic.favourite,
+ cover: comic.cover
+ }
+ };
+ }
+</script>
+
+<script lang="ts">
+ import { src } from '$lib/Utils';
+ import Star from '$lib/icons/Star.svelte';
+
+ export let href: string;
+ export let details: CardDetails;
+ export let compact = false;
+ export let coverOnly = false;
+ export let ellipsis = true;
+</script>
+
+<a
+ {href}
+ class="grid-card-v sm:grid-card-h relative grid overflow-hidden rounded bg-slate-900 shadow-md shadow-slate-950/30"
+ class:compact
+ class:grid-card-cover-only={coverOnly}
+ on:click
+>
+ <slot name="overlay" />
+ {#if details.cover}
+ <img
+ class="h-full w-full object-cover object-[center_top]"
+ width={details.cover.width}
+ height={details.cover.height}
+ src={src(details.cover)}
+ alt=""
+ title={details.title}
+ />
+ {/if}
+ {#if !coverOnly}
+ <article class="flex h-full flex-col gap-2 p-2">
+ <header>
+ <h2
+ class:ellipsis-nowrap={ellipsis}
+ class="self-center text-sm font-medium [grid-area:title]"
+ title={details.title}
+ >
+ {details.title}
+ </h2>
+ {#if details.subtitle}
+ <h3
+ class="ellipsis-nowrap text-xs opacity-60 [grid-area:subtitle]"
+ title={details.subtitle}
+ >
+ {details.subtitle}
+ </h3>
+ {/if}
+ {#if details.favourite}
+ <div class="flex items-center text-lg [grid-area:fav]">
+ <Star favourite />
+ </div>
+ {/if}
+ </header>
+
+ <section class="max-h-full grow overflow-auto border-t border-slate-800/80 pt-2 text-xs">
+ <slot />
+ </section>
+ </article>
+ {/if}
+</a>
+
+<style>
+ a.compact {
+ grid-template-columns: 175px 1fr;
+ grid-template-rows: 250px;
+ }
+
+ img {
+ border-start-start-radius: inherit;
+ border-end-start-radius: inherit;
+ }
+
+ article > header {
+ display: grid;
+
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto;
+
+ grid-template-areas:
+ 'title fav'
+ 'subtitle fav';
+ }
+</style>
diff --git a/frontend/src/lib/components/Cardlet.svelte b/frontend/src/lib/components/Cardlet.svelte
new file mode 100644
index 0000000..04d8599
--- /dev/null
+++ b/frontend/src/lib/components/Cardlet.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { ComicFilter } from '$gql/graphql';
+ import { href } from '$lib/Navigation';
+
+ export let name: string;
+ export let title: string | null | undefined = undefined;
+
+ export let filter: keyof ComicFilter | undefined = undefined;
+ export let id: number | string | undefined = undefined;
+
+ const handleAux = (e: MouseEvent) => {
+ if (filter === undefined || id === undefined || e.button !== 1) return;
+ window.open(href('comics', { filter: { include: { [filter]: { all: [id] } } } }));
+ };
+</script>
+
+<button
+ type="button"
+ class="relative flex overflow-hidden rounded bg-slate-900 text-left shadow-md shadow-slate-950/20"
+ {title}
+ on:click
+ on:auxclick={handleAux}
+>
+ <slot name="overlay" />
+ <article class="group h-full grow items-center gap-2 p-2 text-xs">
+ <h2 class="ellipsis-nowrap text-sm font-medium">{name}</h2>
+ </article>
+</button>
+
+<style>
+ article {
+ display: grid;
+
+ grid-template-columns: 1fr auto;
+ grid-template-rows: 2em;
+ }
+</style>
diff --git a/frontend/src/lib/components/DeleteButton.svelte b/frontend/src/lib/components/DeleteButton.svelte
new file mode 100644
index 0000000..8f5f116
--- /dev/null
+++ b/frontend/src/lib/components/DeleteButton.svelte
@@ -0,0 +1,15 @@
+<script>
+ import { accelerator } from '$lib/Shortcuts';
+
+ export let prominent = false;
+</script>
+
+<button
+ type="button"
+ class={prominent ? 'btn-rose' : 'btn-slate hover:bg-rose-700'}
+ title="Delete forever"
+ on:click
+ use:accelerator={'Delete'}
+>
+ <span class="icon-base icon-[material-symbols--delete-forever]" />
+</button>
diff --git a/frontend/src/lib/components/Dialog.svelte b/frontend/src/lib/components/Dialog.svelte
new file mode 100644
index 0000000..a0bbe5e
--- /dev/null
+++ b/frontend/src/lib/components/Dialog.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { trapFocus } from '$lib/Actions';
+ import { fadeDefault } from '$lib/Transitions';
+ import { closeModal } from 'svelte-modals';
+ import { fade } from 'svelte/transition';
+
+ export let isOpen: boolean;
+</script>
+
+{#if isOpen}
+ <div
+ role="dialog"
+ class="pointer-events-none fixed bottom-0 left-0 right-0 top-0 z-30 flex items-center justify-center"
+ transition:fade|global={fadeDefault}
+ use:trapFocus
+ >
+ <div
+ class="pointer-events-auto flex flex-col rounded-md bg-slate-800 shadow-md shadow-slate-900"
+ >
+ <header class="flex items-center gap-1 border-b-2 border-slate-700/50 p-2">
+ <slot name="header" />
+ <button
+ type="button"
+ class="ml-auto flex items-center text-white/30 hover:text-white"
+ title="Cancel"
+ on:click={closeModal}
+ >
+ <span class="icon-base icon-[material-symbols--close]" />
+ </button>
+ </header>
+ <main class="m-3 w-80 sm:w-[34rem]">
+ <slot />
+ </main>
+ </div>
+ </div>
+{/if}
diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte
new file mode 100644
index 0000000..9e935e4
--- /dev/null
+++ b/frontend/src/lib/components/Dropdown.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+ import { clickOutside } from '$lib/Actions';
+ import { fadeFast } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ export let visible: boolean;
+ export let parent: HTMLElement;
+</script>
+
+{#if visible}
+ <div
+ class="absolute z-[1] mt-1 w-max rounded bg-slate-700 p-1 shadow-sm shadow-slate-900"
+ transition:fade={fadeFast}
+ use:clickOutside={{ handler: () => (visible = false), ignore: parent }}
+ >
+ <slot />
+ </div>
+{/if}
diff --git a/frontend/src/lib/components/Empty.svelte b/frontend/src/lib/components/Empty.svelte
new file mode 100644
index 0000000..7f9557c
--- /dev/null
+++ b/frontend/src/lib/components/Empty.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import logo from '$lib/assets/logo.webp';
+</script>
+
+<div class="col-span-full flex flex-col items-center text-4xl font-medium text-gray-600">
+ <img src={logo} class="w-1/5 opacity-60 grayscale" alt="" />
+ <div class="flex items-center gap-2">
+ <h2>There is nothing here...</h2>
+ </div>
+</div>
diff --git a/frontend/src/lib/components/Expander.svelte b/frontend/src/lib/components/Expander.svelte
new file mode 100644
index 0000000..a382658
--- /dev/null
+++ b/frontend/src/lib/components/Expander.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ export let expanded: boolean;
+ export let title: string;
+</script>
+
+<button
+ class="flex items-center text-base hover:text-white"
+ type="button"
+ on:click={() => (expanded = !expanded)}
+>
+ {#if expanded}
+ <span class="icon-base icon-[material-symbols--expand-less]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--expand-more]" />
+ {/if}
+ {title}
+</button>
diff --git a/frontend/src/lib/components/Guard.svelte b/frontend/src/lib/components/Guard.svelte
new file mode 100644
index 0000000..fd7ded4
--- /dev/null
+++ b/frontend/src/lib/components/Guard.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import { getResultState } from '$lib/Utils';
+ import Spinner from './Spinner.svelte';
+
+ export let result;
+ $: state = getResultState($result);
+</script>
+
+{#if state.fetching}
+ <Spinner />
+{:else}
+ <p>{state.message}</p>
+{/if}
diff --git a/frontend/src/lib/components/Head.svelte b/frontend/src/lib/components/Head.svelte
new file mode 100644
index 0000000..b4aed5b
--- /dev/null
+++ b/frontend/src/lib/components/Head.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ export let section: string;
+ export let title = '';
+
+ function formatTitle(section: string, title?: string) {
+ return [title, section, 'hircine'].filter((i) => i).join(' · ');
+ }
+</script>
+
+<svelte:head>
+ <title>{formatTitle(section, title)}</title>
+</svelte:head>
diff --git a/frontend/src/lib/components/Labelled.svelte b/frontend/src/lib/components/Labelled.svelte
new file mode 100644
index 0000000..4b36ad6
--- /dev/null
+++ b/frontend/src/lib/components/Labelled.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import { idFromLabel } from '$lib/Utils';
+
+ export let label: string;
+
+ const id = idFromLabel(label);
+</script>
+
+<label class="self-center" for={id}>{label}</label>
+<slot {id} />
diff --git a/frontend/src/lib/components/LabelledBlock.svelte b/frontend/src/lib/components/LabelledBlock.svelte
new file mode 100644
index 0000000..feb563e
--- /dev/null
+++ b/frontend/src/lib/components/LabelledBlock.svelte
@@ -0,0 +1,18 @@
+<script lang="ts">
+ import { idFromLabel } from '$lib/Utils';
+
+ export let label: string;
+
+ const id = idFromLabel(label);
+</script>
+
+<div class="flex flex-col">
+ <div class="flex">
+ <label for={id}>{label}</label>
+ {#if $$slots.controls}
+ <div class="grow" />
+ <slot name="controls" />
+ {/if}
+ </div>
+ <slot {id} />
+</div>
diff --git a/frontend/src/lib/components/OrganizedButton.svelte b/frontend/src/lib/components/OrganizedButton.svelte
new file mode 100644
index 0000000..9be985c
--- /dev/null
+++ b/frontend/src/lib/components/OrganizedButton.svelte
@@ -0,0 +1,9 @@
+<script lang="ts">
+ import Organized from '$lib/icons/Organized.svelte';
+
+ export let organized: boolean;
+</script>
+
+<button type="button" title="Toggle organized" class="flex text-base" on:click>
+ <Organized hoverable {organized} />
+</button>
diff --git a/frontend/src/lib/components/RefreshButton.svelte b/frontend/src/lib/components/RefreshButton.svelte
new file mode 100644
index 0000000..afab640
--- /dev/null
+++ b/frontend/src/lib/components/RefreshButton.svelte
@@ -0,0 +1,3 @@
+<button class="btn-blue" title="Refresh" on:click>
+ <span class="icon-base icon-[material-symbols--sync]" />
+</button>
diff --git a/frontend/src/lib/components/RemovePageButton.svelte b/frontend/src/lib/components/RemovePageButton.svelte
new file mode 100644
index 0000000..e23c079
--- /dev/null
+++ b/frontend/src/lib/components/RemovePageButton.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import { accelerator } from '$lib/Shortcuts';
+</script>
+
+<button
+ type="button"
+ class="btn-rose"
+ title="Remove selected pages"
+ on:click
+ use:accelerator={'Delete'}
+>
+ <span class="icon-base icon-[material-symbols--scan-delete]" />
+</button>
diff --git a/frontend/src/lib/components/Select.svelte b/frontend/src/lib/components/Select.svelte
new file mode 100644
index 0000000..83f026c
--- /dev/null
+++ b/frontend/src/lib/components/Select.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ import type { ListItem } from '$lib/Utils';
+ /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any */
+
+ // @ts-ignore
+ import Svelecte from 'svelecte';
+
+ let inputId: string;
+ let valueAsObject = false;
+ let multiple = false;
+
+ type Value = (number | string | ListItem)[] | number | string | ListItem | undefined | null;
+
+ export let clearable = false;
+ export let placeholder = 'Select...';
+ export let options: ListItem[] | undefined;
+ export let value: Value;
+
+ export { inputId as id, valueAsObject as object, multiple as multi };
+
+ function optionsPlaceholder(from: Value) {
+ if (from === undefined || from === null) return [];
+
+ return Array.isArray(from) ? value : [value];
+ }
+</script>
+
+{#if options !== null && options !== undefined}
+ <Svelecte
+ virtualList
+ valueField="id"
+ labelField="name"
+ {options}
+ {multiple}
+ {clearable}
+ {inputId}
+ {valueAsObject}
+ {placeholder}
+ bind:value
+ />
+{:else}
+ <Svelecte
+ virtualList
+ valueField="id"
+ labelField="name"
+ disabled
+ options={optionsPlaceholder(value)}
+ {multiple}
+ {clearable}
+ {inputId}
+ {valueAsObject}
+ {placeholder}
+ {value}
+ />
+{/if}
diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte
new file mode 100644
index 0000000..946329c
--- /dev/null
+++ b/frontend/src/lib/components/Spinner.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { onDestroy } from 'svelte';
+
+ let show = false;
+ const timeout = setTimeout(() => (show = true), 150);
+
+ onDestroy(() => clearTimeout(timeout));
+</script>
+
+{#if show}
+ <div class="flex h-full w-full items-center justify-center">
+ <span class="spinner" />
+ </div>
+{/if}
+
+<style lang="postcss">
+ .spinner {
+ width: 64px;
+ height: 64px;
+ border: 5px solid theme(colors.gray.200);
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ display: inline-block;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+ }
+
+ @keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+</style>
diff --git a/frontend/src/lib/components/SubmitButton.svelte b/frontend/src/lib/components/SubmitButton.svelte
new file mode 100644
index 0000000..8ac90b9
--- /dev/null
+++ b/frontend/src/lib/components/SubmitButton.svelte
@@ -0,0 +1,7 @@
+<script lang="ts">
+ export let active = false;
+
+ $: title = active ? 'Save pending changes' : 'Save (no changes pending)';
+</script>
+
+<button type="submit" class:active class="btn-slate [&.active]:btn-blue" {title}>Save</button>
diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte
new file mode 100644
index 0000000..8aab2dd
--- /dev/null
+++ b/frontend/src/lib/components/Titlebar.svelte
@@ -0,0 +1,32 @@
+<script lang="ts">
+ import Star from '$lib/icons/Star.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ export let title: string;
+ export let subtitle: string | null = '';
+ export let favourite: boolean | undefined = undefined;
+
+ const dispatch = createEventDispatcher<{ favourite: null }>();
+</script>
+
+<div class="flex flex-wrap gap-x-4">
+ <div class="flex overflow-hidden">
+ {#if favourite !== undefined}
+ <button
+ type="button"
+ class="mr-1 flex items-center"
+ title="Toggle favourite"
+ on:click={() => dispatch('favourite')}
+ >
+ <Star large hoverable {favourite} />
+ </button>
+ {/if}
+ <h1 class="xl:ellipsis-nowrap text-2xl font-semibold">{title}</h1>
+ </div>
+
+ {#if subtitle}
+ <h2 class="xl:ellipsis-nowrap self-end text-lg font-light text-gray-400">
+ {subtitle}
+ </h2>
+ {/if}
+</div>
diff --git a/frontend/src/lib/containers/Cardlets.svelte b/frontend/src/lib/containers/Cardlets.svelte
new file mode 100644
index 0000000..129da61
--- /dev/null
+++ b/frontend/src/lib/containers/Cardlets.svelte
@@ -0,0 +1,11 @@
+<script>
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+</script>
+
+<div
+ class="grid gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 min-[1600px]:grid-cols-8 min-[1920px]:grid-cols-10"
+ in:fade={fadeDefault}
+>
+ <slot />
+</div>
diff --git a/frontend/src/lib/containers/Cards.svelte b/frontend/src/lib/containers/Cards.svelte
new file mode 100644
index 0000000..a19e8be
--- /dev/null
+++ b/frontend/src/lib/containers/Cards.svelte
@@ -0,0 +1,8 @@
+<script>
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+</script>
+
+<div class="grid gap-4 xl:grid-cols-2 min-[1920px]:grid-cols-3" in:fade|global={fadeDefault}>
+ <slot />
+</div>
diff --git a/frontend/src/lib/containers/Carousel.svelte b/frontend/src/lib/containers/Carousel.svelte
new file mode 100644
index 0000000..1268a78
--- /dev/null
+++ b/frontend/src/lib/containers/Carousel.svelte
@@ -0,0 +1,15 @@
+<script lang="ts">
+ export let title: string;
+ export let href: string;
+</script>
+
+<div class="flex flex-col gap-1">
+ <h2 class="flex text-2xl font-medium">
+ <a class="hover:text-white" {href}>
+ {title}
+ </a>
+ </h2>
+ <div class="flex flex-wrap gap-5">
+ <slot />
+ </div>
+</div>
diff --git a/frontend/src/lib/containers/Column.svelte b/frontend/src/lib/containers/Column.svelte
new file mode 100644
index 0000000..05daece
--- /dev/null
+++ b/frontend/src/lib/containers/Column.svelte
@@ -0,0 +1,3 @@
+<div class="flex flex-col gap-4">
+ <slot />
+</div>
diff --git a/frontend/src/lib/containers/Grid.svelte b/frontend/src/lib/containers/Grid.svelte
new file mode 100644
index 0000000..1224156
--- /dev/null
+++ b/frontend/src/lib/containers/Grid.svelte
@@ -0,0 +1,23 @@
+<script>
+ import { fadeDefault } from '$lib/Transitions';
+
+ import { fade } from 'svelte/transition';
+</script>
+
+<div
+ class="flex flex-col gap-1 lg:grid lg:h-full lg:max-h-full lg:overflow-auto"
+ in:fade|global={fadeDefault}
+>
+ <slot />
+</div>
+
+<style>
+ div {
+ grid-template-columns: auto 1fr;
+ grid-template-rows: auto 1fr;
+
+ grid-template-areas:
+ 'header header'
+ 'sidebar main';
+ }
+</style>
diff --git a/frontend/src/lib/dialogs/AddArtist.svelte b/frontend/src/lib/dialogs/AddArtist.svelte
new file mode 100644
index 0000000..6ec93c5
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddArtist.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addArtist, type ArtistInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import ArtistForm from '$lib/forms/ArtistForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let artist = { name: '' };
+
+ function add(event: CustomEvent<ArtistInput>) {
+ addArtist(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Artist</h2>
+ </svelte:fragment>
+ <ArtistForm bind:artist on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={artist.name.length > 0} />
+ </div>
+ </ArtistForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddCharacter.svelte b/frontend/src/lib/dialogs/AddCharacter.svelte
new file mode 100644
index 0000000..23fea08
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddCharacter.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addCharacter, type CharacterInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CharacterForm from '$lib/forms/CharacterForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let character = { name: '' };
+
+ function add(event: CustomEvent<CharacterInput>) {
+ addCharacter(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Character</h2>
+ </svelte:fragment>
+ <CharacterForm bind:character on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={character.name.length > 0} />
+ </div>
+ </CharacterForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddCircle.svelte b/frontend/src/lib/dialogs/AddCircle.svelte
new file mode 100644
index 0000000..f0ef014
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddCircle.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addCircle, type CircleInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CircleForm from '$lib/forms/CircleForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let circle = { name: '' };
+
+ function add(event: CustomEvent<CircleInput>) {
+ addCircle(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Circle</h2>
+ </svelte:fragment>
+ <CircleForm bind:circle on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={circle.name.length > 0} />
+ </div>
+ </CircleForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddNamespace.svelte b/frontend/src/lib/dialogs/AddNamespace.svelte
new file mode 100644
index 0000000..e81b22a
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddNamespace.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addNamespace, type NamespaceInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import NamespaceForm from '$lib/forms/NamespaceForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let namespace = { name: '' };
+
+ function add(event: CustomEvent<NamespaceInput>) {
+ addNamespace(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Namespace</h2>
+ </svelte:fragment>
+ <NamespaceForm bind:namespace on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={namespace.name.length > 0} />
+ </div>
+ </NamespaceForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddTag.svelte b/frontend/src/lib/dialogs/AddTag.svelte
new file mode 100644
index 0000000..00d3a03
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddTag.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addTag, type TagInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import TagForm from '$lib/forms/TagForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let tag = { name: '', namespaces: [] };
+
+ function add(event: CustomEvent<TagInput>) {
+ addTag(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add Tag</h2>
+ </svelte:fragment>
+ <TagForm bind:tag on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={tag.name.length > 0} />
+ </div>
+ </TagForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/AddWorld.svelte b/frontend/src/lib/dialogs/AddWorld.svelte
new file mode 100644
index 0000000..ceb946e
--- /dev/null
+++ b/frontend/src/lib/dialogs/AddWorld.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { addWorld, type WorldInput } from '$gql/Mutations';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import WorldForm from '$lib/forms/WorldForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ let world = { name: '' };
+
+ function add(event: CustomEvent<WorldInput>) {
+ addWorld(client, { input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Add World</h2>
+ </svelte:fragment>
+ <WorldForm bind:world on:submit={add}>
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={world.name.length > 0} />
+ </div>
+ </WorldForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/ConfirmDeletion.svelte b/frontend/src/lib/dialogs/ConfirmDeletion.svelte
new file mode 100644
index 0000000..6b0cbf8
--- /dev/null
+++ b/frontend/src/lib/dialogs/ConfirmDeletion.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+ import { accelerator } from '$lib/Shortcuts';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import { closeModal } from 'svelte-modals';
+
+ export let isOpen: boolean;
+ export let callback: () => void;
+
+ export let names: string[];
+ export let typename: string;
+ export let warning: string | undefined = undefined;
+ const multiple = names.length > 1;
+ const formattedTypename = multiple ? `${typename}s` : typename;
+ const formattedNames = multiple ? `${names.length} ${formattedTypename}` : names[0];
+
+ function confirm() {
+ callback();
+ closeModal();
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Delete {formattedTypename}</h2>
+ </svelte:fragment>
+ <form on:submit|preventDefault={confirm}>
+ <div class="flex flex-col">
+ <p class="mb-3">
+ Are you sure you want to delete <span class="font-semibold">{formattedNames}</span>?
+ </p>
+ {#if multiple}
+ <ul class="mb-3 ml-8 list-disc">
+ {#each names.slice(0, 10) as name}
+ <li>{name}</li>
+ {/each}
+ </ul>
+ {#if names.length - 10 > 0}
+ <p>... and {names.length - 10} more.</p>
+ {/if}
+ {/if}
+ {#if warning}
+ <p class="font-medium text-red-600">Warning: {warning}</p>
+ {/if}
+ </div>
+
+ <div class="flex justify-end gap-4">
+ <button type="submit" class="btn-rose" use:accelerator={'Enter'}>Delete</button>
+ <button type="button" on:click={closeModal} class="btn-slate">Cancel</button>
+ </div>
+ </form>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditArtist.svelte b/frontend/src/lib/dialogs/EditArtist.svelte
new file mode 100644
index 0000000..dd08bc6
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditArtist.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteArtists, updateArtists, type ArtistInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Artist } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import ArtistForm from '$lib/forms/ArtistForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let artist: Artist;
+ const original = structuredClone(artist);
+ $: pending = !itemEquals(artist, original);
+
+ function save(event: CustomEvent<ArtistInput>) {
+ updateArtists(client, { ids: artist.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteArtist() {
+ confirmDeletion('Artist', artist.name, () => {
+ deleteArtists(client, { ids: artist.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Artist</h2>
+ </svelte:fragment>
+ <ArtistForm bind:artist on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteArtist} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </ArtistForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditCharacter.svelte b/frontend/src/lib/dialogs/EditCharacter.svelte
new file mode 100644
index 0000000..3b45e78
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditCharacter.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteCharacters, updateCharacters, type CharacterInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Character } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CharacterForm from '$lib/forms/CharacterForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let character: Character;
+ const original = structuredClone(character);
+ $: pending = !itemEquals(original, character);
+
+ function save(event: CustomEvent<CharacterInput>) {
+ updateCharacters(client, { ids: character.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteCharacter() {
+ confirmDeletion('Character', character.name, () => {
+ deleteCharacters(client, { ids: character.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Character</h2>
+ </svelte:fragment>
+ <CharacterForm bind:character on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteCharacter} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </CharacterForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditCircle.svelte b/frontend/src/lib/dialogs/EditCircle.svelte
new file mode 100644
index 0000000..bdc1217
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditCircle.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteCircles, updateCircles, type CircleInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Circle } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import CircleForm from '$lib/forms/CircleForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let circle: Circle;
+ const original = structuredClone(circle);
+ $: pending = !itemEquals(original, circle);
+
+ function save(event: CustomEvent<CircleInput>) {
+ updateCircles(client, { ids: circle.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteCircle() {
+ confirmDeletion('Circle', circle.name, () => {
+ deleteCircles(client, { ids: circle.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Circle</h2>
+ </svelte:fragment>
+ <CircleForm bind:circle on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteCircle} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </CircleForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditNamespace.svelte b/frontend/src/lib/dialogs/EditNamespace.svelte
new file mode 100644
index 0000000..f398b21
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditNamespace.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { deleteNamespaces, updateNamespaces, type NamespaceInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import { type Namespace } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import NamespaceForm from '$lib/forms/NamespaceForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let namespace: Namespace;
+ const original = structuredClone(namespace);
+ $: pending = !itemEquals(original, namespace);
+
+ function save(event: CustomEvent<NamespaceInput>) {
+ updateNamespaces(client, { ids: namespace.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteNamespace() {
+ confirmDeletion('Namespace', namespace.name, () => {
+ deleteNamespaces(client, { ids: namespace.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Namespace</h2>
+ </svelte:fragment>
+ <NamespaceForm bind:namespace on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteNamespace} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </NamespaceForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditTag.svelte b/frontend/src/lib/dialogs/EditTag.svelte
new file mode 100644
index 0000000..d2d0013
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditTag.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ import { deleteTags, updateTags, type TagInput } from '$gql/Mutations';
+ import { tagEquals } from '$gql/Utils';
+ import { type FullTag } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import TagForm from '$lib/forms/TagForm.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let tag: FullTag;
+ const original = structuredClone(tag);
+ $: pending = !tagEquals(original, tag);
+
+ function save(event: CustomEvent<TagInput>) {
+ updateTags(client, { ids: tag.id, input: event.detail }).then(closeModal).catch(toastFinally);
+ }
+
+ function deleteTag() {
+ confirmDeletion('Tag', tag.name, () => {
+ deleteTags(client, { ids: tag.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Tag</h2>
+ </svelte:fragment>
+ <TagForm bind:tag on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteTag} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </TagForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/EditWorld.svelte b/frontend/src/lib/dialogs/EditWorld.svelte
new file mode 100644
index 0000000..82afe6a
--- /dev/null
+++ b/frontend/src/lib/dialogs/EditWorld.svelte
@@ -0,0 +1,46 @@
+<script lang="ts">
+ import { type World } from '$gql/graphql';
+ import { deleteWorlds, updateWorlds, type WorldInput } from '$gql/Mutations';
+ import { itemEquals } from '$gql/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import WorldForm from '$lib/forms/WorldForm.svelte';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+
+ export let world: World;
+ const original = structuredClone(world);
+ $: pending = !itemEquals(original, world);
+
+ function save(event: CustomEvent<WorldInput>) {
+ updateWorlds(client, { ids: world.id, input: event.detail })
+ .then(closeModal)
+ .catch(toastFinally);
+ }
+
+ function deleteWorld() {
+ confirmDeletion('World', world.name, () => {
+ deleteWorlds(client, { ids: world.id }).then(closeModal).catch(toastFinally);
+ });
+ }
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit World</h2>
+ </svelte:fragment>
+ <WorldForm bind:world on:submit={save}>
+ <div class="flex gap-4">
+ <DeleteButton on:click={deleteWorld} />
+ <div class="grow" />
+ <SubmitButton active={pending} />
+ </div>
+ </WorldForm>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/UpdateComics.svelte b/frontend/src/lib/dialogs/UpdateComics.svelte
new file mode 100644
index 0000000..8de9622
--- /dev/null
+++ b/frontend/src/lib/dialogs/UpdateComics.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ import { updateComics } from '$gql/Mutations';
+ import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries';
+ import { categories, censorships, directions, languages, layouts, ratings } from '$lib/Enums';
+ import { toastFinally } from '$lib/Toasts';
+ import { UpdateComicsControls } from '$lib/Update';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import LabelledBlock from '$lib/components/LabelledBlock.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+ import UpdateModeSelector from './components/UpdateModeSelector.svelte';
+
+ const client = getContextClient();
+
+ export let isOpen: boolean;
+ export let ids: number[];
+
+ $: tagsQuery = comicTagList(client);
+ $: artistsQuery = artistList(client);
+ $: charactersQuery = characterList(client);
+ $: circlesQuery = circleList(client);
+ $: worldsQuery = worldList(client);
+
+ $: tags = $tagsQuery.data?.comicTags.edges;
+ $: artists = $artistsQuery.data?.artists.edges;
+ $: characters = $charactersQuery.data?.characters.edges;
+ $: circles = $circlesQuery.data?.circles.edges;
+ $: worlds = $worldsQuery.data?.worlds.edges;
+
+ const controls = new UpdateComicsControls();
+
+ const update = () => {
+ updateComics(client, {
+ ids: ids,
+ input: controls.toInput()
+ })
+ .then(closeModal)
+ .catch(toastFinally);
+ };
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Comics</h2>
+ </svelte:fragment>
+ <form on:submit|preventDefault={update}>
+ <div class="grid-labels">
+ <Labelled label="Category" let:id>
+ <Select clearable {id} options={categories} bind:value={controls.category.value} />
+ </Labelled>
+ <Labelled label="Rating" let:id>
+ <Select clearable {id} options={ratings} bind:value={controls.rating.value} />
+ </Labelled>
+ <Labelled label="Censorship" let:id>
+ <Select clearable {id} options={censorships} bind:value={controls.censorship.value} />
+ </Labelled>
+ <Labelled label="Language" let:id>
+ <Select clearable {id} options={languages} bind:value={controls.language.value} />
+ </Labelled>
+ <Labelled label="Direction" let:id>
+ <Select clearable {id} options={directions} bind:value={controls.direction.value} />
+ </Labelled>
+ <Labelled label="Layout" let:id>
+ <Select clearable {id} options={layouts} bind:value={controls.layout.value} />
+ </Labelled>
+ </div>
+
+ <LabelledBlock label="Artists" let:id>
+ <Select multi {id} options={artists} bind:value={controls.artists.ids} />
+ <UpdateModeSelector bind:mode={controls.artists.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Circles" let:id>
+ <Select multi {id} options={circles} bind:value={controls.circles.ids} />
+ <UpdateModeSelector bind:mode={controls.circles.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Characters" let:id>
+ <Select multi {id} options={characters} bind:value={controls.characters.ids} />
+ <UpdateModeSelector bind:mode={controls.characters.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Worlds" let:id>
+ <Select multi {id} options={worlds} bind:value={controls.worlds.ids} />
+ <UpdateModeSelector bind:mode={controls.worlds.options.mode} slot="controls" />
+ </LabelledBlock>
+ <LabelledBlock label="Tags" let:id>
+ <Select multi {id} options={tags} bind:value={controls.tags.ids} />
+ <UpdateModeSelector bind:mode={controls.tags.options.mode} slot="controls" />
+ </LabelledBlock>
+
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={controls.hasInput()} />
+ </div>
+ </form>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/UpdateTags.svelte b/frontend/src/lib/dialogs/UpdateTags.svelte
new file mode 100644
index 0000000..f753c7f
--- /dev/null
+++ b/frontend/src/lib/dialogs/UpdateTags.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import { updateTags } from '$gql/Mutations';
+ import { namespaceList } from '$gql/Queries';
+ import { toastFinally } from '$lib/Toasts';
+ import { UpdateTagsControls } from '$lib/Update';
+ import Dialog from '$lib/components/Dialog.svelte';
+ import LabelledBlock from '$lib/components/LabelledBlock.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import SubmitButton from '$lib/components/SubmitButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { closeModal } from 'svelte-modals';
+ import UpdateModeSelector from './components/UpdateModeSelector.svelte';
+
+ const client = getContextClient();
+
+ $: namespaceQuery = namespaceList(client);
+ $: namespaces = $namespaceQuery.data?.namespaces.edges;
+
+ export let isOpen: boolean;
+ export let ids: number[];
+
+ const controls = new UpdateTagsControls();
+
+ const update = () => {
+ updateTags(client, { ids: ids, input: controls.toInput() })
+ .then(closeModal)
+ .catch(toastFinally);
+ };
+</script>
+
+<Dialog {isOpen}>
+ <svelte:fragment slot="header">
+ <h2>Edit Tags</h2>
+ </svelte:fragment>
+ <form on:submit|preventDefault={update}>
+ <LabelledBlock label="Namespaces" let:id>
+ <Select multi {id} options={namespaces} bind:value={controls.namespaces.ids} />
+ <UpdateModeSelector bind:mode={controls.namespaces.options.mode} slot="controls" />
+ </LabelledBlock>
+
+ <div class="flex justify-end gap-4">
+ <SubmitButton active={controls.hasInput()} />
+ </div>
+ </form>
+</Dialog>
diff --git a/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte
new file mode 100644
index 0000000..e4b4479
--- /dev/null
+++ b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { UpdateMode } from '$gql/graphql';
+ import { UpdateModeLabel } from '$lib/Enums';
+
+ export let mode: UpdateMode;
+
+ function select(e: string) {
+ mode = e as UpdateMode;
+ }
+</script>
+
+<div class="flex gap-1 pb-1 text-xs">
+ {#each Object.entries(UpdateModeLabel) as [e, label]}
+ <button
+ type="button"
+ class:active={mode === e}
+ class:dangerous={mode !== UpdateMode.Add}
+ class="btn btn-xs hover:bg-slate-700 [&.active.dangerous]:bg-rose-800 [&.active]:bg-indigo-700"
+ on:click={() => select(e)}
+ >
+ {label}
+ </button>
+ {/each}
+</div>
diff --git a/frontend/src/lib/filter/ComicFilterForm.svelte b/frontend/src/lib/filter/ComicFilterForm.svelte
new file mode 100644
index 0000000..13b5320
--- /dev/null
+++ b/frontend/src/lib/filter/ComicFilterForm.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries';
+ import { ComicFilterContext, getFilterContext } from '$lib/Filter';
+ import { getContextClient } from '@urql/svelte';
+ import ComicFilterGroup from './components/ComicFilterGroup.svelte';
+ import FilterForm from './components/FilterForm.svelte';
+
+ const client = getContextClient();
+
+ $: tagsQuery = comicTagList(client, { forFilter: true });
+ $: artistsQuery = artistList(client);
+ $: charactersQuery = characterList(client);
+ $: circlesQuery = circleList(client);
+ $: worldsQuery = worldList(client);
+
+ $: tags = $tagsQuery.data?.comicTags.edges;
+ $: artists = $artistsQuery.data?.artists.edges;
+ $: characters = $charactersQuery.data?.characters.edges;
+ $: circles = $circlesQuery.data?.circles.edges;
+ $: worlds = $worldsQuery.data?.worlds.edges;
+
+ const filter = getFilterContext<ComicFilterContext>();
+ const apply = () => $filter.apply($page.url.searchParams);
+</script>
+
+<FilterForm type="grid" on:submit={apply}>
+ <ComicFilterGroup
+ slot="include"
+ type="include"
+ bind:controls={$filter.include.controls}
+ {tags}
+ {artists}
+ {characters}
+ {circles}
+ {worlds}
+ />
+ <ComicFilterGroup
+ slot="exclude"
+ type="exclude"
+ bind:controls={$filter.exclude.controls}
+ {tags}
+ {artists}
+ {characters}
+ {circles}
+ {worlds}
+ />
+</FilterForm>
diff --git a/frontend/src/lib/filter/TagFilterForm.svelte b/frontend/src/lib/filter/TagFilterForm.svelte
new file mode 100644
index 0000000..be5996e
--- /dev/null
+++ b/frontend/src/lib/filter/TagFilterForm.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { namespaceList } from '$gql/Queries';
+ import { TagFilterContext, getFilterContext } from '$lib/Filter';
+ import { getContextClient } from '@urql/svelte';
+ import FilterForm from './components/FilterForm.svelte';
+ import TagFilterGroup from './components/TagFilterGroup.svelte';
+
+ const client = getContextClient();
+
+ $: namespaceQuery = namespaceList(client);
+ $: namespaces = $namespaceQuery.data?.namespaces.edges;
+
+ const filter = getFilterContext<TagFilterContext>();
+ const apply = () => $filter.apply($page.url.searchParams);
+</script>
+
+<FilterForm on:submit={apply}>
+ <TagFilterGroup
+ slot="include"
+ type="include"
+ bind:controls={$filter.include.controls}
+ {namespaces}
+ />
+ <TagFilterGroup
+ slot="exclude"
+ type="exclude"
+ bind:controls={$filter.exclude.controls}
+ {namespaces}
+ />
+</FilterForm>
diff --git a/frontend/src/lib/filter/components/ComicFilterGroup.svelte b/frontend/src/lib/filter/components/ComicFilterGroup.svelte
new file mode 100644
index 0000000..d302de4
--- /dev/null
+++ b/frontend/src/lib/filter/components/ComicFilterGroup.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { categories, censorships, languages, ratings } from '$lib/Enums';
+ import { ComicFilterControls } from '$lib/Filter';
+ import type { ListItem } from '$lib/Utils';
+ import { setContext } from 'svelte';
+ import Filter from './Filter.svelte';
+
+ export let tags: ListItem[] | undefined;
+ export let artists: ListItem[] | undefined;
+ export let circles: ListItem[] | undefined;
+ export let characters: ListItem[] | undefined;
+ export let worlds: ListItem[] | undefined;
+ export let controls: ComicFilterControls;
+ export let type: 'include' | 'exclude';
+
+ setContext('filter-type', type);
+</script>
+
+<Filter title="Tags" options={tags} bind:filter={controls.tags} --grid-column="span 2" />
+<Filter title="Artists" options={artists} bind:filter={controls.artists} />
+<Filter title="Circles" options={circles} bind:filter={controls.circles} />
+<Filter title="Characters" options={characters} bind:filter={controls.characters} />
+<Filter title="Worlds" options={worlds} bind:filter={controls.worlds} />
+<Filter title="Categories" options={categories} bind:filter={controls.categories} />
+<Filter title="Ratings" options={ratings} bind:filter={controls.ratings} />
+<Filter title="Censorship" options={censorships} bind:filter={controls.censorships} />
+<Filter title="Languages" options={languages} bind:filter={controls.languages} />
diff --git a/frontend/src/lib/filter/components/Filter.svelte b/frontend/src/lib/filter/components/Filter.svelte
new file mode 100644
index 0000000..ead5c4d
--- /dev/null
+++ b/frontend/src/lib/filter/components/Filter.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import { Association, Enum } from '$lib/Filter';
+ import type { ListItem } from '$lib/Utils';
+ import Select from '$lib/components/Select.svelte';
+ import { getContext } from 'svelte';
+
+ export let title: string;
+ const context: 'include' | 'exclude' = getContext('filter-type');
+ $: exclude = context === 'exclude';
+
+ const id = `${context}-${title.toLowerCase()}`;
+
+ export let options: ListItem[] | undefined;
+ export let filter: Association<string> | Enum<string>;
+</script>
+
+<div class:exclude class="filter-container">
+ <div class="flex gap-2">
+ <label for={id}>{title}</label>
+ <div class="ml-auto flex items-center gap-1 self-center text-xs">
+ {#if filter instanceof Association}
+ <button
+ type="button"
+ title="matches all"
+ class:active={filter.mode === 'all'}
+ class="btn btn-xs"
+ on:click={() => (filter.mode = 'all')}
+ >
+ &forall;
+ </button>
+ <button
+ type="button"
+ title="matches any of"
+ class:active={filter.mode === 'any'}
+ class="btn btn-xs"
+ on:click={() => (filter.mode = 'any')}
+ >
+ &exist;
+ </button>
+ <button
+ type="button"
+ title="matches exactly"
+ class:active={filter.mode === 'exact'}
+ class="btn btn-xs"
+ on:click={() => (filter.mode = 'exact')}
+ >
+ &equals;
+ </button>
+ <hr class="border-px border-slate-600" />
+ {/if}
+ <button
+ type="button"
+ title="empty"
+ class:active={filter.empty}
+ class="btn btn-xs"
+ on:click={() => (filter.empty = !filter.empty)}
+ >
+ &empty;
+ </button>
+ </div>
+ </div>
+ <Select multi clearable {options} {id} bind:value={filter.values} />
+</div>
+
+<style lang="postcss">
+ button:hover {
+ @apply bg-slate-700;
+ }
+
+ button.active {
+ @apply bg-indigo-800;
+ }
+
+ .filter-container {
+ grid-column: var(--grid-column);
+ }
+</style>
diff --git a/frontend/src/lib/filter/components/FilterForm.svelte b/frontend/src/lib/filter/components/FilterForm.svelte
new file mode 100644
index 0000000..6fc4c90
--- /dev/null
+++ b/frontend/src/lib/filter/components/FilterForm.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import Expander from '$lib/components/Expander.svelte';
+ import { getFilterContext } from '$lib/Filter';
+
+ const filter = getFilterContext();
+ export let type: 'grid' | 'row' = 'row';
+
+ let exclude = false;
+
+ $: if ($filter.exclude.size > 0) {
+ exclude = true;
+ }
+</script>
+
+<form on:submit|preventDefault class="gap-0">
+ {#if type === 'grid'}
+ <div class="flex flex-col gap-4 px-2 md:grid md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
+ <slot name="include" />
+ </div>
+ <div class="my-2 flex justify-start">
+ <Expander title="Exclude" bind:expanded={exclude} />
+ </div>
+ {#if exclude}
+ <div
+ class="flex flex-col gap-4 bg-rose-950/50 p-2 md:grid md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"
+ >
+ <slot name="exclude" />
+ </div>
+ {/if}
+ {:else}
+ <div
+ class="flex flex-wrap justify-center gap-2 [&>*]:basis-full xl:[&>*]:basis-1/3 2xl:[&>*]:basis-1/5"
+ >
+ <div class="p-2">
+ <slot name="include" />
+ </div>
+ <div class="bg-rose-950/50 p-2">
+ <slot name="exclude" />
+ </div>
+ </div>
+ {/if}
+ <div class=" mt-4 flex items-center">
+ <hr class="flex-1 border-slate-700/70" />
+ <button type="submit" class="btn-blue mx-2">Apply</button>
+ <hr class="flex-1 border-slate-700/70" />
+ </div>
+</form>
diff --git a/frontend/src/lib/filter/components/TagFilterGroup.svelte b/frontend/src/lib/filter/components/TagFilterGroup.svelte
new file mode 100644
index 0000000..83b6997
--- /dev/null
+++ b/frontend/src/lib/filter/components/TagFilterGroup.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import { TagFilterControls } from '$lib/Filter';
+ import type { ListItem } from '$lib/Utils';
+ import { setContext } from 'svelte';
+ import Filter from './Filter.svelte';
+
+ export let namespaces: ListItem[] | undefined;
+ export let controls: TagFilterControls;
+ export let type: 'include' | 'exclude';
+
+ setContext('filter-type', type);
+</script>
+
+<Filter title="Namespaces" options={namespaces} bind:filter={controls.namespaces} />
diff --git a/frontend/src/lib/forms/ArtistForm.svelte b/frontend/src/lib/forms/ArtistForm.svelte
new file mode 100644
index 0000000..7df5e8b
--- /dev/null
+++ b/frontend/src/lib/forms/ArtistForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type ArtistInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Artist } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: ArtistInput }>();
+
+ export let artist: OmitIdentifiers<Artist>;
+
+ function submit() {
+ dispatch('submit', { name: artist.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={artist.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/CharacterForm.svelte b/frontend/src/lib/forms/CharacterForm.svelte
new file mode 100644
index 0000000..4cec37c
--- /dev/null
+++ b/frontend/src/lib/forms/CharacterForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type CharacterInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Character } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: CharacterInput }>();
+
+ export let character: OmitIdentifiers<Character>;
+
+ function submit() {
+ dispatch('submit', { name: character.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={character.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/CircleForm.svelte b/frontend/src/lib/forms/CircleForm.svelte
new file mode 100644
index 0000000..b71256c
--- /dev/null
+++ b/frontend/src/lib/forms/CircleForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type CircleInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Circle } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: CircleInput }>();
+
+ export let circle: OmitIdentifiers<Circle>;
+
+ function submit() {
+ dispatch('submit', { name: circle.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input required autofocus {id} bind:value={circle.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/ComicForm.svelte b/frontend/src/lib/forms/ComicForm.svelte
new file mode 100644
index 0000000..74051c8
--- /dev/null
+++ b/frontend/src/lib/forms/ComicForm.svelte
@@ -0,0 +1,100 @@
+<script lang="ts">
+ import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import type { FullComicFragment, UpdateComicInput } from '$gql/graphql';
+ import { categories, censorships, directions, languages, layouts, ratings } from '$lib/Enums';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import LabelledBlock from '$lib/components/LabelledBlock.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const client = getContextClient();
+ const dispatch = createEventDispatcher<{ submit: UpdateComicInput }>();
+
+ export let comic: OmitIdentifiers<FullComicFragment>;
+
+ $: tagsQuery = comicTagList(client);
+ $: artistsQuery = artistList(client);
+ $: charactersQuery = characterList(client);
+ $: circlesQuery = circleList(client);
+ $: worldsQuery = worldList(client);
+
+ $: tags = $tagsQuery.data?.comicTags.edges;
+ $: artists = $artistsQuery.data?.artists.edges;
+ $: characters = $charactersQuery.data?.characters.edges;
+ $: circles = $circlesQuery.data?.circles.edges;
+ $: worlds = $worldsQuery.data?.worlds.edges;
+
+ function submit() {
+ dispatch('submit', {
+ direction: comic.direction,
+ layout: comic.layout,
+ rating: comic.rating,
+ category: comic.category,
+ censorship: comic.censorship,
+ title: comic.title,
+ originalTitle: comic.originalTitle,
+ url: comic.url,
+ date: comic.date === '' ? null : comic.date,
+ language: comic.language,
+ tags: { ids: comic.tags.map((t) => t.id) },
+ artists: { ids: comic.artists.map((a) => a.id) },
+ characters: { ids: comic.characters.map((c) => c.id) },
+ circles: { ids: comic.circles.map((c) => c.id) },
+ worlds: { ids: comic.worlds.map((w) => w.id) }
+ });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Title" let:id>
+ <input required {id} bind:value={comic.title} title={comic.title} />
+ </Labelled>
+ <Labelled label="Original Title" let:id>
+ <input {id} bind:value={comic.originalTitle} title={comic.originalTitle} />
+ </Labelled>
+ <Labelled label="URL" let:id>
+ <input {id} bind:value={comic.url} />
+ </Labelled>
+ <Labelled label="Date" let:id>
+ <input {id} type="date" bind:value={comic.date} pattern={'d{4}-d{2}-d{2}'} />
+ </Labelled>
+ <Labelled label="Category" let:id>
+ <Select {id} options={categories} bind:value={comic.category} />
+ </Labelled>
+ <Labelled label="Rating" let:id>
+ <Select {id} options={ratings} bind:value={comic.rating} />
+ </Labelled>
+ <Labelled label="Censorship" let:id>
+ <Select {id} options={censorships} bind:value={comic.censorship} />
+ </Labelled>
+ <Labelled label="Language" let:id>
+ <Select {id} options={languages} bind:value={comic.language} />
+ </Labelled>
+ <Labelled label="Direction" let:id>
+ <Select {id} options={directions} bind:value={comic.direction} />
+ </Labelled>
+ <Labelled label="Layout" let:id>
+ <Select {id} options={layouts} bind:value={comic.layout} />
+ </Labelled>
+ </div>
+
+ <LabelledBlock label="Artists" let:id>
+ <Select multi object {id} options={artists} bind:value={comic.artists} />
+ </LabelledBlock>
+ <LabelledBlock label="Circles" let:id>
+ <Select multi object {id} options={circles} bind:value={comic.circles} />
+ </LabelledBlock>
+ <LabelledBlock label="Characters" let:id>
+ <Select multi object {id} options={characters} bind:value={comic.characters} />
+ </LabelledBlock>
+ <LabelledBlock label="Worlds" let:id>
+ <Select multi object {id} options={worlds} bind:value={comic.worlds} />
+ </LabelledBlock>
+ <LabelledBlock label="Tags" let:id>
+ <Select multi object {id} options={tags} bind:value={comic.tags} />
+ </LabelledBlock>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/NamespaceForm.svelte b/frontend/src/lib/forms/NamespaceForm.svelte
new file mode 100644
index 0000000..c05b6d8
--- /dev/null
+++ b/frontend/src/lib/forms/NamespaceForm.svelte
@@ -0,0 +1,28 @@
+<script lang="ts">
+ import { type NamespaceInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type Namespace } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: NamespaceInput }>();
+
+ export let namespace: OmitIdentifiers<Namespace>;
+
+ function submit() {
+ dispatch('submit', { name: namespace.name, sortName: namespace.sortName });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input required autofocus {id} bind:value={namespace.name} />
+ </Labelled>
+ <Labelled label="Sort name" let:id>
+ <input {id} bind:value={namespace.sortName} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/TagForm.svelte b/frontend/src/lib/forms/TagForm.svelte
new file mode 100644
index 0000000..6cc2227
--- /dev/null
+++ b/frontend/src/lib/forms/TagForm.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import type { TagInput } from '$gql/Mutations';
+ import { namespaceList } from '$gql/Queries';
+ import type { OmitIdentifiers } from '$gql/Utils';
+ import type { FullTag } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import Select from '$lib/components/Select.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const client = getContextClient();
+ const dispatch = createEventDispatcher<{ submit: TagInput }>();
+
+ export let tag: OmitIdentifiers<FullTag>;
+
+ $: namespaceQuery = namespaceList(client);
+ $: namespaces = $namespaceQuery.data?.namespaces.edges;
+
+ function submit() {
+ dispatch('submit', {
+ name: tag.name,
+ description: tag.description,
+ namespaces: { ids: tag.namespaces.map((n) => n.id) }
+ });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={tag.name} />
+ </Labelled>
+ <Labelled label="Description" let:id>
+ <textarea rows={3} {id} bind:value={tag.description} />
+ </Labelled>
+ <Labelled label="Namespaces" let:id>
+ <Select multi object {id} options={namespaces} bind:value={tag.namespaces} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/forms/WorldForm.svelte b/frontend/src/lib/forms/WorldForm.svelte
new file mode 100644
index 0000000..103dd5b
--- /dev/null
+++ b/frontend/src/lib/forms/WorldForm.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import { type WorldInput } from '$gql/Mutations';
+ import { type OmitIdentifiers } from '$gql/Utils';
+ import { type World } from '$gql/graphql';
+ import Labelled from '$lib/components/Labelled.svelte';
+ import { createEventDispatcher } from 'svelte';
+
+ const dispatch = createEventDispatcher<{ submit: WorldInput }>();
+
+ export let world: OmitIdentifiers<World>;
+
+ function submit() {
+ dispatch('submit', { name: world.name });
+ }
+</script>
+
+<form on:submit|preventDefault={submit}>
+ <div class="grid-labels">
+ <Labelled label="Name" let:id>
+ <!-- svelte-ignore a11y-autofocus -->
+ <input autofocus required {id} bind:value={world.name} />
+ </Labelled>
+ </div>
+ <slot />
+</form>
diff --git a/frontend/src/lib/gallery/Gallery.svelte b/frontend/src/lib/gallery/Gallery.svelte
new file mode 100644
index 0000000..c3b6386
--- /dev/null
+++ b/frontend/src/lib/gallery/Gallery.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import type { PageFragment } from '$gql/graphql';
+ import GalleryPage from './GalleryPage.svelte';
+
+ export let pages: PageFragment[];
+</script>
+
+<div class="max-h-full gap-2 overflow-auto p-1 pr-3">
+ {#each pages as page, index}
+ <GalleryPage {page} {index} on:open on:cover />
+ {/each}
+</div>
+
+<style>
+ :root {
+ --gallery-image-size: 100px;
+ }
+
+ @media (min-width: 1280px) {
+ :root {
+ --gallery-image-size: 180px;
+ }
+ }
+
+ @media (min-width: 1600px) {
+ :root {
+ --gallery-image-size: 200px;
+ }
+ }
+
+ @media (min-width: 1920px) {
+ :root {
+ --gallery-image-size: 240px;
+ }
+ }
+
+ div {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(var(--gallery-image-size), 1fr));
+ grid-auto-rows: fit-content(400px);
+ }
+</style>
diff --git a/frontend/src/lib/gallery/GalleryPage.svelte b/frontend/src/lib/gallery/GalleryPage.svelte
new file mode 100644
index 0000000..449321c
--- /dev/null
+++ b/frontend/src/lib/gallery/GalleryPage.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+ import type { PageFragment } from '$gql/graphql';
+ import { getSelectionContext } from '$lib/Selection';
+ import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte';
+ import { src } from '$lib/Utils';
+ import { createEventDispatcher } from 'svelte';
+
+ export let page: PageFragment;
+ export let index: number;
+
+ const selection = getSelectionContext<PageFragment>();
+
+ let span: 'single' | 'double' | 'triple';
+
+ $: page.image.aspectRatio, updateSpan();
+
+ function updateSpan() {
+ const aspectRatio = page.image.aspectRatio;
+
+ if (aspectRatio <= 1) {
+ span = 'single';
+ } else if (aspectRatio > 1 && aspectRatio <= 2) {
+ span = 'double';
+ } else if (aspectRatio > 2) {
+ span = 'triple';
+ }
+ }
+
+ const dispatch = createEventDispatcher<{ open: number; cover: number }>();
+
+ function press(event: MouseEvent | KeyboardEvent) {
+ if (event instanceof KeyboardEvent && event.key !== 'Enter') {
+ return;
+ }
+
+ if ($selection.active) {
+ if (event.ctrlKey) {
+ dispatch('open', index);
+ } else if (selectable) {
+ $selection = $selection.update(index, event.shiftKey);
+ }
+ } else if (event.ctrlKey) {
+ dispatch('cover', page.id);
+ } else {
+ dispatch('open', index);
+ }
+
+ event.preventDefault();
+ }
+
+ $: selectable = $selection.selectable(page);
+ $: dim = $selection.active && !selectable;
+ $: selected = $selection.contains(page.id);
+</script>
+
+<div
+ class:dim
+ role="button"
+ tabindex="0"
+ class="{span} relative overflow-hidden rounded"
+ on:click={press}
+ on:keydown={press}
+>
+ <SelectionOverlay position="top" {selected} />
+ <img
+ class="h-full w-full object-cover object-[center_top] transition-opacity"
+ loading="lazy"
+ alt=""
+ width={page.image.width}
+ height={page.image.height}
+ src={src(page.image)}
+ title={`${page.path} (${page.image.width} x ${page.image.height})`}
+ />
+</div>
+
+<style>
+ .dim {
+ cursor: not-allowed;
+ }
+
+ .dim > img {
+ opacity: 0.2;
+ filter: grayscale(1);
+ }
+
+ .double {
+ grid-column: span 2;
+ }
+
+ .triple {
+ grid-column: span 3;
+ }
+</style>
diff --git a/frontend/src/lib/icons/Bookmark.svelte b/frontend/src/lib/icons/Bookmark.svelte
new file mode 100644
index 0000000..6f8e192
--- /dev/null
+++ b/frontend/src/lib/icons/Bookmark.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ export let bookmarked: boolean | undefined = undefined;
+ export let hoverable = false;
+</script>
+
+{#if bookmarked}
+ <span class:hoverable class="icon-gray icon-base icon-[material-symbols--bookmark]" />
+{:else}
+ <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--bookmark-outline]" />
+{/if}
diff --git a/frontend/src/lib/icons/Female.svelte b/frontend/src/lib/icons/Female.svelte
new file mode 100644
index 0000000..c772a6a
--- /dev/null
+++ b/frontend/src/lib/icons/Female.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--female] -mx-[3px]" />
diff --git a/frontend/src/lib/icons/Location.svelte b/frontend/src/lib/icons/Location.svelte
new file mode 100644
index 0000000..e345f83
--- /dev/null
+++ b/frontend/src/lib/icons/Location.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--location-on-outline]" />
diff --git a/frontend/src/lib/icons/Male.svelte b/frontend/src/lib/icons/Male.svelte
new file mode 100644
index 0000000..e3578b7
--- /dev/null
+++ b/frontend/src/lib/icons/Male.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--male] -mx-px" />
diff --git a/frontend/src/lib/icons/Organized.svelte b/frontend/src/lib/icons/Organized.svelte
new file mode 100644
index 0000000..66b5b00
--- /dev/null
+++ b/frontend/src/lib/icons/Organized.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ export let organized: boolean | undefined = undefined;
+ export let hoverable = false;
+ export let tristate = false;
+ export let dim = false;
+</script>
+
+{#if organized}
+ <span class:hoverable class="icon-gray icon-base icon-[material-symbols--check-circle]" />
+{:else if organized === undefined || !tristate}
+ <span
+ class:hoverable
+ class="icon-gray dim icon-base icon-[material-symbols--check-circle-outline]"
+ />
+{:else}
+ <span
+ class:hoverable
+ class:dim
+ class="icon-gray icon-base icon-[material-symbols--unpublished]"
+ />
+{/if}
diff --git a/frontend/src/lib/icons/Star.svelte b/frontend/src/lib/icons/Star.svelte
new file mode 100644
index 0000000..7613c55
--- /dev/null
+++ b/frontend/src/lib/icons/Star.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ export let large = false;
+ export let favourite: boolean | undefined = undefined;
+ export let hoverable = false;
+</script>
+
+{#if favourite}
+ <span class:hoverable class:large class="icon-yellow icon-[material-symbols--star-rounded]" />
+{:else}
+ <span
+ class:hoverable
+ class:large
+ class="icon-yellow dim icon-[material-symbols--star-outline-rounded]"
+ />
+{/if}
+
+<style lang="postcss">
+ span {
+ @apply -m-px -translate-y-px text-[26px];
+ }
+
+ span.large {
+ @apply text-[34px];
+ }
+</style>
diff --git a/frontend/src/lib/icons/Transgender.svelte b/frontend/src/lib/icons/Transgender.svelte
new file mode 100644
index 0000000..7d9adc6
--- /dev/null
+++ b/frontend/src/lib/icons/Transgender.svelte
@@ -0,0 +1 @@
+<span class="icon-xs icon-[material-symbols--transgender]" />
diff --git a/frontend/src/lib/navigation/Link.svelte b/frontend/src/lib/navigation/Link.svelte
new file mode 100644
index 0000000..7297a69
--- /dev/null
+++ b/frontend/src/lib/navigation/Link.svelte
@@ -0,0 +1,20 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { accelerator, type Shortcut } from '$lib/Shortcuts';
+ import type { HTMLAttributeAnchorTarget } from 'svelte/elements';
+
+ export let href: string;
+ export let title: string;
+ export let accel: Shortcut;
+ export let matchExact = false;
+ export let target: HTMLAttributeAnchorTarget | undefined = undefined;
+ $: active = matchExact ? $page.url.pathname === href : $page.url.pathname.startsWith(href);
+</script>
+
+<li class:active class="items-center hover:bg-indigo-700 [&.active]:bg-indigo-700">
+ <a class="flex items-center" {target} {title} {href} use:accelerator={accel}>
+ <div class="flex p-3">
+ <slot />
+ </div>
+ </a>
+</li>
diff --git a/frontend/src/lib/navigation/Navigation.svelte b/frontend/src/lib/navigation/Navigation.svelte
new file mode 100644
index 0000000..76096c8
--- /dev/null
+++ b/frontend/src/lib/navigation/Navigation.svelte
@@ -0,0 +1,5 @@
+<nav>
+ <ul class="flex h-full flex-col bg-slate-700/70 font-medium">
+ <slot />
+ </ul>
+</nav>
diff --git a/frontend/src/lib/pagination/Pagination.svelte b/frontend/src/lib/pagination/Pagination.svelte
new file mode 100644
index 0000000..51612f4
--- /dev/null
+++ b/frontend/src/lib/pagination/Pagination.svelte
@@ -0,0 +1,45 @@
+<script lang="ts">
+ import { getPaginationContext } from '$lib/Pagination';
+ import Target from './Target.svelte';
+
+ const pagination = getPaginationContext();
+ export let context = 2;
+
+ $: totalPages = Math.ceil($pagination.total / $pagination.items);
+ $: rightBoundary = $pagination.page - context;
+ $: leftBoundary = $pagination.page + context;
+
+ $: shiftRight = leftBoundary - totalPages;
+ $: shiftLeft = 1 - rightBoundary;
+
+ $: containedLeft = leftBoundary <= totalPages;
+ $: containedRight = rightBoundary > 0;
+
+ $: start = Math.max(1, containedLeft ? rightBoundary : rightBoundary - shiftRight);
+ $: end = Math.min(totalPages, containedRight ? leftBoundary : leftBoundary + shiftLeft);
+
+ $: leftmost = $pagination.page <= 1;
+ $: rightmost = $pagination.page >= totalPages;
+</script>
+
+{#if totalPages > 1}
+ <div class="flex justify-center gap-2">
+ <Target disabled={leftmost} page={1}>
+ <span class="icon-base icon-[material-symbols--keyboard-double-arrow-left]" />
+ </Target>
+ <Target disabled={leftmost} page={$pagination.page - 1}>
+ <span class="icon-base icon-[material-symbols--keyboard-arrow-left]" />
+ </Target>
+ {#each Array.from({ length: end + 1 - start }, (_, i) => i + start) as page}
+ <Target active={$pagination.page === page} {page}>
+ <p>{page.toString()}</p>
+ </Target>
+ {/each}
+ <Target disabled={rightmost} page={$pagination.page + 1}>
+ <span class="icon-base icon-[material-symbols--keyboard-arrow-right]" />
+ </Target>
+ <Target disabled={rightmost} page={totalPages}>
+ <span class="icon-base icon-[material-symbols--keyboard-double-arrow-right]" />
+ </Target>
+ </div>
+{/if}
diff --git a/frontend/src/lib/pagination/Target.svelte b/frontend/src/lib/pagination/Target.svelte
new file mode 100644
index 0000000..9044bb9
--- /dev/null
+++ b/frontend/src/lib/pagination/Target.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { page as pageStore } from '$app/stores';
+ import { navigate } from '$lib/Navigation';
+
+ export let active = false;
+
+ export let disabled = false;
+ export let page: number;
+</script>
+
+<button
+ on:click={() => {
+ navigate({ pagination: { page: page } }, $pageStore.url.searchParams);
+ }}
+ class:bg-slate-700={active}
+ class:bg-slate-800={!active}
+ class="flex h-8 w-8 items-center justify-center rounded-sm p-0 text-base hover:text-white disabled:text-slate-600"
+ {disabled}
+>
+ <slot />
+</button>
diff --git a/frontend/src/lib/pills/AssociationPill.svelte b/frontend/src/lib/pills/AssociationPill.svelte
new file mode 100644
index 0000000..85dbe39
--- /dev/null
+++ b/frontend/src/lib/pills/AssociationPill.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import Pill from './Pill.svelte';
+
+ type Association = 'artist' | 'circle' | 'world' | 'character';
+
+ export let name: string;
+ export let type: Association;
+</script>
+
+<Pill {name}>
+ <span class={`${type} icon-xs`} slot="icon" />
+</Pill>
+
+<style lang="postcss">
+ .artist {
+ @apply icon-[material-symbols--person] -mx-px;
+ }
+
+ .character {
+ @apply icon-[material-symbols--face];
+ }
+
+ .circle {
+ @apply icon-[material-symbols--group] mx-px;
+ }
+
+ .world {
+ @apply icon-[material-symbols--public];
+ }
+</style>
diff --git a/frontend/src/lib/pills/ComicPills.svelte b/frontend/src/lib/pills/ComicPills.svelte
new file mode 100644
index 0000000..671bbf2
--- /dev/null
+++ b/frontend/src/lib/pills/ComicPills.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { ComicFragment } from '$gql/graphql';
+ import AssociationPill from '$lib/pills/AssociationPill.svelte';
+ import TagPill from '$lib/pills/TagPill.svelte';
+
+ export let comic: ComicFragment;
+</script>
+
+<div class="flex flex-col gap-1">
+ {#if comic.artists.length || comic.circles.length}
+ <div class="flex flex-wrap gap-1">
+ {#each comic.artists as { name } (name)}
+ <AssociationPill {name} type="artist" />
+ {/each}
+ {#each comic.circles as { name } (name)}
+ <AssociationPill {name} type="circle" />
+ {/each}
+ </div>
+ {/if}
+ {#if comic.characters.length || comic.worlds.length}
+ <div class="flex flex-wrap gap-1">
+ {#each comic.worlds as { name } (name)}
+ <AssociationPill {name} type="world" />
+ {/each}
+ {#each comic.characters as { name } (name)}
+ <AssociationPill {name} type="character" />
+ {/each}
+ </div>
+ {/if}
+ {#if comic.tags.length}
+ <div class="flex flex-wrap gap-1">
+ {#each comic.tags as { name, description } (name)}
+ <TagPill {name} {description} />
+ {/each}
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/lib/pills/Pill.svelte b/frontend/src/lib/pills/Pill.svelte
new file mode 100644
index 0000000..7aa9670
--- /dev/null
+++ b/frontend/src/lib/pills/Pill.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+ export type PillColour = 'pink' | 'blue' | 'violet' | 'amber' | 'zinc' | 'sky';
+</script>
+
+<script lang="ts">
+ export let name: string;
+ export let tooltip: string | null | undefined = undefined;
+ export let colour: PillColour = 'zinc';
+</script>
+
+<div class="flex items-center rounded border p-0.5 {colour}" title={tooltip}>
+ <slot name="icon" />
+ <span>{name}</span>
+</div>
+
+<style lang="postcss">
+ .pink {
+ @apply border-pink-800 bg-pink-800/20 text-pink-200;
+ }
+
+ .blue {
+ @apply border-blue-800 bg-blue-800/20 text-blue-200;
+ }
+
+ .violet {
+ @apply border-violet-800 bg-violet-800/20 text-violet-200;
+ }
+
+ .amber {
+ @apply border-amber-800 bg-amber-800/20 text-amber-200;
+ }
+
+ .sky {
+ @apply border-sky-800 bg-sky-800/20 text-sky-200;
+ }
+
+ .zinc {
+ @apply border-zinc-700 bg-zinc-700/20 text-zinc-300;
+ }
+</style>
diff --git a/frontend/src/lib/pills/TagPill.svelte b/frontend/src/lib/pills/TagPill.svelte
new file mode 100644
index 0000000..60221bd
--- /dev/null
+++ b/frontend/src/lib/pills/TagPill.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import Female from '$lib/icons/Female.svelte';
+ import Location from '$lib/icons/Location.svelte';
+ import Male from '$lib/icons/Male.svelte';
+ import Transgender from '$lib/icons/Transgender.svelte';
+ import { SvelteComponent } from 'svelte';
+ import Pill, { type PillColour } from './Pill.svelte';
+
+ export let name: string;
+ export let description: string | undefined | null = undefined;
+
+ let [namespace, tag] = name.split(':');
+
+ const styles: Record<string, PillColour> = {
+ female: 'pink',
+ male: 'blue',
+ trans: 'violet',
+ mixed: 'amber',
+ location: 'sky',
+ rest: 'zinc'
+ };
+
+ const icons: Record<string, typeof SvelteComponent<Record<string, unknown>>> = {
+ female: Female,
+ male: Male,
+ trans: Transgender,
+ location: Location
+ };
+
+ const colour = styles[namespace] ?? styles.rest;
+ const icon = icons[namespace];
+
+ function formatTooltip() {
+ return [name, description].filter((v) => v).join('\n\n');
+ }
+</script>
+
+<Pill name={tag} tooltip={formatTooltip()} {colour}>
+ <svelte:component this={icon} slot="icon" />
+</Pill>
diff --git a/frontend/src/lib/reader/PageView.svelte b/frontend/src/lib/reader/PageView.svelte
new file mode 100644
index 0000000..cc4d10e
--- /dev/null
+++ b/frontend/src/lib/reader/PageView.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import { Direction, Layout, type PageFragment } from '$gql/graphql';
+ import { getReaderContext, partition, type Chunk } from '$lib/Reader';
+ import { binds } from '$lib/Shortcuts';
+ import ReaderPage from './ReaderPage.svelte';
+
+ const reader = getReaderContext();
+
+ export let direction: Direction;
+ export let layout: Layout;
+
+ let chunks: Chunk[] = [];
+ let lookup: number[] = [];
+
+ let main: PageFragment;
+ let secondary: PageFragment | undefined;
+
+ function gotoChunk(to: number) {
+ if (to < 0 || to >= chunks.length) return;
+
+ $reader.page = chunks[to].index;
+ }
+
+ const next = () => gotoChunk(lookup[$reader.page] + 1);
+ const prev = () => gotoChunk(lookup[$reader.page] - 1);
+
+ const clickLeft = () => (direction === Direction.LeftToRight ? prev() : next());
+ const clickRight = () => (direction === Direction.RightToLeft ? prev() : next());
+
+ function clickMain(event: MouseEvent & { currentTarget: EventTarget | null }) {
+ if (event.currentTarget instanceof Element) {
+ const rect = event.currentTarget.getBoundingClientRect();
+
+ if (event.clientX - rect.left < rect.width / 2) {
+ clickLeft();
+ } else {
+ clickRight();
+ }
+ }
+ }
+
+ $: [chunks, lookup] = partition($reader.pages, layout);
+ $: layout, ({ main, secondary } = chunks[lookup[$reader.page]]);
+</script>
+
+<svelte:document
+ use:binds={[
+ ['ArrowLeft', clickLeft],
+ ['ArrowRight', clickRight],
+ ['ArrowUp', prev],
+ ['ArrowDown', next],
+ ['PageUp', prev],
+ ['PageDown', next],
+ [' ', next],
+ ['Backspace', prev]
+ ]}
+/>
+
+{#if !secondary}
+ <ReaderPage page={main} on:click={clickMain} --justify="center" />
+{:else if direction === Direction.LeftToRight}
+ <ReaderPage page={main} on:click={prev} --justify="flex-end" />
+ <ReaderPage page={secondary} on:click={next} --justify="flex-start" />
+{:else}
+ <ReaderPage page={secondary} on:click={next} --justify="flex-end" />
+ <ReaderPage page={main} on:click={prev} --justify="flex-start" />
+{/if}
diff --git a/frontend/src/lib/reader/Reader.svelte b/frontend/src/lib/reader/Reader.svelte
new file mode 100644
index 0000000..0b1450a
--- /dev/null
+++ b/frontend/src/lib/reader/Reader.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ import { trapFocus } from '$lib/Actions';
+ import { getReaderContext } from '$lib/Reader';
+ import { fadeDefault, slideXDefault } from '$lib/Transitions';
+ import { fade, slide } from 'svelte/transition';
+ import CloseReaderButton from './components/CloseReaderButton.svelte';
+ import ReaderMenuButton from './components/ReaderMenuButton.svelte';
+
+ const reader = getReaderContext();
+</script>
+
+{#if $reader.visible}
+ <div
+ role="dialog"
+ class="fixed bottom-0 left-0 right-0 top-0 z-10 flex h-full w-full bg-black"
+ transition:fade={fadeDefault}
+ use:trapFocus
+ >
+ {#if $$slots.sidebar && $reader.sidebar}
+ <aside class="w-[36rem] shrink-0 bg-slate-800" transition:slide={slideXDefault}>
+ <div class="flex h-full min-w-[36rem] flex-col gap-4 overflow-auto p-4">
+ <slot name="sidebar" />
+ </div>
+ </aside>
+ {/if}
+ <main class="relative flex grow">
+ <div class="absolute flex w-full p-1 text-lg [&>*:last-child]:ml-auto">
+ {#if $$slots.sidebar}
+ <ReaderMenuButton />
+ {/if}
+ <CloseReaderButton />
+ </div>
+
+ <div class="flex grow">
+ <slot />
+ </div>
+ </main>
+ </div>
+{/if}
diff --git a/frontend/src/lib/reader/ReaderPage.svelte b/frontend/src/lib/reader/ReaderPage.svelte
new file mode 100644
index 0000000..fb3e780
--- /dev/null
+++ b/frontend/src/lib/reader/ReaderPage.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import type { PageFragment } from '$gql/graphql';
+ import { src } from '$lib/Utils';
+
+ export let page: PageFragment;
+</script>
+
+<!-- svelte-ignore a11y-click-events-have-key-events -->
+<!-- svelte-ignore a11y-no-static-element-interactions -->
+<div class="flex grow" on:click>
+ <img
+ class="h-auto w-auto object-contain"
+ width={page.image.width}
+ height={page.image.height}
+ src={src(page.image, 'full')}
+ alt={page.path}
+ />
+</div>
+
+<style>
+ div {
+ justify-content: var(--justify);
+ }
+</style>
diff --git a/frontend/src/lib/reader/components/CloseReaderButton.svelte b/frontend/src/lib/reader/components/CloseReaderButton.svelte
new file mode 100644
index 0000000..0c88323
--- /dev/null
+++ b/frontend/src/lib/reader/components/CloseReaderButton.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { getReaderContext } from '$lib/Reader';
+ import { accelerator } from '$lib/Shortcuts';
+
+ const reader = getReaderContext();
+</script>
+
+<button
+ type="button"
+ class="btn floating"
+ title="Close reader"
+ on:click={() => {
+ $reader.visible = false;
+ $reader.sidebar = false;
+ }}
+ use:accelerator={'Escape'}
+>
+ <span class="icon-lg icon-[material-symbols--close]" />
+</button>
diff --git a/frontend/src/lib/reader/components/ReaderMenuButton.svelte b/frontend/src/lib/reader/components/ReaderMenuButton.svelte
new file mode 100644
index 0000000..aa20206
--- /dev/null
+++ b/frontend/src/lib/reader/components/ReaderMenuButton.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import { getReaderContext } from '$lib/Reader';
+ import { accelerator } from '$lib/Shortcuts';
+
+ const reader = getReaderContext();
+</script>
+
+<button
+ type="button"
+ class="btn floating invisible xl:visible"
+ title={`${$reader.sidebar ? 'Hide' : 'Show'} menu`}
+ on:click={() => ($reader.sidebar = !$reader.sidebar)}
+ use:accelerator={'z'}
+>
+ <span class="icon-lg icon-[material-symbols--dock-to-right]" />
+</button>
diff --git a/frontend/src/lib/scraper/ComicScrapeForm.svelte b/frontend/src/lib/scraper/ComicScrapeForm.svelte
new file mode 100644
index 0000000..30ad89b
--- /dev/null
+++ b/frontend/src/lib/scraper/ComicScrapeForm.svelte
@@ -0,0 +1,138 @@
+<script lang="ts">
+ import { upsertComics } from '$gql/Mutations';
+ import { comicScrapersQuery, scrapeComic } from '$gql/Queries';
+ import { isError } from '$gql/Utils';
+ import { OnMissing, type FullComicFragment } from '$gql/graphql';
+ import { ScrapedComicSelector, getScraperContext } from '$lib/Scraper';
+ import { toastError, toastFinally } from '$lib/Toasts';
+ import Select from '$lib/components/Select.svelte';
+ import Spinner from '$lib/components/Spinner.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import SelectorGroup from './components/SelectorGroup.svelte';
+ import SelectorItem from './components/SelectorItem.svelte';
+
+ let client = getContextClient();
+ const context = getScraperContext();
+
+ export let comic: FullComicFragment;
+ let createMissing = false;
+ let loading = false;
+
+ $: scrapersResult = comicScrapersQuery(client, { id: comic.id });
+ $: scrapers = $scrapersResult.data?.comicScrapers;
+
+ function scrape() {
+ loading = true;
+ scrapeComic(client, { id: comic.id, scraper: $context.scraper })
+ .then((result) => {
+ if (result.error) {
+ toastError(result.error.message);
+ return;
+ }
+
+ if (result.data) {
+ if (isError(result.data.scrapeComic)) {
+ toastError(result.data.scrapeComic.message);
+ return;
+ }
+
+ if (result.data.scrapeComic.__typename === 'ScrapeComicResult') {
+ $context.selector = new ScrapedComicSelector(result.data.scrapeComic.data, comic);
+ $context.warnings = result.data.scrapeComic.warnings;
+ }
+ }
+ })
+ .catch(toastFinally)
+ .finally(() => (loading = false));
+ }
+
+ function updateFromScrape(createMissing: boolean) {
+ if (!$context.selector) return;
+
+ upsertComics(client, {
+ ids: comic.id,
+ input: $context.selector.toInput(createMissing ? OnMissing.Create : OnMissing.Ignore)
+ })
+ .then(() => {
+ $context.selector = undefined;
+ $context.warnings = [];
+ })
+ .catch(toastFinally);
+ }
+</script>
+
+<div class="flex flex-col gap-4 text-sm">
+ {#if scrapers && scrapers.length === 0}
+ <h2 class="text-base">No scrapers available.</h2>
+ {:else}
+ <form on:submit|preventDefault={scrape}>
+ <div class="grid grid-cols-6 gap-2">
+ <div class="col-span-5">
+ <Select
+ id="scrapers"
+ options={scrapers}
+ placeholder={'Select scraper...'}
+ bind:value={$context.scraper}
+ />
+ </div>
+ <button type="submit" disabled={!$context.scraper} class="btn-blue">Scrape</button>
+ </div>
+ </form>
+ {/if}
+
+ {#if loading}
+ <Spinner />
+ {:else if $context.selector}
+ {#if $context.warnings.length > 0}
+ <div class="flex flex-col gap-2">
+ <h2 class="flex gap-1 border-b border-slate-700 text-base font-medium">Warnings</h2>
+ <ul class="ml-2 list-inside list-disc">
+ {#each $context.warnings as warning}
+ <li>{warning}</li>
+ {/each}
+ </ul>
+ </div>
+ {/if}
+ {#if !$context.selector.hasData()}
+ <h2 class="text-base">No data to merge.</h2>
+ {:else}
+ <div class="flex flex-col gap-2">
+ <h2 class="border-b border-slate-700 text-base font-medium">Results</h2>
+ <form on:submit|preventDefault={() => updateFromScrape(createMissing)}>
+ <div class="grid grid-cols-6 gap-4 pb-2">
+ <SelectorItem title="Title" selector={$context.selector.title} />
+ <SelectorItem title="Original Title" selector={$context.selector.originalTitle} />
+ <SelectorItem title="URL" selector={$context.selector.url} />
+ <SelectorItem title="Date" selector={$context.selector.date} --span="2" />
+ <SelectorItem title="Category" selector={$context.selector.category} --span="2" />
+ <SelectorItem title="Language" selector={$context.selector.language} --span="2" />
+ <SelectorItem title="Rating" selector={$context.selector.rating} --span="2" />
+ <SelectorItem title="Censorship" selector={$context.selector.censorship} --span="2" />
+ <SelectorItem title="Direction" selector={$context.selector.direction} --span="2" />
+ <SelectorItem title="Layout" selector={$context.selector.layout} --span="2" />
+ <SelectorGroup title="Artists" selectors={$context.selector.artists} />
+ <SelectorGroup title="Circles" selectors={$context.selector.circles} />
+ <SelectorGroup title="Characters" selectors={$context.selector.characters} />
+ <SelectorGroup title="Worlds" selectors={$context.selector.worlds} />
+ <SelectorGroup title="Tags" selectors={$context.selector.tags} />
+ </div>
+ <div class="flex flex-col gap-2">
+ <h2 class="border-b border-slate-700 text-base font-medium">Options</h2>
+ <div class="flex items-center gap-1">
+ <input
+ class="h-4 w-4"
+ type="checkbox"
+ id="create-missing"
+ bind:checked={createMissing}
+ />
+ <label class="shrink-0" for="create-missing">Create missing items</label>
+ </div>
+ </div>
+ <div class="flex gap-4">
+ <button type="submit" class="btn-blue">Merge</button>
+ </div>
+ </form>
+ </div>
+ {/if}
+ {/if}
+</div>
diff --git a/frontend/src/lib/scraper/components/SelectorButton.svelte b/frontend/src/lib/scraper/components/SelectorButton.svelte
new file mode 100644
index 0000000..b786f89
--- /dev/null
+++ b/frontend/src/lib/scraper/components/SelectorButton.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import { Selector } from '$lib/Scraper';
+
+ export let selector: Selector<string>;
+</script>
+
+<button
+ type="button"
+ class="ml-1 flex rounded-sm border-slate-700 bg-slate-900 hover:brightness-110"
+ on:click={() => (selector.keep = !selector.keep)}
+>
+ <div class="flex self-center pl-1">
+ {#if selector.keep}
+ <span class="icon-base icon-[material-symbols--check] text-green-400" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--close] text-red-400" />
+ {/if}
+ </div>
+ <p class:opacity-50={!selector.keep} class="p-1 text-left">
+ {selector}
+ </p>
+</button>
diff --git a/frontend/src/lib/scraper/components/SelectorGroup.svelte b/frontend/src/lib/scraper/components/SelectorGroup.svelte
new file mode 100644
index 0000000..ae7287a
--- /dev/null
+++ b/frontend/src/lib/scraper/components/SelectorGroup.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import { Selector } from '$lib/Scraper';
+ import SelectorButton from './SelectorButton.svelte';
+
+ export let title: string;
+ export let selectors: Selector<string>[];
+
+ function invert() {
+ for (let selector of selectors) {
+ selector.keep = !selector.keep;
+ }
+ selectors = selectors;
+ }
+</script>
+
+{#if selectors.length > 0}
+ <div class="group col-span-6 flex flex-col gap-1">
+ <div class="flex gap-2">
+ <h2>{title}</h2>
+ <button
+ type="button"
+ class="flex items-end opacity-0 brightness-75 transition-opacity hover:brightness-110 group-hover:opacity-100"
+ on:click={invert}
+ title="Invert selection"
+ >
+ <span class="icon-xs icon-[material-symbols--compare-arrows]"></span>
+ </button>
+ </div>
+ <div class="flex flex-wrap gap-y-1">
+ {#each selectors as selector}
+ <SelectorButton {selector} />
+ {/each}
+ </div>
+ </div>
+{/if}
diff --git a/frontend/src/lib/scraper/components/SelectorItem.svelte b/frontend/src/lib/scraper/components/SelectorItem.svelte
new file mode 100644
index 0000000..dd3f5b4
--- /dev/null
+++ b/frontend/src/lib/scraper/components/SelectorItem.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { Selector } from '$lib/Scraper';
+ import SelectorButton from './SelectorButton.svelte';
+
+ export let title: string;
+ export let selector: Selector<string> | undefined;
+</script>
+
+{#if selector}
+ <div class="flex flex-col gap-1">
+ <h2>{title}</h2>
+ <SelectorButton {selector} />
+ </div>
+{/if}
+
+<style>
+ :root {
+ --span: 6;
+ }
+
+ div {
+ grid-column: span var(--span) / span var(--span);
+ }
+</style>
diff --git a/frontend/src/lib/selection/Selectable.svelte b/frontend/src/lib/selection/Selectable.svelte
new file mode 100644
index 0000000..48b6ac7
--- /dev/null
+++ b/frontend/src/lib/selection/Selectable.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+
+ export let id: number;
+ export let index: number;
+
+ export let edit: ((id: number) => void) | undefined = undefined;
+
+ const selection = getSelectionContext();
+
+ $: selected = $selection.contains(id);
+
+ const handle = (event: MouseEvent) => {
+ if ($selection.active) {
+ $selection = $selection.update(index, event.shiftKey);
+ event.preventDefault();
+ } else if (edit) {
+ edit(id);
+ event.preventDefault();
+ }
+ };
+</script>
+
+<slot {handle} {selected} />
diff --git a/frontend/src/lib/selection/SelectionOverlay.svelte b/frontend/src/lib/selection/SelectionOverlay.svelte
new file mode 100644
index 0000000..04ff382
--- /dev/null
+++ b/frontend/src/lib/selection/SelectionOverlay.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ export let selected: boolean;
+ export let position: 'top' | 'right' | 'left' | 'bottom';
+ export let centered = false;
+</script>
+
+{#if selected}
+ <div
+ class:items-center={centered}
+ class="{position} pointer-events-none absolute z-[1] flex bg-emerald-700/95"
+ >
+ <span class="icon-base icon-[material-symbols--check] text-[2rem]" />
+ </div>
+{/if}
+
+<style lang="postcss">
+ .top,
+ .bottom {
+ width: 100%;
+ }
+
+ .left,
+ .right {
+ height: 100%;
+ }
+
+ .bottom {
+ bottom: 0;
+ }
+
+ .right {
+ right: 0;
+ }
+</style>
diff --git a/frontend/src/lib/tabs/AddOverlay.svelte b/frontend/src/lib/tabs/AddOverlay.svelte
new file mode 100644
index 0000000..b1c98bf
--- /dev/null
+++ b/frontend/src/lib/tabs/AddOverlay.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import { updateComics } from '$gql/Mutations';
+ import { UpdateMode } from '$gql/graphql';
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import { fadeDefault } from '$lib/Transitions';
+ import { getContextClient } from '@urql/svelte';
+ import { fade } from 'svelte/transition';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let id: number;
+
+ function addPages() {
+ updateComics(client, {
+ ids: id,
+ input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Add } } }
+ })
+ .then(() => ($selection = $selection.none()))
+ .catch(toastFinally);
+ }
+</script>
+
+{#if $selection.size > 0}
+ <div class="absolute left-1 top-1" transition:fade={fadeDefault}>
+ <button
+ type="button"
+ class="btn-blue rounded-full shadow-sm shadow-black"
+ title="Add to this comic"
+ on:click|preventDefault={addPages}
+ >
+ <span class="icon-base icon-[material-symbols--note-add]" />
+ </button>
+ </div>
+{/if}
diff --git a/frontend/src/lib/tabs/ArchiveDelete.svelte b/frontend/src/lib/tabs/ArchiveDelete.svelte
new file mode 100644
index 0000000..b0e3c58
--- /dev/null
+++ b/frontend/src/lib/tabs/ArchiveDelete.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { deleteArchives } from '$gql/Mutations';
+ import type { FullArchiveFragment } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+
+ export let archive: FullArchiveFragment;
+
+ function deleteArchive() {
+ confirmDeletion('Archive', archive.name, () => {
+ deleteArchives(client, { ids: archive.id })
+ .then(() => goto('/archives/'))
+ .catch(toastFinally);
+ });
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <div>
+ <p>
+ Deleting this archive will remove the
+ <span class="cursor-help font-medium underline" title={archive.path}>archive file</span> on disk.
+ </p>
+ {#if archive.comics.length > 0}
+ <p>The following comics will also be deleted:</p>
+ <ul class="ml-8 list-disc">
+ {#each archive.comics as comic}
+ <li><a href="/comics/{comic.id}" class="underline">{comic.title}</a></li>
+ {/each}
+ </ul>
+ {/if}
+ <p class="mt-2 font-medium">This action is irrevocable.</p>
+ </div>
+ <div class="flex">
+ <DeleteButton prominent on:click={deleteArchive} />
+ </div>
+</div>
diff --git a/frontend/src/lib/tabs/ArchiveDetails.svelte b/frontend/src/lib/tabs/ArchiveDetails.svelte
new file mode 100644
index 0000000..9554557
--- /dev/null
+++ b/frontend/src/lib/tabs/ArchiveDetails.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import type { FullArchiveFragment } from '$gql/graphql';
+ import { formatListSize, joinText } from '$lib/Utils';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import ComicPills from '$lib/pills/ComicPills.svelte';
+ import { formatDistance, formatISO9075 } from 'date-fns';
+ import { filesize } from 'filesize';
+ import Header from './DetailsHeader.svelte';
+ import Section from './DetailsSection.svelte';
+
+ export let archive: FullArchiveFragment;
+
+ const now = Date.now();
+ const modifiedDate = new Date(archive.mtime);
+ const createdDate = new Date(archive.createdAt);
+
+ const title = joinText(['Archive', formatListSize('image', archive.pageCount)]);
+</script>
+
+<div class="flex flex-col gap-4 text-sm">
+ <Header {title} />
+ <div class="grid grid-cols-3 gap-4">
+ <Section title="Size">
+ <span>{filesize(archive.size, { base: 2 })}</span>
+ </Section>
+ <Section title="Created">
+ <span title={formatISO9075(createdDate)}>
+ {formatDistance(createdDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ <Section title="File last modified">
+ <span title={formatISO9075(modifiedDate)}>
+ {formatDistance(modifiedDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ </div>
+
+ {#if archive.comics.length > 0}
+ <div class="flex flex-col gap-1">
+ <h2 class="text-base font-medium">Comics</h2>
+ <div class="flex shrink-0 flex-col gap-4">
+ {#each archive.comics as comic}
+ <Card compact {...comicCard(comic)}>
+ <ComicPills {comic} />
+ </Card>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/lib/tabs/ArchiveEdit.svelte b/frontend/src/lib/tabs/ArchiveEdit.svelte
new file mode 100644
index 0000000..80efaed
--- /dev/null
+++ b/frontend/src/lib/tabs/ArchiveEdit.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import { addComic, updateArchives } from '$gql/Mutations';
+ import { type FullArchiveFragment } from '$gql/graphql';
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import AddButton from '$lib/components/AddButton.svelte';
+ import Card, { comicCard } from '$lib/components/Card.svelte';
+ import OrganizedButton from '$lib/components/OrganizedButton.svelte';
+ import ComicPills from '$lib/pills/ComicPills.svelte';
+ import SelectionControls from '$lib/toolbar/SelectionControls.svelte';
+ import { getContextClient } from '@urql/svelte';
+ import AddOverlay from './AddOverlay.svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let archive: FullArchiveFragment;
+
+ function addNew() {
+ addComic(client, {
+ input: {
+ archive: { id: archive.id },
+ title: archive.name,
+ pages: { ids: $selection.ids },
+ cover: { id: archive.pages[$selection.indices.toSorted((a, b) => a - b)[0]].id }
+ }
+ })
+ .then((mutatation) => {
+ const data = mutatation.addComic;
+ if (data.__typename === 'AddComicSuccess' && !data.archivePagesRemaining) {
+ $selection = $selection.clear();
+ } else {
+ $selection = $selection.none();
+ }
+ })
+ .catch(toastFinally);
+ }
+
+ function toggleOrganized() {
+ updateArchives(client, { ids: archive.id, input: { organized: !archive.organized } }).catch(
+ toastFinally
+ );
+ }
+</script>
+
+<div class="flex flex-col gap-4">
+ <div class="flex gap-2 text-sm">
+ <SelectionControls page>
+ <AddButton title="Add Comic from selected" on:click={addNew} />
+ </SelectionControls>
+ <div class="grow" />
+ <OrganizedButton organized={archive.organized} on:click={toggleOrganized} />
+ </div>
+
+ {#if archive.comics.length > 0}
+ <div class="flex flex-col gap-1">
+ <h2 class="text-base font-medium">Comics</h2>
+ <div class="flex shrink-0 flex-col gap-4">
+ {#each archive.comics as comic}
+ <Card compact {...comicCard(comic)}>
+ <AddOverlay slot="overlay" id={comic.id} />
+ <ComicPills {comic} />
+ </Card>
+ {/each}
+ </div>
+ </div>
+ {/if}
+</div>
diff --git a/frontend/src/lib/tabs/ComicDelete.svelte b/frontend/src/lib/tabs/ComicDelete.svelte
new file mode 100644
index 0000000..a10f6b2
--- /dev/null
+++ b/frontend/src/lib/tabs/ComicDelete.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import { goto } from '$app/navigation';
+ import { deleteComics } from '$gql/Mutations';
+ import type { FullComicFragment } from '$gql/graphql';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+
+ export let comic: FullComicFragment;
+
+ function deleteComic() {
+ confirmDeletion('Comic', comic.title, () => {
+ deleteComics(client, { ids: comic.id })
+ .then(() => goto('/comics/'))
+ .catch(toastFinally);
+ });
+ }
+</script>
+
+<div class="flex flex-col gap-2">
+ <div>
+ <p>
+ Deleting this comic will make all of its pages available again for allocation. All of its
+ metadata will be lost.
+ </p>
+ <p class="mt-2 font-medium">This action is irrevocable.</p>
+ </div>
+ <div class="flex">
+ <DeleteButton prominent on:click={deleteComic} />
+ </div>
+</div>
diff --git a/frontend/src/lib/tabs/ComicDetails.svelte b/frontend/src/lib/tabs/ComicDetails.svelte
new file mode 100644
index 0000000..0a131af
--- /dev/null
+++ b/frontend/src/lib/tabs/ComicDetails.svelte
@@ -0,0 +1,121 @@
+<script lang="ts">
+ import type { ComicFilter, FullComicFragment } from '$gql/graphql';
+ import { CategoryLabel, CensorshipLabel, LanguageLabel, RatingLabel } from '$lib/Enums';
+ import { href } from '$lib/Navigation';
+ import { formatListSize, joinText } from '$lib/Utils';
+ import AssociationPill from '$lib/pills/AssociationPill.svelte';
+ import TagPill from '$lib/pills/TagPill.svelte';
+ import { formatDistance, formatISO9075 } from 'date-fns';
+ import Header from './DetailsHeader.svelte';
+ import Section from './DetailsSection.svelte';
+
+ export let comic: FullComicFragment;
+
+ const now = Date.now();
+ const updatedDate = new Date(comic.updatedAt);
+ const createdDate = new Date(comic.createdAt);
+
+ const title = joinText([
+ comic.category ? CategoryLabel[comic.category] : '',
+ formatListSize('page', comic.pages.length)
+ ]);
+
+ function filterFor(filter: keyof ComicFilter, id: number | string) {
+ return href('comics', { filter: { include: { [filter]: { all: [id] } } } });
+ }
+</script>
+
+<div class="flex flex-col gap-4 text-sm">
+ <Header {title}>
+ {#if comic.url}
+ <a href={comic.url} target="_blank" rel="noreferrer" class="btn-slate" title="Open URL">
+ <span class="icon-base icon-[material-symbols--link]" />
+ </a>
+ {/if}
+ <a href={`/archives/${comic.archive.id}`} class="btn-slate" title="Go to Archive">
+ <span class="icon-base icon-[material-symbols--folder-zip]" />
+ </a>
+ </Header>
+
+ <div class="grid grid-cols-3 gap-4">
+ {#if comic.language}
+ <Section title="Language">
+ <span>{LanguageLabel[comic.language]}</span>
+ </Section>
+ {/if}
+ {#if comic.censorship}
+ <Section title="Censorship">
+ <span>{CensorshipLabel[comic.censorship]}</span>
+ </Section>
+ {/if}
+ {#if comic.rating}
+ <Section title="Rating">
+ <span>{RatingLabel[comic.rating]}</span>
+ </Section>
+ {/if}
+ </div>
+
+ <div class="grid grid-cols-3 gap-4">
+ {#if comic.date}
+ <Section title="Released">
+ <span>{formatISO9075(new Date(comic.date), { representation: 'date' })}</span>
+ </Section>
+ {/if}
+ <Section title="Created">
+ <span title={formatISO9075(createdDate)}>
+ {formatDistance(createdDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ <Section title="Updated">
+ <span title={formatISO9075(updatedDate)}>
+ {formatDistance(updatedDate, now, { addSuffix: true })}
+ </span>
+ </Section>
+ </div>
+
+ {#if comic.artists.length}
+ <Section title="Artists">
+ {#each comic.artists as { id, name } (id)}
+ <a href={filterFor('artists', id)}>
+ <AssociationPill {name} type="artist" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.circles.length}
+ <Section title="Circles">
+ {#each comic.circles as { id, name } (id)}
+ <a href={filterFor('circles', id)}>
+ <AssociationPill {name} type="circle" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.characters.length}
+ <Section title="Characters">
+ {#each comic.characters as { id, name } (id)}
+ <a href={filterFor('characters', id)}>
+ <AssociationPill {name} type="character" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.worlds.length}
+ <Section title="Worlds">
+ {#each comic.worlds as { id, name } (id)}
+ <a href={filterFor('worlds', id)}>
+ <AssociationPill {name} type="world" />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+ {#if comic.tags.length}
+ <Section title="Tags">
+ {#each comic.tags as { id, name, description } (id)}
+ <a href={filterFor('tags', id)}>
+ <TagPill {name} {description} />
+ </a>
+ {/each}
+ </Section>
+ {/if}
+</div>
diff --git a/frontend/src/lib/tabs/DetailsHeader.svelte b/frontend/src/lib/tabs/DetailsHeader.svelte
new file mode 100644
index 0000000..f980f75
--- /dev/null
+++ b/frontend/src/lib/tabs/DetailsHeader.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ export let title: string;
+</script>
+
+<div class="flex items-center gap-2">
+ <h2 class="flex text-base">
+ {title}
+ </h2>
+ <div class="grow"></div>
+ <slot />
+</div>
diff --git a/frontend/src/lib/tabs/DetailsSection.svelte b/frontend/src/lib/tabs/DetailsSection.svelte
new file mode 100644
index 0000000..9a6ad51
--- /dev/null
+++ b/frontend/src/lib/tabs/DetailsSection.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ export let title: string;
+</script>
+
+<section class="flex flex-col gap-1">
+ <h2 class="text-base font-medium">{title}</h2>
+ <div class="flex flex-wrap gap-1 text-gray-300">
+ <slot />
+ </div>
+</section>
diff --git a/frontend/src/lib/tabs/Tab.svelte b/frontend/src/lib/tabs/Tab.svelte
new file mode 100644
index 0000000..0a6be57
--- /dev/null
+++ b/frontend/src/lib/tabs/Tab.svelte
@@ -0,0 +1,14 @@
+<script lang="ts">
+ import { getTabContext } from '$lib/Tabs';
+ import { fadeDefault } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ const context = getTabContext();
+ export let id: string;
+</script>
+
+{#if $context.current === id}
+ <div class="h-full overflow-auto py-2 pe-3" in:fade={fadeDefault}>
+ <slot />
+ </div>
+{/if}
diff --git a/frontend/src/lib/tabs/Tabs.svelte b/frontend/src/lib/tabs/Tabs.svelte
new file mode 100644
index 0000000..09cdbdd
--- /dev/null
+++ b/frontend/src/lib/tabs/Tabs.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import { getTabContext } from '$lib/Tabs';
+ import { fadeFast } from '$lib/Transitions';
+ import { fade } from 'svelte/transition';
+
+ const context = getTabContext();
+</script>
+
+<div class="flex h-full max-h-full flex-col">
+ <nav>
+ <ul class="me-3 flex border-b-2 border-slate-700 text-sm">
+ {#each Object.entries($context.tabs) as [id, { title, badge }]}
+ <li class="-mb-0.5">
+ <button
+ type="button"
+ class:active={$context.current === id}
+ class="relative flex gap-1 p-1 px-3 hover:border-b-2 hover:border-slate-200"
+ on:click={() => ($context.current = id)}
+ >
+ {#if badge}
+ <div
+ class="absolute right-0 top-1 h-2 w-2 rounded-full bg-emerald-400"
+ title="There are pending changes"
+ transition:fade={fadeFast}
+ />
+ {/if}
+ <span>{title}</span>
+ </button>
+ </li>
+ {/each}
+ </ul>
+ </nav>
+ <slot />
+</div>
+
+<style lang="postcss">
+ button.active {
+ @apply border-b-2 border-indigo-500;
+ }
+</style>
diff --git a/frontend/src/lib/toolbar/DeleteSelection.svelte b/frontend/src/lib/toolbar/DeleteSelection.svelte
new file mode 100644
index 0000000..7459a87
--- /dev/null
+++ b/frontend/src/lib/toolbar/DeleteSelection.svelte
@@ -0,0 +1,26 @@
+<script lang="ts">
+ import type { DeleteMutation } from '$gql/Mutations';
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import { confirmDeletion } from '$lib/Utils';
+ import DeleteButton from '$lib/components/DeleteButton.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: DeleteMutation;
+ export let warning: string | undefined = undefined;
+
+ function remove() {
+ const mutate = () => {
+ mutation(client, { ids: $selection.ids })
+ .then(() => ($selection = $selection.clear()))
+ .catch(toastFinally);
+ };
+
+ confirmDeletion($selection.typename, $selection.names, mutate, warning);
+ }
+</script>
+
+<DeleteButton on:click={remove} />
diff --git a/frontend/src/lib/toolbar/EditSelection.svelte b/frontend/src/lib/toolbar/EditSelection.svelte
new file mode 100644
index 0000000..50e6656
--- /dev/null
+++ b/frontend/src/lib/toolbar/EditSelection.svelte
@@ -0,0 +1,29 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { accelerator } from '$lib/Shortcuts';
+ import type { SvelteComponent } from 'svelte';
+ import { openModal } from 'svelte-modals';
+
+ const selection = getSelectionContext();
+
+ export let dialog: typeof SvelteComponent<{
+ isOpen: boolean;
+ ids: number[];
+ }>;
+
+ function edit() {
+ openModal(dialog, {
+ ids: $selection.ids
+ });
+ }
+</script>
+
+<button
+ type="button"
+ class="btn-slate hover:bg-blue-700"
+ title="Edit selection"
+ on:click={edit}
+ use:accelerator={'e'}
+>
+ <span class="icon-base icon-[material-symbols--edit]" />
+</button>
diff --git a/frontend/src/lib/toolbar/FilterBookmarked.svelte b/frontend/src/lib/toolbar/FilterBookmarked.svelte
new file mode 100644
index 0000000..bcbe295
--- /dev/null
+++ b/frontend/src/lib/toolbar/FilterBookmarked.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+ import Bookmark from '$lib/icons/Bookmark.svelte';
+
+ const filter = getFilterContext<ComicFilterContext>();
+ $: bookmarked = $filter.include.controls.bookmarked.value;
+
+ const toggle = () => {
+ $filter.include.controls.bookmarked.value = cycleBooleanFilter(bookmarked, false);
+ $filter.apply($page.url.searchParams);
+ };
+</script>
+
+<button
+ class:toggled={bookmarked}
+ class="btn-slate"
+ title="Filter bookmarked"
+ on:click={toggle}
+ use:accelerator={'b'}
+>
+ <Bookmark {bookmarked} />
+</button>
diff --git a/frontend/src/lib/toolbar/FilterFavourites.svelte b/frontend/src/lib/toolbar/FilterFavourites.svelte
new file mode 100644
index 0000000..6591cef
--- /dev/null
+++ b/frontend/src/lib/toolbar/FilterFavourites.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+ import Star from '$lib/icons/Star.svelte';
+
+ const filter = getFilterContext<ComicFilterContext>();
+ $: favourite = $filter.include.controls.favourite.value;
+
+ const toggle = () => {
+ $filter.include.controls.favourite.value = cycleBooleanFilter(favourite, false);
+ $filter.apply($page.url.searchParams);
+ };
+</script>
+
+<button
+ class:toggled={favourite}
+ class="btn-slate"
+ title="Filter favourites"
+ on:click={toggle}
+ use:accelerator={'f'}
+>
+ <Star {favourite} />
+</button>
diff --git a/frontend/src/lib/toolbar/FilterOrganized.svelte b/frontend/src/lib/toolbar/FilterOrganized.svelte
new file mode 100644
index 0000000..754e663
--- /dev/null
+++ b/frontend/src/lib/toolbar/FilterOrganized.svelte
@@ -0,0 +1,30 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import {
+ ArchiveFilterContext,
+ ComicFilterContext,
+ cycleBooleanFilter,
+ getFilterContext
+ } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+ import Organized from '$lib/icons/Organized.svelte';
+
+ const filter = getFilterContext<ArchiveFilterContext | ComicFilterContext>();
+ $: organized = $filter.include.controls.organized.value;
+
+ const toggle = () => {
+ $filter.include.controls.organized.value = cycleBooleanFilter(organized);
+ $filter.apply($page.url.searchParams);
+ };
+</script>
+
+<button
+ type="button"
+ class:toggled={organized !== undefined}
+ class="btn-slate"
+ title="Filter organized"
+ on:click={toggle}
+ use:accelerator={'o'}
+>
+ <Organized tristate {organized} />
+</button>
diff --git a/frontend/src/lib/toolbar/MarkBookmark.svelte b/frontend/src/lib/toolbar/MarkBookmark.svelte
new file mode 100644
index 0000000..792b84f
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkBookmark.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import Bookmark from '$lib/icons/Bookmark.svelte';
+ import { Client, getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: (
+ client: Client,
+ args: { ids: number[]; input: { bookmarked: boolean } }
+ ) => Promise<unknown>;
+
+ function mutate(bookmarked: boolean) {
+ mutation(client, { ids: $selection.ids, input: { bookmarked } }).catch(toastFinally);
+ }
+</script>
+
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(true)}>
+ <Bookmark bookmarked={true} />
+ <span>Bookmark</span>
+</button>
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}>
+ <Bookmark bookmarked={false} />
+ <span>Unbookmark</span>
+</button>
diff --git a/frontend/src/lib/toolbar/MarkFavourite.svelte b/frontend/src/lib/toolbar/MarkFavourite.svelte
new file mode 100644
index 0000000..42eaa39
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkFavourite.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import Star from '$lib/icons/Star.svelte';
+ import { Client, getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: (
+ client: Client,
+ args: { ids: number[]; input: { favourite: boolean } }
+ ) => Promise<unknown>;
+
+ function mutate(favourite: boolean) {
+ mutation(client, { ids: $selection.ids, input: { favourite } }).catch(toastFinally);
+ }
+</script>
+
+<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(true)}>
+ <Star favourite={true} />
+ <span>Favourite</span>
+</button>
+<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(false)}>
+ <Star favourite={false} />
+ <span>Unfavourite</span>
+</button>
diff --git a/frontend/src/lib/toolbar/MarkOrganized.svelte b/frontend/src/lib/toolbar/MarkOrganized.svelte
new file mode 100644
index 0000000..4dc3a83
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkOrganized.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { toastFinally } from '$lib/Toasts';
+ import Organized from '$lib/icons/Organized.svelte';
+ import { Client, getContextClient } from '@urql/svelte';
+
+ const client = getContextClient();
+ const selection = getSelectionContext();
+
+ export let mutation: (
+ client: Client,
+ args: { ids: number[]; input: { organized: boolean } }
+ ) => Promise<unknown>;
+
+ function mutate(organized: boolean) {
+ mutation(client, { ids: $selection.ids, input: { organized } }).catch(toastFinally);
+ }
+</script>
+
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(true)}>
+ <Organized tristate organized={true} />
+ <span>Organized</span>
+</button>
+<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}>
+ <Organized dim tristate organized={false} />
+ <span>Unorganized</span>
+</button>
diff --git a/frontend/src/lib/toolbar/MarkSelection.svelte b/frontend/src/lib/toolbar/MarkSelection.svelte
new file mode 100644
index 0000000..27eb2c7
--- /dev/null
+++ b/frontend/src/lib/toolbar/MarkSelection.svelte
@@ -0,0 +1,24 @@
+<script lang="ts">
+ import Dropdown from '$lib/components/Dropdown.svelte';
+
+ let visible = false;
+ let button: HTMLElement;
+</script>
+
+<div class="relative">
+ <button
+ type="button"
+ class="btn-slate rounded-inherit relative hover:bg-blue-700 [&:not(:only-child)]:bg-blue-700"
+ title="Set flag..."
+ bind:this={button}
+ on:click={() => (visible = !visible)}
+ >
+ <span class="icon-base icon-[material-symbols--flag] pointer-events-none" />
+ </button>
+
+ <Dropdown parent={button} bind:visible>
+ <div class="grid grid-cols-[min-content_min-content] gap-1">
+ <slot />
+ </div>
+ </Dropdown>
+</div>
diff --git a/frontend/src/lib/toolbar/Search.svelte b/frontend/src/lib/toolbar/Search.svelte
new file mode 100644
index 0000000..f033258
--- /dev/null
+++ b/frontend/src/lib/toolbar/Search.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { debounce } from '$lib/Actions';
+ import { BasicFilterContext, getFilterContext } from '$lib/Filter';
+ import { accelerator } from '$lib/Shortcuts';
+
+ const filter = getFilterContext<BasicFilterContext>();
+
+ export let name: string;
+ export let field: string;
+</script>
+
+<input
+ type="text"
+ size={25}
+ class="btn-slate w-min"
+ placeholder="Search {name}..."
+ bind:value={field}
+ use:debounce={{ callback: () => $filter.apply($page.url.searchParams) }}
+ use:accelerator={'F'}
+/>
diff --git a/frontend/src/lib/toolbar/SelectItems.svelte b/frontend/src/lib/toolbar/SelectItems.svelte
new file mode 100644
index 0000000..7ff339e
--- /dev/null
+++ b/frontend/src/lib/toolbar/SelectItems.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { getPaginationContext } from '$lib/Pagination';
+
+ const pagination = getPaginationContext();
+
+ $: values = new Set([24, 48, 72, 90, 120, 150, 180, $pagination.items].sort((a, b) => a - b));
+</script>
+
+<select
+ class="btn-slate"
+ bind:value={$pagination.items}
+ on:change={() => $pagination.apply($page.url.searchParams)}
+ title="Limit displayed items to..."
+>
+ {#each values as value}
+ <option {value}>{value}</option>
+ {/each}
+</select>
diff --git a/frontend/src/lib/toolbar/SelectSort.svelte b/frontend/src/lib/toolbar/SelectSort.svelte
new file mode 100644
index 0000000..fdcb057
--- /dev/null
+++ b/frontend/src/lib/toolbar/SelectSort.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { SortDirection } from '$gql/graphql';
+
+ import { getSortContext } from '$lib/Sort';
+ import { slideXFast } from '$lib/Transitions';
+ import { getRandomInt } from '$lib/Utils';
+ import { slide } from 'svelte/transition';
+
+ const sort = getSortContext();
+
+ function toggle() {
+ if ($sort.direction === SortDirection.Ascending) {
+ $sort.direction = SortDirection.Descending;
+ } else {
+ $sort.direction = SortDirection.Ascending;
+ }
+
+ apply();
+ }
+
+ function apply() {
+ if ($sort.on === 'RANDOM' && $sort.seed === undefined) {
+ $sort.seed = getRandomInt(0, 1000000000);
+ }
+ $sort.apply($page.url.searchParams);
+ }
+
+ function reshuffle() {
+ $sort.seed = undefined;
+ apply();
+ }
+</script>
+
+<div class="rounded-group flex flex-row">
+ <select class="btn-slate" bind:value={$sort.on} on:change={apply} title="Sort on...">
+ {#each Object.entries($sort.labels) as [value, label]}
+ <option {value}>{label}</option>
+ {/each}
+ </select>
+ <button type="button" class="btn-slate" title="Toggle sort direction" on:click={toggle}>
+ {#if $sort.direction === SortDirection.Ascending}
+ <span class="icon-base icon-[material-symbols--sort] -scale-y-100" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--sort]" />
+ {/if}
+ </button>
+ {#if $sort.on === 'RANDOM'}
+ <button
+ type="button"
+ class="btn-slate"
+ title="Reshuffle"
+ on:click={reshuffle}
+ transition:slide={slideXFast}
+ >
+ <div class="flex">
+ <span class="icon-base icon-[material-symbols--shuffle]" />
+ </div>
+ </button>
+ {/if}
+</div>
diff --git a/frontend/src/lib/toolbar/SelectionControls.svelte b/frontend/src/lib/toolbar/SelectionControls.svelte
new file mode 100644
index 0000000..4d309df
--- /dev/null
+++ b/frontend/src/lib/toolbar/SelectionControls.svelte
@@ -0,0 +1,57 @@
+<script lang="ts">
+ import { getSelectionContext } from '$lib/Selection';
+ import { accelerator } from '$lib/Shortcuts';
+ import { fadeDefault, slideXFast } from '$lib/Transitions';
+ import Badge from '$lib/components/Badge.svelte';
+ import { onDestroy } from 'svelte';
+ import { fade, slide } from 'svelte/transition';
+
+ const selection = getSelectionContext();
+
+ export let page = false;
+
+ const toggle = () => ($selection = $selection.toggle());
+ const all = () => ($selection = $selection.all());
+ const none = () => ($selection = $selection.none());
+
+ onDestroy(() => ($selection = $selection.clear()));
+</script>
+
+<div class="rounded-group flex">
+ <button
+ type="button"
+ class="btn-slate relative"
+ class:toggled={$selection.active}
+ title={`${$selection.active ? 'Exit' : 'Enter'} ${page ? 'page ' : ' '}selection mode`}
+ on:click={toggle}
+ use:accelerator={'s'}
+ >
+ {#if $selection.active}
+ {#if page}
+ <span class="icon-base icon-[material-symbols--edit-document]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--remove-selection]" />
+ {/if}
+ {:else if page}
+ <span class="icon-base icon-[material-symbols--edit-document-outline]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--select]" />
+ {/if}
+ <Badge number={$selection.size} />
+ </button>
+ {#if $selection.active}
+ <div class="rounded-group-end flex" transition:slide={slideXFast}>
+ <button type="button" class="btn-slate" title="Select all" on:click={all}>
+ <span class="icon-base icon-[material-symbols--select-all]" />
+ </button>
+ <button type="button" class="btn-slate" title="Select none" on:click={none}>
+ <span class="icon-base icon-[material-symbols--deselect]" />
+ </button>
+ </div>
+ {/if}
+</div>
+{#if $selection.size > 0}
+ <div class="rounded-group flex" transition:fade={fadeDefault}>
+ <slot />
+ </div>
+{/if}
diff --git a/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte
new file mode 100644
index 0000000..2e7869f
--- /dev/null
+++ b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte
@@ -0,0 +1,40 @@
+<script lang="ts">
+ import { page } from '$app/stores';
+ import { getFilterContext } from '$lib/Filter';
+ import { navigate } from '$lib/Navigation';
+ import { slideXFast } from '$lib/Transitions';
+ import Badge from '$lib/components/Badge.svelte';
+ import { slide } from 'svelte/transition';
+ import { getToolbarContext } from './Toolbar.svelte';
+
+ const toolbar = getToolbarContext();
+ const filter = getFilterContext();
+</script>
+
+<div class="rounded-group flex">
+ <button
+ class:toggled={$toolbar.expand}
+ class="btn-slate relative"
+ title={`${$toolbar.expand ? 'Hide' : 'Show'} filters`}
+ on:click={() => ($toolbar.expand = !$toolbar.expand)}
+ >
+ {#if $toolbar.expand}
+ <span class="icon-base icon-[material-symbols--filter-alt]" />
+ {:else}
+ <span class="icon-base icon-[material-symbols--filter-alt-outline]" />
+ {/if}
+ <Badge number={$filter.include.size + $filter.exclude.size} />
+ </button>
+ {#if $filter.include.size + $filter.exclude.size > 0}
+ <button
+ class="btn-slate relative hover:bg-rose-700"
+ on:click={() => navigate({ filter: {} }, $page.url.searchParams)}
+ transition:slide={slideXFast}
+ title="Reset filters"
+ >
+ <div class="flex">
+ <span class="icon-base icon-[material-symbols--filter-alt-off]" />
+ </div>
+ </button>
+ {/if}
+</div>
diff --git a/frontend/src/lib/toolbar/Toolbar.svelte b/frontend/src/lib/toolbar/Toolbar.svelte
new file mode 100644
index 0000000..e87d731
--- /dev/null
+++ b/frontend/src/lib/toolbar/Toolbar.svelte
@@ -0,0 +1,42 @@
+<script lang="ts" context="module">
+ import { writable, type Writable } from 'svelte/store';
+
+ interface ToolbarContext {
+ expand: boolean;
+ }
+
+ function initToolbarContext() {
+ return setContext<Writable<ToolbarContext>>('toolbar', writable({ expand: false }));
+ }
+
+ export function getToolbarContext() {
+ return getContext<Writable<ToolbarContext>>('toolbar');
+ }
+</script>
+
+<script lang="ts">
+ import { getContext, setContext } from 'svelte';
+
+ const toolbar = initToolbarContext();
+</script>
+
+<div class="flex flex-col">
+ <div
+ class="flex flex-row flex-wrap gap-4 text-sm xl:grid xl:grid-flow-col xl:grid-cols-[1fr_2fr_1fr]"
+ >
+ <div class="flex flex-row justify-start gap-2">
+ <slot name="start" />
+ </div>
+ <div class="flex flex-row flex-wrap justify-start gap-2 xl:flex-nowrap xl:justify-center">
+ <slot name="center" />
+ </div>
+ <div class="flex flex-row justify-end gap-2">
+ <slot name="end" />
+ </div>
+ </div>
+ {#if $toolbar.expand}
+ <div class="mt-4">
+ <slot />
+ </div>
+ {/if}
+</div>
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)
+ };
+}