diff options
author | Wolfgang Müller | 2024-03-05 18:08:09 +0100 |
---|---|---|
committer | Wolfgang Müller | 2024-03-05 19:25:59 +0100 |
commit | d1d654ebac2d51e3841675faeb56480e440f622f (patch) | |
tree | 56ef123c1a15a10dfd90836e4038e27efde950c6 /frontend/src | |
download | hircine-d1d654ebac2d51e3841675faeb56480e440f622f.tar.gz |
Initial commit0.1.0
Diffstat (limited to 'frontend/src')
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 Binary files differnew file mode 100644 index 0000000..e41cbb0 --- /dev/null +++ b/frontend/src/lib/assets/logo.webp 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')} + > + ∀ + </button> + <button + type="button" + title="matches any of" + class:active={filter.mode === 'any'} + class="btn btn-xs" + on:click={() => (filter.mode = 'any')} + > + ∃ + </button> + <button + type="button" + title="matches exactly" + class:active={filter.mode === 'exact'} + class="btn btn-xs" + on:click={() => (filter.mode = 'exact')} + > + = + </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)} + > + ∅ + </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) + }; +} |