diff options
Diffstat (limited to 'frontend/src')
154 files changed, 3303 insertions, 2765 deletions
diff --git a/frontend/src/app.css b/frontend/src/app.css index e1b6ca0..3f15de7 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1,178 +1,219 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; +@import 'tailwindcss'; +@config '../tailwind.config.cjs'; +@plugin "@iconify/tailwind4"; + +@theme { + --breakpoint-3xl: 120rem; +} @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; + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; } +} - input, - textarea { - @apply w-full rounded bg-slate-900 p-[.4rem] focus:outline focus:outline-1 focus:outline-slate-500; - } +@utility btn { + @apply flex items-center justify-center rounded-xs p-2 text-white transition-colors focus-visible:outline disabled:opacity-40; +} - label { - @apply mb-0.5 inline-block; - } +@utility btn-xs { + @apply btn rounded-xs p-0.5 py-0; +} - form { - @apply flex flex-col gap-4 p-px text-sm; - } +@utility btn-blue { + @apply btn bg-blue-700 hover:bg-blue-600 focus:outline-blue-400 focus-visible:outline-blue-400 disabled:bg-blue-900; +} - .rounded-group > * { - @apply rounded-none first:rounded-l-sm last:rounded-r-sm !important; - } +@utility btn-rose { + @apply btn bg-rose-700 hover:bg-rose-600 focus:outline-rose-400 focus-visible:outline-rose-400 disabled:bg-rose-900; +} - .rounded-group-start > * { - @apply rounded-none first:rounded-l-sm !important; - } +@utility btn-slate { + @apply btn bg-slate-700 hover:bg-slate-600 focus:outline-slate-400 focus-visible:outline-slate-400 disabled:bg-slate-800; +} - .rounded-group-end > * { - @apply rounded-none last:rounded-r-sm !important; - } +@utility btn-transparent { + @apply btn rounded-full bg-black/50 p-1 text-white/80 backdrop-blur-xs hover:bg-black/50 hover:text-white; +} - .grid-labels { - @apply grid grid-cols-[1fr_5fr] gap-2; - } +@utility icon-xs { + @apply text-[18px]; +} - header { - grid-area: header; - } +@utility icon-base { + @apply text-[24px]; +} - aside { - overflow: auto; - grid-area: sidebar; - @apply lg:w-[28rem] xl:w-[32rem] min-[1920px]:w-[36rem]; - } +@utility icon-lg { + @apply text-[28px]; +} - main { - grid-area: main; - } +@utility icon-2xl { + @apply text-[48px]; } -@layer components { - .btn { - @apply flex items-center justify-center rounded-sm p-2 text-white transition-colors disabled:opacity-40; +@utility icon-gray { + &.hoverable:hover { + @apply text-gray-200/80; } - .btn-xs { - @apply btn rounded-sm p-0.5 py-0; + &.dim { + @apply text-gray-100/40; } +} - .btn-blue { - @apply btn bg-blue-700 hover:bg-blue-600 disabled:bg-blue-900; +@utility hoverable { + &.icon-gray:hover { + @apply text-gray-200/80; } - .btn-rose { - @apply btn bg-rose-700 hover:bg-rose-600 disabled:bg-rose-900; + &.icon-yellow:hover { + @apply text-yellow-300/80; } +} - .btn-slate { - @apply btn bg-slate-700 hover:bg-slate-600 disabled:bg-slate-800; +@utility dim { + &.icon-gray { + @apply text-gray-100/40; } - .btn-indigo { - @apply btn bg-indigo-700 hover:bg-indigo-600 disabled:bg-indigo-800; + &.icon-yellow { + @apply text-slate-100/40; } +} + +@utility icon-yellow { + @apply text-yellow-300; - .icon-xs { - @apply text-[18px]; + &.hoverable:hover { + @apply text-yellow-300/80; } - .icon-base { - @apply text-[24px]; + &.dim { + @apply text-slate-100/40; } +} + +@utility toggled { + @apply bg-indigo-700 hover:bg-indigo-600; +} + +@utility ellipsis-nowrap { + @apply overflow-hidden text-ellipsis whitespace-nowrap; +} + +@utility rounded-inherit { + border-radius: inherit; +} - .icon-lg { - @apply text-[28px]; +@utility grid-card-h { + grid-template-columns: 210px 1fr; + grid-template-rows: 300px; +} + +@utility grid-card-cover-only { + @apply !grid-card-h; +} + +@utility grid-card-v { + grid-template-columns: 1fr; + grid-template-rows: 500px 1fr; +} + +@layer base { + body { + display: grid; + grid-template-columns: 3rem 1fr; + scrollbar-color: var(--color-gray-500) rgba(0, 0, 0, 0); + font-family: 'Noto Sans', sans-serif; } - .icon-2xl { - @apply text-[48px]; + input, + textarea { + @apply w-full rounded-sm bg-slate-900 p-[.4rem]; } - .icon-gray.hoverable:hover { - @apply text-gray-200/80; + input, + textarea, + select { + @apply focus:isolate focus:outline focus:outline-slate-400 focus-visible:isolate focus-visible:outline-slate-400; } - .icon-gray.dim { - @apply text-gray-100/40; + button { + @apply focus:outline-slate-400 focus-visible:isolate focus-visible:outline focus-visible:outline-slate-400; } - .icon-yellow { - @apply text-yellow-300; + a { + @apply focus-visible:isolate focus-visible:outline-2 focus-visible:outline-blue-600; } - .icon-yellow.hoverable:hover { - @apply text-yellow-300/80; + label { + @apply mb-0.5 inline-block; } - .icon-yellow.dim { - @apply text-slate-100/40; + form { + @apply flex flex-col gap-4 p-px text-sm; } -} -@layer utilities { - .toggled { - @apply bg-indigo-700 hover:bg-indigo-600; + .rounded-group > * { + @apply rounded-none! first:rounded-l-xs! last:rounded-r-xs!; } - .floating { - @apply rounded-full bg-black/50 p-1 text-white/80 backdrop-blur-sm hover:bg-black/50 hover:text-white; + .rounded-group-start > * { + @apply rounded-none! first:rounded-l-xs!; } - .ellipsis-nowrap { - @apply overflow-hidden text-ellipsis whitespace-nowrap; + .rounded-group-end > * { + @apply rounded-none! last:rounded-r-xs!; } - .rounded-inherit { - border-radius: inherit; + .grid-labels { + @apply grid grid-cols-[1fr_5fr] gap-2; } - .grid-card-h { - grid-template-columns: 210px 1fr; - grid-template-rows: 300px; + header { + grid-area: header; } - .grid-card-cover-only { - @apply !grid-card-h; + aside { + overflow: auto; + grid-area: sidebar; + @apply 3xl:w-[36rem] lg:w-[28rem] xl:w-[32rem]; } - .grid-card-v { - grid-template-columns: 1fr; - grid-template-rows: 500px 1fr; + main { + grid-area: main; } } .svelecte { - --sv-bg: theme(colors.slate.900); - --sv-disabled-bg: theme(colors.slate.900); + --sv-bg: var(--color-slate-900); + --sv-disabled-bg: var(--color-slate-900); --sv-border: 1px solid rgba(0, 0, 0, 0); - --sv-dropdown-active-bg: theme(colors.indigo.800); - --sv-item-selected-bg: theme(colors.indigo.800); - --sv-item-btn-bg-hover: theme(colors.rose.900); - --sv-item-btn-color-hover: theme(colors.rose.100); - --sv-separator-bg: theme(colors.gray.700); + --sv-dropdown-active-bg: var(--color-indigo-800); + --sv-item-selected-bg: var(--color-indigo-800); + --sv-item-btn-bg-hover: var(--color-rose-900); + --sv-item-btn-color-hover: var(--color-rose-100); + --sv-separator-bg: var(--color-gray-700); + --sv-placeholder-color: var(--color-gray-500); --sv-min-height: 38px; --sv-item-wrap-padding: 3px 3px 3px 5px; + --sv-selection-multi-wrap-padding: 3px 3px 3px 5px; } .svelecte.is-focused { - --sv-border: 1px solid theme(colors.slate.500); + --sv-border: 1px solid var(--color-slate-400); } -.svelecte input::placeholder { - color: theme(colors.gray.500); +.exclude .svelecte { + --sv-border: 1px solid var(--color-red-900); + --sv-item-selected-bg: var(--color-rose-800); + --sv-dropdown-active-bg: var(--color-rose-800); } -.exclude .svelecte { - --sv-border: 1px solid theme(colors.red.900); - --sv-item-selected-bg: theme(colors.rose.800); - --sv-dropdown-active-bg: theme(colors.rose.800); +.exclude .svelecte.is-focused { + --sv-border: 1px solid var(--color-red-500); } .sv-item--btn { diff --git a/frontend/src/gql/Queries.ts b/frontend/src/gql/Queries.ts index cc9dd4c..c2f1432 100644 --- a/frontend/src/gql/Queries.ts +++ b/frontend/src/gql/Queries.ts @@ -192,10 +192,19 @@ export function worldsQuery(client: Client, args?: gql.WorldsQueryVariables) { }); } -export function frontpageQuery(client: Client) { +export function frontpageQuery(client: Client, args: gql.FrontpageQueryVariables) { return queryStore({ client: client, query: gql.FrontpageDocument, + variables: args, + requestPolicy: 'network-only' + }); +} + +export function statisticsQuery(client: Client) { + return queryStore({ + client: client, + query: gql.StatisticsDocument, requestPolicy: 'network-only' }); } diff --git a/frontend/src/gql/Utils.ts b/frontend/src/gql/Utils.ts index dd21bbe..6fedd05 100644 --- a/frontend/src/gql/Utils.ts +++ b/frontend/src/gql/Utils.ts @@ -1,9 +1,25 @@ -import equal from 'fast-deep-equal'; +import { omit } from '$lib/Utils'; +import type { Client } from '@urql/svelte'; import * as gql from './graphql'; -export type OmitIdentifiers<T> = Omit<T, 'id' | '__typename'>; +type Typename = '__typename'; +type Identifiers = Typename | 'id'; + +export type OmitTypename<T> = Omit<T, Typename>; +export type OmitIdentifiers<T> = Omit<T, Identifiers>; export type RequiredName<T> = T & { name: string }; +export type MutationWith<T> = ( + client: Client, + args: { ids: number[] | number; input: T } +) => Promise<unknown>; + +export function omitIdentifiers<T extends { __typename?: unknown; id: number }>( + obj: T +): OmitIdentifiers<T> { + return omit(obj, '__typename', 'id'); +} +// eslint-disable-next-line @typescript-eslint/no-explicit-any export function isSuccess(object: any): object is gql.Success { if (object.__typename === undefined) { return false; @@ -12,63 +28,10 @@ export function isSuccess(object: any): object is gql.Success { return object.__typename.endsWith('Success') && (object as gql.Success).message !== undefined; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any 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 index 4d09e75..03fd972 100644 --- a/frontend/src/gql/graphql.ts +++ b/frontend/src/gql/graphql.ts @@ -122,6 +122,7 @@ export type Artist = { }; export type ArtistFilter = { + comics?: InputMaybe<BasicCountFilter>; name?: InputMaybe<StringFilter>; }; @@ -139,6 +140,7 @@ export type ArtistFilterResult = { export type ArtistResponse = Artist | IdNotFoundError; export enum ArtistSort { + ComicCount = 'COMIC_COUNT', CreatedAt = 'CREATED_AT', Name = 'NAME', Random = 'RANDOM', @@ -164,10 +166,14 @@ export type ArtistsUpsertInput = { export type AssociationFilter = { all?: InputMaybe<Array<Scalars['Int']['input']>>; any?: InputMaybe<Array<Scalars['Int']['input']>>; - empty?: InputMaybe<Scalars['Boolean']['input']>; + count?: InputMaybe<CountFilter>; exact?: InputMaybe<Array<Scalars['Int']['input']>>; }; +export type BasicCountFilter = { + count: CountFilter; +}; + export enum Category { Artbook = 'ARTBOOK', Comic = 'COMIC', @@ -203,6 +209,7 @@ export type Character = { }; export type CharacterFilter = { + comics?: InputMaybe<BasicCountFilter>; name?: InputMaybe<StringFilter>; }; @@ -220,6 +227,7 @@ export type CharacterFilterResult = { export type CharacterResponse = Character | IdNotFoundError; export enum CharacterSort { + ComicCount = 'COMIC_COUNT', CreatedAt = 'CREATED_AT', Name = 'NAME', Random = 'RANDOM', @@ -249,6 +257,7 @@ export type Circle = { }; export type CircleFilter = { + comics?: InputMaybe<BasicCountFilter>; name?: InputMaybe<StringFilter>; }; @@ -266,6 +275,7 @@ export type CircleFilterResult = { export type CircleResponse = Circle | IdNotFoundError; export enum CircleSort { + ComicCount = 'COMIC_COUNT', CreatedAt = 'CREATED_AT', Name = 'NAME', Random = 'RANDOM', @@ -348,6 +358,9 @@ export type ComicScraper = { }; export enum ComicSort { + ArtistCount = 'ARTIST_COUNT', + CharacterCount = 'CHARACTER_COUNT', + CircleCount = 'CIRCLE_COUNT', CreatedAt = 'CREATED_AT', Date = 'DATE', OriginalTitle = 'ORIGINAL_TITLE', @@ -355,7 +368,8 @@ export enum ComicSort { Random = 'RANDOM', TagCount = 'TAG_COUNT', Title = 'TITLE', - UpdatedAt = 'UPDATED_AT' + UpdatedAt = 'UPDATED_AT', + WorldCount = 'WORLD_COUNT' } export type ComicSortInput = { @@ -387,6 +401,20 @@ export type ComicTagsUpsertInput = { options?: InputMaybe<UpsertOptions>; }; +export type ComicTotals = { + __typename?: 'ComicTotals'; + artists: Scalars['Int']['output']; + characters: Scalars['Int']['output']; + circles: Scalars['Int']['output']; + tags: Scalars['Int']['output']; + worlds: Scalars['Int']['output']; +}; + +export type CountFilter = { + operator?: InputMaybe<Operator>; + value: Scalars['Int']['input']; +}; + export type CoverInput = { id: Scalars['Int']['input']; }; @@ -853,6 +881,7 @@ export type Namespace = { export type NamespaceFilter = { name?: InputMaybe<StringFilter>; + tags?: InputMaybe<BasicCountFilter>; }; export type NamespaceFilterInput = { @@ -873,6 +902,7 @@ export enum NamespaceSort { Name = 'NAME', Random = 'RANDOM', SortName = 'SORT_NAME', + TagCount = 'TAG_COUNT', UpdatedAt = 'UPDATED_AT' } @@ -896,6 +926,12 @@ export enum OnMissing { Ignore = 'IGNORE' } +export enum Operator { + Equal = 'EQUAL', + GreaterThan = 'GREATER_THAN', + LowerThan = 'LOWER_THAN' +} + export type Page = { __typename?: 'Page'; comicId?: Maybe<Scalars['Int']['output']>; @@ -940,6 +976,7 @@ export type Query = { namespace: NamespaceResponse; namespaces: NamespaceFilterResult; scrapeComic: ScrapeComicResponse; + statistics: Statistics; tag: TagResponse; tags: TagFilterResult; world: WorldResponse; @@ -1120,6 +1157,11 @@ export enum SortDirection { Descending = 'DESCENDING' } +export type Statistics = { + __typename?: 'Statistics'; + total: Totals; +}; + export type StringFilter = { contains?: InputMaybe<Scalars['String']['input']>; }; @@ -1138,11 +1180,12 @@ export type Tag = { export type TagAssociationFilter = { all?: InputMaybe<Array<Scalars['String']['input']>>; any?: InputMaybe<Array<Scalars['String']['input']>>; - empty?: InputMaybe<Scalars['Boolean']['input']>; + count?: InputMaybe<CountFilter>; exact?: InputMaybe<Array<Scalars['String']['input']>>; }; export type TagFilter = { + comics?: InputMaybe<BasicCountFilter>; name?: InputMaybe<StringFilter>; namespaces?: InputMaybe<AssociationFilter>; }; @@ -1161,8 +1204,10 @@ export type TagFilterResult = { export type TagResponse = FullTag | IdNotFoundError; export enum TagSort { + ComicCount = 'COMIC_COUNT', CreatedAt = 'CREATED_AT', Name = 'NAME', + NamespaceCount = 'NAMESPACE_COUNT', Random = 'RANDOM', UpdatedAt = 'UPDATED_AT' } @@ -1173,6 +1218,22 @@ export type TagSortInput = { seed?: InputMaybe<Scalars['Int']['input']>; }; +export type Totals = { + __typename?: 'Totals'; + archives: Scalars['Int']['output']; + artists: Scalars['Int']['output']; + characters: Scalars['Int']['output']; + circles: Scalars['Int']['output']; + comic: ComicTotals; + comics: Scalars['Int']['output']; + images: Scalars['Int']['output']; + namespaces: Scalars['Int']['output']; + pages: Scalars['Int']['output']; + scrapers: Scalars['Int']['output']; + tags: Scalars['Int']['output']; + worlds: Scalars['Int']['output']; +}; + export type UniquePagesInput = { ids: Array<Scalars['Int']['input']>; }; @@ -1293,6 +1354,7 @@ export type World = { }; export type WorldFilter = { + comics?: InputMaybe<BasicCountFilter>; name?: InputMaybe<StringFilter>; }; @@ -1310,6 +1372,7 @@ export type WorldFilterResult = { export type WorldResponse = IdNotFoundError | World; export enum WorldSort { + ComicCount = 'COMIC_COUNT', CreatedAt = 'CREATED_AT', Name = 'NAME', Random = 'RANDOM', @@ -1336,9 +1399,9 @@ export type ImageFragment = { __typename?: 'Image', hash: string, width: 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 ComicFragment = { __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, date?: string | null, pageCount: number, 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 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, date?: string | null, pageCount: number, 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 } }; @@ -1355,7 +1418,7 @@ export type ComicsQueryVariables = Exact<{ }>; -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 ComicsQuery = { __typename?: 'Query', comics: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, date?: string | null, pageCount: number, 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; @@ -1371,7 +1434,7 @@ export type ArchiveQueryVariables = Exact<{ }>; -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 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, date?: string | null, pageCount: number, 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']; @@ -1523,10 +1586,17 @@ export type ScrapeComicQueryVariables = Exact<{ 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 FrontpageQueryVariables = Exact<{ + seed: Scalars['Int']['input']; +}>; + + +export type FrontpageQuery = { __typename?: 'Query', recent: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, date?: string | null, pageCount: number, 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, date?: string | null, pageCount: number, 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, date?: string | null, pageCount: number, 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 StatisticsQueryVariables = 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 StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', total: { __typename?: 'Totals', archives: number, artists: number, characters: number, circles: number, comics: number, images: number, scrapers: number, pages: number, namespaces: number, tags: number, worlds: number, comic: { __typename?: 'ComicTotals', artists: number, characters: number, circles: number, tags: number, worlds: number } } } }; export type AddComicMutationVariables = Exact<{ input: AddComicInput; @@ -1707,15 +1777,15 @@ export type DeleteWorldsMutation = { __typename?: 'Mutation', deleteWorlds: { __ 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 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":"date"}},{"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":"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":"date"}},{"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":"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 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":"date"}},{"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":"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 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":"date"}},{"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":"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>; @@ -1737,7 +1807,8 @@ export const WorldsDocument = {"kind":"Document","definitions":[{"kind":"Operati 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 FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"frontpage"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"seed"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"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"}},{"kind":"ObjectField","name":{"kind":"Name","value":"seed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"seed"}}}]}}],"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"}},{"kind":"ObjectField","name":{"kind":"Name","value":"seed"},"value":{"kind":"Variable","name":{"kind":"Name","value":"seed"}}}]}}],"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":"date"}},{"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":"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 StatisticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"statistics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statistics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archives"}},{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"comic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comics"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"scrapers"}},{"kind":"Field","name":{"kind":"Name","value":"pages"}},{"kind":"Field","name":{"kind":"Name","value":"namespaces"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}}]}}]}}]} as unknown as DocumentNode<StatisticsQuery, StatisticsQueryVariables>; 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>; diff --git a/frontend/src/lib/Actions.ts b/frontend/src/lib/Actions.ts index 7231c2f..2c15b61 100644 --- a/frontend/src/lib/Actions.ts +++ b/frontend/src/lib/Actions.ts @@ -23,28 +23,6 @@ export function debounce( }; } -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]', diff --git a/frontend/src/lib/Enums.ts b/frontend/src/lib/Enums.ts index 876aec8..c557cfa 100644 --- a/frontend/src/lib/Enums.ts +++ b/frontend/src/lib/Enums.ts @@ -10,11 +10,13 @@ import { Language, Layout, NamespaceSort, + Operator, Rating, TagSort, UpdateMode, WorldSort } from '$gql/graphql'; +import type { Key } from './Utils'; export interface EnumOption<T> { id: T; @@ -60,8 +62,8 @@ export const ArchiveSortLabel: Record<ArchiveSort, string> = { [ArchiveSort.Path]: 'Path', [ArchiveSort.Size]: 'File Size', [ArchiveSort.CreatedAt]: 'Created At', - [ArchiveSort.PageCount]: 'Page Count', - [ArchiveSort.Random]: 'Random' + [ArchiveSort.Random]: 'Random', + [ArchiveSort.PageCount]: '# Pages' }; export const ComicSortLabel: Record<ComicSort, string> = { @@ -70,30 +72,37 @@ export const ComicSortLabel: Record<ComicSort, string> = { [ComicSort.Date]: 'Date', [ComicSort.CreatedAt]: 'Created At', [ComicSort.UpdatedAt]: 'Updated At', - [ComicSort.TagCount]: 'Tag Count', - [ComicSort.PageCount]: 'Page Count', - [ComicSort.Random]: 'Random' + [ComicSort.Random]: 'Random', + [ComicSort.ArtistCount]: '# Artists', + [ComicSort.CharacterCount]: '# Characters', + [ComicSort.CircleCount]: '# Circles', + [ComicSort.PageCount]: '# Pages', + [ComicSort.TagCount]: '# Tags', + [ComicSort.WorldCount]: '# Worlds' }; export const ArtistSortLabel: Record<ArtistSort, string> = { [ArtistSort.Name]: 'Name', [ArtistSort.CreatedAt]: 'Created At', [ArtistSort.UpdatedAt]: 'Updated At', - [ArchiveSort.Random]: 'Random' + [ArtistSort.Random]: 'Random', + [ArtistSort.ComicCount]: '# Count' }; export const CharacterSortLabel: Record<CharacterSort, string> = { [CharacterSort.Name]: 'Name', [CharacterSort.CreatedAt]: 'Created At', [CharacterSort.UpdatedAt]: 'Updated At', - [ArchiveSort.Random]: 'Random' + [CharacterSort.Random]: 'Random', + [CharacterSort.ComicCount]: '# Comics' }; export const CircleSortLabel: Record<CircleSort, string> = { [CircleSort.Name]: 'Name', [CircleSort.CreatedAt]: 'Created At', [CircleSort.UpdatedAt]: 'Updated At', - [ArchiveSort.Random]: 'Random' + [CircleSort.Random]: 'Random', + [CircleSort.ComicCount]: '# Comics' }; export const NamespaceSortLabel: Record<NamespaceSort, string> = { @@ -101,21 +110,25 @@ export const NamespaceSortLabel: Record<NamespaceSort, string> = { [NamespaceSort.SortName]: 'Sort Name', [NamespaceSort.CreatedAt]: 'Created At', [NamespaceSort.UpdatedAt]: 'Updated At', - [ArchiveSort.Random]: 'Random' + [NamespaceSort.Random]: 'Random', + [NamespaceSort.TagCount]: '# Tags' }; export const TagSortLabel: Record<TagSort, string> = { [TagSort.Name]: 'Name', [TagSort.CreatedAt]: 'Created At', [TagSort.UpdatedAt]: 'Updated At', - [ArchiveSort.Random]: 'Random' + [TagSort.Random]: 'Random', + [TagSort.ComicCount]: '# Comics', + [TagSort.NamespaceCount]: '# Namespaces' }; export const WorldSortLabel: Record<WorldSort, string> = { [WorldSort.Name]: 'Name', [WorldSort.CreatedAt]: 'Created At', [WorldSort.UpdatedAt]: 'Updated At', - [ArchiveSort.Random]: 'Random' + [WorldSort.Random]: 'Random', + [WorldSort.ComicCount]: '# Comics' }; export const UpdateModeLabel: Record<UpdateMode, string> = { @@ -124,6 +137,12 @@ export const UpdateModeLabel: Record<UpdateMode, string> = { [UpdateMode.Replace]: 'Replace' }; +export const OperatorLabel: Record<Operator, string> = { + [Operator.Equal]: 'Equal', + [Operator.GreaterThan]: 'Greater than', + [Operator.LowerThan]: 'Lower than,' +}; + export const LanguageLabel: Record<Language, string> = { [Language.Ab]: 'Abkhazian', [Language.Aa]: 'Afar', @@ -318,8 +337,6 @@ export const censorships: EnumOption<Censorship>[] = optionsFromLabel(Censorship 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>[] { +function optionsFromLabel<T extends Key>(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.svelte.ts index 8e419f3..390b98a 100644 --- a/frontend/src/lib/Filter.ts +++ b/frontend/src/lib/Filter.svelte.ts @@ -1,40 +1,47 @@ import { + Operator, type ArchiveFilter, type ArchiveFilterInput, type ComicFilter, type ComicFilterInput, + type NamespaceFilter, + type NamespaceFilterInput, 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'; +import { numKeys, type Key } from './Utils'; interface FilterInput<T> { include?: T | null; exclude?: T | null; } -interface BasicFilter { +interface NameFilter { name?: { contains?: string | null } | null; } -type FilterMode = 'any' | 'all' | 'exact'; +interface AssociationCount { + count: { value: number; operator?: Operator | null }; +} -type Key = string | number | symbol; +interface BasicFilter extends NameFilter { + comics?: AssociationCount | null; +} -type Filter<T, K extends Key> = { - [Property in K]?: T | null; -}; +export type FilterType = 'include' | 'exclude'; + +type FilterMode = 'any' | 'all' | 'exact'; + +type Filter<T, K extends Key> = Partial<Record<K, T | null>>; type AssocFilter<T, K extends Key> = Filter< { any?: T[] | null; all?: T[] | null; exact?: T[] | null; - empty?: boolean | null; + count?: { value: number; operator?: Operator | null } | null; }, K >; @@ -52,10 +59,10 @@ interface Integrateable<F> { } class ComplexMember<K extends Key> { - values: unknown[] = []; + values: unknown[] = $state([]); key: K; - mode: FilterMode; - empty?: boolean | null; + mode: FilterMode = $state('all'); + empty?: boolean | null = $state(null); constructor(key: K, mode: FilterMode) { this.key = key; @@ -66,15 +73,11 @@ class ComplexMember<K extends Key> { 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)[] = []; + values: (string | number)[] = $state([]); constructor(key: K, mode: FilterMode, filter?: AssocFilter<string | number, K> | null) { super(key, mode); @@ -84,7 +87,9 @@ export class Association<K extends Key> extends ComplexMember<K> { } const prop = filter[key]; - this.empty = prop?.empty; + this.empty = + prop?.count?.value === 0 && + (prop.count.operator === undefined || prop.count.operator === Operator.Equal); if (prop?.all && prop.all.length > 0) { this.mode = 'all'; @@ -97,10 +102,17 @@ export class Association<K extends Key> extends ComplexMember<K> { this.values = prop.exact; } } + + integrate(filter: AssocFilter<unknown, K>) { + super.integrate(filter); + if (this.empty) { + filter[this.key] = { ...filter[this.key], count: { value: 0, operator: Operator.Equal } }; + } + } } export class Enum<K extends Key> extends ComplexMember<K> { - values: string[] = []; + values: string[] = $state([]); constructor(key: K, filter?: EnumFilter<K> | null) { super(key, 'any'); @@ -116,11 +128,18 @@ export class Enum<K extends Key> extends ComplexMember<K> { this.values = prop.any; } } + + integrate(filter: EnumFilter<K>) { + super.integrate(filter); + if (this.empty) { + filter[this.key] = { ...filter[this.key], empty: this.empty }; + } + } } class Bool<K extends Key> { key: K; - value?: boolean = undefined; + value?: boolean = $state(undefined); constructor(key: K, filter?: Filter<boolean, K> | null) { this.key = key; @@ -137,9 +156,30 @@ class Bool<K extends Key> { } } +class Orphan<K extends Key> { + key: K; + value?: boolean = false; + + constructor(key: K, filter?: Filter<AssociationCount, K> | null) { + this.key = key; + + if (filter) { + this.value = + filter[key]?.count?.value === 0 && + (filter[key].count.operator === undefined || filter[key].count.operator === Operator.Equal); + } + } + + integrate(filter: Filter<AssociationCount, K>) { + if (this.value) { + filter[this.key] = { count: { value: 0, operator: Operator.Equal } }; + } + } +} + class Str<K extends Key> { key: K; - contains = ''; + contains = $state(''); constructor(key: K, filter?: Filter<StringFilter, K> | null) { this.key = key; @@ -168,7 +208,7 @@ export class ArchiveFilterControls extends Controls<ArchiveFilter> { path: Str<'path'>; organized: Bool<'organized'>; - constructor(filter: ArchiveFilter | null | undefined) { + constructor(filter?: ArchiveFilter | null) { super(); this.path = new Str('path', filter); @@ -178,6 +218,7 @@ export class ArchiveFilterControls extends Controls<ArchiveFilter> { export class ComicFilterControls extends Controls<ComicFilter> { title: Str<'title'>; + url: Str<'url'>; categories: Enum<'category'>; censorships: Enum<'censorship'>; ratings: Enum<'rating'>; @@ -197,6 +238,7 @@ export class ComicFilterControls extends Controls<ComicFilter> { super(); this.title = new Str('title', filter); + this.url = new Str('url', filter); this.favourite = new Bool('favourite', filter); this.organized = new Bool('organized', filter); this.bookmarked = new Bool('bookmarked', filter); @@ -212,16 +254,26 @@ export class ComicFilterControls extends Controls<ComicFilter> { } } -export class BasicFilterControls extends Controls<BasicFilter> { +export class NameFilterControls extends Controls<NameFilter> { name: Str<'name'>; - constructor(filter?: BasicFilter | null) { + constructor(filter?: NameFilter | null) { super(); this.name = new Str('name', filter); } } +export class BasicFilterControls extends NameFilterControls { + orphan: Orphan<'comics'>; + + constructor(filter?: BasicFilter | null) { + super(filter); + + this.orphan = new Orphan('comics', filter); + } +} + export class TagFilterControls extends BasicFilterControls { namespaces: Association<'namespaces'>; @@ -232,6 +284,16 @@ export class TagFilterControls extends BasicFilterControls { } } +export class NamespaceFilterControls extends NameFilterControls { + orphan: Orphan<'tags'>; + + constructor(filter?: NamespaceFilter | null) { + super(filter); + + this.orphan = new Orphan('tags', filter); + } +} + function buildFilterInput<F>(include?: F, exclude?: F) { const input: FilterInput<F> = {}; @@ -247,103 +309,88 @@ function buildFilterInput<F>(include?: F, exclude?: F) { } abstract class FilterContext<F> { - include!: { controls: Controls<F>; size: number }; - exclude!: { controls: Controls<F>; size: number }; + include!: Controls<F>; + exclude!: Controls<F>; + includes = 0; + excludes = 0; - apply(params: URLSearchParams) { + apply = (params: URLSearchParams) => { navigate( { - filter: buildFilterInput( - this.include.controls.buildFilter(), - this.exclude.controls.buildFilter() - ) + filter: buildFilterInput(this.include.buildFilter(), this.exclude.buildFilter()) }, params ); - } + }; } export class ArchiveFilterContext extends FilterContext<ArchiveFilter> { - include: { controls: ArchiveFilterControls; size: number }; - exclude: { controls: ArchiveFilterControls; size: number }; + include: ArchiveFilterControls; + exclude: ArchiveFilterControls; 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) - }; + this.include = new ArchiveFilterControls(filter.include); + this.exclude = new ArchiveFilterControls(filter.exclude); + this.includes = numKeys(filter.include, ArchiveFilterContext.ignore); + this.excludes = numKeys(filter.exclude, ArchiveFilterContext.ignore); } } export class ComicFilterContext extends FilterContext<ComicFilter> { - include: { controls: ComicFilterControls; size: number }; - exclude: { controls: ComicFilterControls; size: number }; + include: ComicFilterControls; + exclude: ComicFilterControls; 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) - }; + this.include = new ComicFilterControls(filter.include, 'all'); + this.exclude = new ComicFilterControls(filter.exclude, 'any'); + this.includes = numKeys(filter.include, ComicFilterContext.ignore); + this.excludes = numKeys(filter.exclude, ComicFilterContext.ignore); } } export class BasicFilterContext extends FilterContext<BasicFilter> { - include: { controls: BasicFilterControls; size: number }; - exclude: { controls: BasicFilterControls; size: number }; + include: BasicFilterControls; + exclude: BasicFilterControls; constructor(filter: FilterInput<BasicFilter>) { super(); - this.include = { - controls: new BasicFilterControls(filter.include), - size: numKeys(filter.include) - }; - this.exclude = { - controls: new BasicFilterControls(), - size: 0 - }; + this.include = new BasicFilterControls(filter.include); + this.exclude = new BasicFilterControls(); } } export class TagFilterContext extends FilterContext<TagFilter> { - include: { controls: TagFilterControls; size: number }; - exclude: { controls: TagFilterControls; size: number }; - private static ignore = ['name']; + include: TagFilterControls; + exclude: TagFilterControls; + private static ignore = ['name', 'comics']; 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) - }; + this.include = new TagFilterControls(filter.include, 'all'); + this.exclude = new TagFilterControls(filter.exclude, 'any'); + this.includes = numKeys(filter.include, TagFilterContext.ignore); + this.excludes = numKeys(filter.exclude, TagFilterContext.ignore); } } -export function initFilterContext<F extends FilterContext<unknown>>() { - return setContext<Writable<F>>('filter', writable()); -} +export class NamespaceFilterContext extends FilterContext<NamespaceFilter> { + include: NamespaceFilterControls; + exclude: NamespaceFilterControls; -export function getFilterContext<F extends FilterContext<unknown>>() { - return getContext<Writable<F>>('filter'); + constructor(filter: NamespaceFilterInput) { + super(); + + this.include = new NamespaceFilterControls(filter.include); + this.exclude = new NamespaceFilterControls(); + } } export function cycleBooleanFilter(value: boolean | undefined, tristate = true) { diff --git a/frontend/src/lib/Form.ts b/frontend/src/lib/Form.ts new file mode 100644 index 0000000..b6d06f4 --- /dev/null +++ b/frontend/src/lib/Form.ts @@ -0,0 +1,76 @@ +import type { FullComicFragment, FullTag, Namespace } from '$gql/graphql'; +import type { OmitIdentifiers } from '$gql/Utils'; +import equal from 'fast-deep-equal'; +import type { Snippet } from 'svelte'; + +export interface FormProps<I, P> { + initial: OmitIdentifiers<I>; + submit: (input: P) => void; + children?: Snippet; +} + +interface Item { + id: number | string; + name: string; +} + +function stringPending(a?: string | null, b?: string | null) { + if (a?.length === 0) { + a = null; + } + + if (b?.length === 0) { + b = null; + } + + return a !== b; +} + +function associationPending(as: Item[], bs: Item[]) { + return !equal( + as.map((a) => a.id), + bs.map((b) => b.id) + ); +} + +export function itemPending(initial: OmitIdentifiers<Item>, current: OmitIdentifiers<Item>) { + return stringPending(initial.name, current.name); +} + +export function namespacePending( + initial: OmitIdentifiers<Namespace>, + current: OmitIdentifiers<Namespace> +) { + return itemPending(initial, current) || stringPending(initial.sortName, current.sortName); +} + +export function tagPending(a: OmitIdentifiers<FullTag>, b: OmitIdentifiers<FullTag>) { + return ( + itemPending(a, b) || + stringPending(a.description, b.description) || + associationPending(a.namespaces, b.namespaces) + ); +} + +export function comicPending(a?: FullComicFragment, b?: OmitIdentifiers<FullComicFragment>) { + if (a === undefined) return b !== undefined; + if (b === undefined) return a !== undefined; + + return ( + stringPending(a.title, b.title) || + stringPending(a.originalTitle, b.originalTitle) || + stringPending(a.url, b.url) || + stringPending(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 || + associationPending(a.artists, b.artists) || + associationPending(a.circles, b.circles) || + associationPending(a.characters, b.characters) || + associationPending(a.tags, b.tags) || + associationPending(a.worlds, b.worlds) + ); +} diff --git a/frontend/src/lib/Meta.ts b/frontend/src/lib/Meta.ts index 8cfad6b..24012cb 100644 --- a/frontend/src/lib/Meta.ts +++ b/frontend/src/lib/Meta.ts @@ -1 +1 @@ -export const codename = 'Satanic Satyr'; +export const codename = 'Profligate Pixie'; diff --git a/frontend/src/lib/Navigation.ts b/frontend/src/lib/Navigation.ts index e6b17cd..4dcb998 100644 --- a/frontend/src/lib/Navigation.ts +++ b/frontend/src/lib/Navigation.ts @@ -1,36 +1,44 @@ import { goto as svelteGoto } from '$app/navigation'; -import { SortDirection } from '$gql/graphql'; +import { SortDirection, type ComicFilter } from '$gql/graphql'; import JsonURL from '@jsonurl/jsonurl'; -import { type PaginationData } from './Pagination'; -import { type SortData } from './Sort'; import { toastError } from './Toasts'; +import type { Key } from './Utils'; -function paramToNum<T>(value: string | null, fallback: T) { - if (value) { - const number = +value; +export interface PaginationData { + page: number; + items: number; +} - if (Number.isNaN(number) || number < 0) { - return fallback; - } +export interface SortData<T extends Key> { + on: T; + direction: SortDirection; + seed: number | undefined; +} + +function number<T>(value: string | null, fallback: T) { + if (!value) return fallback; - return number; + const number = +value; + + if (Number.isNaN(number) || number < 0) { + return fallback; } - return fallback; + return number; } -export function parseSortData<T>(params: URLSearchParams, fallback: T): SortData<T> { +export function parseSortData<T extends Key>(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) + seed: number(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) + page: number(params.get('p'), 1), + items: number(params.get('i'), defaultItems) }; } @@ -41,7 +49,7 @@ export function parseFilter<T>(params: URLSearchParams): T { try { return JsonURL.parse(param, { AQF: true, impliedObject: {} }) as T; - } catch (e) { + } catch { return {} as T; } } @@ -62,7 +70,7 @@ interface NavigationParameters<T> { pagination?: Partial<PaginationData>; } -function paramsFrom<T>( +function parametersFrom<T>( { pagination, filter, sort }: NavigationParameters<T>, current?: URLSearchParams ) { @@ -102,13 +110,17 @@ function paramsFrom<T>( return params; } -export function navigate(parameters: NavigationParameters<object>, current?: URLSearchParams) { +export function navigate(params: NavigationParameters<object>, current?: URLSearchParams) { goto({ - params: paramsFrom(parameters, current), + params: parametersFrom(params, current), options: { noScroll: false, keepFocus: true, replaceState: true } }); } export function href<T>(base: string, params: NavigationParameters<T>) { - return `/${base}/?${paramsFrom(params).toString()}`; + return `/${base}/?${parametersFrom(params).toString()}`; +} + +export function quickComicFilter(id: number | string, filter: keyof ComicFilter) { + window.open(href('comics', { filter: { include: { [filter]: { all: [id] } } } })); } diff --git a/frontend/src/lib/Pagination.ts b/frontend/src/lib/Pagination.ts deleted file mode 100644 index f05492b..0000000 --- a/frontend/src/lib/Pagination.ts +++ /dev/null @@ -1,31 +0,0 @@ -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.svelte.ts index 8777b9b..f5a5322 100644 --- a/frontend/src/lib/Reader.ts +++ b/frontend/src/lib/Reader.svelte.ts @@ -1,6 +1,5 @@ import { Layout, type PageFragment } from '$gql/graphql'; import { getContext, setContext } from 'svelte'; -import { writable, type Writable } from 'svelte/store'; export interface Chunk { main: PageFragment; @@ -9,25 +8,23 @@ export interface Chunk { } class ReaderContext { - visible = false; - sidebar = false; - pages: PageFragment[] = []; - page = 0; + visible = $state(false); + sidebar = $state(false); + pages: PageFragment[] = $state([]); + page = $state(0); - open(page: number) { + open = (page: number) => { this.page = page; this.visible = true; - - return this; - } + }; } export function initReaderContext() { - return setContext<Writable<ReaderContext>>('reader', writable(new ReaderContext())); + return setContext<ReaderContext>('reader', new ReaderContext()); } export function getReaderContext() { - return getContext<Writable<ReaderContext>>('reader'); + return getContext<ReaderContext>('reader'); } export function partition(pages: PageFragment[], layout: Layout): [Chunk[], number[]] { diff --git a/frontend/src/lib/Selection.ts b/frontend/src/lib/Selection.ts deleted file mode 100644 index 0ea85cc..0000000 --- a/frontend/src/lib/Selection.ts +++ /dev/null @@ -1,141 +0,0 @@ -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 index 063bd40..259500c 100644 --- a/frontend/src/lib/Shortcuts.ts +++ b/frontend/src/lib/Shortcuts.ts @@ -1,5 +1,4 @@ -import { closeModal, modals } from 'svelte-modals'; -import { get } from 'svelte/store'; +import { modals } from 'svelte-modals'; type LowercaseLetter = | 'a' @@ -32,12 +31,12 @@ type UppercaseLetter = Uppercase<LowercaseLetter>; type Letter = LowercaseLetter | UppercaseLetter; type Special = '?' | 'Enter' | 'Escape' | 'Delete'; -const modeSwitches = ['n', 'g', 'i'] as const; +const modeSwitches = ['n', 'g', 'i', 'e'] 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; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return modeSwitches.includes(s as any); } type Key = Letter | Special; @@ -68,8 +67,8 @@ export function handleShortcuts(event: KeyboardEvent) { } if (event.key === 'Escape') { - if (get(modals).length > 0) { - closeModal(); + if (modals.stack.length > 0) { + modals.close(); event.preventDefault(); event.stopImmediatePropagation(); return; @@ -84,7 +83,7 @@ export function handleShortcuts(event: KeyboardEvent) { const handler = handlers.get(mode === undefined ? event.key : `${mode}${event.key}`); - if (!handler || get(modals).length > 0) { + if (!handler || modals.stack.length > 0) { mode = undefined; return; } diff --git a/frontend/src/lib/Sort.ts b/frontend/src/lib/Sort.ts deleted file mode 100644 index 4c9a353..0000000 --- a/frontend/src/lib/Sort.ts +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index 1c43068..0000000 --- a/frontend/src/lib/Tabs.ts +++ /dev/null @@ -1,18 +0,0 @@ -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 index abc9a7d..224989b 100644 --- a/frontend/src/lib/Toasts.ts +++ b/frontend/src/lib/Toasts.ts @@ -15,5 +15,5 @@ export function toastError(message: string) { }); } -// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any +// eslint-disable-next-line @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 index 59ebaf2..4e854f4 100644 --- a/frontend/src/lib/Transitions.ts +++ b/frontend/src/lib/Transitions.ts @@ -1,10 +1,8 @@ -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 slideYDefault: SlideParams = { axis: 'y', duration: 300 }; +export const slideXDefault: SlideParams = { axis: 'x', duration: 300 }; export const slideXFast: SlideParams = { axis: 'x', duration: 200 }; diff --git a/frontend/src/lib/Update.ts b/frontend/src/lib/Update.svelte.ts index 507dd52..1d684d5 100644 --- a/frontend/src/lib/Update.ts +++ b/frontend/src/lib/Update.svelte.ts @@ -4,17 +4,14 @@ import { type UpdateOptions, type UpdateTagInput } from '$gql/graphql'; - -type Key = string | number | symbol; +import type { Key } from './Utils'; interface AssociationUpdate { ids?: number[] | string[] | null; options?: UpdateOptions | null; } -type Input<T, K extends Key> = { - [Property in K]?: T | null; -}; +type Input<T, K extends Key> = Partial<Record<K, T | null>>; abstract class Entry<K extends Key> { key: K; @@ -28,10 +25,10 @@ abstract class Entry<K extends Key> { } class Association<K extends Key> extends Entry<K> { - ids = []; - options = { + ids = $state([]); + options = $state({ mode: UpdateMode.Add - }; + }); constructor(key: K) { super(key); @@ -49,7 +46,7 @@ class Association<K extends Key> extends Entry<K> { } class Enum<K extends Key> extends Entry<K> { - value?: string = undefined; + value?: string = $state(undefined); constructor(key: K) { super(key); @@ -67,13 +64,13 @@ class Enum<K extends Key> extends Entry<K> { } abstract class Controls<I> { - toInput() { + input() { const input = {} as I; Object.values(this).forEach((v: Entry<keyof I>) => v.integrate(input)); return input; } - hasInput() { + pending() { return Object.values(this).some((i: Entry<keyof I>) => i.hasInput()); } } diff --git a/frontend/src/lib/Utils.ts b/frontend/src/lib/Utils.ts index 1a07be1..c347544 100644 --- a/frontend/src/lib/Utils.ts +++ b/frontend/src/lib/Utils.ts @@ -2,7 +2,8 @@ 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 { modals } from 'svelte-modals'; +import { toastFinally } from './Toasts'; import ConfirmDeletion from './dialogs/ConfirmDeletion.svelte'; export function range(from: number, to: number) { @@ -16,6 +17,8 @@ export function getRandomInt(min: number, max: number) { return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); } +export type Key = string | number | symbol; + export interface ListItem { id: number | string; name: string; @@ -32,7 +35,6 @@ export function getResultState(state: OperationResultState): ResultState { 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; @@ -68,11 +70,14 @@ export function confirmDeletion( callback: () => void, warning?: string ) { - openModal( - ConfirmDeletion, - { names: Array.isArray(names) ? names : [names], typename, callback: callback, warning }, - { replace: true } - ); + modals + .open(ConfirmDeletion, { + names: Array.isArray(names) ? names : [names], + typename, + callback, + warning + }) + .catch(toastFinally); } export function idFromLabel(label: string) { @@ -87,7 +92,7 @@ export function formatListSize(word: string, size: number) { return `${size} ${pluralize(word, size)}`; } -export function joinText(items: string[], separator = ', ') { +export function joinText(items: (string | undefined | null)[], separator = ', ') { return items.filter((i) => i).join(separator); } @@ -106,3 +111,12 @@ export function preventOnPending({ to, cancel }: BeforeNavigate, pending: boolea cancel(); } +export function omit<T, K extends keyof T>(obj: T, ...props: K[]): Omit<T, K> { + return props.reduce( + (o, k) => { + delete o[k]; + return o; + }, + { ...obj } + ); +} diff --git a/frontend/src/lib/components/AddButton.svelte b/frontend/src/lib/components/AddButton.svelte index 9c0ab29..7a07bd7 100644 --- a/frontend/src/lib/components/AddButton.svelte +++ b/frontend/src/lib/components/AddButton.svelte @@ -1,7 +1,14 @@ <script lang="ts"> - export let title: string; + import type { MouseEventHandler } from 'svelte/elements'; + + interface Props { + title: string; + onclick: MouseEventHandler<HTMLButtonElement>; + } + + let { title, onclick }: Props = $props(); </script> -<button class="btn-blue" {title} on:click> - <span class="icon-base icon-[material-symbols--add]" /> +<button class="btn-blue" {title} aria-label={title} {onclick}> + <span class="icon-base icon-[material-symbols--add]"></span> </button> diff --git a/frontend/src/lib/components/ArchiveCard.svelte b/frontend/src/lib/components/ArchiveCard.svelte new file mode 100644 index 0000000..c9d283b --- /dev/null +++ b/frontend/src/lib/components/ArchiveCard.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import type { ArchiveFragment } from '$gql/graphql'; + import FooterPill from '$lib/pills/FooterPill.svelte'; + import { filesize } from 'filesize'; + import { type Snippet } from 'svelte'; + import Card from './Card.svelte'; + + interface Props { + archive: ArchiveFragment; + overlay?: Snippet; + onclick?: (event: MouseEvent) => void; + } + + let { archive, overlay, onclick }: Props = $props(); + + let details = $derived({ + title: archive.name, + cover: archive.cover + }); + let href = $derived(`/archives/${archive.id.toString()}`); +</script> + +<Card {details} {href} {onclick} {overlay}> + {#snippet footer()} + <div class="flex flex-wrap gap-1"> + <FooterPill text={`${archive.pageCount} pages`}> + {#snippet icon()} + <span class="icon-[material-symbols--description] mr-0.5 text-sm"></span> + {/snippet} + </FooterPill> + <div class="flex grow"></div> + <FooterPill text={filesize(archive.size, { base: 2 })}> + {#snippet icon()} + <span class="icon-[material-symbols--hard-drive] mr-0.5 text-sm"></span> + {/snippet} + </FooterPill> + </div> + {/snippet} +</Card> diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte index 7ad3173..8de5e34 100644 --- a/frontend/src/lib/components/Badge.svelte +++ b/frontend/src/lib/components/Badge.svelte @@ -2,12 +2,12 @@ import { fadeDefault } from '$lib/Transitions'; import { fade } from 'svelte/transition'; - export let number: number; + let { number }: { number: number } = $props(); </script> {#if number > 0} <span - class="absolute -right-[3px] -top-[6px] z-[1] rounded-lg bg-teal-600 px-1 text-xs" + class="absolute -top-1.5 -right-1 z-1 rounded-xs bg-teal-600 px-1 text-xs font-semibold drop-shadow-sm" transition:fade={fadeDefault} > {number} diff --git a/frontend/src/lib/components/BookmarkButton.svelte b/frontend/src/lib/components/BookmarkButton.svelte index 89570e6..bdcbd75 100644 --- a/frontend/src/lib/components/BookmarkButton.svelte +++ b/frontend/src/lib/components/BookmarkButton.svelte @@ -1,9 +1,15 @@ <script lang="ts"> import Bookmark from '$lib/icons/Bookmark.svelte'; + import type { MouseEventHandler } from 'svelte/elements'; - export let bookmarked: boolean; + interface Props { + bookmarked: boolean; + onclick: MouseEventHandler<HTMLButtonElement>; + } + + let { bookmarked, onclick }: Props = $props(); </script> -<button type="button" title="Toggle bookmark" class="flex text-base" on:click> +<button type="button" title="Toggle bookmark" class="flex text-base" {onclick}> <Bookmark hoverable {bookmarked} /> </button> diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte index 2384799..8a2b047 100644 --- a/frontend/src/lib/components/Card.svelte +++ b/frontend/src/lib/components/Card.svelte @@ -1,5 +1,8 @@ -<script lang="ts" context="module"> - import type { ComicFragment, ImageFragment } from '$gql/graphql'; +<script lang="ts"> + import type { ImageFragment } from '$gql/graphql'; + import { src } from '$lib/Utils'; + import Star from '$lib/icons/Star.svelte'; + import type { Snippet } from 'svelte'; interface CardDetails { title: string; @@ -8,41 +11,40 @@ 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 - } - }; + interface Props { + href: string; + details: CardDetails; + compact?: boolean; + coverOnly?: boolean; + overlay?: Snippet; + children?: Snippet; + footer?: Snippet; + onclick?: (event: MouseEvent) => void; } -</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; + let { + href, + details, + compact = false, + coverOnly = false, + overlay, + children, + footer, + onclick + }: Props = $props(); </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="grid-card-v sm:grid-card-h relative grid overflow-hidden rounded-sm bg-slate-900 shadow-md shadow-slate-950/30 focus-visible:outline-4 focus-visible:outline-blue-600" class:compact class:grid-card-cover-only={coverOnly} - on:click + {onclick} > - <slot name="overlay" /> + {@render overlay?.()} {#if details.cover} <img - class="h-full w-full object-cover object-[center_top]" + class="h-full w-full object-cover object-[left_top]" width={details.cover.width} height={details.cover.height} src={src(details.cover)} @@ -51,13 +53,9 @@ /> {/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} - > + <article class="p flex h-full flex-col p-2 pb-1"> + <header class="mb-2"> + <h2 class="self-center text-sm font-medium [grid-area:title]" title={details.title}> {details.title} </h2> {#if details.subtitle} @@ -75,9 +73,15 @@ {/if} </header> - <section class="max-h-full grow overflow-auto border-t border-slate-800/80 pt-2 text-xs"> - <slot /> + <section class="max-h-full grow overflow-auto border-y border-slate-800/80 pt-2 text-xs"> + {@render children?.()} </section> + + {#if footer} + <div class="mt-1 text-xs"> + {@render footer()} + </div> + {/if} </article> {/if} </a> diff --git a/frontend/src/lib/components/Cardlet.svelte b/frontend/src/lib/components/Cardlet.svelte index 04d8599..cfbbd58 100644 --- a/frontend/src/lib/components/Cardlet.svelte +++ b/frontend/src/lib/components/Cardlet.svelte @@ -1,27 +1,25 @@ <script lang="ts"> - import type { ComicFilter } from '$gql/graphql'; - import { href } from '$lib/Navigation'; + import type { Snippet } from 'svelte'; - 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; + interface Props { + name: string; + title?: string | null; + overlay?: Snippet; + onclick: (event: MouseEvent) => void; + onauxclick?: (event: MouseEvent) => void; + } - const handleAux = (e: MouseEvent) => { - if (filter === undefined || id === undefined || e.button !== 1) return; - window.open(href('comics', { filter: { include: { [filter]: { all: [id] } } } })); - }; + let { name, title = undefined, overlay, onclick, onauxclick = undefined }: Props = $props(); </script> <button type="button" - class="relative flex overflow-hidden rounded bg-slate-900 text-left shadow-md shadow-slate-950/20" + class="relative flex overflow-hidden rounded-sm bg-slate-900 text-left shadow-md shadow-slate-950/20" {title} - on:click - on:auxclick={handleAux} + {onclick} + {onauxclick} > - <slot name="overlay" /> + {@render 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> diff --git a/frontend/src/lib/components/ComicCard.svelte b/frontend/src/lib/components/ComicCard.svelte new file mode 100644 index 0000000..1a648b2 --- /dev/null +++ b/frontend/src/lib/components/ComicCard.svelte @@ -0,0 +1,75 @@ +<script lang="ts"> + import type { ComicFragment } from '$gql/graphql'; + import FooterPill from '$lib/pills/FooterPill.svelte'; + import Pill from '$lib/pills/Pill.svelte'; + import TagPill from '$lib/pills/TagPill.svelte'; + import { type Snippet } from 'svelte'; + import Card from './Card.svelte'; + + interface Props { + comic: ComicFragment; + overlay?: Snippet; + compact?: boolean; + coverOnly?: boolean; + onclick?: (event: MouseEvent) => void; + } + + let { comic, overlay, compact, coverOnly, onclick }: Props = $props(); + + let details = $derived({ + title: comic.title, + subtitle: comic.originalTitle, + favourite: comic.favourite, + cover: comic.cover + }); + let href = $derived(`/comics/${comic.id.toString()}`); +</script> + +<Card {details} {href} {compact} {onclick} {overlay} {coverOnly}> + <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)} + <Pill {name} style="artist" /> + {/each} + {#each comic.circles as { name } (name)} + <Pill {name} style="circle" /> + {/each} + </div> + {/if} + {#if comic.characters.length || comic.worlds.length} + <div class="flex flex-wrap gap-1"> + {#each comic.worlds as { name } (name)} + <Pill {name} style="world" /> + {/each} + {#each comic.characters as { name } (name)} + <Pill {name} style="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> + {#snippet footer()} + <div class="flex flex-wrap gap-1"> + <FooterPill text={`${comic.pageCount} pages`}> + {#snippet icon()} + <span class="icon-[material-symbols--description] mr-0.5 text-sm"></span> + {/snippet} + </FooterPill> + <div class="flex grow"></div> + {#if comic.date} + <FooterPill text={comic.date}> + {#snippet icon()} + <span class="icon-[material-symbols--calendar-today] mr-0.5 text-sm"></span> + {/snippet} + </FooterPill> + {/if} + </div> + {/snippet} +</Card> diff --git a/frontend/src/lib/components/DeleteButton.svelte b/frontend/src/lib/components/DeleteButton.svelte index 8f5f116..4659e13 100644 --- a/frontend/src/lib/components/DeleteButton.svelte +++ b/frontend/src/lib/components/DeleteButton.svelte @@ -1,15 +1,23 @@ -<script> +<script lang="ts"> import { accelerator } from '$lib/Shortcuts'; + import type { MouseEventHandler } from 'svelte/elements'; - export let prominent = false; + interface Props { + prominent?: boolean; + onclick: MouseEventHandler<HTMLButtonElement>; + } + + let { prominent = false, onclick }: Props = $props(); </script> <button type="button" - class={prominent ? 'btn-rose' : 'btn-slate hover:bg-rose-700'} + class:prominent + class="[&.prominent]:btn-rose btn-slate hover:bg-rose-700" title="Delete forever" - on:click + aria-label="Delete forever" + {onclick} use:accelerator={'Delete'} > - <span class="icon-base icon-[material-symbols--delete-forever]" /> + <span class="icon-base icon-[material-symbols--delete-forever]"></span> </button> diff --git a/frontend/src/lib/components/Dialog.svelte b/frontend/src/lib/components/Dialog.svelte index a0bbe5e..ec647ba 100644 --- a/frontend/src/lib/components/Dialog.svelte +++ b/frontend/src/lib/components/Dialog.svelte @@ -1,16 +1,23 @@ <script lang="ts"> import { trapFocus } from '$lib/Actions'; import { fadeDefault } from '$lib/Transitions'; - import { closeModal } from 'svelte-modals'; + import type { Snippet } from 'svelte'; + import type { ModalProps } from 'svelte-modals'; import { fade } from 'svelte/transition'; - export let isOpen: boolean; + interface Props extends ModalProps { + title: string; + children?: Snippet; + } + + // eslint-disable-next-line svelte/no-unused-props + let { isOpen, close, title, children }: Props = $props(); </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" + class="pointer-events-none fixed top-0 right-0 bottom-0 left-0 z-30 flex items-center justify-center" transition:fade|global={fadeDefault} use:trapFocus > @@ -18,18 +25,19 @@ 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" /> + <h2>{title}</h2> <button type="button" class="ml-auto flex items-center text-white/30 hover:text-white" title="Cancel" - on:click={closeModal} + aria-label="Cancel" + onclick={close} > - <span class="icon-base icon-[material-symbols--close]" /> + <span class="icon-base icon-[material-symbols--close]"></span> </button> </header> <main class="m-3 w-80 sm:w-[34rem]"> - <slot /> + {@render children?.()} </main> </div> </div> diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte index 9e935e4..e2979e6 100644 --- a/frontend/src/lib/components/Dropdown.svelte +++ b/frontend/src/lib/components/Dropdown.svelte @@ -1,18 +1,37 @@ <script lang="ts"> - import { clickOutside } from '$lib/Actions'; import { fadeFast } from '$lib/Transitions'; + import type { Snippet } from 'svelte'; import { fade } from 'svelte/transition'; - export let visible: boolean; - export let parent: HTMLElement; + interface Props { + button: Snippet<[() => void]>; + children?: Snippet; + } + + let { button, children }: Props = $props(); + + let visible = $state(false); + + function onfocusout(event: FocusEvent & { currentTarget: EventTarget & HTMLDivElement }) { + if ( + event.relatedTarget instanceof HTMLElement && + event.currentTarget.contains(event.relatedTarget) + ) { + return; + } + + visible = false; + } </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} +<div class="relative" {onfocusout}> + {@render button(() => (visible = !visible))} + {#if visible} + <div + class="absolute z-1 mt-1 w-max rounded-sm bg-slate-700 p-1 shadow-xs shadow-slate-900" + transition:fade={fadeFast} + > + {@render children?.()} + </div> + {/if} +</div> diff --git a/frontend/src/lib/components/Expander.svelte b/frontend/src/lib/components/Expander.svelte deleted file mode 100644 index a382658..0000000 --- a/frontend/src/lib/components/Expander.svelte +++ /dev/null @@ -1,17 +0,0 @@ -<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 index fd7ded4..38cbd65 100644 --- a/frontend/src/lib/components/Guard.svelte +++ b/frontend/src/lib/components/Guard.svelte @@ -1,9 +1,10 @@ <script lang="ts"> import { getResultState } from '$lib/Utils'; + import type { OperationResultStore } from '@urql/svelte'; import Spinner from './Spinner.svelte'; - export let result; - $: state = getResultState($result); + let { result }: { result: OperationResultStore } = $props(); + let state = $derived(getResultState($result)); </script> {#if state.fetching} diff --git a/frontend/src/lib/components/Head.svelte b/frontend/src/lib/components/Head.svelte index b4aed5b..5ddd543 100644 --- a/frontend/src/lib/components/Head.svelte +++ b/frontend/src/lib/components/Head.svelte @@ -1,6 +1,5 @@ <script lang="ts"> - export let section: string; - export let title = ''; + let { section, title = '' }: { section: string; title?: string } = $props(); function formatTitle(section: string, title?: string) { return [title, section, 'hircine'].filter((i) => i).join(' · '); diff --git a/frontend/src/lib/components/Labelled.svelte b/frontend/src/lib/components/Labelled.svelte deleted file mode 100644 index 4b36ad6..0000000 --- a/frontend/src/lib/components/Labelled.svelte +++ /dev/null @@ -1,10 +0,0 @@ -<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 index feb563e..8f93667 100644 --- a/frontend/src/lib/components/LabelledBlock.svelte +++ b/frontend/src/lib/components/LabelledBlock.svelte @@ -1,7 +1,14 @@ <script lang="ts"> import { idFromLabel } from '$lib/Utils'; + import type { Snippet } from 'svelte'; - export let label: string; + interface Props { + label: string; + side?: Snippet; + children?: Snippet<[{ id: string }]>; + } + + let { label, side, children }: Props = $props(); const id = idFromLabel(label); </script> @@ -9,10 +16,10 @@ <div class="flex flex-col"> <div class="flex"> <label for={id}>{label}</label> - {#if $$slots.controls} - <div class="grow" /> - <slot name="controls" /> + {#if side} + <div class="grow"></div> + {@render side?.()} {/if} </div> - <slot {id} /> + {@render children?.({ id })} </div> diff --git a/frontend/src/lib/components/OrganizedButton.svelte b/frontend/src/lib/components/OrganizedButton.svelte index 9be985c..3838f7d 100644 --- a/frontend/src/lib/components/OrganizedButton.svelte +++ b/frontend/src/lib/components/OrganizedButton.svelte @@ -1,9 +1,15 @@ <script lang="ts"> import Organized from '$lib/icons/Organized.svelte'; + import type { MouseEventHandler } from 'svelte/elements'; - export let organized: boolean; + interface Props { + organized: boolean; + onclick: MouseEventHandler<HTMLButtonElement>; + } + + let { organized, onclick }: Props = $props(); </script> -<button type="button" title="Toggle organized" class="flex text-base" on:click> +<button type="button" title="Toggle organized" class="flex text-base" {onclick}> <Organized hoverable {organized} /> </button> diff --git a/frontend/src/lib/components/RefreshButton.svelte b/frontend/src/lib/components/RefreshButton.svelte index afab640..70ee2d1 100644 --- a/frontend/src/lib/components/RefreshButton.svelte +++ b/frontend/src/lib/components/RefreshButton.svelte @@ -1,3 +1,9 @@ -<button class="btn-blue" title="Refresh" on:click> - <span class="icon-base icon-[material-symbols--sync]" /> +<script lang="ts"> + import type { MouseEventHandler } from 'svelte/elements'; + + let { onclick }: { onclick: MouseEventHandler<HTMLButtonElement> } = $props(); +</script> + +<button class="btn-blue" title="Refresh" aria-label="Refresh" {onclick}> + <span class="icon-base icon-[material-symbols--sync]"></span> </button> diff --git a/frontend/src/lib/components/RemovePageButton.svelte b/frontend/src/lib/components/RemovePageButton.svelte index e23c079..8045f32 100644 --- a/frontend/src/lib/components/RemovePageButton.svelte +++ b/frontend/src/lib/components/RemovePageButton.svelte @@ -1,13 +1,17 @@ <script lang="ts"> import { accelerator } from '$lib/Shortcuts'; + import type { MouseEventHandler } from 'svelte/elements'; + + let { onclick }: { onclick: MouseEventHandler<HTMLButtonElement> } = $props(); </script> <button type="button" class="btn-rose" title="Remove selected pages" - on:click + aria-label="Remove selected pages" + {onclick} use:accelerator={'Delete'} > - <span class="icon-base icon-[material-symbols--scan-delete]" /> + <span class="icon-base icon-[material-symbols--scan-delete]"></span> </button> diff --git a/frontend/src/lib/components/Select.svelte b/frontend/src/lib/components/Select.svelte index dece4a5..44828d3 100644 --- a/frontend/src/lib/components/Select.svelte +++ b/frontend/src/lib/components/Select.svelte @@ -2,19 +2,28 @@ import type { ListItem } from '$lib/Utils'; import Svelecte from 'svelecte'; - let inputId: string; - let valueAsObject = false; - let multiple = false; - type Item = number | string | ListItem; type Value = Item | Item[] | undefined | null; - export let clearable = false; - export let placeholder = 'Select...'; - export let options: ListItem[] | undefined; - export let value: Value; + interface Props { + id: string; + object?: boolean; + multi?: boolean; + clearable?: boolean; + placeholder?: string; + options: ListItem[] | undefined; + value: Value; + } - export { inputId as id, valueAsObject as object, multiple as multi }; + let { + id: inputId, + object: valueAsObject = false, + multi: multiple = false, + clearable = false, + placeholder = 'Select...', + options, + value = $bindable() + }: Props = $props(); </script> {#if options !== null && options !== undefined} diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte index 946329c..d85c4f4 100644 --- a/frontend/src/lib/components/Spinner.svelte +++ b/frontend/src/lib/components/Spinner.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { onDestroy } from 'svelte'; - let show = false; + let show = $state(false); const timeout = setTimeout(() => (show = true), 150); onDestroy(() => clearTimeout(timeout)); @@ -9,28 +9,7 @@ {#if show} <div class="flex h-full w-full items-center justify-center"> - <span class="spinner" /> + <span class="h-16 w-16 animate-spin rounded-full border-4 border-white/80 border-b-transparent" + ></span> </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 index 8ac90b9..3b89ba7 100644 --- a/frontend/src/lib/components/SubmitButton.svelte +++ b/frontend/src/lib/components/SubmitButton.svelte @@ -1,7 +1,7 @@ <script lang="ts"> - export let active = false; + let { pending = false }: { pending?: boolean } = $props(); - $: title = active ? 'Save pending changes' : 'Save (no changes pending)'; + let title = $derived(pending ? 'Save pending changes' : 'Save (no changes pending)'); </script> -<button type="submit" class:active class="btn-slate [&.active]:btn-blue" {title}>Save</button> +<button type="submit" class:pending class="btn-slate [&.pending]:btn-blue" {title}>Save</button> diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte index 8aab2dd..bb36d8f 100644 --- a/frontend/src/lib/components/Titlebar.svelte +++ b/frontend/src/lib/components/Titlebar.svelte @@ -1,12 +1,15 @@ <script lang="ts"> import Star from '$lib/icons/Star.svelte'; - import { createEventDispatcher } from 'svelte'; + import type { MouseEventHandler } from 'svelte/elements'; - export let title: string; - export let subtitle: string | null = ''; - export let favourite: boolean | undefined = undefined; + interface Props { + title: string; + subtitle?: string | null; + favourite?: boolean; + onfavourite?: MouseEventHandler<HTMLButtonElement>; + } - const dispatch = createEventDispatcher<{ favourite: null }>(); + let { title, subtitle, favourite, onfavourite }: Props = $props(); </script> <div class="flex flex-wrap gap-x-4"> @@ -14,9 +17,9 @@ {#if favourite !== undefined} <button type="button" - class="mr-1 flex items-center" + class="mr-1 flex items-center focus-visible:bg-yellow-400/20 focus-visible:outline-hidden" title="Toggle favourite" - on:click={() => dispatch('favourite')} + onclick={onfavourite} > <Star large hoverable {favourite} /> </button> diff --git a/frontend/src/lib/containers/Cardlets.svelte b/frontend/src/lib/containers/Cardlets.svelte index 129da61..107ebee 100644 --- a/frontend/src/lib/containers/Cardlets.svelte +++ b/frontend/src/lib/containers/Cardlets.svelte @@ -1,11 +1,13 @@ <script> import { fadeDefault } from '$lib/Transitions'; import { fade } from 'svelte/transition'; + + let { children } = $props(); </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" + class="3xl:grid-cols-10 grid gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 2xl:grid-cols-8" in:fade={fadeDefault} > - <slot /> + {@render children?.()} </div> diff --git a/frontend/src/lib/containers/Cards.svelte b/frontend/src/lib/containers/Cards.svelte index a19e8be..ab1f0fb 100644 --- a/frontend/src/lib/containers/Cards.svelte +++ b/frontend/src/lib/containers/Cards.svelte @@ -1,8 +1,10 @@ <script> import { fadeDefault } from '$lib/Transitions'; import { fade } from 'svelte/transition'; + + let { children } = $props(); </script> -<div class="grid gap-4 xl:grid-cols-2 min-[1920px]:grid-cols-3" in:fade|global={fadeDefault}> - <slot /> +<div class="3xl:grid-cols-3 grid gap-4 xl:grid-cols-2" in:fade|global={fadeDefault}> + {@render children?.()} </div> diff --git a/frontend/src/lib/containers/Carousel.svelte b/frontend/src/lib/containers/Carousel.svelte index 1268a78..fb05b7d 100644 --- a/frontend/src/lib/containers/Carousel.svelte +++ b/frontend/src/lib/containers/Carousel.svelte @@ -1,6 +1,13 @@ <script lang="ts"> - export let title: string; - export let href: string; + import type { Snippet } from 'svelte'; + + interface Props { + title: string; + href: string; + children?: Snippet; + } + + let { title, href, children }: Props = $props(); </script> <div class="flex flex-col gap-1"> @@ -10,6 +17,6 @@ </a> </h2> <div class="flex flex-wrap gap-5"> - <slot /> + {@render children?.()} </div> </div> diff --git a/frontend/src/lib/containers/Column.svelte b/frontend/src/lib/containers/Column.svelte index 05daece..fe5ac47 100644 --- a/frontend/src/lib/containers/Column.svelte +++ b/frontend/src/lib/containers/Column.svelte @@ -1,3 +1,7 @@ +<script> + let { children } = $props(); +</script> + <div class="flex flex-col gap-4"> - <slot /> + {@render children?.()} </div> diff --git a/frontend/src/lib/containers/Grid.svelte b/frontend/src/lib/containers/Grid.svelte index 1224156..af5125a 100644 --- a/frontend/src/lib/containers/Grid.svelte +++ b/frontend/src/lib/containers/Grid.svelte @@ -1,14 +1,16 @@ -<script> +<script lang="ts"> import { fadeDefault } from '$lib/Transitions'; - + import type { Snippet } from 'svelte'; import { fade } from 'svelte/transition'; + + let { children }: { children?: Snippet } = $props(); </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 /> + {@render children?.()} </div> <style> diff --git a/frontend/src/lib/dialogs/AddArtist.svelte b/frontend/src/lib/dialogs/AddArtist.svelte index 6ec93c5..9fc2ca1 100644 --- a/frontend/src/lib/dialogs/AddArtist.svelte +++ b/frontend/src/lib/dialogs/AddArtist.svelte @@ -1,30 +1,22 @@ <script lang="ts"> - import { addArtist, type ArtistInput } from '$gql/Mutations'; + import type { AddArtistInput } from '$gql/graphql'; + import { addArtist } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + let modal: ModalProps = $props(); + const initial = { name: '' }; - let artist = { name: '' }; - - function add(event: CustomEvent<ArtistInput>) { - addArtist(client, { input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: AddArtistInput) { + addArtist(client, { input }).then(modal.close).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 title="Add Artist" {...modal}> + <ArtistForm {initial} {submit} /> </Dialog> diff --git a/frontend/src/lib/dialogs/AddCharacter.svelte b/frontend/src/lib/dialogs/AddCharacter.svelte index 23fea08..1585e34 100644 --- a/frontend/src/lib/dialogs/AddCharacter.svelte +++ b/frontend/src/lib/dialogs/AddCharacter.svelte @@ -1,30 +1,22 @@ <script lang="ts"> - import { addCharacter, type CharacterInput } from '$gql/Mutations'; + import type { AddCharacterInput } from '$gql/graphql'; + import { addCharacter } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + let modal: ModalProps = $props(); + const initial = { name: '' }; - let character = { name: '' }; - - function add(event: CustomEvent<CharacterInput>) { - addCharacter(client, { input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: AddCharacterInput) { + addCharacter(client, { input }).then(modal.close).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 title="Add Character" {...modal}> + <CharacterForm {initial} {submit} /> </Dialog> diff --git a/frontend/src/lib/dialogs/AddCircle.svelte b/frontend/src/lib/dialogs/AddCircle.svelte index f0ef014..faffc63 100644 --- a/frontend/src/lib/dialogs/AddCircle.svelte +++ b/frontend/src/lib/dialogs/AddCircle.svelte @@ -1,30 +1,22 @@ <script lang="ts"> - import { addCircle, type CircleInput } from '$gql/Mutations'; + import type { AddCircleInput } from '$gql/graphql'; + import { addCircle } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + let modal: ModalProps = $props(); + const initial = { name: '' }; - let circle = { name: '' }; - - function add(event: CustomEvent<CircleInput>) { - addCircle(client, { input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: AddCircleInput) { + addCircle(client, { input }).then(modal.close).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 title="Add Circle" {...modal}> + <CircleForm {initial} {submit} /> </Dialog> diff --git a/frontend/src/lib/dialogs/AddNamespace.svelte b/frontend/src/lib/dialogs/AddNamespace.svelte index e81b22a..45183f4 100644 --- a/frontend/src/lib/dialogs/AddNamespace.svelte +++ b/frontend/src/lib/dialogs/AddNamespace.svelte @@ -1,30 +1,22 @@ <script lang="ts"> - import { addNamespace, type NamespaceInput } from '$gql/Mutations'; + import type { AddNamespaceInput } from '$gql/graphql'; + import { addNamespace } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + let modal: ModalProps = $props(); + const initial = { name: '' }; - let namespace = { name: '' }; - - function add(event: CustomEvent<NamespaceInput>) { - addNamespace(client, { input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: AddNamespaceInput) { + addNamespace(client, { input }).then(modal.close).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 title="Add Namespace" {...modal}> + <NamespaceForm {initial} {submit} /> </Dialog> diff --git a/frontend/src/lib/dialogs/AddTag.svelte b/frontend/src/lib/dialogs/AddTag.svelte index 00d3a03..da78bce 100644 --- a/frontend/src/lib/dialogs/AddTag.svelte +++ b/frontend/src/lib/dialogs/AddTag.svelte @@ -1,30 +1,22 @@ <script lang="ts"> - import { addTag, type TagInput } from '$gql/Mutations'; + import type { AddTagInput } from '$gql/graphql'; + import { addTag } 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'; + import { type ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + let modal: ModalProps = $props(); + const initial = { name: '', namespaces: [] }; - let tag = { name: '', namespaces: [] }; - - function add(event: CustomEvent<TagInput>) { - addTag(client, { input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: AddTagInput) { + addTag(client, { input }).then(modal.close).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 title="Add Tag" {...modal}> + <TagForm {initial} {submit} /> </Dialog> diff --git a/frontend/src/lib/dialogs/AddWorld.svelte b/frontend/src/lib/dialogs/AddWorld.svelte index ceb946e..075d872 100644 --- a/frontend/src/lib/dialogs/AddWorld.svelte +++ b/frontend/src/lib/dialogs/AddWorld.svelte @@ -1,30 +1,22 @@ <script lang="ts"> - import { addWorld, type WorldInput } from '$gql/Mutations'; + import type { AddWorldInput } from '$gql/graphql'; + import { addWorld } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + let modal: ModalProps = $props(); + const initial = { name: '' }; - let world = { name: '' }; - - function add(event: CustomEvent<WorldInput>) { - addWorld(client, { input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: AddWorldInput) { + addWorld(client, { input }).then(modal.close).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 title="Add World" {...modal}> + <WorldForm {initial} {submit} /> </Dialog> diff --git a/frontend/src/lib/dialogs/ConfirmDeletion.svelte b/frontend/src/lib/dialogs/ConfirmDeletion.svelte index 6b0cbf8..53b1dd4 100644 --- a/frontend/src/lib/dialogs/ConfirmDeletion.svelte +++ b/frontend/src/lib/dialogs/ConfirmDeletion.svelte @@ -1,36 +1,37 @@ <script lang="ts"> import { accelerator } from '$lib/Shortcuts'; import Dialog from '$lib/components/Dialog.svelte'; - import { closeModal } from 'svelte-modals'; + import type { ModalProps } from 'svelte-modals'; - export let isOpen: boolean; - export let callback: () => void; + interface Props extends ModalProps { + callback: () => void; + names: string[]; + typename: string; + warning?: string; + } + + let { callback, names, typename, warning = undefined, ...modal }: Props = $props(); - 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() { + function confirm(event: SubmitEvent) { + event.preventDefault(); callback(); - closeModal(); + modal.close(); } </script> -<Dialog {isOpen}> - <svelte:fragment slot="header"> - <h2>Delete {formattedTypename}</h2> - </svelte:fragment> - <form on:submit|preventDefault={confirm}> +<Dialog title="Delete {formattedTypename}" {...modal}> + <form onsubmit={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} + {#each names.slice(0, 10) as name (name)} <li>{name}</li> {/each} </ul> @@ -39,13 +40,15 @@ {/if} {/if} {#if warning} - <p class="font-medium text-red-600">Warning: {warning}</p> + <p class="rounded-sm border border-rose-700 bg-rose-800/70 p-2 text-white"> + {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> + <button type="button" onclick={() => modal.close()} class="btn-slate">Cancel</button> </div> </form> </Dialog> diff --git a/frontend/src/lib/dialogs/EditArtist.svelte b/frontend/src/lib/dialogs/EditArtist.svelte index dd08bc6..fa5c143 100644 --- a/frontend/src/lib/dialogs/EditArtist.svelte +++ b/frontend/src/lib/dialogs/EditArtist.svelte @@ -1,46 +1,37 @@ <script lang="ts"> - import { deleteArtists, updateArtists, type ArtistInput } from '$gql/Mutations'; - import { itemEquals } from '$gql/Utils'; - import { type Artist } from '$gql/graphql'; + import { deleteArtists, updateArtists } from '$gql/Mutations'; + import { omitIdentifiers } from '$gql/Utils'; + import type { Artist, UpdateArtistInput } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + interface Props extends ModalProps { + artist: Artist; + } - export let artist: Artist; - const original = structuredClone(artist); - $: pending = !itemEquals(artist, original); + let { artist, ...modal }: Props = $props(); + const initial = omitIdentifiers(artist); - function save(event: CustomEvent<ArtistInput>) { - updateArtists(client, { ids: artist.id, input: event.detail }) - .then(closeModal) - .catch(toastFinally); + function submit(input: UpdateArtistInput) { + updateArtists(client, { ids: artist.id, input }).then(modal.close).catch(toastFinally); } function deleteArtist() { confirmDeletion('Artist', artist.name, () => { - deleteArtists(client, { ids: artist.id }).then(closeModal).catch(toastFinally); + deleteArtists(client, { ids: artist.id }).then(modal.close).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> +<Dialog title="Edit Artist" {...modal}> + <ArtistForm {initial} {submit}> + <DeleteButton onclick={deleteArtist} /> </ArtistForm> </Dialog> diff --git a/frontend/src/lib/dialogs/EditCharacter.svelte b/frontend/src/lib/dialogs/EditCharacter.svelte index 3b45e78..71125db 100644 --- a/frontend/src/lib/dialogs/EditCharacter.svelte +++ b/frontend/src/lib/dialogs/EditCharacter.svelte @@ -1,46 +1,37 @@ <script lang="ts"> - import { deleteCharacters, updateCharacters, type CharacterInput } from '$gql/Mutations'; - import { itemEquals } from '$gql/Utils'; - import { type Character } from '$gql/graphql'; + import { deleteCharacters, updateCharacters } from '$gql/Mutations'; + import { omitIdentifiers } from '$gql/Utils'; + import type { Character, UpdateCharacterInput } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + interface Props extends ModalProps { + character: Character; + } - export let character: Character; - const original = structuredClone(character); - $: pending = !itemEquals(original, character); + let { character, ...modal }: Props = $props(); + const initial = omitIdentifiers(character); - function save(event: CustomEvent<CharacterInput>) { - updateCharacters(client, { ids: character.id, input: event.detail }) - .then(closeModal) - .catch(toastFinally); + function submit(input: UpdateCharacterInput) { + updateCharacters(client, { ids: character.id, input }).then(modal.close).catch(toastFinally); } function deleteCharacter() { confirmDeletion('Character', character.name, () => { - deleteCharacters(client, { ids: character.id }).then(closeModal).catch(toastFinally); + deleteCharacters(client, { ids: character.id }).then(modal.close).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> +<Dialog title="Edit Character" {...modal}> + <CharacterForm {initial} {submit}> + <DeleteButton onclick={deleteCharacter} /> </CharacterForm> </Dialog> diff --git a/frontend/src/lib/dialogs/EditCircle.svelte b/frontend/src/lib/dialogs/EditCircle.svelte index bdc1217..7cb0f14 100644 --- a/frontend/src/lib/dialogs/EditCircle.svelte +++ b/frontend/src/lib/dialogs/EditCircle.svelte @@ -1,46 +1,37 @@ <script lang="ts"> - import { deleteCircles, updateCircles, type CircleInput } from '$gql/Mutations'; - import { itemEquals } from '$gql/Utils'; - import { type Circle } from '$gql/graphql'; + import { deleteCircles, updateCircles } from '$gql/Mutations'; + import { omitIdentifiers } from '$gql/Utils'; + import type { Circle, UpdateCircleInput } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + interface Props extends ModalProps { + circle: Circle; + } - export let circle: Circle; - const original = structuredClone(circle); - $: pending = !itemEquals(original, circle); + let { circle, ...modal }: Props = $props(); + const initial = omitIdentifiers(circle); - function save(event: CustomEvent<CircleInput>) { - updateCircles(client, { ids: circle.id, input: event.detail }) - .then(closeModal) - .catch(toastFinally); + function submit(input: UpdateCircleInput) { + updateCircles(client, { ids: circle.id, input }).then(modal.close).catch(toastFinally); } function deleteCircle() { confirmDeletion('Circle', circle.name, () => { - deleteCircles(client, { ids: circle.id }).then(closeModal).catch(toastFinally); + deleteCircles(client, { ids: circle.id }).then(modal.close).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> +<Dialog title="Edit Circle" {...modal}> + <CircleForm {initial} {submit}> + <DeleteButton onclick={deleteCircle} /> </CircleForm> </Dialog> diff --git a/frontend/src/lib/dialogs/EditNamespace.svelte b/frontend/src/lib/dialogs/EditNamespace.svelte index f398b21..b104f83 100644 --- a/frontend/src/lib/dialogs/EditNamespace.svelte +++ b/frontend/src/lib/dialogs/EditNamespace.svelte @@ -1,46 +1,37 @@ <script lang="ts"> - import { deleteNamespaces, updateNamespaces, type NamespaceInput } from '$gql/Mutations'; - import { itemEquals } from '$gql/Utils'; - import { type Namespace } from '$gql/graphql'; + import { deleteNamespaces, updateNamespaces } from '$gql/Mutations'; + import { omitIdentifiers } from '$gql/Utils'; + import type { Namespace, UpdateNamespaceInput } 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + interface Props extends ModalProps { + namespace: Namespace; + } - export let namespace: Namespace; - const original = structuredClone(namespace); - $: pending = !itemEquals(original, namespace); + let { namespace, ...modal }: Props = $props(); + const initial = omitIdentifiers(namespace); - function save(event: CustomEvent<NamespaceInput>) { - updateNamespaces(client, { ids: namespace.id, input: event.detail }) - .then(closeModal) - .catch(toastFinally); + function submit(input: UpdateNamespaceInput) { + updateNamespaces(client, { ids: namespace.id, input }).then(modal.close).catch(toastFinally); } function deleteNamespace() { confirmDeletion('Namespace', namespace.name, () => { - deleteNamespaces(client, { ids: namespace.id }).then(closeModal).catch(toastFinally); + deleteNamespaces(client, { ids: namespace.id }).then(modal.close).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> +<Dialog title="Edit Namespace" {...modal}> + <NamespaceForm {initial} {submit}> + <DeleteButton onclick={deleteNamespace} /> </NamespaceForm> </Dialog> diff --git a/frontend/src/lib/dialogs/EditTag.svelte b/frontend/src/lib/dialogs/EditTag.svelte index d2d0013..555d6d1 100644 --- a/frontend/src/lib/dialogs/EditTag.svelte +++ b/frontend/src/lib/dialogs/EditTag.svelte @@ -1,44 +1,37 @@ <script lang="ts"> - import { deleteTags, updateTags, type TagInput } from '$gql/Mutations'; - import { tagEquals } from '$gql/Utils'; - import { type FullTag } from '$gql/graphql'; + import { deleteTags, updateTags } from '$gql/Mutations'; + import { omitIdentifiers } from '$gql/Utils'; + import { type FullTag, type UpdateTagInput } 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'; + import { type ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + interface Props extends ModalProps { + tag: FullTag; + } - export let tag: FullTag; - const original = structuredClone(tag); - $: pending = !tagEquals(original, tag); + let { tag, ...modal }: Props = $props(); + const initial = omitIdentifiers(tag); - function save(event: CustomEvent<TagInput>) { - updateTags(client, { ids: tag.id, input: event.detail }).then(closeModal).catch(toastFinally); + function submit(input: UpdateTagInput) { + updateTags(client, { ids: tag.id, input }).then(modal.close).catch(toastFinally); } function deleteTag() { confirmDeletion('Tag', tag.name, () => { - deleteTags(client, { ids: tag.id }).then(closeModal).catch(toastFinally); + deleteTags(client, { ids: tag.id }).then(modal.close).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> +<Dialog title="Edit Tag" {...modal}> + <TagForm {initial} {submit}> + <DeleteButton onclick={deleteTag} /> </TagForm> </Dialog> diff --git a/frontend/src/lib/dialogs/EditWorld.svelte b/frontend/src/lib/dialogs/EditWorld.svelte index 82afe6a..869dc21 100644 --- a/frontend/src/lib/dialogs/EditWorld.svelte +++ b/frontend/src/lib/dialogs/EditWorld.svelte @@ -1,46 +1,37 @@ <script lang="ts"> - import { type World } from '$gql/graphql'; - import { deleteWorlds, updateWorlds, type WorldInput } from '$gql/Mutations'; - import { itemEquals } from '$gql/Utils'; + import { deleteWorlds, updateWorlds } from '$gql/Mutations'; + import { omitIdentifiers } from '$gql/Utils'; + import type { UpdateWorldInput, World } 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 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'; + import type { ModalProps } from 'svelte-modals'; const client = getContextClient(); - export let isOpen: boolean; + interface Props extends ModalProps { + world: World; + } - export let world: World; - const original = structuredClone(world); - $: pending = !itemEquals(original, world); + let { world, ...modal }: Props = $props(); + const initial = omitIdentifiers(world); - function save(event: CustomEvent<WorldInput>) { - updateWorlds(client, { ids: world.id, input: event.detail }) - .then(closeModal) - .catch(toastFinally); + function submit(input: UpdateWorldInput) { + updateWorlds(client, { ids: world.id, input }).then(modal.close).catch(toastFinally); } function deleteWorld() { confirmDeletion('World', world.name, () => { - deleteWorlds(client, { ids: world.id }).then(closeModal).catch(toastFinally); + deleteWorlds(client, { ids: world.id }).then(modal.close).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> +<Dialog title="Edit World" {...modal}> + <WorldForm {initial} {submit}> + <DeleteButton onclick={deleteWorld} /> </WorldForm> </Dialog> diff --git a/frontend/src/lib/dialogs/UpdateComics.svelte b/frontend/src/lib/dialogs/UpdateComics.svelte index 8de9622..483e379 100644 --- a/frontend/src/lib/dialogs/UpdateComics.svelte +++ b/frontend/src/lib/dialogs/UpdateComics.svelte @@ -3,94 +3,109 @@ 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 { UpdateComicsControls } from '$lib/Update.svelte'; 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 type { ModalProps } from 'svelte-modals'; import UpdateModeSelector from './components/UpdateModeSelector.svelte'; const client = getContextClient(); - export let isOpen: boolean; - export let ids: number[]; + interface Props extends ModalProps { + ids: number[]; + } - $: tagsQuery = comicTagList(client); - $: artistsQuery = artistList(client); - $: charactersQuery = characterList(client); - $: circlesQuery = circleList(client); - $: worldsQuery = worldList(client); + let { ids, ...modal }: Props = $props(); - $: 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; + let tagsQuery = $derived(comicTagList(client)); + let artistsQuery = $derived(artistList(client)); + let charactersQuery = $derived(characterList(client)); + let circlesQuery = $derived(circleList(client)); + let worldsQuery = $derived(worldList(client)); + + let tags = $derived($tagsQuery.data?.comicTags.edges); + let artists = $derived($artistsQuery.data?.artists.edges); + let characters = $derived($charactersQuery.data?.characters.edges); + let circles = $derived($circlesQuery.data?.circles.edges); + let worlds = $derived($worldsQuery.data?.worlds.edges); const controls = new UpdateComicsControls(); - const update = () => { - updateComics(client, { - ids: ids, - input: controls.toInput() - }) - .then(closeModal) - .catch(toastFinally); - }; + function update(event: SubmitEvent) { + event.preventDefault(); + + updateComics(client, { ids, input: controls.input() }).then(modal.close).catch(toastFinally); + } </script> -<Dialog {isOpen}> - <svelte:fragment slot="header"> - <h2>Edit Comics</h2> - </svelte:fragment> - <form on:submit|preventDefault={update}> +<Dialog title="Edit Comics" {...modal}> + <form onsubmit={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> + <label class="self-center" for="category">Category</label> + <Select clearable id="category" options={categories} bind:value={controls.category.value} /> + + <label class="self-center" for="rating">Rating</label> + <Select clearable id="rating" options={ratings} bind:value={controls.rating.value} /> + + <label class="self-center" for="censor">Censorship</label> + <Select clearable id="censor" options={censorships} bind:value={controls.censorship.value} /> + + <label class="self-center" for="language">Language</label> + <Select clearable id="language" options={languages} bind:value={controls.language.value} /> + + <label class="self-center" for="direction">Direction</label> + <Select clearable id="direction" options={directions} bind:value={controls.direction.value} /> + + <label class="self-center" for="layout">Layout</label> + <Select clearable id="layout" options={layouts} bind:value={controls.layout.value} /> </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 label="Artists"> + {#snippet children({ id })} + <Select multi {id} options={artists} bind:value={controls.artists.ids} /> + {/snippet} + {#snippet side()} + <UpdateModeSelector bind:mode={controls.artists.options.mode} /> + {/snippet} </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 label="Circles"> + {#snippet children({ id })} + <Select multi {id} options={circles} bind:value={controls.circles.ids} /> + {/snippet} + {#snippet side()} + <UpdateModeSelector bind:mode={controls.circles.options.mode} /> + {/snippet} </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 label="Characters"> + {#snippet children({ id })} + <Select multi {id} options={characters} bind:value={controls.characters.ids} /> + {/snippet} + {#snippet side()} + <UpdateModeSelector bind:mode={controls.characters.options.mode} /> + {/snippet} </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 label="Worlds"> + {#snippet children({ id })} + <Select multi {id} options={worlds} bind:value={controls.worlds.ids} /> + {/snippet} + {#snippet side()} + <UpdateModeSelector bind:mode={controls.worlds.options.mode} /> + {/snippet} </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 label="Tags"> + {#snippet children({ id })} + <Select multi {id} options={tags} bind:value={controls.tags.ids} /> + {/snippet} + {#snippet side()} + <UpdateModeSelector bind:mode={controls.tags.options.mode} /> + {/snippet} </LabelledBlock> <div class="flex justify-end gap-4"> - <SubmitButton active={controls.hasInput()} /> + <SubmitButton pending={controls.pending()} /> </div> </form> </Dialog> diff --git a/frontend/src/lib/dialogs/UpdateTags.svelte b/frontend/src/lib/dialogs/UpdateTags.svelte index f753c7f..840e92e 100644 --- a/frontend/src/lib/dialogs/UpdateTags.svelte +++ b/frontend/src/lib/dialogs/UpdateTags.svelte @@ -2,44 +2,49 @@ import { updateTags } from '$gql/Mutations'; import { namespaceList } from '$gql/Queries'; import { toastFinally } from '$lib/Toasts'; - import { UpdateTagsControls } from '$lib/Update'; + import { UpdateTagsControls } from '$lib/Update.svelte'; 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 { modals, type ModalProps } from 'svelte-modals'; import UpdateModeSelector from './components/UpdateModeSelector.svelte'; const client = getContextClient(); - $: namespaceQuery = namespaceList(client); - $: namespaces = $namespaceQuery.data?.namespaces.edges; + let namespaceQuery = $derived(namespaceList(client)); + let namespaces = $derived($namespaceQuery.data?.namespaces.edges); - export let isOpen: boolean; - export let ids: number[]; + interface Props extends ModalProps { + ids: number[]; + } - const controls = new UpdateTagsControls(); + let { ids, ...modal }: Props = $props(); + let controls = new UpdateTagsControls(); - const update = () => { - updateTags(client, { ids: ids, input: controls.toInput() }) - .then(closeModal) + function update(event: SubmitEvent) { + event.preventDefault(); + + updateTags(client, { ids, input: controls.input() }) + .then(() => modals.close()) .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" /> +<Dialog title="Edit Tags" {...modal}> + <form onsubmit={update}> + <LabelledBlock label="Namespaces"> + {#snippet children({ id })} + <Select multi {id} options={namespaces} bind:value={controls.namespaces.ids} /> + {/snippet} + {#snippet side()} + <UpdateModeSelector bind:mode={controls.namespaces.options.mode} /> + {/snippet} </LabelledBlock> <div class="flex justify-end gap-4"> - <SubmitButton active={controls.hasInput()} /> + <SubmitButton pending={controls.pending()} /> </div> </form> </Dialog> diff --git a/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte index e4b4479..876657e 100644 --- a/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte +++ b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte @@ -2,21 +2,17 @@ import { UpdateMode } from '$gql/graphql'; import { UpdateModeLabel } from '$lib/Enums'; - export let mode: UpdateMode; - - function select(e: string) { - mode = e as UpdateMode; - } + let { mode = $bindable() }: { mode: UpdateMode } = $props(); </script> <div class="flex gap-1 pb-1 text-xs"> - {#each Object.entries(UpdateModeLabel) as [e, label]} + {#each Object.entries(UpdateModeLabel) as [e, label] (e)} <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)} + class="btn-xs hover:bg-slate-700 [&.active]:bg-indigo-700 [&.active.dangerous]:bg-rose-800" + onclick={() => (mode = e as UpdateMode)} > {label} </button> diff --git a/frontend/src/lib/filter/ComicFilterForm.svelte b/frontend/src/lib/filter/ComicFilterForm.svelte index 13b5320..b8be75b 100644 --- a/frontend/src/lib/filter/ComicFilterForm.svelte +++ b/frontend/src/lib/filter/ComicFilterForm.svelte @@ -1,48 +1,73 @@ <script lang="ts"> - import { page } from '$app/stores'; import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries'; - import { ComicFilterContext, getFilterContext } from '$lib/Filter'; + import { categories, censorships, languages, ratings } from '$lib/Enums'; + import { ComicFilterContext } from '$lib/Filter.svelte'; + import { accelerator } from '$lib/Shortcuts'; import { getContextClient } from '@urql/svelte'; - import ComicFilterGroup from './components/ComicFilterGroup.svelte'; + import Filter from './components/Filter.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); + let { filter }: { filter: ComicFilterContext } = $props(); - $: 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; + let tagsQuery = $derived(comicTagList(client, { forFilter: true })); + let artistsQuery = $derived(artistList(client)); + let charactersQuery = $derived(characterList(client)); + let circlesQuery = $derived(circleList(client)); + let worldsQuery = $derived(worldList(client)); - const filter = getFilterContext<ComicFilterContext>(); - const apply = () => $filter.apply($page.url.searchParams); + let tags = $derived($tagsQuery.data?.comicTags.edges); + let artists = $derived($artistsQuery.data?.artists.edges); + let characters = $derived($charactersQuery.data?.characters.edges); + let circles = $derived($circlesQuery.data?.circles.edges); + let worlds = $derived($worldsQuery.data?.worlds.edges); + + let inc = $derived(filter.include); + let exc = $derived(filter.exclude); </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 type="grid" apply={filter.apply}> + {#snippet include(type)} + <Filter {type} wide title="Tags" options={tags} filter={inc.tags} accel="it" /> + <Filter {type} title="Artists" options={artists} filter={inc.artists} accel="ia" /> + <Filter {type} title="Circles" options={circles} filter={inc.circles} accel="ii" /> + <Filter {type} title="Characters" options={characters} filter={inc.characters} accel="ih" /> + <Filter {type} title="Worlds" options={worlds} filter={inc.worlds} accel="iw" /> + <Filter {type} title="Categories" options={categories} filter={inc.categories} accel="ig" /> + <Filter {type} title="Ratings" options={ratings} filter={inc.ratings} accel="ir" /> + <Filter {type} title="Censorship" options={censorships} filter={inc.censorships} accel="is" /> + <Filter {type} title="Languages" options={languages} filter={inc.languages} accel="il" /> + <div class="flex flex-col"> + <label for="include-url">URL</label> + <input + use:accelerator={'iu'} + id="include-url" + class="h-full" + placeholder="Contains..." + bind:value={inc.url.contains} + /> + </div> + {/snippet} + {#snippet exclude(type)} + <Filter {type} wide title="Tags" options={tags} filter={exc.tags} accel="et" /> + <Filter {type} title="Artists" options={artists} filter={exc.artists} accel="ea" /> + <Filter {type} title="Circles" options={circles} filter={exc.circles} accel="ei" /> + <Filter {type} title="Characters" options={characters} filter={exc.characters} accel="eh" /> + <Filter {type} title="Worlds" options={worlds} filter={exc.worlds} accel="ew" /> + <Filter {type} title="Categories" options={categories} filter={exc.categories} accel="eg" /> + <Filter {type} title="Ratings" options={ratings} filter={exc.ratings} accel="er" /> + <Filter {type} title="Censorship" options={censorships} filter={exc.censorships} accel="es" /> + <Filter {type} title="Languages" options={languages} filter={exc.languages} accel="el" /> + <div class="flex flex-col"> + <label for="exclude-url">URL</label> + <input + use:accelerator={'eu'} + id="exclude-url" + class="h-full border border-red-900 outline-none focus:border-red-500" + placeholder="Does not contain..." + bind:value={exc.url.contains} + /> + </div> + {/snippet} </FilterForm> diff --git a/frontend/src/lib/filter/TagFilterForm.svelte b/frontend/src/lib/filter/TagFilterForm.svelte index be5996e..c514163 100644 --- a/frontend/src/lib/filter/TagFilterForm.svelte +++ b/frontend/src/lib/filter/TagFilterForm.svelte @@ -1,31 +1,26 @@ <script lang="ts"> - import { page } from '$app/stores'; import { namespaceList } from '$gql/Queries'; - import { TagFilterContext, getFilterContext } from '$lib/Filter'; + import { TagFilterContext } from '$lib/Filter.svelte'; import { getContextClient } from '@urql/svelte'; + import Filter from './components/Filter.svelte'; import FilterForm from './components/FilterForm.svelte'; - import TagFilterGroup from './components/TagFilterGroup.svelte'; const client = getContextClient(); - $: namespaceQuery = namespaceList(client); - $: namespaces = $namespaceQuery.data?.namespaces.edges; + let { filter }: { filter: TagFilterContext } = $props(); - const filter = getFilterContext<TagFilterContext>(); - const apply = () => $filter.apply($page.url.searchParams); + let namespaceQuery = $derived(namespaceList(client)); + let namespaces = $derived($namespaceQuery.data?.namespaces.edges); + + let inc = $derived(filter.include); + let exc = $derived(filter.exclude); </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 apply={filter.apply}> + {#snippet include(type)} + <Filter {type} title="Namespaces" options={namespaces} filter={inc.namespaces} accel="in" /> + {/snippet} + {#snippet exclude(type)} + <Filter {type} title="Namespaces" options={namespaces} filter={exc.namespaces} accel="en" /> + {/snippet} </FilterForm> diff --git a/frontend/src/lib/filter/components/ComicFilterGroup.svelte b/frontend/src/lib/filter/components/ComicFilterGroup.svelte deleted file mode 100644 index d302de4..0000000 --- a/frontend/src/lib/filter/components/ComicFilterGroup.svelte +++ /dev/null @@ -1,27 +0,0 @@ -<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 index ead5c4d..cf7252b 100644 --- a/frontend/src/lib/filter/components/Filter.svelte +++ b/frontend/src/lib/filter/components/Filter.svelte @@ -1,48 +1,54 @@ <script lang="ts"> - import { Association, Enum } from '$lib/Filter'; + import { Association, Enum, type FilterType } from '$lib/Filter.svelte'; + import { accelerator, type Shortcut } from '$lib/Shortcuts'; 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'; + interface Props { + title: string; + type: FilterType; + options: ListItem[] | undefined; + filter: Association<string> | Enum<string>; + accel: Shortcut; + wide?: boolean; + } - const id = `${context}-${title.toLowerCase()}`; + let { title, type, options, filter, accel, wide = false }: Props = $props(); + let exclude = $derived(type === 'exclude'); + let placeholder = $derived(exclude ? 'Exclude...' : 'Include...'); - export let options: ListItem[] | undefined; - export let filter: Association<string> | Enum<string>; + const id = `${type}-${title.toLowerCase()}`; </script> -<div class:exclude class="filter-container"> +<div class:exclude class:wide class="[&.wide]:col-span-2"> <div class="flex gap-2"> - <label for={id}>{title}</label> + <label use:accelerator={accel} 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" + title="match all" class:active={filter.mode === 'all'} - class="btn btn-xs" - on:click={() => (filter.mode = 'all')} + class="btn-xs hover:bg-slate-700 [&.active]:bg-indigo-800" + onclick={() => (filter.mode = 'all')} > ∀ </button> <button type="button" - title="matches any of" + title="match any" class:active={filter.mode === 'any'} - class="btn btn-xs" - on:click={() => (filter.mode = 'any')} + class="btn-xs hover:bg-slate-700 [&.active]:bg-indigo-800" + onclick={() => (filter.mode = 'any')} > ∃ </button> <button type="button" - title="matches exactly" + title="match exactly" class:active={filter.mode === 'exact'} - class="btn btn-xs" - on:click={() => (filter.mode = 'exact')} + class="btn-xs hover:bg-slate-700 [&.active]:bg-indigo-800" + onclick={() => (filter.mode = 'exact')} > = </button> @@ -50,28 +56,14 @@ {/if} <button type="button" - title="empty" + title="match empty" class:active={filter.empty} - class="btn btn-xs" - on:click={() => (filter.empty = !filter.empty)} + class="btn-xs hover:bg-slate-700 [&.active]:bg-indigo-800" + onclick={() => (filter.empty = !filter.empty)} > ∅ </button> </div> </div> - <Select multi clearable {options} {id} bind:value={filter.values} /> + <Select multi clearable {placeholder} {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 index 6fc4c90..717a56d 100644 --- a/frontend/src/lib/filter/components/FilterForm.svelte +++ b/frontend/src/lib/filter/components/FilterForm.svelte @@ -1,41 +1,41 @@ <script lang="ts"> - import Expander from '$lib/components/Expander.svelte'; - import { getFilterContext } from '$lib/Filter'; + import { page } from '$app/state'; + import type { FilterType } from '$lib/Filter.svelte'; + import type { Snippet } from 'svelte'; - const filter = getFilterContext(); - export let type: 'grid' | 'row' = 'row'; + interface Props { + type?: 'grid' | 'row'; + include?: Snippet<[FilterType]>; + exclude?: Snippet<[FilterType]>; + apply: (params: URLSearchParams) => void; + } - let exclude = false; + let { type = 'row', include, exclude, apply }: Props = $props(); - $: if ($filter.exclude.size > 0) { - exclude = true; + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + apply(page.url.searchParams); } </script> -<form on:submit|preventDefault class="gap-0"> +<form {onsubmit} class="gap-4"> {#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} /> + {@render include?.('include')} </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" + 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" > + {@render exclude?.('exclude')} + </div> + {: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" /> + {@render include?.('include')} </div> <div class="bg-rose-950/50 p-2"> - <slot name="exclude" /> + {@render exclude?.('exclude')} </div> </div> {/if} diff --git a/frontend/src/lib/filter/components/TagFilterGroup.svelte b/frontend/src/lib/filter/components/TagFilterGroup.svelte deleted file mode 100644 index 83b6997..0000000 --- a/frontend/src/lib/filter/components/TagFilterGroup.svelte +++ /dev/null @@ -1,14 +0,0 @@ -<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 index 7df5e8b..663c3ae 100644 --- a/frontend/src/lib/forms/ArtistForm.svelte +++ b/frontend/src/lib/forms/ArtistForm.svelte @@ -1,25 +1,29 @@ <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'; + import type { AddArtistInput, Artist } from '$gql/graphql'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { itemPending, type FormProps } from '$lib/Form'; - const dispatch = createEventDispatcher<{ submit: ArtistInput }>(); + let { initial, submit, children }: FormProps<Artist, AddArtistInput> = $props(); - export let artist: OmitIdentifiers<Artist>; + let input = $state(initial); + let pending = $derived(input.name.length > 0 && itemPending(initial, input)); - function submit() { - dispatch('submit', { name: artist.name }); + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ ...input }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <div class="grid-labels"> - <Labelled label="Name" let:id> - <!-- svelte-ignore a11y-autofocus --> - <input autofocus required {id} bind:value={artist.name} /> - </Labelled> + <label class="self-center" for="name">Name</label> + <!-- svelte-ignore a11y_autofocus --> + <input autofocus required id="name" bind:value={input.name} /> + </div> + <div class="flex gap-4"> + {@render children?.()} + <div class="grow"></div> + <SubmitButton {pending} /> </div> - <slot /> </form> diff --git a/frontend/src/lib/forms/CharacterForm.svelte b/frontend/src/lib/forms/CharacterForm.svelte index 4cec37c..23b3ef7 100644 --- a/frontend/src/lib/forms/CharacterForm.svelte +++ b/frontend/src/lib/forms/CharacterForm.svelte @@ -1,25 +1,29 @@ <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'; + import type { AddCharacterInput, Character } from '$gql/graphql'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { itemPending, type FormProps } from '$lib/Form'; - const dispatch = createEventDispatcher<{ submit: CharacterInput }>(); + let { initial, submit, children }: FormProps<Character, AddCharacterInput> = $props(); - export let character: OmitIdentifiers<Character>; + let input = $state(initial); + let pending = $derived(input.name.length > 0 && itemPending(initial, input)); - function submit() { - dispatch('submit', { name: character.name }); + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ ...input }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <div class="grid-labels"> - <Labelled label="Name" let:id> - <!-- svelte-ignore a11y-autofocus --> - <input autofocus required {id} bind:value={character.name} /> - </Labelled> + <label class="self-center" for="name">Name</label> + <!-- svelte-ignore a11y_autofocus --> + <input autofocus required id="name" bind:value={input.name} /> + </div> + <div class="flex gap-4"> + {@render children?.()} + <div class="grow"></div> + <SubmitButton {pending} /> </div> - <slot /> </form> diff --git a/frontend/src/lib/forms/CircleForm.svelte b/frontend/src/lib/forms/CircleForm.svelte index b71256c..ba6013a 100644 --- a/frontend/src/lib/forms/CircleForm.svelte +++ b/frontend/src/lib/forms/CircleForm.svelte @@ -1,25 +1,29 @@ <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'; + import type { AddCircleInput, Circle } from '$gql/graphql'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { itemPending, type FormProps } from '$lib/Form'; - const dispatch = createEventDispatcher<{ submit: CircleInput }>(); + let { initial, submit, children }: FormProps<Circle, AddCircleInput> = $props(); - export let circle: OmitIdentifiers<Circle>; + let input = $state(initial); + let pending = $derived(input.name.length > 0 && itemPending(initial, input)); - function submit() { - dispatch('submit', { name: circle.name }); + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ ...input }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <div class="grid-labels"> - <Labelled label="Name" let:id> - <!-- svelte-ignore a11y-autofocus --> - <input required autofocus {id} bind:value={circle.name} /> - </Labelled> + <label class="self-center" for="name">Name</label> + <!-- svelte-ignore a11y_autofocus --> + <input autofocus required id="name" bind:value={input.name} /> + </div> + <div class="flex gap-4"> + {@render children?.()} + <div class="grow"></div> + <SubmitButton {pending} /> </div> - <slot /> </form> diff --git a/frontend/src/lib/forms/ComicForm.svelte b/frontend/src/lib/forms/ComicForm.svelte index 74051c8..adc6a34 100644 --- a/frontend/src/lib/forms/ComicForm.svelte +++ b/frontend/src/lib/forms/ComicForm.svelte @@ -3,98 +3,113 @@ 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'; + import { type Snippet } 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) } + + interface Props { + input: OmitIdentifiers<FullComicFragment>; + submit: (input: UpdateComicInput) => void; + children?: Snippet; + } + + let { input = $bindable(), submit, children }: Props = $props(); + + let tagsQuery = $derived(comicTagList(client)); + let artistsQuery = $derived(artistList(client)); + let charactersQuery = $derived(characterList(client)); + let circlesQuery = $derived(circleList(client)); + let worldsQuery = $derived(worldList(client)); + + let tags = $derived($tagsQuery.data?.comicTags.edges); + let artists = $derived($artistsQuery.data?.artists.edges); + let characters = $derived($charactersQuery.data?.characters.edges); + let circles = $derived($circlesQuery.data?.circles.edges); + let worlds = $derived($worldsQuery.data?.worlds.edges); + + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ + direction: input.direction, + layout: input.layout, + rating: input.rating, + category: input.category, + censorship: input.censorship, + title: input.title, + originalTitle: input.originalTitle, + url: input.url, + date: input.date === '' ? null : input.date, + language: input.language, + tags: { ids: input.tags.map((t) => t.id) }, + artists: { ids: input.artists.map((a) => a.id) }, + characters: { ids: input.characters.map((c) => c.id) }, + circles: { ids: input.circles.map((c) => c.id) }, + worlds: { ids: input.worlds.map((w) => w.id) } }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <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> + <label class="self-center" for="title">Title</label> + <input required id="title" bind:value={input.title} title={input.title} /> + + <label class="self-center" for="original-title">Original Title</label> + <input id="original-title" bind:value={input.originalTitle} title={input.originalTitle} /> + + <label class="self-center" for="url">URL</label> + <input id="url" bind:value={input.url} /> + + <label class="self-center" for="date">Date</label> + <input id="date" type="date" bind:value={input.date} pattern={'d{4}-d{2}-d{2}'} /> + + <label class="self-center" for="category">Category</label> + <Select id="category" options={categories} bind:value={input.category} /> + + <label class="self-center" for="rating">Rating</label> + <Select id="rating" options={ratings} bind:value={input.rating} /> + + <label class="self-center" for="censorship">Censorship</label> + <Select id="censorship" options={censorships} bind:value={input.censorship} /> + + <label class="self-center" for="language">Language</label> + <Select id="language" options={languages} bind:value={input.language} /> + + <label class="self-center" for="direction">Direction</label> + <Select id="direction" options={directions} bind:value={input.direction} /> + + <label class="self-center" for="layout">Layout</label> + <Select id="layout" options={layouts} bind:value={input.layout} /> </div> - <LabelledBlock label="Artists" let:id> - <Select multi object {id} options={artists} bind:value={comic.artists} /> + <LabelledBlock label="Artists"> + {#snippet children({ id })} + <Select multi object {id} options={artists} bind:value={input.artists} /> + {/snippet} </LabelledBlock> - <LabelledBlock label="Circles" let:id> - <Select multi object {id} options={circles} bind:value={comic.circles} /> + <LabelledBlock label="Circles"> + {#snippet children({ id })} + <Select multi object {id} options={circles} bind:value={input.circles} /> + {/snippet} </LabelledBlock> - <LabelledBlock label="Characters" let:id> - <Select multi object {id} options={characters} bind:value={comic.characters} /> + <LabelledBlock label="Characters"> + {#snippet children({ id })} + <Select multi object {id} options={characters} bind:value={input.characters} /> + {/snippet} </LabelledBlock> - <LabelledBlock label="Worlds" let:id> - <Select multi object {id} options={worlds} bind:value={comic.worlds} /> + <LabelledBlock label="Worlds"> + {#snippet children({ id })} + <Select multi object {id} options={worlds} bind:value={input.worlds} /> + {/snippet} </LabelledBlock> - <LabelledBlock label="Tags" let:id> - <Select multi object {id} options={tags} bind:value={comic.tags} /> + <LabelledBlock label="Tags"> + {#snippet children({ id })} + <Select multi object {id} options={tags} bind:value={input.tags} /> + {/snippet} </LabelledBlock> - <slot /> + {@render children?.()} </form> diff --git a/frontend/src/lib/forms/NamespaceForm.svelte b/frontend/src/lib/forms/NamespaceForm.svelte index c05b6d8..3631d84 100644 --- a/frontend/src/lib/forms/NamespaceForm.svelte +++ b/frontend/src/lib/forms/NamespaceForm.svelte @@ -1,28 +1,31 @@ <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'; + import type { AddNamespaceInput, Namespace } from '$gql/graphql'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { namespacePending, type FormProps } from '$lib/Form'; - const dispatch = createEventDispatcher<{ submit: NamespaceInput }>(); + let { initial, submit, children }: FormProps<Namespace, AddNamespaceInput> = $props(); - export let namespace: OmitIdentifiers<Namespace>; + let input = $state(initial); + let pending = $derived(input.name.length > 0 && namespacePending(initial, input)); - function submit() { - dispatch('submit', { name: namespace.name, sortName: namespace.sortName }); + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ ...input }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <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> + <label class="self-center" for="name">Name</label> + <!-- svelte-ignore a11y_autofocus --> + <input autofocus required id="name" bind:value={input.name} /> + <label class="self-center" for="sort-name">Sort name</label> + <input id="name" bind:value={input.sortName} /> + </div> + <div class="flex gap-4"> + {@render children?.()} + <div class="grow"></div> + <SubmitButton {pending} /> </div> - <slot /> </form> diff --git a/frontend/src/lib/forms/TagForm.svelte b/frontend/src/lib/forms/TagForm.svelte index 6cc2227..2789587 100644 --- a/frontend/src/lib/forms/TagForm.svelte +++ b/frontend/src/lib/forms/TagForm.svelte @@ -1,42 +1,41 @@ <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 type { AddTagInput, FullTag } from '$gql/graphql'; + import { tagPending, type FormProps } from '$lib/Form'; import Select from '$lib/components/Select.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; import { getContextClient } from '@urql/svelte'; - import { createEventDispatcher } from 'svelte'; - const client = getContextClient(); - const dispatch = createEventDispatcher<{ submit: TagInput }>(); + let { initial, submit, children }: FormProps<FullTag, AddTagInput> = $props(); - export let tag: OmitIdentifiers<FullTag>; + let input = $state(initial); + let pending = $derived(input.name.length > 0 && tagPending(initial, input)); - $: namespaceQuery = namespaceList(client); - $: namespaces = $namespaceQuery.data?.namespaces.edges; + let namespaceQuery = $derived(namespaceList(getContextClient())); + let namespaces = $derived($namespaceQuery.data?.namespaces.edges); - function submit() { - dispatch('submit', { - name: tag.name, - description: tag.description, - namespaces: { ids: tag.namespaces.map((n) => n.id) } - }); + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ ...input, namespaces: { ids: input.namespaces.map((n) => n.id) } }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <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> + <label class="self-center" for="name">Name</label> + <!-- svelte-ignore a11y_autofocus --> + <input autofocus required id="name" bind:value={input.name} /> + + <label class="self-center" for="description">Description</label> + <textarea rows={3} id="description" bind:value={input.description}></textarea> + + <label class="self-center" for="namespaces">Namespaces</label> + <Select multi object id="namespaces" options={namespaces} bind:value={input.namespaces} /> + </div> + <div class="flex gap-4"> + {@render children?.()} + <div class="grow"></div> + <SubmitButton {pending} /> </div> - <slot /> </form> diff --git a/frontend/src/lib/forms/WorldForm.svelte b/frontend/src/lib/forms/WorldForm.svelte index 103dd5b..e6b821f 100644 --- a/frontend/src/lib/forms/WorldForm.svelte +++ b/frontend/src/lib/forms/WorldForm.svelte @@ -1,25 +1,29 @@ <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'; + import type { AddWorldInput, World } from '$gql/graphql'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { itemPending, type FormProps } from '$lib/Form'; - const dispatch = createEventDispatcher<{ submit: WorldInput }>(); + let { initial, submit, children }: FormProps<World, AddWorldInput> = $props(); - export let world: OmitIdentifiers<World>; + let input = $state(initial); + let pending = $derived(input.name.length > 0 && itemPending(initial, input)); - function submit() { - dispatch('submit', { name: world.name }); + function onsubmit(event: SubmitEvent) { + event.preventDefault(); + + submit({ ...input }); } </script> -<form on:submit|preventDefault={submit}> +<form {onsubmit}> <div class="grid-labels"> - <Labelled label="Name" let:id> - <!-- svelte-ignore a11y-autofocus --> - <input autofocus required {id} bind:value={world.name} /> - </Labelled> + <label class="self-center" for="name">Name</label> + <!-- svelte-ignore a11y_autofocus --> + <input autofocus required id="name" bind:value={input.name} /> + </div> + <div class="flex gap-4"> + {@render children?.()} + <div class="grow"></div> + <SubmitButton {pending} /> </div> - <slot /> </form> diff --git a/frontend/src/lib/gallery/Gallery.svelte b/frontend/src/lib/gallery/Gallery.svelte index c3b6386..f94747a 100644 --- a/frontend/src/lib/gallery/Gallery.svelte +++ b/frontend/src/lib/gallery/Gallery.svelte @@ -2,12 +2,18 @@ import type { PageFragment } from '$gql/graphql'; import GalleryPage from './GalleryPage.svelte'; - export let pages: PageFragment[]; + interface Props { + pages: PageFragment[]; + open: (page: number) => void; + updateCover: (page: number) => void; + } + + let { pages, open, updateCover }: Props = $props(); </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 /> +<div class="max-h-full gap-2 overflow-auto p-1 pr-3" tabindex="-1"> + {#each pages as page, index (page.id)} + <GalleryPage {page} {index} {open} {updateCover} /> {/each} </div> diff --git a/frontend/src/lib/gallery/GalleryPage.svelte b/frontend/src/lib/gallery/GalleryPage.svelte index 449321c..13bbfc8 100644 --- a/frontend/src/lib/gallery/GalleryPage.svelte +++ b/frontend/src/lib/gallery/GalleryPage.svelte @@ -1,65 +1,64 @@ <script lang="ts"> import type { PageFragment } from '$gql/graphql'; - import { getSelectionContext } from '$lib/Selection'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; 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>(); + interface Props { + page: PageFragment; + index: number; + open: (page: number) => void; + updateCover: (page: number) => void; + } - let span: 'single' | 'double' | 'triple'; + let { page, index, open, updateCover }: Props = $props(); - $: page.image.aspectRatio, updateSpan(); + const selection = getSelectionContext<PageFragment>(); - function updateSpan() { + let span: 'single' | 'double' | 'triple' = $derived.by(() => { const aspectRatio = page.image.aspectRatio; if (aspectRatio <= 1) { - span = 'single'; + return 'single'; } else if (aspectRatio > 1 && aspectRatio <= 2) { - span = 'double'; - } else if (aspectRatio > 2) { - span = 'triple'; + return 'double'; + } else { + return 'triple'; } - } - - const dispatch = createEventDispatcher<{ open: number; cover: number }>(); + }); - function press(event: MouseEvent | KeyboardEvent) { + function onclick(event: MouseEvent | KeyboardEvent) { if (event instanceof KeyboardEvent && event.key !== 'Enter') { return; } - if ($selection.active) { + if (selection.active) { if (event.ctrlKey) { - dispatch('open', index); + open(index); } else if (selectable) { - $selection = $selection.update(index, event.shiftKey); + selection.update(index, event.shiftKey); } } else if (event.ctrlKey) { - dispatch('cover', page.id); + updateCover(page.id); } else { - dispatch('open', index); + open(index); } event.preventDefault(); } - $: selectable = $selection.selectable(page); - $: dim = $selection.active && !selectable; - $: selected = $selection.contains(page.id); + let selectable = $derived(selection.selectable(page)); + let dim = $derived(selection.active && !selectable); + let selected = $derived(selection.contains(page.id)); </script> <div class:dim role="button" tabindex="0" - class="{span} relative overflow-hidden rounded" - on:click={press} - on:keydown={press} + class="{span} relative overflow-hidden rounded-sm focus-visible:outline-4 focus-visible:outline-blue-600" + {onclick} + onkeydown={onclick} > <SelectionOverlay position="top" {selected} /> <img diff --git a/frontend/src/lib/icons/Bookmark.svelte b/frontend/src/lib/icons/Bookmark.svelte index 6f8e192..21b54ed 100644 --- a/frontend/src/lib/icons/Bookmark.svelte +++ b/frontend/src/lib/icons/Bookmark.svelte @@ -1,10 +1,15 @@ <script lang="ts"> - export let bookmarked: boolean | undefined = undefined; - export let hoverable = false; + interface Props { + bookmarked?: boolean; + hoverable?: boolean; + } + + let { bookmarked, hoverable = false }: Props = $props(); </script> {#if bookmarked} - <span class:hoverable class="icon-gray icon-base icon-[material-symbols--bookmark]" /> + <span class:hoverable class="icon-gray icon-base icon-[material-symbols--bookmark]"></span> {:else} - <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--bookmark-outline]" /> + <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--bookmark-outline]" + ></span> {/if} diff --git a/frontend/src/lib/icons/Female.svelte b/frontend/src/lib/icons/Female.svelte deleted file mode 100644 index c772a6a..0000000 --- a/frontend/src/lib/icons/Female.svelte +++ /dev/null @@ -1 +0,0 @@ -<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 deleted file mode 100644 index e345f83..0000000 --- a/frontend/src/lib/icons/Location.svelte +++ /dev/null @@ -1 +0,0 @@ -<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 deleted file mode 100644 index e3578b7..0000000 --- a/frontend/src/lib/icons/Male.svelte +++ /dev/null @@ -1 +0,0 @@ -<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 index 66b5b00..ff177fa 100644 --- a/frontend/src/lib/icons/Organized.svelte +++ b/frontend/src/lib/icons/Organized.svelte @@ -1,21 +1,22 @@ <script lang="ts"> - export let organized: boolean | undefined = undefined; - export let hoverable = false; - export let tristate = false; - export let dim = false; + interface Props { + organized?: boolean; + hoverable?: boolean; + tristate?: boolean; + dim?: boolean; + } + + let { organized, hoverable = false, tristate = false, dim = false }: Props = $props(); </script> {#if organized} - <span class:hoverable class="icon-gray icon-base icon-[material-symbols--check-circle]" /> + <span class:hoverable class="icon-gray icon-base icon-[material-symbols--check-circle]"></span> {:else if organized === undefined || !tristate} <span class:hoverable class="icon-gray dim icon-base icon-[material-symbols--check-circle-outline]" - /> + ></span> {:else} - <span - class:hoverable - class:dim - class="icon-gray icon-base icon-[material-symbols--unpublished]" - /> + <span class:hoverable class:dim class="icon-gray icon-base icon-[material-symbols--unpublished]" + ></span> {/if} diff --git a/frontend/src/lib/icons/Orphan.svelte b/frontend/src/lib/icons/Orphan.svelte new file mode 100644 index 0000000..7d947d2 --- /dev/null +++ b/frontend/src/lib/icons/Orphan.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + interface Props { + orphaned?: boolean; + hoverable?: boolean; + } + + let { orphaned, hoverable = false }: Props = $props(); +</script> + +{#if orphaned} + <span class:hoverable class="icon-gray icon-base icon-[material-symbols--fmd-bad]"></span> +{:else} + <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--fmd-bad-outline]" + ></span> +{/if} diff --git a/frontend/src/lib/icons/Star.svelte b/frontend/src/lib/icons/Star.svelte index 7613c55..acce54d 100644 --- a/frontend/src/lib/icons/Star.svelte +++ b/frontend/src/lib/icons/Star.svelte @@ -1,22 +1,27 @@ <script lang="ts"> - export let large = false; - export let favourite: boolean | undefined = undefined; - export let hoverable = false; + interface Props { + large?: boolean; + favourite?: boolean; + hoverable?: boolean; + } + + let { large = false, favourite, hoverable = false }: Props = $props(); </script> {#if favourite} - <span class:hoverable class:large class="icon-yellow icon-[material-symbols--star-rounded]" /> + <span class:hoverable class:large class="icon-yellow icon-[material-symbols--star-rounded]" + ></span> {:else} <span class:hoverable class:large class="icon-yellow dim icon-[material-symbols--star-outline-rounded]" - /> + ></span> {/if} <style lang="postcss"> span { - @apply -m-px -translate-y-px text-[26px]; + @apply -m-px text-[26px]; } span.large { diff --git a/frontend/src/lib/icons/Transgender.svelte b/frontend/src/lib/icons/Transgender.svelte deleted file mode 100644 index 7d9adc6..0000000 --- a/frontend/src/lib/icons/Transgender.svelte +++ /dev/null @@ -1 +0,0 @@ -<span class="icon-xs icon-[material-symbols--transgender]" /> diff --git a/frontend/src/lib/navigation/Link.svelte b/frontend/src/lib/navigation/Link.svelte index 7297a69..d18fe3e 100644 --- a/frontend/src/lib/navigation/Link.svelte +++ b/frontend/src/lib/navigation/Link.svelte @@ -1,20 +1,37 @@ <script lang="ts"> - import { page } from '$app/stores'; + import { page } from '$app/state'; import { accelerator, type Shortcut } from '$lib/Shortcuts'; - import type { HTMLAttributeAnchorTarget } from 'svelte/elements'; + import type { Snippet } from 'svelte'; + import type { HTMLAnchorAttributes } 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); + interface Props extends Pick<HTMLAnchorAttributes, 'title' | 'target'> { + href: string; + accel: Shortcut; + matchExact?: boolean; + children?: Snippet; + } + + let { href, title, accel, matchExact = false, target, children }: Props = $props(); + + let active = $derived.by(() => { + if (matchExact) { + return page.url.pathname === href; + } else { + return 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}> +<li class:active class="items-center hover:bg-slate-600 [&.active]:bg-indigo-700"> + <a + class="flex items-center focus-visible:bg-slate-600 focus-visible:outline-hidden" + {target} + {title} + {href} + use:accelerator={accel} + > <div class="flex p-3"> - <slot /> + {@render children?.()} </div> </a> </li> diff --git a/frontend/src/lib/navigation/Navigation.svelte b/frontend/src/lib/navigation/Navigation.svelte index 76096c8..6734272 100644 --- a/frontend/src/lib/navigation/Navigation.svelte +++ b/frontend/src/lib/navigation/Navigation.svelte @@ -1,5 +1,11 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + let { children }: { children?: Snippet } = $props(); +</script> + <nav> <ul class="flex h-full flex-col bg-slate-700/70 font-medium"> - <slot /> + {@render children?.()} </ul> </nav> diff --git a/frontend/src/lib/pagination/Pagination.svelte b/frontend/src/lib/pagination/Pagination.svelte index 51612f4..28fbeb2 100644 --- a/frontend/src/lib/pagination/Pagination.svelte +++ b/frontend/src/lib/pagination/Pagination.svelte @@ -1,45 +1,52 @@ <script lang="ts"> - import { getPaginationContext } from '$lib/Pagination'; + import type { PaginationData } from '$lib/Navigation'; import Target from './Target.svelte'; - const pagination = getPaginationContext(); - export let context = 2; + interface Props { + context?: number; + pagination: PaginationData; + total: number; + } - $: totalPages = Math.ceil($pagination.total / $pagination.items); - $: rightBoundary = $pagination.page - context; - $: leftBoundary = $pagination.page + context; + let { context = 2, pagination, total }: Props = $props(); - $: shiftRight = leftBoundary - totalPages; - $: shiftLeft = 1 - rightBoundary; + let totalPages = $derived(Math.ceil(total / pagination.items)); + let rightBoundary = $derived(pagination.page - context); + let leftBoundary = $derived(pagination.page + context); - $: containedLeft = leftBoundary <= totalPages; - $: containedRight = rightBoundary > 0; + let shiftRight = $derived(leftBoundary - totalPages); + let shiftLeft = $derived(1 - rightBoundary); - $: start = Math.max(1, containedLeft ? rightBoundary : rightBoundary - shiftRight); - $: end = Math.min(totalPages, containedRight ? leftBoundary : leftBoundary + shiftLeft); + let containedLeft = $derived(leftBoundary <= totalPages); + let containedRight = $derived(rightBoundary > 0); - $: leftmost = $pagination.page <= 1; - $: rightmost = $pagination.page >= totalPages; + let start = $derived(Math.max(1, containedLeft ? rightBoundary : rightBoundary - shiftRight)); + let end = $derived( + Math.min(totalPages, containedRight ? leftBoundary : leftBoundary + shiftLeft) + ); + + let leftmost = $derived(pagination.page <= 1); + let rightmost = $derived(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 disabled={leftmost} target={1}> + <span class="icon-base icon-[material-symbols--keyboard-double-arrow-left]"></span> </Target> - <Target disabled={leftmost} page={$pagination.page - 1}> - <span class="icon-base icon-[material-symbols--keyboard-arrow-left]" /> + <Target disabled={leftmost} target={pagination.page - 1}> + <span class="icon-base icon-[material-symbols--keyboard-arrow-left]"></span> </Target> - {#each Array.from({ length: end + 1 - start }, (_, i) => i + start) as page} - <Target active={$pagination.page === page} {page}> - <p>{page.toString()}</p> + {#each Array.from({ length: end + 1 - start }, (_, i) => i + start) as target (target)} + <Target active={pagination.page === target} {target}> + <p>{target.toString()}</p> </Target> {/each} - <Target disabled={rightmost} page={$pagination.page + 1}> - <span class="icon-base icon-[material-symbols--keyboard-arrow-right]" /> + <Target disabled={rightmost} target={pagination.page + 1}> + <span class="icon-base icon-[material-symbols--keyboard-arrow-right]"></span> </Target> - <Target disabled={rightmost} page={totalPages}> - <span class="icon-base icon-[material-symbols--keyboard-double-arrow-right]" /> + <Target disabled={rightmost} target={totalPages}> + <span class="icon-base icon-[material-symbols--keyboard-double-arrow-right]"></span> </Target> </div> {/if} diff --git a/frontend/src/lib/pagination/Target.svelte b/frontend/src/lib/pagination/Target.svelte index 9044bb9..76e0d9e 100644 --- a/frontend/src/lib/pagination/Target.svelte +++ b/frontend/src/lib/pagination/Target.svelte @@ -1,21 +1,27 @@ <script lang="ts"> - import { page as pageStore } from '$app/stores'; + import { page } from '$app/state'; import { navigate } from '$lib/Navigation'; + import type { Snippet } from 'svelte'; - export let active = false; + interface Props { + active?: boolean; + disabled?: boolean; + target: number; + children?: Snippet; + } - export let disabled = false; - export let page: number; + let { active = false, disabled = false, target, children }: Props = $props(); + + function onclick() { + navigate({ pagination: { page: target } }, page.url.searchParams); + } </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" + {onclick} + class:active + class="flex h-8 w-8 items-center justify-center rounded-sm p-0 text-base font-medium hover:bg-slate-700/50 hover:text-white disabled:text-slate-600 disabled:hover:bg-inherit [&.active]:bg-slate-700 [&:not(active)]:bg-slate-800" {disabled} > - <slot /> + {@render children?.()} </button> diff --git a/frontend/src/lib/pills/AssociationPill.svelte b/frontend/src/lib/pills/AssociationPill.svelte deleted file mode 100644 index 85dbe39..0000000 --- a/frontend/src/lib/pills/AssociationPill.svelte +++ /dev/null @@ -1,30 +0,0 @@ -<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 deleted file mode 100644 index 671bbf2..0000000 --- a/frontend/src/lib/pills/ComicPills.svelte +++ /dev/null @@ -1,37 +0,0 @@ -<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/FooterPill.svelte b/frontend/src/lib/pills/FooterPill.svelte new file mode 100644 index 0000000..3da1811 --- /dev/null +++ b/frontend/src/lib/pills/FooterPill.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + interface Props { + text: string; + icon?: Snippet; + } + + let { text, icon }: Props = $props(); +</script> + +<div class="flex items-center rounded-sm p-0.5 text-zinc-300"> + {@render icon?.()} + <span>{text}</span> +</div> diff --git a/frontend/src/lib/pills/Pill.svelte b/frontend/src/lib/pills/Pill.svelte index 7aa9670..98d9b5a 100644 --- a/frontend/src/lib/pills/Pill.svelte +++ b/frontend/src/lib/pills/Pill.svelte @@ -1,40 +1,83 @@ -<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'; + interface Props { + name: string; + tooltip?: string | null; + style: string; + highlight?: boolean; + } + + let { name, tooltip, style, highlight = false }: Props = $props(); </script> -<div class="flex items-center rounded border p-0.5 {colour}" title={tooltip}> - <slot name="icon" /> +<div class:highlight class="flex items-center rounded-sm border p-0.5 {style}" title={tooltip}> + {#if style === 'female'} + <span class="icon-xs icon-[material-symbols--female] -mx-[3px]"></span> + {:else if style === 'male'} + <span class="icon-xs icon-[material-symbols--male] -mx-px"></span> + {:else if style === 'trans'} + <span class="icon-xs icon-[material-symbols--transgender]"></span> + {:else if style === 'location'} + <span class="icon-xs icon-[material-symbols--location-on-outline]"></span> + {:else if style === 'artist'} + <span class="icon-xs icon-[material-symbols--person] -mx-px"></span> + {:else if style === 'character'} + <span class="icon-xs icon-[material-symbols--face]"></span> + {:else if style === 'circle'} + <span class="icon-xs icon-[material-symbols--group] mx-px"></span> + {:else if style === 'world'} + <span class="icon-xs icon-[material-symbols--public]"></span> + {/if} <span>{name}</span> </div> <style lang="postcss"> - .pink { + @reference "tailwindcss/theme"; + + div { + @apply border-zinc-700 bg-zinc-700/20 text-zinc-300; + } + + div.highlight { + @apply transition-colors hover:border-zinc-600 hover:bg-zinc-500/20 hover:text-zinc-200; + } + + .female { @apply border-pink-800 bg-pink-800/20 text-pink-200; } - .blue { + .female.highlight { + @apply hover:border-pink-700 hover:bg-pink-600/20 hover:text-pink-100; + } + + .male { @apply border-blue-800 bg-blue-800/20 text-blue-200; } - .violet { + .male.highlight { + @apply hover:border-blue-700 hover:bg-blue-600/20 hover:text-blue-100; + } + + .trans { @apply border-violet-800 bg-violet-800/20 text-violet-200; } - .amber { + .trans.highlight { + @apply hover:border-violet-600 hover:bg-violet-600/20 hover:text-violet-100; + } + + .mixed { @apply border-amber-800 bg-amber-800/20 text-amber-200; } - .sky { + .mixed.highlight { + @apply hover:border-amber-700 hover:bg-amber-600/20 hover:text-amber-100; + } + + .location { @apply border-sky-800 bg-sky-800/20 text-sky-200; } - .zinc { - @apply border-zinc-700 bg-zinc-700/20 text-zinc-300; + .location.highlight { + @apply hover:border-sky-700 hover:bg-sky-600/20 hover:text-sky-100; } </style> diff --git a/frontend/src/lib/pills/TagPill.svelte b/frontend/src/lib/pills/TagPill.svelte index 60221bd..bbd3c55 100644 --- a/frontend/src/lib/pills/TagPill.svelte +++ b/frontend/src/lib/pills/TagPill.svelte @@ -1,40 +1,16 @@ <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'; + import type { ComicTag } from '$gql/graphql'; + import { joinText } from '$lib/Utils'; + import Pill 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 - }; + interface Props extends Pick<ComicTag, 'name' | 'description'> { + highlight?: boolean; + } - const colour = styles[namespace] ?? styles.rest; - const icon = icons[namespace]; + let { name, description, highlight = false }: Props = $props(); - function formatTooltip() { - return [name, description].filter((v) => v).join('\n\n'); - } + let [namespace, tag] = name.split(':'); + let tooltip = joinText([name, description], '\n\n'); </script> -<Pill name={tag} tooltip={formatTooltip()} {colour}> - <svelte:component this={icon} slot="icon" /> -</Pill> +<Pill {highlight} name={tag} style={namespace} {tooltip}></Pill> diff --git a/frontend/src/lib/reader/PageView.svelte b/frontend/src/lib/reader/PageView.svelte index 08764b7..2b61a78 100644 --- a/frontend/src/lib/reader/PageView.svelte +++ b/frontend/src/lib/reader/PageView.svelte @@ -1,45 +1,45 @@ <script lang="ts"> import { Direction, Layout, type PageFragment } from '$gql/graphql'; - import { getReaderContext, partition, type Chunk } from '$lib/Reader'; + import { type Chunk, getReaderContext, partition } from '$lib/Reader.svelte'; import { binds } from '$lib/Shortcuts'; import { src } from '$lib/Utils'; import ReaderPage from './ReaderPage.svelte'; + import SliderMargin from './components/SliderMargin.svelte'; + import SliderTooltip from './components/SliderTooltip.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; + let { direction, layout }: { direction: Direction; layout: Layout } = $props(); function gotoChunk(to: number) { if (to < 0 || to >= chunks.length) return; - $reader.page = chunks[to].index; + reader.page = chunks[to].index; } function pagesAround(around: number) { - const peek = (at: number) => { - if (at < 0 || at >= chunks.length) return []; + const pages: PageFragment[] = []; - const pages = [chunks[at].main]; + const push = (at: number) => { + if (at < 0 || at >= chunks.length) return; + + pages.push(chunks[at].main); if (chunks[at].secondary) { pages.push(chunks[at].secondary); } - - return pages; }; - return [...peek(lookup[around] + 1), ...peek(lookup[around] - 1)]; + for (let i = 1; i <= 2; i++) { + push(lookup[around] + i); + push(lookup[around] - i); + } + + return pages; } - const next = () => gotoChunk(lookup[$reader.page] + 1); - const prev = () => gotoChunk(lookup[$reader.page] - 1); + 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()); @@ -56,8 +56,11 @@ } } - $: [chunks, lookup] = partition($reader.pages, layout); - $: layout, ({ main, secondary } = chunks[lookup[$reader.page]]); + let [chunks, lookup] = $derived(partition(reader.pages, layout)); + let currentChunk = $derived(chunks[lookup[reader.page]]); + let { main, secondary } = $derived(currentChunk); + + let reverse = $derived(direction === Direction.RightToLeft); </script> <svelte:document @@ -76,16 +79,64 @@ /> {#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" /> + <ReaderPage page={main} onclick={clickMain} --justify="center" /> +{:else if reverse} + <ReaderPage page={secondary} onclick={next} --justify="flex-end" /> + <ReaderPage page={main} onclick={prev} --justify="flex-start" /> {:else} - <ReaderPage page={secondary} on:click={next} --justify="flex-end" /> - <ReaderPage page={main} on:click={prev} --justify="flex-start" /> + <ReaderPage page={main} onclick={prev} --justify="flex-end" /> + <ReaderPage page={secondary} onclick={next} --justify="flex-start" /> {/if} + +{#snippet pagesIn(chunk: Chunk)} + {#if chunk.secondary} + {chunk.index + 1} - {chunk.index + 2} + {:else} + {chunk.index + 1} + {/if} +{/snippet} + +<div class="group/slider absolute bottom-0 z-1 flex w-full pt-20"> + <div class:reverse class="flex h-1 w-full transition-[height] group-hover/slider:h-8"> + <SliderMargin> + {@render pagesIn(currentChunk)} + </SliderMargin> + <div class:reverse class="flex w-full bg-gray-400/60 backdrop-blur-2xl"> + <!-- eslint-disable-next-line svelte/require-each-key --> + {#each chunks as chunk, index} + <button + type="button" + class:read={index <= lookup[reader.page]} + class="group/page relative grow" + onclick={() => reader.open(chunk.index)} + aria-label={`Open page ${chunk.index + 1}`} + > + <SliderTooltip> + {@render pagesIn(chunk)} + </SliderTooltip> + </button> + {/each} + </div> + <SliderMargin> + {reader.pages.length} + </SliderMargin> + </div> +</div> + <div class="invisible absolute"> - {#each pagesAround($reader.page) as page} + {#each pagesAround(reader.page) as page (page.id)} <img src={src(page.image, 'full')} alt="" /> {/each} </div> + +<style lang="postcss"> + @reference "tailwindcss/theme"; + + .reverse { + flex-direction: row-reverse; + } + + button.read { + @apply bg-blue-600/60; + } +</style> diff --git a/frontend/src/lib/reader/Reader.svelte b/frontend/src/lib/reader/Reader.svelte index 15ebdf4..a720a77 100644 --- a/frontend/src/lib/reader/Reader.svelte +++ b/frontend/src/lib/reader/Reader.svelte @@ -1,43 +1,52 @@ <script lang="ts"> import { trapFocus } from '$lib/Actions'; - import { getReaderContext } from '$lib/Reader'; + import { getReaderContext } from '$lib/Reader.svelte'; import { fadeDefault, slideXDefault } from '$lib/Transitions'; + import type { Snippet } from 'svelte'; import { fade, slide } from 'svelte/transition'; import CloseReaderButton from './components/CloseReaderButton.svelte'; - import PageIndicator from './components/PageIndicator.svelte'; import ReaderMenuButton from './components/ReaderMenuButton.svelte'; + import ToggleFullscreenButton from './components/ToggleFullscreenButton.svelte'; + + let { sidebar, children }: { sidebar?: Snippet; children?: Snippet } = $props(); const reader = getReaderContext(); + + let dialog: HTMLDivElement | undefined = $state(); </script> -{#if $reader.visible} +{#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" + class="fixed top-0 right-0 bottom-0 left-0 z-10 flex h-full w-full bg-black" transition:fade={fadeDefault} use:trapFocus + bind:this={dialog} > - {#if $$slots.sidebar && $reader.sidebar} - <aside class="w-[36rem] shrink-0 bg-slate-800" transition:slide={slideXDefault}> + {#if sidebar && reader.sidebar} + <aside + class="z-10 w-[36rem] shrink-0 bg-slate-800 shadow-md shadow-slate-800" + transition:slide={slideXDefault} + > <div class="flex h-full min-w-[36rem] flex-col gap-4 overflow-auto p-4"> - <slot name="sidebar" /> + {@render sidebar?.()} </div> </aside> {/if} <main class="relative flex grow"> - <div class="absolute z-10 flex w-full p-1 text-lg [&>*:last-child]:ml-auto"> - {#if $$slots.sidebar} - <ReaderMenuButton /> - {/if} - <CloseReaderButton /> - </div> - <div class="absolute bottom-0 right-0 z-10 flex p-1 text-lg"> - <PageIndicator /> + <div class="absolute flex w-full p-1 text-lg"> + <div class="flex flex-col gap-1"> + {#if sidebar} + <ReaderMenuButton /> + {/if} + </div> + <div class="ml-auto flex flex-col gap-1"> + <CloseReaderButton /> + <ToggleFullscreenButton {dialog} /> + </div> </div> - <div class="flex grow"> - <slot /> - </div> + {@render children?.()} </main> </div> {/if} diff --git a/frontend/src/lib/reader/ReaderPage.svelte b/frontend/src/lib/reader/ReaderPage.svelte index c86414d..4a19c6e 100644 --- a/frontend/src/lib/reader/ReaderPage.svelte +++ b/frontend/src/lib/reader/ReaderPage.svelte @@ -1,48 +1,25 @@ <script lang="ts"> import type { PageFragment } from '$gql/graphql'; - import Spinner from '$lib/components/Spinner.svelte'; import { src } from '$lib/Utils'; - import { onDestroy } from 'svelte'; + import type { MouseEventHandler } from 'svelte/elements'; - export let page: PageFragment; - - let loading = false; - let loadingTimeout: NodeJS.Timeout; - let lastId = -1; - - $: page.id, updateLoadingState(); - - function updateLoadingState() { - if (page.id === lastId) return; - lastId = page.id; - - loadingTimeout = setTimeout(() => (loading = true), 150); - } - - function finishLoading() { - clearTimeout(loadingTimeout); - loading = false; + interface Props { + page: PageFragment; + onclick: MouseEventHandler<HTMLDivElement>; } - onDestroy(() => clearTimeout(loadingTimeout)); + let { page, onclick }: Props = $props(); </script> -<!-- svelte-ignore a11y-click-events-have-key-events --> -<!-- svelte-ignore a11y-no-static-element-interactions --> -<div class="relative flex grow" on:click> - <div class="absolute right-0 top-0 z-0 h-full w-full"> - {#if loading} - <Spinner /> - {/if} - </div> +<!-- svelte-ignore a11y_click_events_have_key_events --> +<!-- svelte-ignore a11y_no_static_element_interactions --> +<div class="flex w-full" {onclick}> <img - class="h-auto w-auto object-contain transition-opacity duration-200" - class:opacity-0={loading} + class="h-auto w-auto object-contain" width={page.image.width} height={page.image.height} src={src(page.image, 'full')} - alt="" - on:load={finishLoading} + alt={page.path} /> </div> diff --git a/frontend/src/lib/reader/components/CloseReaderButton.svelte b/frontend/src/lib/reader/components/CloseReaderButton.svelte index 0c88323..6a31fd2 100644 --- a/frontend/src/lib/reader/components/CloseReaderButton.svelte +++ b/frontend/src/lib/reader/components/CloseReaderButton.svelte @@ -1,19 +1,22 @@ <script lang="ts"> - import { getReaderContext } from '$lib/Reader'; + import { getReaderContext } from '$lib/Reader.svelte'; import { accelerator } from '$lib/Shortcuts'; const reader = getReaderContext(); + + function onclick() { + reader.visible = false; + reader.sidebar = false; + } </script> <button type="button" - class="btn floating" + class="btn-transparent" title="Close reader" - on:click={() => { - $reader.visible = false; - $reader.sidebar = false; - }} + aria-label="Close reader" + {onclick} use:accelerator={'Escape'} > - <span class="icon-lg icon-[material-symbols--close]" /> + <span class="icon-lg icon-[material-symbols--close]"></span> </button> diff --git a/frontend/src/lib/reader/components/PageIndicator.svelte b/frontend/src/lib/reader/components/PageIndicator.svelte deleted file mode 100644 index f79fc00..0000000 --- a/frontend/src/lib/reader/components/PageIndicator.svelte +++ /dev/null @@ -1,9 +0,0 @@ -<script lang="ts"> - import { getReaderContext } from '$lib/Reader'; - - const reader = getReaderContext(); -</script> - -<div class="floating !p-2"> - {$reader.page + 1}/{$reader.pages.length} -</div> diff --git a/frontend/src/lib/reader/components/ReaderMenuButton.svelte b/frontend/src/lib/reader/components/ReaderMenuButton.svelte index aa20206..924342f 100644 --- a/frontend/src/lib/reader/components/ReaderMenuButton.svelte +++ b/frontend/src/lib/reader/components/ReaderMenuButton.svelte @@ -1,16 +1,19 @@ <script lang="ts"> - import { getReaderContext } from '$lib/Reader'; + import { getReaderContext } from '$lib/Reader.svelte'; import { accelerator } from '$lib/Shortcuts'; const reader = getReaderContext(); + + let title = $derived(`${reader.sidebar ? 'Hide' : 'Show'} menu`); </script> <button type="button" - class="btn floating invisible xl:visible" - title={`${$reader.sidebar ? 'Hide' : 'Show'} menu`} - on:click={() => ($reader.sidebar = !$reader.sidebar)} + class="btn-transparent hidden xl:flex" + {title} + aria-label={title} + onclick={() => (reader.sidebar = !reader.sidebar)} use:accelerator={'z'} > - <span class="icon-lg icon-[material-symbols--dock-to-right]" /> + <span class="icon-lg icon-[material-symbols--dock-to-right]"></span> </button> diff --git a/frontend/src/lib/reader/components/SliderMargin.svelte b/frontend/src/lib/reader/components/SliderMargin.svelte new file mode 100644 index 0000000..c2f9a55 --- /dev/null +++ b/frontend/src/lib/reader/components/SliderMargin.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + let { children }: { children: Snippet } = $props(); +</script> + +<div + class="flex h-full w-22 items-center justify-center px-1 font-semibold text-white/0 transition-colors group-hover/slider:bg-black group-hover/slider:text-white" +> + {@render children()} +</div> diff --git a/frontend/src/lib/reader/components/SliderTooltip.svelte b/frontend/src/lib/reader/components/SliderTooltip.svelte new file mode 100644 index 0000000..9e0322d --- /dev/null +++ b/frontend/src/lib/reader/components/SliderTooltip.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + let { children }: { children: Snippet } = $props(); +</script> + +<div + class="invisible absolute bottom-10 w-20 rounded-xl bg-blue-500 p-1 font-semibold text-white drop-shadow-md group-hover/page:visible" +> + {@render children()} +</div> + +<style lang="postcss"> + div { + left: calc(50% - 2.5rem); + } +</style> diff --git a/frontend/src/lib/reader/components/ToggleFullscreenButton.svelte b/frontend/src/lib/reader/components/ToggleFullscreenButton.svelte new file mode 100644 index 0000000..9ad4ce6 --- /dev/null +++ b/frontend/src/lib/reader/components/ToggleFullscreenButton.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import { accelerator } from '$lib/Shortcuts'; + import { toastFinally } from '$lib/Toasts'; + + let { dialog }: { dialog?: HTMLElement } = $props(); + + function onclick() { + if (isFullscreen) { + document.exitFullscreen().catch(toastFinally); + } else if (dialog?.requestFullscreen) { + dialog.requestFullscreen().catch(toastFinally); + } + } + + let fullscreenElement: HTMLElement | null = $state(null); + let isFullscreen = $derived(fullscreenElement !== null); +</script> + +<svelte:document bind:fullscreenElement /> + +<button + type="button" + class="btn-transparent" + title="Toggle fullscreen" + aria-label="Toggle fullscreen" + {onclick} + use:accelerator={'f'} +> + {#if isFullscreen} + <span class="icon-lg icon-[material-symbols--fullscreen-exit]"></span> + {:else} + <span class="icon-lg icon-[material-symbols--fullscreen]"></span> + {/if} +</button> diff --git a/frontend/src/lib/scraper/ComicScrapeForm.svelte b/frontend/src/lib/scraper/ComicScrapeForm.svelte index 30ad89b..6f995a9 100644 --- a/frontend/src/lib/scraper/ComicScrapeForm.svelte +++ b/frontend/src/lib/scraper/ComicScrapeForm.svelte @@ -2,60 +2,69 @@ 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 { OnMissing, type FullComicFragment, type ScrapeComicQuery } from '$gql/graphql'; 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 { getContextClient, type OperationResult } from '@urql/svelte'; + import { getScraperContext, ScrapedComicSelector } from './Scraper.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; + interface Props { + comic: FullComicFragment; + onupsert: () => void; + } - $: scrapersResult = comicScrapersQuery(client, { id: comic.id }); - $: scrapers = $scrapersResult.data?.comicScrapers; + let { comic, onupsert }: Props = $props(); + let createMissing = $state(false); + let loading = $state(false); - function scrape() { - loading = true; - scrapeComic(client, { id: comic.id, scraper: $context.scraper }) - .then((result) => { - if (result.error) { - toastError(result.error.message); - return; - } + let scrapersResult = $derived(comicScrapersQuery(client, { id: comic.id })); + let scrapers = $derived($scrapersResult.data?.comicScrapers); - if (result.data) { - if (isError(result.data.scrapeComic)) { - toastError(result.data.scrapeComic.message); - return; - } + function scrape(event: SubmitEvent) { + event.preventDefault(); + if (!context.scraper) return; - if (result.data.scrapeComic.__typename === 'ScrapeComicResult') { - $context.selector = new ScrapedComicSelector(result.data.scrapeComic.data, comic); - $context.warnings = result.data.scrapeComic.warnings; - } - } - }) + loading = true; + scrapeComic(client, { id: comic.id, scraper: context.scraper }) + .then(handleScrapeResult) .catch(toastFinally) .finally(() => (loading = false)); } - function updateFromScrape(createMissing: boolean) { - if (!$context.selector) return; + function handleScrapeResult(result: OperationResult<ScrapeComicQuery>) { + 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; + } + } + } + + function upsert(event: SubmitEvent) { + event.preventDefault(); + if (!context.selector) return; - upsertComics(client, { - ids: comic.id, - input: $context.selector.toInput(createMissing ? OnMissing.Create : OnMissing.Ignore) - }) + const input = context.selector.input(createMissing ? OnMissing.Create : OnMissing.Ignore); + upsertComics(client, { ids: comic.id, input }) .then(() => { - $context.selector = undefined; - $context.warnings = []; + onupsert(); + context.reset(); }) .catch(toastFinally); } @@ -65,56 +74,57 @@ {#if scrapers && scrapers.length === 0} <h2 class="text-base">No scrapers available.</h2> {:else} - <form on:submit|preventDefault={scrape}> + <form onsubmit={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} + placeholder="Select scraper..." + bind:value={context.scraper} /> </div> - <button type="submit" disabled={!$context.scraper} class="btn-blue">Scrape</button> + <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} + {: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} + <!-- eslint-disable-next-line svelte/require-each-key --> + {#each context.warnings as warning} <li>{warning}</li> {/each} </ul> </div> {/if} - {#if !$context.selector.hasData()} + {#if !context.selector.pending()} <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)}> + <form onsubmit={upsert}> <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} /> + <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> diff --git a/frontend/src/lib/Scraper.ts b/frontend/src/lib/scraper/Scraper.svelte.ts index 4baf370..93e756b 100644 --- a/frontend/src/lib/Scraper.ts +++ b/frontend/src/lib/scraper/Scraper.svelte.ts @@ -20,24 +20,28 @@ import { RatingLabel } from '$lib/Enums'; import { getContext, setContext } from 'svelte'; -import { writable, type Writable } from 'svelte/store'; -interface ScraperContext { - scraper: string; - warnings: string[]; - selector?: ScrapedComicSelector; +class ScraperContext { + scraper?: string = $state(); + warnings: string[] = $state([]); + selector?: ScrapedComicSelector = $state(); + + reset = () => { + this.selector = undefined; + this.warnings = []; + }; } export function initScraperContext() { - return setContext<Writable<ScraperContext>>('scraper', writable({ scraper: '', warnings: [] })); + return setContext<ScraperContext>('scraper', new ScraperContext()); } export function getScraperContext() { - return getContext<Writable<ScraperContext>>('scraper'); + return getContext<ScraperContext>('scraper'); } export class Selector<T extends string> { - keep = true; + keep = $state(true); value: T; display: string | undefined; @@ -46,6 +50,10 @@ export class Selector<T extends string> { this.display = display; } + toggle = () => { + this.keep = !this.keep; + }; + toString() { return this.display ?? this.value; } @@ -121,7 +129,7 @@ export class ScrapedComicSelector { this.worlds = Selector.fromList(scraped.worlds, comic.worlds); } - hasData() { + pending() { return ( Object.values(this).filter((i) => { if (i === undefined) { @@ -134,7 +142,7 @@ export class ScrapedComicSelector { ); } - toInput(onMissing: OnMissing): UpsertComicInput { + input(onMissing: OnMissing): UpsertComicInput { return { title: keepItem(this.title), originalTitle: keepItem(this.originalTitle), diff --git a/frontend/src/lib/scraper/components/SelectorButton.svelte b/frontend/src/lib/scraper/components/SelectorButton.svelte index b786f89..48b2d66 100644 --- a/frontend/src/lib/scraper/components/SelectorButton.svelte +++ b/frontend/src/lib/scraper/components/SelectorButton.svelte @@ -1,19 +1,19 @@ <script lang="ts"> - import { Selector } from '$lib/Scraper'; + import { Selector } from '../Scraper.svelte'; - export let selector: Selector<string>; + let { selector }: { selector: Selector<string> } = $props(); </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)} + class="ml-1 flex rounded-xs border-slate-700 bg-slate-900 hover:brightness-110" + onclick={selector.toggle} > <div class="flex self-center pl-1"> {#if selector.keep} - <span class="icon-base icon-[material-symbols--check] text-green-400" /> + <span class="icon-base icon-[material-symbols--check] text-green-400"></span> {:else} - <span class="icon-base icon-[material-symbols--close] text-red-400" /> + <span class="icon-base icon-[material-symbols--close] text-red-400"></span> {/if} </div> <p class:opacity-50={!selector.keep} class="p-1 text-left"> diff --git a/frontend/src/lib/scraper/components/SelectorGroup.svelte b/frontend/src/lib/scraper/components/SelectorGroup.svelte index ae7287a..5cf0cf0 100644 --- a/frontend/src/lib/scraper/components/SelectorGroup.svelte +++ b/frontend/src/lib/scraper/components/SelectorGroup.svelte @@ -1,9 +1,13 @@ <script lang="ts"> - import { Selector } from '$lib/Scraper'; + import { Selector } from '../Scraper.svelte'; import SelectorButton from './SelectorButton.svelte'; - export let title: string; - export let selectors: Selector<string>[]; + interface Props { + title: string; + selectors: Selector<string>[]; + } + + let { title, selectors = $bindable() }: Props = $props(); function invert() { for (let selector of selectors) { @@ -19,14 +23,16 @@ <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} + class="flex items-end opacity-75 brightness-75 transition-opacity hover:opacity-100 hover:brightness-110 focus-visible:opacity-100" + onclick={invert} title="Invert selection" + aria-label="Invert selection" > <span class="icon-xs icon-[material-symbols--compare-arrows]"></span> </button> </div> <div class="flex flex-wrap gap-y-1"> + <!-- eslint-disable-next-line svelte/require-each-key --> {#each selectors as selector} <SelectorButton {selector} /> {/each} diff --git a/frontend/src/lib/scraper/components/SelectorItem.svelte b/frontend/src/lib/scraper/components/SelectorItem.svelte index dd3f5b4..5beba50 100644 --- a/frontend/src/lib/scraper/components/SelectorItem.svelte +++ b/frontend/src/lib/scraper/components/SelectorItem.svelte @@ -1,9 +1,8 @@ <script lang="ts"> - import { Selector } from '$lib/Scraper'; + import { Selector } from '../Scraper.svelte'; import SelectorButton from './SelectorButton.svelte'; - export let title: string; - export let selector: Selector<string> | undefined; + let { title, selector }: { title: string; selector?: Selector<string> } = $props(); </script> {#if selector} diff --git a/frontend/src/lib/selection/Selectable.svelte b/frontend/src/lib/selection/Selectable.svelte index 48b6ac7..439d6b7 100644 --- a/frontend/src/lib/selection/Selectable.svelte +++ b/frontend/src/lib/selection/Selectable.svelte @@ -1,24 +1,48 @@ <script lang="ts"> - import { getSelectionContext } from '$lib/Selection'; + import type { Snippet } from 'svelte'; + import { getSelectionContext } from './Selection.svelte'; - export let id: number; - export let index: number; + interface SnippetProps { + onclick: (event: MouseEvent) => void; + onauxclick: (event: MouseEvent) => void; + selected: boolean; + } - export let edit: ((id: number) => void) | undefined = undefined; + interface Props { + id: number; + index: number; + onclick?: (id: number) => void; + onauxclick?: (id: number) => void; + children?: Snippet<[SnippetProps]>; + } - const selection = getSelectionContext(); + let { + id, + index, + onclick: onclick = undefined, + onauxclick = undefined, + children + }: Props = $props(); - $: selected = $selection.contains(id); + let selection = getSelectionContext(); - const handle = (event: MouseEvent) => { - if ($selection.active) { - $selection = $selection.update(index, event.shiftKey); + const click = (event: MouseEvent) => { + if (selection.active) { + selection.update(index, event.shiftKey); event.preventDefault(); - } else if (edit) { - edit(id); + } else if (event.ctrlKey && onauxclick) { + onauxclick(id); + } else if (onclick) { + onclick(id); event.preventDefault(); } }; + + const auxclick = (event: MouseEvent) => { + if (event.button === 1 && onauxclick) { + onauxclick(id); + } + }; </script> -<slot {handle} {selected} /> +{@render children?.({ onclick: click, onauxclick: auxclick, selected: selection.contains(id) })} diff --git a/frontend/src/lib/selection/Selection.svelte.ts b/frontend/src/lib/selection/Selection.svelte.ts new file mode 100644 index 0000000..dc294d0 --- /dev/null +++ b/frontend/src/lib/selection/Selection.svelte.ts @@ -0,0 +1,121 @@ +import { getContext, setContext } from 'svelte'; +import { SvelteSet } from 'svelte/reactivity'; +import { range } from '../Utils'; + +interface Selectable { + id: number; +} + +export function initSelectionContext<T extends Selectable>( + typename: string, + toName: (item: T) => string, + selectable?: (item: T) => boolean +) { + return setContext('selection', new ItemSelection(typename, toName, selectable)); +} + +export function getSelectionContext<T extends Selectable>() { + return getContext<ItemSelection<T>>('selection'); +} + +export class ItemSelection<T extends Selectable> { + active = $state(false); + view: T[] = $state([]); + + #ids = $state(new SvelteSet<number>()); + #masked = $derived(new SvelteSet([...this.#ids].filter((i) => this.#indexOf(i) >= 0))); + + typename: string; + #toName: (item: T) => string; + selectable: (item: T) => boolean; + + constructor( + typename: string, + toName: (item: T) => string, + selectable: (item: T) => boolean = () => true + ) { + this.typename = typename; + this.#toName = toName; + this.selectable = selectable; + } + + #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 SvelteSet([...this.#ids, ...selectableRange(last, index)]); + } else if (index < last) { + this.#ids = new SvelteSet([...this.#ids, ...selectableRange(index, last)]); + } + } else { + if (this.#ids.has(id)) { + this.#ids.delete(id); + } else { + this.#ids.add(id); + } + } + }; + + toggle = () => { + this.active = !this.active; + + if (!this.active) { + this.none(); + } + }; + + all = () => { + this.#ids = new SvelteSet(this.view.filter(this.selectable).map((i) => i.id)); + }; + + none = () => { + this.#ids.clear(); + this.#masked.clear(); + }; + + clear = () => { + this.active = false; + this.none(); + }; + + contains(id: number) { + return this.#masked.has(id); + } + + 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/selection/SelectionOverlay.svelte b/frontend/src/lib/selection/SelectionOverlay.svelte index 04ff382..e172e16 100644 --- a/frontend/src/lib/selection/SelectionOverlay.svelte +++ b/frontend/src/lib/selection/SelectionOverlay.svelte @@ -1,15 +1,19 @@ <script lang="ts"> - export let selected: boolean; - export let position: 'top' | 'right' | 'left' | 'bottom'; - export let centered = false; + interface Props { + selected: boolean; + position: 'top' | 'right' | 'left' | 'bottom'; + centered?: boolean; + } + + let { selected, position, centered = false }: Props = $props(); </script> {#if selected} <div class:items-center={centered} - class="{position} pointer-events-none absolute z-[1] flex bg-emerald-700/95" + class="{position} pointer-events-none absolute z-1 flex bg-emerald-700/95" > - <span class="icon-base icon-[material-symbols--check] text-[2rem]" /> + <span class="icon-[material-symbols--check] text-[2rem]"></span> </div> {/if} diff --git a/frontend/src/lib/statistics/Stat.svelte b/frontend/src/lib/statistics/Stat.svelte new file mode 100644 index 0000000..7e03e09 --- /dev/null +++ b/frontend/src/lib/statistics/Stat.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + interface Props { + title: string; + value: number; + precision?: number; + unit?: string; + } + + let { title, value, precision = 0, unit = '' }: Props = $props(); + + function format(value: number) { + if (Number.isNaN(value) || !Number.isFinite(value)) { + return 0; + } + + if (Number.isInteger(value)) { + return value; + } else { + return value.toFixed(precision); + } + } +</script> + +<div class="flex flex-col"> + <h2 class="text-lg font-medium"> + {title} + </h2> + <span class="text-base font-medium"> + {format(value)}{unit} + </span> +</div> diff --git a/frontend/src/lib/statistics/StatGroup.svelte b/frontend/src/lib/statistics/StatGroup.svelte new file mode 100644 index 0000000..91f8d3d --- /dev/null +++ b/frontend/src/lib/statistics/StatGroup.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import type { Snippet } from 'svelte'; + + let { title, children }: { title: string; children?: Snippet } = $props(); +</script> + +<section + class="flex flex-col gap-2 rounded-sm bg-slate-900 p-2 font-medium shadow-md shadow-slate-950/30" +> + <h2 class="text-2xl">{title}</h2> + <div class="flex flex-row flex-wrap gap-10"> + {@render children?.()} + </div> +</section> diff --git a/frontend/src/lib/tabs/AddOverlay.svelte b/frontend/src/lib/tabs/AddOverlay.svelte index b1c98bf..4d5ec49 100644 --- a/frontend/src/lib/tabs/AddOverlay.svelte +++ b/frontend/src/lib/tabs/AddOverlay.svelte @@ -1,7 +1,7 @@ <script lang="ts"> - import { updateComics } from '$gql/Mutations'; import { UpdateMode } from '$gql/graphql'; - import { getSelectionContext } from '$lib/Selection'; + import { updateComics } from '$gql/Mutations'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; import { toastFinally } from '$lib/Toasts'; import { fadeDefault } from '$lib/Transitions'; import { getContextClient } from '@urql/svelte'; @@ -10,27 +10,30 @@ const client = getContextClient(); const selection = getSelectionContext(); - export let id: number; + let { id }: { id: number } = $props(); + + function onclick(event: MouseEvent) { + event.preventDefault(); - function addPages() { updateComics(client, { ids: id, - input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Add } } } + input: { pages: { ids: selection.ids, options: { mode: UpdateMode.Add } } } }) - .then(() => ($selection = $selection.none())) + .then(() => selection.none()) .catch(toastFinally); } </script> -{#if $selection.size > 0} +{#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" + class="btn-blue rounded-full shadow-xs shadow-black" title="Add to this comic" - on:click|preventDefault={addPages} + aria-label="Add to this comic" + {onclick} > - <span class="icon-base icon-[material-symbols--note-add]" /> + <span class="icon-base icon-[material-symbols--note-add]"></span> </button> </div> {/if} diff --git a/frontend/src/lib/tabs/ArchiveDelete.svelte b/frontend/src/lib/tabs/ArchiveDelete.svelte index b0e3c58..d2b2465 100644 --- a/frontend/src/lib/tabs/ArchiveDelete.svelte +++ b/frontend/src/lib/tabs/ArchiveDelete.svelte @@ -9,7 +9,7 @@ const client = getContextClient(); - export let archive: FullArchiveFragment; + let { archive }: { archive: FullArchiveFragment } = $props(); function deleteArchive() { confirmDeletion('Archive', archive.name, () => { @@ -21,22 +21,13 @@ </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> + <p> + Deleting this archive will remove all of its comics as well as the + <span class="cursor-help font-medium underline" title={archive.path}>archive file</span> on + disk. + <span class="font-medium">This action is irrevocable.</span> + </p> <div class="flex"> - <DeleteButton prominent on:click={deleteArchive} /> + <DeleteButton prominent onclick={deleteArchive} /> </div> </div> diff --git a/frontend/src/lib/tabs/ArchiveDetails.svelte b/frontend/src/lib/tabs/ArchiveDetails.svelte index 9554557..c1ad68e 100644 --- a/frontend/src/lib/tabs/ArchiveDetails.svelte +++ b/frontend/src/lib/tabs/ArchiveDetails.svelte @@ -1,14 +1,13 @@ <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 ComicCard from '$lib/components/ComicCard.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; + let { archive }: { archive: FullArchiveFragment } = $props(); const now = Date.now(); const modifiedDate = new Date(archive.mtime); @@ -39,10 +38,8 @@ <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 archive.comics as comic (comic.id)} + <ComicCard compact {comic} /> {/each} </div> </div> diff --git a/frontend/src/lib/tabs/ArchiveEdit.svelte b/frontend/src/lib/tabs/ArchiveEdit.svelte index 80efaed..c6ea684 100644 --- a/frontend/src/lib/tabs/ArchiveEdit.svelte +++ b/frontend/src/lib/tabs/ArchiveEdit.svelte @@ -1,12 +1,11 @@ <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 ComicCard from '$lib/components/ComicCard.svelte'; import OrganizedButton from '$lib/components/OrganizedButton.svelte'; - import ComicPills from '$lib/pills/ComicPills.svelte'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; import { getContextClient } from '@urql/svelte'; import AddOverlay from './AddOverlay.svelte'; @@ -14,23 +13,23 @@ const client = getContextClient(); const selection = getSelectionContext(); - export let archive: FullArchiveFragment; + let { archive }: { archive: FullArchiveFragment } = $props(); 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 } + 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(); + selection.clear(); } else { - $selection = $selection.none(); + selection.none(); } }) .catch(toastFinally); @@ -46,21 +45,22 @@ <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} /> + <AddButton title="Add Comic from selected" onclick={addNew} /> </SelectionControls> - <div class="grow" /> - <OrganizedButton organized={archive.organized} on:click={toggleOrganized} /> + <div class="grow"></div> + <OrganizedButton organized={archive.organized} onclick={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 archive.comics as comic (comic.id)} + <ComicCard compact {comic}> + {#snippet overlay()} + <AddOverlay id={comic.id} /> + {/snippet} + </ComicCard> {/each} </div> </div> diff --git a/frontend/src/lib/tabs/ComicDelete.svelte b/frontend/src/lib/tabs/ComicDelete.svelte index a10f6b2..93fa106 100644 --- a/frontend/src/lib/tabs/ComicDelete.svelte +++ b/frontend/src/lib/tabs/ComicDelete.svelte @@ -9,9 +9,9 @@ const client = getContextClient(); - export let comic: FullComicFragment; + let { comic }: { comic: FullComicFragment } = $props(); - function deleteComic() { + function onclick() { confirmDeletion('Comic', comic.title, () => { deleteComics(client, { ids: comic.id }) .then(() => goto('/comics/')) @@ -21,14 +21,13 @@ </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> + <p> + Deleting this comic will make all of its pages available again for allocation. All of its + metadata will be lost. + <span class="font-medium">This action is irrevocable.</span> + </p> + <div class="flex"> - <DeleteButton prominent on:click={deleteComic} /> + <DeleteButton prominent {onclick} /> </div> </div> diff --git a/frontend/src/lib/tabs/ComicDetails.svelte b/frontend/src/lib/tabs/ComicDetails.svelte index 0a131af..3f9090e 100644 --- a/frontend/src/lib/tabs/ComicDetails.svelte +++ b/frontend/src/lib/tabs/ComicDetails.svelte @@ -1,15 +1,15 @@ <script lang="ts"> - import type { ComicFilter, FullComicFragment } from '$gql/graphql'; + import { type ComicFilter, type 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 Pill from '$lib/pills/Pill.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; + let { comic }: { comic: FullComicFragment } = $props(); const now = Date.now(); const updatedDate = new Date(comic.updatedAt); @@ -27,13 +27,13 @@ <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 + href={`/archives/${comic.archive.id}`} + class="btn-slate" + title="Go to Archive" + aria-label="Go to Archive" + > + <span class="icon-base icon-[material-symbols--folder-zip]"></span> </a> </Header> @@ -73,11 +73,21 @@ </Section> </div> + {#if comic.url} + <Section title="URL"> + <a + class="ellipsis-nowrap transition-colors hover:text-white hover:underline" + rel="noreferrer" + href={comic.url}>{comic.url}</a + > + </Section> + {/if} + {#if comic.artists.length} <Section title="Artists"> {#each comic.artists as { id, name } (id)} <a href={filterFor('artists', id)}> - <AssociationPill {name} type="artist" /> + <Pill highlight {name} style="artist" /> </a> {/each} </Section> @@ -86,7 +96,7 @@ <Section title="Circles"> {#each comic.circles as { id, name } (id)} <a href={filterFor('circles', id)}> - <AssociationPill {name} type="circle" /> + <Pill highlight {name} style="circle" /> </a> {/each} </Section> @@ -95,7 +105,7 @@ <Section title="Characters"> {#each comic.characters as { id, name } (id)} <a href={filterFor('characters', id)}> - <AssociationPill {name} type="character" /> + <Pill highlight {name} style="character" /> </a> {/each} </Section> @@ -104,7 +114,7 @@ <Section title="Worlds"> {#each comic.worlds as { id, name } (id)} <a href={filterFor('worlds', id)}> - <AssociationPill {name} type="world" /> + <Pill highlight {name} style="world" /> </a> {/each} </Section> @@ -113,7 +123,7 @@ <Section title="Tags"> {#each comic.tags as { id, name, description } (id)} <a href={filterFor('tags', id)}> - <TagPill {name} {description} /> + <TagPill highlight {name} {description} /> </a> {/each} </Section> diff --git a/frontend/src/lib/tabs/DetailsHeader.svelte b/frontend/src/lib/tabs/DetailsHeader.svelte index f980f75..ee5fa23 100644 --- a/frontend/src/lib/tabs/DetailsHeader.svelte +++ b/frontend/src/lib/tabs/DetailsHeader.svelte @@ -1,5 +1,7 @@ <script lang="ts"> - export let title: string; + import type { Snippet } from 'svelte'; + + let { title, children }: { title: string; children?: Snippet } = $props(); </script> <div class="flex items-center gap-2"> @@ -7,5 +9,5 @@ {title} </h2> <div class="grow"></div> - <slot /> + {@render children?.()} </div> diff --git a/frontend/src/lib/tabs/DetailsSection.svelte b/frontend/src/lib/tabs/DetailsSection.svelte index 9a6ad51..5514aa3 100644 --- a/frontend/src/lib/tabs/DetailsSection.svelte +++ b/frontend/src/lib/tabs/DetailsSection.svelte @@ -1,10 +1,12 @@ <script lang="ts"> - export let title: string; + import type { Snippet } from 'svelte'; + + let { title, children }: { title: string; children?: Snippet } = $props(); </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 /> + {@render children?.()} </div> </section> diff --git a/frontend/src/lib/tabs/Tab.svelte b/frontend/src/lib/tabs/Tab.svelte index 0a6be57..f8dc67c 100644 --- a/frontend/src/lib/tabs/Tab.svelte +++ b/frontend/src/lib/tabs/Tab.svelte @@ -1,14 +1,28 @@ <script lang="ts"> - import { getTabContext } from '$lib/Tabs'; import { fadeDefault } from '$lib/Transitions'; + import type { Snippet } from 'svelte'; import { fade } from 'svelte/transition'; + import { getTabContext } from './Tabs.svelte'; + + interface Props { + id: string; + title: string; + initial?: boolean; + children: Snippet; + } + + let { id, title, initial = false, children }: Props = $props(); const context = getTabContext(); - export let id: string; + + context.tabs = { ...context.tabs, [id]: { title } }; + if (initial) { + context.current = id; + } </script> -{#if $context.current === id} - <div class="h-full overflow-auto py-2 pe-3" in:fade={fadeDefault}> - <slot /> +{#if context.current === id} + <div class="h-full overflow-auto py-2 pe-3 ps-1" in:fade={fadeDefault}> + {@render children?.()} </div> {/if} diff --git a/frontend/src/lib/tabs/Tabs.svelte b/frontend/src/lib/tabs/Tabs.svelte index 09cdbdd..59b3220 100644 --- a/frontend/src/lib/tabs/Tabs.svelte +++ b/frontend/src/lib/tabs/Tabs.svelte @@ -1,28 +1,50 @@ +<script lang="ts" module> + import { getContext, setContext } from 'svelte'; + + type Tab = string; + type Tabs = Record<Tab, { title: string }>; + + class TabContext { + tabs: Tabs = $state({}); + current: Tab = $state(''); + } + + export function getTabContext() { + return getContext<TabContext>('tabs'); + } + + function initTabContext() { + return setContext('tabs', new TabContext()); + } +</script> + <script lang="ts"> - import { getTabContext } from '$lib/Tabs'; import { fadeFast } from '$lib/Transitions'; + import type { Snippet } from 'svelte'; import { fade } from 'svelte/transition'; - const context = getTabContext(); + let { badges = {}, children }: { badges?: Record<Tab, boolean>; children?: Snippet } = $props(); + + const context = initTabContext(); </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 }]} + <ul class="ms-1 me-3 flex border-b-2 border-slate-700 text-sm"> + {#each Object.entries(context.tabs) as [id, { title }] (id)} <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)} + class:active={context.current === id} + class="relative flex gap-1 p-1 px-3 hover:border-b-2 hover:border-slate-200 focus-visible:border-b-2 focus-visible:!border-slate-200 focus-visible:outline-hidden [&.active]:border-b-2 [&.active]:border-indigo-500" + onclick={() => (context.current = id)} > - {#if badge} + {#if badges[id]} <div - class="absolute right-0 top-1 h-2 w-2 rounded-full bg-emerald-400" + class="absolute top-1 right-0 h-2 w-2 rounded-full bg-emerald-400" title="There are pending changes" transition:fade={fadeFast} - /> + ></div> {/if} <span>{title}</span> </button> @@ -30,11 +52,5 @@ {/each} </ul> </nav> - <slot /> + {@render children?.()} </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 index 7459a87..7b37313 100644 --- a/frontend/src/lib/toolbar/DeleteSelection.svelte +++ b/frontend/src/lib/toolbar/DeleteSelection.svelte @@ -1,26 +1,28 @@ <script lang="ts"> import type { DeleteMutation } from '$gql/Mutations'; - import { getSelectionContext } from '$lib/Selection'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; 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; + interface Props { + mutation: DeleteMutation; + warning?: string; + } + + let { mutation, warning = undefined }: Props = $props(); + let selection = getSelectionContext(); - function remove() { + function onclick() { const mutate = () => { - mutation(client, { ids: $selection.ids }) - .then(() => ($selection = $selection.clear())) - .catch(toastFinally); + mutation(client, { ids: selection.ids }).then(selection.clear).catch(toastFinally); }; - confirmDeletion($selection.typename, $selection.names, mutate, warning); + confirmDeletion(selection.typename, selection.names, mutate, warning); } </script> -<DeleteButton on:click={remove} /> +<DeleteButton {onclick} /> diff --git a/frontend/src/lib/toolbar/EditSelection.svelte b/frontend/src/lib/toolbar/EditSelection.svelte index 50e6656..1803ed4 100644 --- a/frontend/src/lib/toolbar/EditSelection.svelte +++ b/frontend/src/lib/toolbar/EditSelection.svelte @@ -1,20 +1,19 @@ <script lang="ts"> - import { getSelectionContext } from '$lib/Selection'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; import { accelerator } from '$lib/Shortcuts'; - import type { SvelteComponent } from 'svelte'; - import { openModal } from 'svelte-modals'; + import { toastFinally } from '$lib/Toasts'; + import { modals, type ModalComponent, type ModalProps } from 'svelte-modals'; const selection = getSelectionContext(); - export let dialog: typeof SvelteComponent<{ - isOpen: boolean; + interface DialogProps extends ModalProps { ids: number[]; - }>; + } + + let { dialog }: { dialog: ModalComponent<DialogProps> } = $props(); function edit() { - openModal(dialog, { - ids: $selection.ids - }); + modals.open(dialog, { ids: selection.ids }).catch(toastFinally); } </script> @@ -22,8 +21,9 @@ type="button" class="btn-slate hover:bg-blue-700" title="Edit selection" - on:click={edit} + aria-label="Edit selection" + onclick={edit} use:accelerator={'e'} > - <span class="icon-base icon-[material-symbols--edit]" /> + <span class="icon-base icon-[material-symbols--edit]"></span> </button> diff --git a/frontend/src/lib/toolbar/FilterBookmarked.svelte b/frontend/src/lib/toolbar/FilterBookmarked.svelte index bcbe295..44895c6 100644 --- a/frontend/src/lib/toolbar/FilterBookmarked.svelte +++ b/frontend/src/lib/toolbar/FilterBookmarked.svelte @@ -1,23 +1,24 @@ <script lang="ts"> - import { page } from '$app/stores'; - import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter'; + import { page } from '$app/state'; + import { cycleBooleanFilter, type ComicFilterContext } from '$lib/Filter.svelte'; + import { accelerator } from '$lib/Shortcuts'; import Bookmark from '$lib/icons/Bookmark.svelte'; - const filter = getFilterContext<ComicFilterContext>(); - $: bookmarked = $filter.include.controls.bookmarked.value; + let { filter }: { filter: ComicFilterContext } = $props(); + let bookmarked = $derived(filter.include.bookmarked.value); const toggle = () => { - $filter.include.controls.bookmarked.value = cycleBooleanFilter(bookmarked, false); - $filter.apply($page.url.searchParams); + filter.include.bookmarked.value = cycleBooleanFilter(bookmarked, false); + filter.apply(page.url.searchParams); }; </script> <button class:toggled={bookmarked} class="btn-slate" - title="Filter bookmarked" - on:click={toggle} + title="Toggle bookmarks" + onclick={toggle} use:accelerator={'b'} > <Bookmark {bookmarked} /> diff --git a/frontend/src/lib/toolbar/FilterFavourites.svelte b/frontend/src/lib/toolbar/FilterFavourites.svelte index 6591cef..cdb497c 100644 --- a/frontend/src/lib/toolbar/FilterFavourites.svelte +++ b/frontend/src/lib/toolbar/FilterFavourites.svelte @@ -1,23 +1,23 @@ <script lang="ts"> - import { page } from '$app/stores'; - import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter'; + import { page } from '$app/state'; + import { ComicFilterContext, cycleBooleanFilter } from '$lib/Filter.svelte'; import { accelerator } from '$lib/Shortcuts'; import Star from '$lib/icons/Star.svelte'; - const filter = getFilterContext<ComicFilterContext>(); - $: favourite = $filter.include.controls.favourite.value; + let { filter }: { filter: ComicFilterContext } = $props(); + let favourite = $derived(filter.include.favourite.value); const toggle = () => { - $filter.include.controls.favourite.value = cycleBooleanFilter(favourite, false); - $filter.apply($page.url.searchParams); + filter.include.favourite.value = cycleBooleanFilter(favourite, false); + filter.apply(page.url.searchParams); }; </script> <button class:toggled={favourite} class="btn-slate" - title="Filter favourites" - on:click={toggle} + title="Toggle favourites" + onclick={toggle} use:accelerator={'f'} > <Star {favourite} /> diff --git a/frontend/src/lib/toolbar/FilterOrganized.svelte b/frontend/src/lib/toolbar/FilterOrganized.svelte index 754e663..d01a4f0 100644 --- a/frontend/src/lib/toolbar/FilterOrganized.svelte +++ b/frontend/src/lib/toolbar/FilterOrganized.svelte @@ -1,20 +1,20 @@ <script lang="ts"> - import { page } from '$app/stores'; + import { page } from '$app/state'; import { ArchiveFilterContext, - ComicFilterContext, cycleBooleanFilter, - getFilterContext - } from '$lib/Filter'; + type ComicFilterContext + } from '$lib/Filter.svelte'; + import { accelerator } from '$lib/Shortcuts'; import Organized from '$lib/icons/Organized.svelte'; - const filter = getFilterContext<ArchiveFilterContext | ComicFilterContext>(); - $: organized = $filter.include.controls.organized.value; + let { filter }: { filter: ComicFilterContext | ArchiveFilterContext } = $props(); + let organized = $derived(filter.include.organized.value); const toggle = () => { - $filter.include.controls.organized.value = cycleBooleanFilter(organized); - $filter.apply($page.url.searchParams); + filter.include.organized.value = cycleBooleanFilter(organized); + filter.apply(page.url.searchParams); }; </script> @@ -22,9 +22,9 @@ type="button" class:toggled={organized !== undefined} class="btn-slate" - title="Filter organized" - on:click={toggle} - use:accelerator={'o'} + title="Toggle organized" + onclick={toggle} + use:accelerator={'z'} > <Organized tristate {organized} /> </button> diff --git a/frontend/src/lib/toolbar/FilterOrphaned.svelte b/frontend/src/lib/toolbar/FilterOrphaned.svelte new file mode 100644 index 0000000..7e79be1 --- /dev/null +++ b/frontend/src/lib/toolbar/FilterOrphaned.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { page } from '$app/state'; + import { BasicFilterContext, NamespaceFilterContext } from '$lib/Filter.svelte'; + import { accelerator } from '$lib/Shortcuts'; + import Orphan from '$lib/icons/Orphan.svelte'; + + let { filter }: { filter: BasicFilterContext | NamespaceFilterContext } = $props(); + let orphaned = $derived(filter.include.orphan.value); + + const toggle = () => { + filter.include.orphan.value = !orphaned; + filter.apply(page.url.searchParams); + }; +</script> + +<button + class:toggled={orphaned} + class="btn-slate" + title="Filter orphaned" + onclick={toggle} + use:accelerator={'r'} +> + <Orphan {orphaned} /> +</button> diff --git a/frontend/src/lib/toolbar/MarkBookmark.svelte b/frontend/src/lib/toolbar/MarkBookmark.svelte index 792b84f..e9693fc 100644 --- a/frontend/src/lib/toolbar/MarkBookmark.svelte +++ b/frontend/src/lib/toolbar/MarkBookmark.svelte @@ -1,27 +1,25 @@ <script lang="ts"> - import { getSelectionContext } from '$lib/Selection'; - import { toastFinally } from '$lib/Toasts'; + import type { MutationWith } from '$gql/Utils'; import Bookmark from '$lib/icons/Bookmark.svelte'; - import { Client, getContextClient } from '@urql/svelte'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; const client = getContextClient(); const selection = getSelectionContext(); - export let mutation: ( - client: Client, - args: { ids: number[]; input: { bookmarked: boolean } } - ) => Promise<unknown>; + let { mutation }: { mutation: MutationWith<{ bookmarked: boolean }> } = $props(); function mutate(bookmarked: boolean) { - mutation(client, { ids: $selection.ids, input: { bookmarked } }).catch(toastFinally); + 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)}> +<button type="button" class="btn-slate justify-start gap-1" onclick={() => mutate(true)}> <Bookmark bookmarked={true} /> <span>Bookmark</span> </button> -<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}> +<button type="button" class="btn-slate justify-start gap-1" onclick={() => 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 index 42eaa39..1af5d60 100644 --- a/frontend/src/lib/toolbar/MarkFavourite.svelte +++ b/frontend/src/lib/toolbar/MarkFavourite.svelte @@ -1,27 +1,25 @@ <script lang="ts"> - import { getSelectionContext } from '$lib/Selection'; - import { toastFinally } from '$lib/Toasts'; + import type { MutationWith } from '$gql/Utils'; import Star from '$lib/icons/Star.svelte'; - import { Client, getContextClient } from '@urql/svelte'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; const client = getContextClient(); const selection = getSelectionContext(); - export let mutation: ( - client: Client, - args: { ids: number[]; input: { favourite: boolean } } - ) => Promise<unknown>; + let { mutation }: { mutation: MutationWith<{ favourite: boolean }> } = $props(); function mutate(favourite: boolean) { - mutation(client, { ids: $selection.ids, input: { favourite } }).catch(toastFinally); + mutation(client, { ids: selection.ids, input: { favourite } }).catch(toastFinally); } </script> -<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(true)}> +<button type="button" class="btn-slate justify-start gap-1" onclick={() => mutate(true)}> <Star favourite={true} /> <span>Favourite</span> </button> -<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(false)}> +<button type="button" class="btn-slate justify-start gap-1" onclick={() => 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 index 4dc3a83..c526393 100644 --- a/frontend/src/lib/toolbar/MarkOrganized.svelte +++ b/frontend/src/lib/toolbar/MarkOrganized.svelte @@ -1,27 +1,25 @@ <script lang="ts"> - import { getSelectionContext } from '$lib/Selection'; - import { toastFinally } from '$lib/Toasts'; + import type { MutationWith } from '$gql/Utils'; import Organized from '$lib/icons/Organized.svelte'; - import { Client, getContextClient } from '@urql/svelte'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; const client = getContextClient(); const selection = getSelectionContext(); - export let mutation: ( - client: Client, - args: { ids: number[]; input: { organized: boolean } } - ) => Promise<unknown>; + let { mutation }: { mutation: MutationWith<{ organized: boolean }> } = $props(); function mutate(organized: boolean) { - mutation(client, { ids: $selection.ids, input: { organized } }).catch(toastFinally); + 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)}> +<button type="button" class="btn-slate justify-start gap-1" onclick={() => 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)}> +<button type="button" class="btn-slate justify-start gap-1" onclick={() => 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 index 27eb2c7..8985369 100644 --- a/frontend/src/lib/toolbar/MarkSelection.svelte +++ b/frontend/src/lib/toolbar/MarkSelection.svelte @@ -1,24 +1,23 @@ <script lang="ts"> import Dropdown from '$lib/components/Dropdown.svelte'; + import type { Snippet } from 'svelte'; - let visible = false; - let button: HTMLElement; + let { children }: { children: Snippet } = $props(); </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> +<Dropdown> + {#snippet button(onclick)} + <button + type="button" + class="btn-slate rounded-inherit relative not-only:bg-blue-700 hover:bg-blue-700" + title="Set flag..." + aria-label="Set flag..." + {onclick} + > + <span class="icon-base icon-[material-symbols--flag] pointer-events-none"></span> + </button> + {/snippet} + <div class="grid grid-cols-[min-content_min-content] gap-1"> + {@render children?.()} + </div> +</Dropdown> diff --git a/frontend/src/lib/toolbar/Search.svelte b/frontend/src/lib/toolbar/Search.svelte index f033258..d5971bc 100644 --- a/frontend/src/lib/toolbar/Search.svelte +++ b/frontend/src/lib/toolbar/Search.svelte @@ -1,13 +1,15 @@ <script lang="ts"> - import { page } from '$app/stores'; + import { page } from '$app/state'; import { debounce } from '$lib/Actions'; - import { BasicFilterContext, getFilterContext } from '$lib/Filter'; import { accelerator } from '$lib/Shortcuts'; - const filter = getFilterContext<BasicFilterContext>(); + interface Props { + name: string; + field: string; + filter: { apply: (params: URLSearchParams) => void }; + } - export let name: string; - export let field: string; + let { name, field = $bindable(), filter }: Props = $props(); </script> <input @@ -16,6 +18,6 @@ class="btn-slate w-min" placeholder="Search {name}..." bind:value={field} - use:debounce={{ callback: () => $filter.apply($page.url.searchParams) }} - use:accelerator={'F'} + use:debounce={{ callback: () => filter.apply(page.url.searchParams) }} + use:accelerator={'q'} /> diff --git a/frontend/src/lib/toolbar/SelectItems.svelte b/frontend/src/lib/toolbar/SelectItems.svelte index 7ff339e..ce8045e 100644 --- a/frontend/src/lib/toolbar/SelectItems.svelte +++ b/frontend/src/lib/toolbar/SelectItems.svelte @@ -1,19 +1,20 @@ <script lang="ts"> - import { page } from '$app/stores'; - import { getPaginationContext } from '$lib/Pagination'; + import { page } from '$app/state'; + import { navigate, type PaginationData } from '$lib/Navigation'; - const pagination = getPaginationContext(); + let { pagination }: { pagination: PaginationData } = $props(); - $: values = new Set([24, 48, 72, 90, 120, 150, 180, $pagination.items].sort((a, b) => a - b)); + let values = $derived( + new Set([24, 48, 72, 90, 120, 150, 180, pagination.items].sort((a, b) => a - b)) + ); + + function onchange(e: Event & { currentTarget: EventTarget & HTMLSelectElement }) { + navigate({ pagination: { items: +e.currentTarget.value } }, page.url.searchParams); + } </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} +<select class="btn-slate" value={pagination.items} {onchange} title="Limit displayed items to..."> + {#each values as value (value)} <option {value}>{value}</option> {/each} </select> diff --git a/frontend/src/lib/toolbar/SelectSort.svelte b/frontend/src/lib/toolbar/SelectSort.svelte index fdcb057..cbcbd0e 100644 --- a/frontend/src/lib/toolbar/SelectSort.svelte +++ b/frontend/src/lib/toolbar/SelectSort.svelte @@ -1,60 +1,68 @@ <script lang="ts"> - import { page } from '$app/stores'; + import { page } from '$app/state'; import { SortDirection } from '$gql/graphql'; - - import { getSortContext } from '$lib/Sort'; + import { navigate, type SortData } from '$lib/Navigation'; import { slideXFast } from '$lib/Transitions'; import { getRandomInt } from '$lib/Utils'; import { slide } from 'svelte/transition'; - const sort = getSortContext(); + let { sort, labels }: { sort: SortData<string>; labels: Record<string, string> } = $props(); + + function apply(sort: SortData<string>) { + navigate({ sort }, page.url.searchParams); + } function toggle() { - if ($sort.direction === SortDirection.Ascending) { - $sort.direction = SortDirection.Descending; + if (sort.direction === SortDirection.Ascending) { + apply({ ...sort, direction: SortDirection.Descending }); } else { - $sort.direction = SortDirection.Ascending; + apply({ ...sort, direction: SortDirection.Ascending }); } + } - apply(); + function newSeed() { + return getRandomInt(0, 1000000000); } - function apply() { - if ($sort.on === 'RANDOM' && $sort.seed === undefined) { - $sort.seed = getRandomInt(0, 1000000000); - } - $sort.apply($page.url.searchParams); + function shuffle() { + apply({ ...sort, seed: newSeed() }); } - function reshuffle() { - $sort.seed = undefined; - apply(); + function onchange(e: Event & { currentTarget: EventTarget & HTMLSelectElement }) { + let seed: number | undefined = undefined; + + if (e.currentTarget.value === 'RANDOM') { + seed = newSeed(); + } + + apply({ ...sort, on: e.currentTarget.value, seed }); } </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]} + <select class="btn-slate appearance-none" value={sort.on} {onchange} title="Sort by..."> + {#each Object.entries(labels) as [value, label] (value)} <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" /> + <button type="button" class="btn-slate" title="Toggle sort direction" onclick={toggle}> + {#if sort.direction === SortDirection.Ascending} + <span class="icon-base icon-[material-symbols--sort] -scale-y-100"></span> {:else} - <span class="icon-base icon-[material-symbols--sort]" /> + <span class="icon-base icon-[material-symbols--sort]"></span> {/if} </button> - {#if $sort.on === 'RANDOM'} + {#if sort.on === 'RANDOM'} <button type="button" class="btn-slate" title="Reshuffle" - on:click={reshuffle} + aria-label="Reshuffle" + onclick={shuffle} transition:slide={slideXFast} > <div class="flex"> - <span class="icon-base icon-[material-symbols--shuffle]" /> + <span class="icon-base icon-[material-symbols--shuffle]"></span> </div> </button> {/if} diff --git a/frontend/src/lib/toolbar/SelectionControls.svelte b/frontend/src/lib/toolbar/SelectionControls.svelte index 4d309df..f0026c8 100644 --- a/frontend/src/lib/toolbar/SelectionControls.svelte +++ b/frontend/src/lib/toolbar/SelectionControls.svelte @@ -1,57 +1,64 @@ <script lang="ts"> - import { getSelectionContext } from '$lib/Selection'; + import Badge from '$lib/components/Badge.svelte'; + import { getSelectionContext } from '$lib/selection/Selection.svelte'; import { accelerator } from '$lib/Shortcuts'; import { fadeDefault, slideXFast } from '$lib/Transitions'; - import Badge from '$lib/components/Badge.svelte'; - import { onDestroy } from 'svelte'; + import { onDestroy, type Snippet } 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()); + let { page = false, children }: { page?: boolean; children?: Snippet } = $props(); + let selection = getSelectionContext(); - onDestroy(() => ($selection = $selection.clear())); + onDestroy(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} + class:toggled={selection.active} + title={`${selection.active ? 'Exit' : 'Enter'} ${page ? 'page ' : ' '}selection mode`} + onclick={selection.toggle} use:accelerator={'s'} > - {#if $selection.active} + {#if selection.active} {#if page} - <span class="icon-base icon-[material-symbols--edit-document]" /> + <span class="icon-base icon-[material-symbols--edit-document]"></span> {:else} - <span class="icon-base icon-[material-symbols--remove-selection]" /> + <span class="icon-base icon-[material-symbols--remove-selection]"></span> {/if} {:else if page} - <span class="icon-base icon-[material-symbols--edit-document-outline]" /> + <span class="icon-base icon-[material-symbols--edit-document-outline]"></span> {:else} - <span class="icon-base icon-[material-symbols--select]" /> + <span class="icon-base icon-[material-symbols--select]"></span> {/if} - <Badge number={$selection.size} /> + <Badge number={selection.size} /> </button> - {#if $selection.active} + {#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 + type="button" + class="btn-slate" + title="Select all" + aria-label="Select all" + onclick={selection.all} + > + <span class="icon-base icon-[material-symbols--select-all]"></span> </button> - <button type="button" class="btn-slate" title="Select none" on:click={none}> - <span class="icon-base icon-[material-symbols--deselect]" /> + <button + type="button" + class="btn-slate" + title="Select none" + aria-label="Select all" + onclick={selection.none} + > + <span class="icon-base icon-[material-symbols--deselect]"></span> </button> </div> {/if} </div> -{#if $selection.size > 0} +{#if selection.size > 0} <div class="rounded-group flex" transition:fade={fadeDefault}> - <slot /> + {@render children?.()} </div> {/if} diff --git a/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte index 2e7869f..2ef63f4 100644 --- a/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte +++ b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte @@ -1,39 +1,45 @@ <script lang="ts"> - import { page } from '$app/stores'; - import { getFilterContext } from '$lib/Filter'; + import { page } from '$app/state'; import { navigate } from '$lib/Navigation'; + import { accelerator } from '$lib/Shortcuts'; import { slideXFast } from '$lib/Transitions'; import Badge from '$lib/components/Badge.svelte'; import { slide } from 'svelte/transition'; - import { getToolbarContext } from './Toolbar.svelte'; + import type { ToolbarState } from './Toolbar.svelte'; - const toolbar = getToolbarContext(); - const filter = getFilterContext(); + interface Props extends ToolbarState { + filterSize: number; + } + + let { expanded, toggle, filterSize }: Props = $props(); </script> <div class="rounded-group flex"> <button - class:toggled={$toolbar.expand} + class:toggled={expanded} class="btn-slate relative" - title={`${$toolbar.expand ? 'Hide' : 'Show'} filters`} - on:click={() => ($toolbar.expand = !$toolbar.expand)} + title={`${expanded ? 'Hide' : 'Show'} filters`} + onclick={toggle} + use:accelerator={'F'} > - {#if $toolbar.expand} - <span class="icon-base icon-[material-symbols--filter-alt]" /> + {#if expanded} + <span class="icon-base icon-[material-symbols--filter-alt]"></span> {:else} - <span class="icon-base icon-[material-symbols--filter-alt-outline]" /> + <span class="icon-base icon-[material-symbols--filter-alt-outline]"></span> {/if} - <Badge number={$filter.include.size + $filter.exclude.size} /> + <Badge number={filterSize} /> </button> - {#if $filter.include.size + $filter.exclude.size > 0} + {#if filterSize > 0} <button class="btn-slate relative hover:bg-rose-700" - on:click={() => navigate({ filter: {} }, $page.url.searchParams)} + onclick={() => navigate({ filter: {} }, page.url.searchParams)} transition:slide={slideXFast} title="Reset filters" + aria-label="Reset filters" + use:accelerator={'X'} > <div class="flex"> - <span class="icon-base icon-[material-symbols--filter-alt-off]" /> + <span class="icon-base icon-[material-symbols--filter-alt-off]"></span> </div> </button> {/if} diff --git a/frontend/src/lib/toolbar/Toolbar.svelte b/frontend/src/lib/toolbar/Toolbar.svelte index e87d731..fefc151 100644 --- a/frontend/src/lib/toolbar/Toolbar.svelte +++ b/frontend/src/lib/toolbar/Toolbar.svelte @@ -1,23 +1,28 @@ -<script lang="ts" context="module"> - import { writable, type Writable } from 'svelte/store'; +<script lang="ts"> + import { slideYDefault } from '$lib/Transitions'; + import { type Snippet } from 'svelte'; + import { slide } from 'svelte/transition'; - interface ToolbarContext { - expand: boolean; + export interface ToolbarState { + expanded: boolean; + toggle: () => void; } - function initToolbarContext() { - return setContext<Writable<ToolbarContext>>('toolbar', writable({ expand: false })); + interface Props { + start?: Snippet<[ToolbarState]>; + center?: Snippet<[ToolbarState]>; + end?: Snippet<[ToolbarState]>; + expansion?: Snippet; + expand?: boolean; } - export function getToolbarContext() { - return getContext<Writable<ToolbarContext>>('toolbar'); - } -</script> + let { start, center, end, expansion, expand = false }: Props = $props(); -<script lang="ts"> - import { getContext, setContext } from 'svelte'; + let expanded = $state(expand); - const toolbar = initToolbarContext(); + function toggle() { + expanded = !expanded; + } </script> <div class="flex flex-col"> @@ -25,18 +30,18 @@ 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" /> + {@render start?.({ expanded, toggle })} </div> <div class="flex flex-row flex-wrap justify-start gap-2 xl:flex-nowrap xl:justify-center"> - <slot name="center" /> + {@render center?.({ expanded, toggle })} </div> <div class="flex flex-row justify-end gap-2"> - <slot name="end" /> + {@render end?.({ expanded, toggle })} </div> </div> - {#if $toolbar.expand} - <div class="mt-4"> - <slot /> + {#if expanded} + <div class="mt-4" transition:slide={slideYDefault}> + {@render expansion?.()} </div> {/if} </div> diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 0eefed1..29a1c16 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import { addShortcut, handleShortcuts } from '$lib/Shortcuts'; + import { toastFinally } from '$lib/Toasts'; import { fadeDefault } from '$lib/Transitions'; import AddArtist from '$lib/dialogs/AddArtist.svelte'; import AddCharacter from '$lib/dialogs/AddCharacter.svelte'; @@ -11,7 +12,7 @@ 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 { Modals, modals, type ModalComponent } from 'svelte-modals'; import { fade } from 'svelte/transition'; import '../app.css'; @@ -20,12 +21,16 @@ 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 open(modal: ModalComponent) { + modals.open(modal).catch(toastFinally); + } + + addShortcut('na', () => open(AddArtist)); + addShortcut('nh', () => open(AddCharacter)); + addShortcut('ni', () => open(AddCircle)); + addShortcut('nn', () => open(AddNamespace)); + addShortcut('nt', () => open(AddTag)); + addShortcut('nw', () => open(AddWorld)); function keydown(event: KeyboardEvent) { handleShortcuts(event); @@ -36,51 +41,55 @@ <Navigation> <Link matchExact href="/" title="Home" accel="go"> - <span class="icon-base icon-[material-symbols--home]" /> + <span class="icon-base icon-[material-symbols--home]"></span> </Link> <Link href="/comics/" title="Comics" accel="gc"> - <span class="icon-base icon-[material-symbols--menu-book]" /> + <span class="icon-base icon-[material-symbols--menu-book]"></span> </Link> <Link href="/namespaces/" title="Namespaces" accel="gn"> - <span class="icon-base icon-[material-symbols--inbox]" /> + <span class="icon-base icon-[material-symbols--inbox]"></span> </Link> <Link href="/tags/" title="Tags" accel="gt"> - <span class="icon-base icon-[material-symbols--label]" /> + <span class="icon-base icon-[material-symbols--label]"></span> </Link> <Link href="/artists/" title="Artists" accel="ga"> - <span class="icon-base icon-[material-symbols--person]" /> + <span class="icon-base icon-[material-symbols--person]"></span> </Link> <Link href="/circles/" title="Circles" accel="gi"> - <span class="icon-base icon-[material-symbols--group]" /> + <span class="icon-base icon-[material-symbols--group]"></span> </Link> <Link href="/characters/" title="Characters" accel="gh"> - <span class="icon-base icon-[material-symbols--face]" /> + <span class="icon-base icon-[material-symbols--face]"></span> </Link> <Link href="/worlds/" title="Worlds" accel="gw"> - <span class="icon-base icon-[material-symbols--public]" /> + <span class="icon-base icon-[material-symbols--public]"></span> </Link> <Link href="/archives/" title="Archives" accel="gz"> - <span class="icon-base icon-[material-symbols--folder-zip]" /> + <span class="icon-base icon-[material-symbols--folder-zip]"></span> + </Link> + <div class="mb-auto"></div> + <Link href="/statistics/" title="Statistics" accel="gs"> + <span class="icon-base icon-[material-symbols--bar-chart]"></span> </Link> - <div class="mb-auto" /> <Link href="/help/" title="Help" accel="?" target="_blank"> - <span class="icon-base icon-[material-symbols--help]" /> + <span class="icon-base icon-[material-symbols--help]"></span> </Link> </Navigation> -<div class="min-w-[360px] overflow-auto p-4"> +<div class="min-w-[360px] overflow-auto p-4" tabindex="-1"> <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" - /> + {#snippet backdrop({ close })} + <!-- svelte-ignore a11y-no-static-element-interactions --> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <div + onclick={() => close()} + transition:fade={fadeDefault} + class="fixed bottom-0 left-0 right-0 top-0 z-20 bg-stone-800/80" + ></div> + {/snippet} </Modals> <SvelteToast options={{ reversed: true, intro: { y: 192 } }} /> diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 97a7a60..4b921bb 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -6,29 +6,40 @@ 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 ComicCard from '$lib/components/ComicCard.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 bookmarkLink = href('comics', { + filter: { include: { bookmarked: true } }, + sort: { on: ComicSort.Random, seed: dailySeed() } + }); const recentLink = href('comics', { sort: { on: ComicSort.CreatedAt, direction: SortDirection.Descending } }); - const favouriteLink = href('comics', { filter: { include: { favourite: true } } }); + const favouriteLink = href('comics', { + filter: { include: { favourite: true } }, + sort: { on: ComicSort.Random, seed: dailySeed() } + }); + + function dailySeed() { + const date = new Date(); + return +`${date.getFullYear()}${date.getMonth() + 1}${date.getDate()}`; + } - $: query = frontpageQuery(getContextClient()); - $: recent = $query.data?.recent; - $: favourites = $query.data?.favourites; - $: bookmarked = $query.data?.bookmarked; + let query = $derived(frontpageQuery(getContextClient(), { seed: dailySeed() })); + let recent = $derived($query.data?.recent); + let favourites = $derived($query.data?.favourites); + let bookmarked = $derived($query.data?.bookmarked); </script> <Head section="Home" /> -<div class="flex flex-col justify-center gap-16 xl:flex-row"> - {#if $query.data} +{#if $query.data} + <div class="flex flex-col justify-center gap-16 xl:flex-row"> <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"> @@ -40,27 +51,27 @@ <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 recent.edges as comic (comic.id)} + <ComicCard coverOnly {comic} /> {/each} </Carousel> {/if} {#if favourites && favourites.count > 0} <Carousel title="Favourites" href={favouriteLink}> - {#each favourites.edges as comic} - <Card coverOnly {...comicCard(comic)} /> + {#each favourites.edges as comic (comic.id)} + <ComicCard coverOnly {comic} /> {/each} </Carousel> {/if} {#if bookmarked && bookmarked.count > 0} <Carousel title="Bookmarks" href={bookmarkLink}> - {#each bookmarked.edges as comic} - <Card coverOnly {...comicCard(comic)} /> + {#each bookmarked.edges as comic (comic.id)} + <ComicCard coverOnly {comic} /> {/each} </Carousel> {/if} </div> - {:else} - <Guard result={query} /> - {/if} -</div> + </div> +{:else} + <Guard result={query} /> +{/if} diff --git a/frontend/src/routes/archives/+page.svelte b/frontend/src/routes/archives/+page.svelte index 545058a..2bc6703 100644 --- a/frontend/src/routes/archives/+page.svelte +++ b/frontend/src/routes/archives/+page.svelte @@ -3,11 +3,8 @@ 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 { ArchiveFilterContext } from '$lib/Filter.svelte'; + import ArchiveCard from '$lib/components/ArchiveCard.svelte'; import Empty from '$lib/components/Empty.svelte'; import Guard from '$lib/components/Guard.svelte'; import Head from '$lib/components/Head.svelte'; @@ -15,8 +12,8 @@ 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 { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte'; @@ -28,91 +25,74 @@ 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'; + import type { PageProps } from './$types'; - let client = getContextClient(); + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); - export let data: PageData; + const client = getContextClient(); + let result = $derived(archivesQuery(client, { ...data })); + let archives = $derived($result.data?.archives); - $: result = archivesQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort + let selection = initSelectionContext<ArchiveFragment>('Archive', (a) => a.name); + $effect(() => { + if (archives) { + selection.view = archives.edges; + } }); - $: 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' }); - } + let filter = $state(new ArchiveFilterContext(data.filter)); + $effect(() => { + filter = new ArchiveFilterContext(data.filter); + }); </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} /> + {#snippet start()} + <SelectionControls> + <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> + {/snippet} + {#snippet center()} + <Search name="Archives" {filter} bind:field={filter.include.path.contains} /> + <FilterOrganized {filter} /> + <SelectSort {sort} labels={ArchiveSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <RefreshButton onclick={() => result.reexecute({ requestPolicy: 'network-only' })} /> + {/snippet} </Toolbar> {#if archives} - <Pagination /> + <Pagination {pagination} total={archives.count} /> <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> + {#each archives.edges as archive, index (archive.id)} + <Selectable {index} id={archive.id}> + {#snippet children({ onclick, selected })} + <ArchiveCard {archive} {onclick}> + {#snippet overlay()} + <SelectionOverlay position="left" {selected} /> + {/snippet} + </ArchiveCard> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cards> </main> - <Pagination /> + <Pagination {pagination} total={archives.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/archives/[id]/+page.svelte b/frontend/src/routes/archives/[id]/+page.svelte index 50a2940..9eedcb2 100644 --- a/frontend/src/routes/archives/[id]/+page.svelte +++ b/frontend/src/routes/archives/[id]/+page.svelte @@ -2,9 +2,7 @@ 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 { initReaderContext } from '$lib/Reader.svelte'; import { toastFinally } from '$lib/Toasts'; import Guard from '$lib/components/Guard.svelte'; import Head from '$lib/components/Head.svelte'; @@ -13,51 +11,39 @@ import Gallery from '$lib/gallery/Gallery.svelte'; import PageView from '$lib/reader/PageView.svelte'; import Reader from '$lib/reader/Reader.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.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'; + import type { PageProps } from './$types'; - export let data: PageData; + let { data }: PageProps = $props(); 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 - ); + function updateCover(id: number) { + updateArchives(client, { ids: data.id, input: { cover: { id } } }).catch(toastFinally); } - let archive: FullArchiveFragment; + let selection = initSelectionContext<PageFragment>( + 'Page', + (p) => p.path, + (p) => p.comicId === null + ); - $: $result, update(); - function update() { - if (!$result.stale && $result.data?.archive.__typename === 'FullArchive') { - archive = structuredClone($result.data.archive); + let result = $derived(archiveQuery(client, { id: data.id })); + let archive: FullArchiveFragment | undefined = $state(); - $reader.pages = archive.pages; + $effect(() => { + if (!$result.stale && $result.data?.archive.__typename === 'FullArchive') { + archive = $result.data.archive; + reader.pages = $result.data.archive.pages; + selection.view = $result.data.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} /> @@ -70,24 +56,20 @@ <aside> <Tabs> - <Tab id="details"> + <Tab initial id="details" title="Details"> <ArchiveDetails {archive} /> </Tab> - <Tab id="edit"> + <Tab id="edit" title="Edit"> <ArchiveEdit {archive} /> </Tab> - <Tab id="deletion"> + <Tab id="deletion" title="Delete"> <ArchiveDelete {archive} /> </Tab> </Tabs> </aside> <main class="overflow-auto"> - <Gallery - pages={archive.pages} - on:open={(e) => ($reader = $reader.open(e.detail))} - on:cover={updateCover} - /> + <Gallery pages={archive.pages} open={reader.open} {updateCover} /> </main> </Grid> {:else} diff --git a/frontend/src/routes/artists/+page.svelte b/frontend/src/routes/artists/+page.svelte index e07338c..9fff9d2 100644 --- a/frontend/src/routes/artists/+page.svelte +++ b/frontend/src/routes/artists/+page.svelte @@ -3,10 +3,8 @@ 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 { BasicFilterContext } from '$lib/Filter.svelte'; + import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; import Cardlet from '$lib/components/Cardlet.svelte'; @@ -19,82 +17,87 @@ import EditArtist from '$lib/dialogs/EditArtist.svelte'; import Pagination from '$lib/pagination/Pagination.svelte'; import Selectable from '$lib/selection/Selectable.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.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'; + import { modals } from 'svelte-modals'; + import type { PageProps } from './$types'; + + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); const client = getContextClient(); - export let data: PageData; + let result = $derived(artistsQuery(client, { ...data })); + let artists = $derived($result.data?.artists); - $: result = artistsQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort + let selection = initSelectionContext<Artist>('Artist', (a) => a.name); + $effect(() => { + if (artists) { + selection.view = artists.edges; + } }); - $: 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; + let filter = $state(new BasicFilterContext(data.filter)); + $effect(() => { + filter = new BasicFilterContext(data.filter); + }); const edit = (id: number) => { fetchArtist(client, id) - .then((artist) => openModal(EditArtist, { artist })) + .then((artist) => modals.open(EditArtist, { artist })) .catch(toastFinally); }; + + const quickFilter = (id: number) => quickComicFilter(id, 'artists'); </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> + {#snippet start()} + <SelectionControls> + <DeleteSelection mutation={deleteArtists} /> + </SelectionControls> + {/snippet} + {#snippet center()} + <Search name="Artists" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> + <SelectSort {sort} labels={ArtistSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <AddButton title="Add Artist" onclick={() => modals.open(AddArtist)} /> + {/snippet} </Toolbar> {#if artists} - <Pagination /> + <Pagination {pagination} total={artists.count} /> <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 {index} {id} onclick={edit} onauxclick={quickFilter}> + {#snippet children({ onclick, onauxclick, selected })} + <Cardlet {name} {onclick} {onauxclick}> + {#snippet overlay()} + <SelectionOverlay position="right" centered {selected} /> + {/snippet} + </Cardlet> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cardlets> </main> - <Pagination /> + <Pagination {pagination} total={artists.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/characters/+page.svelte b/frontend/src/routes/characters/+page.svelte index 0934bab..970a8fa 100644 --- a/frontend/src/routes/characters/+page.svelte +++ b/frontend/src/routes/characters/+page.svelte @@ -3,10 +3,8 @@ 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 { BasicFilterContext } from '$lib/Filter.svelte'; + import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; import Cardlet from '$lib/components/Cardlet.svelte'; @@ -19,82 +17,87 @@ import EditCharacter from '$lib/dialogs/EditCharacter.svelte'; import Pagination from '$lib/pagination/Pagination.svelte'; import Selectable from '$lib/selection/Selectable.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.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'; + import { modals } from 'svelte-modals'; + import type { PageProps } from './$types'; + + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); const client = getContextClient(); - export let data: PageData; + let result = $derived(charactersQuery(client, { ...data })); + let characters = $derived($result.data?.characters); - $: result = charactersQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort + let selection = initSelectionContext<Character>('Character', (a) => a.name); + $effect(() => { + if (characters) { + selection.view = characters.edges; + } }); - $: 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; + let filter = $state(new BasicFilterContext(data.filter)); + $effect(() => { + filter = new BasicFilterContext(data.filter); + }); const edit = (id: number) => { fetchCharacter(client, id) - .then((character) => openModal(EditCharacter, { character })) + .then((character) => modals.open(EditCharacter, { character })) .catch(toastFinally); }; + + const quickFilter = (id: number) => quickComicFilter(id, 'characters'); </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> + {#snippet start()} + <SelectionControls> + <DeleteSelection mutation={deleteCharacters} /> + </SelectionControls> + {/snippet} + {#snippet center()} + <Search name="Characters" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> + <SelectSort {sort} labels={CharacterSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <AddButton title="Add Character" onclick={() => modals.open(AddCharacter)} /> + {/snippet} </Toolbar> {#if characters} - <Pagination /> + <Pagination {pagination} total={characters.count} /> <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 {index} {id} onclick={edit} onauxclick={quickFilter}> + {#snippet children({ onclick, onauxclick, selected })} + <Cardlet {name} {onclick} {onauxclick}> + {#snippet overlay()} + <SelectionOverlay position="right" centered {selected} /> + {/snippet} + </Cardlet> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cardlets> </main> - <Pagination /> + <Pagination {pagination} total={characters.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/circles/+page.svelte b/frontend/src/routes/circles/+page.svelte index 14b0866..bad1e1d 100644 --- a/frontend/src/routes/circles/+page.svelte +++ b/frontend/src/routes/circles/+page.svelte @@ -3,10 +3,8 @@ 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 { BasicFilterContext } from '$lib/Filter.svelte'; + import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; import Cardlet from '$lib/components/Cardlet.svelte'; @@ -19,82 +17,87 @@ import EditCircle from '$lib/dialogs/EditCircle.svelte'; import Pagination from '$lib/pagination/Pagination.svelte'; import Selectable from '$lib/selection/Selectable.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.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'; + import { modals } from 'svelte-modals'; + import type { PageProps } from './$types'; + + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); const client = getContextClient(); - export let data: PageData; + let result = $derived(circlesQuery(client, { ...data })); + let circles = $derived($result.data?.circles); - $: result = circlesQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort + let selection = initSelectionContext<Circle>('Circle', (a) => a.name); + $effect(() => { + if (circles) { + selection.view = circles.edges; + } }); - $: 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; + let filter = $state(new BasicFilterContext(data.filter)); + $effect(() => { + filter = new BasicFilterContext(data.filter); + }); const edit = (id: number) => { fetchCircle(client, id) - .then((circle) => openModal(EditCircle, { circle })) + .then((circle) => modals.open(EditCircle, { circle })) .catch(toastFinally); }; + + const quickFilter = (id: number) => quickComicFilter(id, 'circles'); </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> + {#snippet start()} + <SelectionControls> + <DeleteSelection mutation={deleteCircles} /> + </SelectionControls> + {/snippet} + {#snippet center()} + <Search name="Circles" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> + <SelectSort {sort} labels={CircleSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <AddButton title="Add Circle" onclick={() => modals.open(AddCircle)} /> + {/snippet} </Toolbar> {#if circles} - <Pagination /> + <Pagination {pagination} total={circles.count} /> <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 {index} {id} onclick={edit} onauxclick={quickFilter}> + {#snippet children({ onclick, onauxclick, selected })} + <Cardlet {name} {onclick} {onauxclick}> + {#snippet overlay()} + <SelectionOverlay position="right" centered {selected} /> + {/snippet} + </Cardlet> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cardlets> </main> - <Pagination /> + <Pagination {pagination} total={circles.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/comics/+page.svelte b/frontend/src/routes/comics/+page.svelte index 353d69c..11289e7 100644 --- a/frontend/src/routes/comics/+page.svelte +++ b/frontend/src/routes/comics/+page.svelte @@ -3,11 +3,8 @@ 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 { ComicFilterContext } from '$lib/Filter.svelte'; + import ComicCard from '$lib/components/ComicCard.svelte'; import Empty from '$lib/components/Empty.svelte'; import Guard from '$lib/components/Guard.svelte'; import Head from '$lib/components/Head.svelte'; @@ -16,8 +13,8 @@ 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 { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; import EditSelection from '$lib/toolbar/EditSelection.svelte'; @@ -35,81 +32,82 @@ import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte'; import Toolbar from '$lib/toolbar/Toolbar.svelte'; import { getContextClient } from '@urql/svelte'; - import type { PageData } from './$types'; + import type { PageProps } from './$types'; - export let data: PageData; + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); const client = getContextClient(); - - $: result = comicsQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort - }); - - $: comics = $result.data?.comics; + let result = $derived(comicsQuery(client, { ...data })); + let comics = $derived($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; + $effect(() => { + if (comics) { + selection.view = comics.edges; + } + }); - const pagination = initPaginationContext(); - $: $pagination.update = data.pagination; + let filter = $state(new ComicFilterContext(data.filter)); + let filterSize = $derived(filter.includes + filter.excludes); + $effect(() => { + filter = new ComicFilterContext(data.filter); + }); </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 /> + <Toolbar expand={filterSize > 0}> + {#snippet start()} + <SelectionControls> + <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> + {/snippet} + {#snippet center({ expanded, toggle })} + <Search name="Comics" {filter} bind:field={filter.include.title.contains} /> + <ToggleAdvancedFilters {expanded} {toggle} filterSize={filter.includes + filter.excludes} /> <div class="rounded-group flex"> - <FilterFavourites /> - <FilterBookmarked /> - <FilterOrganized /> + <FilterFavourites {filter} /> + <FilterBookmarked {filter} /> + <FilterOrganized {filter} /> </div> - <SelectSort /> - <SelectItems /> - </svelte:fragment> - <ComicFilterForm /> + <SelectSort {sort} labels={ComicSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet expansion()} + <ComicFilterForm {filter} /> + {/snippet} </Toolbar> {#if comics} - <Pagination /> + <Pagination {pagination} total={comics.count} /> <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 {index} id={comic.id}> + {#snippet children({ onclick, selected })} + <ComicCard {comic} {onclick}> + {#snippet overlay()} + <SelectionOverlay position="left" {selected} /> + {/snippet} + </ComicCard> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cards> </main> - <Pagination /> + <Pagination {pagination} total={comics.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/comics/[id]/+page.svelte b/frontend/src/routes/comics/[id]/+page.svelte index cfc5840..28a8dde 100644 --- a/frontend/src/routes/comics/[id]/+page.svelte +++ b/frontend/src/routes/comics/[id]/+page.svelte @@ -2,12 +2,15 @@ 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 { omitIdentifiers, type OmitIdentifiers } from '$gql/Utils'; + import { + UpdateMode, + type FullComicFragment, + type PageFragment, + type UpdateComicInput + } from '$gql/graphql'; + import { comicPending } from '$lib/Form'; + import { initReaderContext } from '$lib/Reader.svelte'; import { toastFinally } from '$lib/Toasts'; import { preventOnPending } from '$lib/Utils'; import BookmarkButton from '$lib/components/BookmarkButton.svelte'; @@ -23,153 +26,138 @@ import PageView from '$lib/reader/PageView.svelte'; import Reader from '$lib/reader/Reader.svelte'; import ComicScrapeForm from '$lib/scraper/ComicScrapeForm.svelte'; + import { initScraperContext } from '$lib/scraper/Scraper.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.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'; + import { untrack } from 'svelte'; + import type { PageProps } from './$types'; + let { data }: PageProps = $props(); 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' - }); + const selection = initSelectionContext<PageFragment>('Page', (p) => p.path); - 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; - } + let comic: FullComicFragment | undefined = $state(); + let input: OmitIdentifiers<FullComicFragment> | undefined = $state(); + let updateInput = $state(true); + + function invalidateInput() { + updateInput = true; } - function toggle(field: keyof Omit<UpdateComicInput, 'cover'>) { - updateComics(client, { ids: comic.id, input: { [field]: !comic[field] } }) - .then(() => (updatePartial = true)) - .catch(toastFinally); + function submit(input: UpdateComicInput) { + updateComics(client, { ids: data.id, input }).then(invalidateInput).catch(toastFinally); } - function updateComic(event: CustomEvent<UpdateComicInput>) { - updateComics(client, { ids: comic.id, input: event.detail }).catch(toastFinally); + function toggle(field: keyof Pick<UpdateComicInput, 'bookmarked' | 'favourite' | 'organized'>) { + if (!comic) return; + updateComics(client, { ids: data.id, input: { [field]: !comic[field] } }).catch(toastFinally); } - function updateCover(event: CustomEvent<number>) { - updateComics(client, { ids: comic.id, input: { cover: { id: event.detail } } }) - .then(() => (updatePartial = true)) - .catch(toastFinally); + function updateCover(id: number) { + updateComics(client, { ids: data.id, input: { cover: { id } } }).catch(toastFinally); } function removePages() { updateComics(client, { - ids: comic.id, - input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Remove } } } + ids: data.id, + input: { pages: { ids: selection.ids, options: { mode: UpdateMode.Remove } } } }) - .then(() => { - updatePartial = true; - $selection = $selection.clear(); - }) + .then(selection.clear) .catch(toastFinally); } beforeNavigate((navigation) => preventOnPending(navigation, pending)); + let result = $derived(comicQuery(client, { id: data.id })); + let pending = $derived(comicPending(comic, input)); + + $effect(() => { + if (!$result.stale) { + untrack(() => { + if ($result.data?.comic.__typename === 'FullComic') { + comic = $result.data.comic; + + if (updateInput) { + input = omitIdentifiers($result.data.comic); + updateInput = false; + } + + reader.pages = comic.pages; + selection.view = comic.pages; + scraper.reset(); + } + }); + } + }); </script> -<Head section="Comic" title={original?.title} /> +<Head section="Comic" title={comic?.title} /> -{#if comic} +{#if comic && input} <Grid> <header> <Titlebar - title={original.title} - subtitle={original.originalTitle} - bind:favourite={comic.favourite} - on:favourite={() => toggle('favourite')} + title={comic.title} + subtitle={comic.originalTitle} + favourite={comic.favourite} + onfavourite={() => toggle('favourite')} /> </header> <aside> - <Tabs> - <Tab id="details"> - <ComicDetails comic={original} /> + <Tabs badges={{ edit: pending }}> + <Tab initial id="details" title="Details"> + <ComicDetails {comic} /> </Tab> - <Tab id="edit"> + <Tab id="edit" title="Edit"> <div class="flex flex-col gap-4"> <div class="flex gap-2 text-sm"> <SelectionControls page> - <RemovePageButton on:click={removePages} /> + <RemovePageButton onclick={removePages} /> </SelectionControls> - <div class="grow" /> - <BookmarkButton bookmarked={comic.bookmarked} on:click={() => toggle('bookmarked')} /> - <OrganizedButton organized={comic.organized} on:click={() => toggle('organized')} /> + <div class="grow"></div> + <BookmarkButton bookmarked={comic.bookmarked} onclick={() => toggle('bookmarked')} /> + <OrganizedButton organized={comic.organized} onclick={() => toggle('organized')} /> </div> - <ComicForm bind:comic on:submit={updateComic}> + <ComicForm bind:input {submit}> <div class="flex gap-2"> - <div class="grow" /> - <SubmitButton active={pending} /> + <div class="grow"></div> + <SubmitButton {pending} /> </div> </ComicForm> </div> </Tab> - <Tab id="scrape"> - <ComicScrapeForm {comic} /> + <Tab id="scrape" title="Scrape"> + <ComicScrapeForm {comic} onupsert={invalidateInput} /> </Tab> - <Tab id="deletion"> + <Tab id="deletion" title="Delete"> <ComicDelete {comic} /> </Tab> </Tabs> </aside> <main class="overflow-auto"> - <Gallery - pages={comic.pages} - on:open={(e) => ($reader = $reader.open(e.detail))} - on:cover={updateCover} - /> + <Gallery pages={comic.pages} open={reader.open} {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> + <PageView layout={input.layout} direction={input.direction} /> + {#snippet sidebar()} + {#if input} + <ComicForm bind:input {submit}> + <div class="flex justify-end gap-2"> + <SubmitButton {pending} /> + </div> + </ComicForm> + {/if} + {/snippet} </Reader> {:else} <Guard {result} /> diff --git a/frontend/src/routes/namespaces/+page.svelte b/frontend/src/routes/namespaces/+page.svelte index f6568f9..429432f 100644 --- a/frontend/src/routes/namespaces/+page.svelte +++ b/frontend/src/routes/namespaces/+page.svelte @@ -3,10 +3,8 @@ 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 { NamespaceFilterContext } from '$lib/Filter.svelte'; + import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; import Cardlet from '$lib/components/Cardlet.svelte'; @@ -19,82 +17,87 @@ import EditNamespace from '$lib/dialogs/EditNamespace.svelte'; import Pagination from '$lib/pagination/Pagination.svelte'; import Selectable from '$lib/selection/Selectable.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.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'; + import { modals } from 'svelte-modals'; + import type { PageProps } from './$types'; + + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); const client = getContextClient(); - export let data: PageData; + let result = $derived(namespacesQuery(client, { ...data })); + let namespaces = $derived($result.data?.namespaces); - $: result = namespacesQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort + let selection = initSelectionContext<Namespace>('Namespace', (n) => n.name); + $effect(() => { + if (namespaces) { + selection.view = namespaces.edges; + } }); - $: 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; + let filter = $state(new NamespaceFilterContext(data.filter)); + $effect(() => { + filter = new NamespaceFilterContext(data.filter); + }); const edit = (id: number) => { fetchNamespace(client, id) - .then((namespace) => openModal(EditNamespace, { namespace })) + .then((namespace) => modals.open(EditNamespace, { namespace })) .catch(toastFinally); }; + + const quickFilter = (id: number) => quickComicFilter(`${id}:`, 'tags'); </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> + {#snippet start()} + <SelectionControls> + <DeleteSelection mutation={deleteNamespaces} /> + </SelectionControls> + {/snippet} + {#snippet center()} + <Search name="Namespaces" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> + <SelectSort {sort} labels={NamespaceSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <AddButton title="Add Namespace" onclick={() => modals.open(AddNamespace)} /> + {/snippet} </Toolbar> {#if namespaces} - <Pagination /> + <Pagination {pagination} total={namespaces.count} /> <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 {index} {id} onclick={edit} onauxclick={quickFilter}> + {#snippet children({ onclick, onauxclick, selected })} + <Cardlet {name} {onclick} {onauxclick}> + {#snippet overlay()} + <SelectionOverlay position="right" centered {selected} /> + {/snippet} + </Cardlet> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cardlets> </main> - <Pagination /> + <Pagination {pagination} total={namespaces.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/statistics/+page.svelte b/frontend/src/routes/statistics/+page.svelte new file mode 100644 index 0000000..53c56a4 --- /dev/null +++ b/frontend/src/routes/statistics/+page.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { statisticsQuery } from '$gql/Queries'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Stat from '$lib/statistics/Stat.svelte'; + import StatGroup from '$lib/statistics/StatGroup.svelte'; + import { getContextClient } from '@urql/svelte'; + + let query = $derived(statisticsQuery(getContextClient())); + let totals = $derived($query.data?.statistics.total); +</script> + +<Head section="Statistics" /> + +{#if $query.data && totals} + <div class="flex flex-row flex-wrap gap-8"> + <StatGroup title="Content"> + <Stat title="Archives" value={totals.archives} /> + <Stat title="Comics" value={totals.comics} /> + <Stat title="Images" value={totals.images} /> + <Stat title="Pages" value={totals.pages} /> + <Stat title="Dedup" value={(1 - totals.images / totals.pages) * 100} precision={2} unit="%" /> + </StatGroup> + <StatGroup title="Metadata"> + <Stat title="Namespaces" value={totals.namespaces} /> + <Stat title="Tags" value={totals.tags} /> + <Stat title="Artists" value={totals.artists} /> + <Stat title="Circles" value={totals.circles} /> + <Stat title="Characters" value={totals.characters} /> + <Stat title="Worlds" value={totals.worlds} /> + </StatGroup> + <StatGroup title="Average per Comic"> + <Stat title="Tags" value={totals.comic.tags / totals.comics} precision={2} /> + <Stat title="Artists" value={totals.comic.artists / totals.comics} precision={2} /> + <Stat title="Circles" value={totals.comic.circles / totals.comics} precision={2} /> + <Stat title="Characters" value={totals.comic.characters / totals.comics} precision={2} /> + <Stat title="Worlds" value={totals.comic.worlds / totals.comics} precision={2} /> + </StatGroup> + <StatGroup title="Average per Archive"> + <Stat title="Comics" value={totals.comics / totals.archives} precision={2} /> + <Stat title="Images" value={totals.images / totals.archives} precision={2} /> + </StatGroup> + </div> +{:else} + <Guard result={query} /> +{/if} diff --git a/frontend/src/routes/statistics/+page.ts b/frontend/src/routes/statistics/+page.ts new file mode 100644 index 0000000..d3c3250 --- /dev/null +++ b/frontend/src/routes/statistics/+page.ts @@ -0,0 +1 @@ +export const trailingSlash = 'always'; diff --git a/frontend/src/routes/tags/+page.svelte b/frontend/src/routes/tags/+page.svelte index e0909ad..2cb6f87 100644 --- a/frontend/src/routes/tags/+page.svelte +++ b/frontend/src/routes/tags/+page.svelte @@ -3,10 +3,8 @@ 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 { TagFilterContext } from '$lib/Filter.svelte'; + import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; import Cardlet from '$lib/components/Cardlet.svelte'; @@ -17,13 +15,15 @@ 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 UpdateTags 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 { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; import EditSelection from '$lib/toolbar/EditSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.svelte'; import Search from '$lib/toolbar/Search.svelte'; import SelectItems from '$lib/toolbar/SelectItems.svelte'; import SelectSort from '$lib/toolbar/SelectSort.svelte'; @@ -31,78 +31,83 @@ 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'; + import { modals } from 'svelte-modals'; + import type { PageProps } from './$types'; - const client = getContextClient(); - - export let data: PageData; + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); - $: result = tagsQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort - }); - - $: tags = $result.data?.tags; + const client = getContextClient(); + let result = $derived(tagsQuery(client, { ...data })); + let tags = $derived($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; + $effect(() => { + if (tags) { + selection.view = tags.edges; + } + }); - const pagination = initPaginationContext(); - $: $pagination.update = data.pagination; + let filter = $state(new TagFilterContext(data.filter)); + let filterSize = $derived(filter.includes + filter.excludes); + $effect(() => { + filter = new TagFilterContext(data.filter); + }); const edit = (id: number) => { fetchTag(client, id) - .then((tag) => openModal(EditTag, { tag })) + .then((tag) => modals.open(EditTag, { tag })) .catch(toastFinally); }; + + const quickFilter = (id: number) => quickComicFilter(`:${id}`, 'tags'); </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 /> + {#snippet start()} + <SelectionControls> + <EditSelection dialog={UpdateTags} /> + <DeleteSelection mutation={deleteTags} /> + </SelectionControls> + {/snippet} + {#snippet center({ expanded, toggle })} + <Search name="Tags" {filter} bind:field={filter.include.name.contains} /> + <ToggleAdvancedFilters {expanded} {toggle} {filterSize} /> + <FilterOrphaned {filter} /> + <SelectSort {sort} labels={TagSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <AddButton title="Add Tag" onclick={() => modals.open(AddTag)} /> + {/snippet} + {#snippet expansion()} + <TagFilterForm {filter} /> + {/snippet} </Toolbar> {#if tags} - <Pagination /> + <Pagination {pagination} total={tags.count} /> <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 {index} {id} onclick={edit} onauxclick={quickFilter}> + {#snippet children({ onclick, onauxclick, selected })} + <Cardlet {name} title={description} {onclick} {onauxclick}> + {#snippet overlay()} + <SelectionOverlay position="right" centered {selected} /> + {/snippet} + </Cardlet> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cardlets> </main> - <Pagination /> + <Pagination {pagination} total={tags.count} /> {:else} <Guard {result} /> {/if} diff --git a/frontend/src/routes/worlds/+page.svelte b/frontend/src/routes/worlds/+page.svelte index e0366e9..133dc27 100644 --- a/frontend/src/routes/worlds/+page.svelte +++ b/frontend/src/routes/worlds/+page.svelte @@ -3,10 +3,8 @@ 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 { BasicFilterContext } from '$lib/Filter.svelte'; + import { quickComicFilter } from '$lib/Navigation'; import { toastFinally } from '$lib/Toasts'; import AddButton from '$lib/components/AddButton.svelte'; import Cardlet from '$lib/components/Cardlet.svelte'; @@ -19,83 +17,87 @@ import EditWorld from '$lib/dialogs/EditWorld.svelte'; import Pagination from '$lib/pagination/Pagination.svelte'; import Selectable from '$lib/selection/Selectable.svelte'; + import { initSelectionContext } from '$lib/selection/Selection.svelte'; import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrphaned from '$lib/toolbar/FilterOrphaned.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'; + import { modals } from 'svelte-modals'; + import type { PageProps } from './$types'; - const client = getContextClient(); + let { data }: PageProps = $props(); + let pagination = $derived(data.pagination); + let sort = $derived(data.sort); - export let data: PageData; + const client = getContextClient(); + let result = $derived(worldsQuery(client, { ...data })); + let worlds = $derived($result.data?.worlds); - $: result = worldsQuery(client, { - pagination: data.pagination, - filter: data.filter, - sort: data.sort + let selection = initSelectionContext<World>('World', (a) => a.name); + $effect(() => { + if (worlds) { + selection.view = worlds.edges; + } }); - $: 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; + let filter = $state(new BasicFilterContext(data.filter)); + $effect(() => { + filter = new BasicFilterContext(data.filter); + }); const edit = (id: number) => { fetchWorld(client, id) - .then((world) => openModal(EditWorld, { world })) + .then((world) => modals.open(EditWorld, { world })) .catch(toastFinally); }; + + const quickFilter = (id: number) => quickComicFilter(id, 'worlds'); </script> -<Head section="Worlds" /> +<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> + {#snippet start()} + <SelectionControls> + <DeleteSelection mutation={deleteWorlds} /> + </SelectionControls> + {/snippet} + {#snippet center()} + <Search name="Worlds" {filter} bind:field={filter.include.name.contains} /> + <FilterOrphaned {filter} /> + <SelectSort {sort} labels={WorldSortLabel} /> + <SelectItems {pagination} /> + {/snippet} + {#snippet end()} + <AddButton title="Add World" onclick={() => modals.open(AddWorld)} /> + {/snippet} </Toolbar> {#if worlds} - <Pagination /> + <Pagination {pagination} total={worlds.count} /> <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 {index} {id} onclick={edit} onauxclick={quickFilter}> + {#snippet children({ onclick, onauxclick, selected })} + <Cardlet {name} {onclick} {onauxclick}> + {#snippet overlay()} + <SelectionOverlay position="right" centered {selected} /> + {/snippet} + </Cardlet> + {/snippet} </Selectable> {:else} <Empty /> {/each} </Cardlets> </main> - <Pagination /> + <Pagination {pagination} total={worlds.count} /> {:else} <Guard {result} /> {/if} |