summaryrefslogtreecommitdiffstatshomepage
path: root/frontend/src
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src')
-rw-r--r--frontend/src/app.css249
-rw-r--r--frontend/src/gql/Queries.ts11
-rw-r--r--frontend/src/gql/Utils.ts75
-rw-r--r--frontend/src/gql/graphql.ts99
-rw-r--r--frontend/src/lib/Actions.ts22
-rw-r--r--frontend/src/lib/Enums.ts45
-rw-r--r--frontend/src/lib/Filter.svelte.ts (renamed from frontend/src/lib/Filter.ts)205
-rw-r--r--frontend/src/lib/Form.ts76
-rw-r--r--frontend/src/lib/Meta.ts2
-rw-r--r--frontend/src/lib/Navigation.ts52
-rw-r--r--frontend/src/lib/Pagination.ts31
-rw-r--r--frontend/src/lib/Reader.svelte.ts (renamed from frontend/src/lib/Reader.ts)19
-rw-r--r--frontend/src/lib/Selection.ts141
-rw-r--r--frontend/src/lib/Shortcuts.ts15
-rw-r--r--frontend/src/lib/Sort.ts42
-rw-r--r--frontend/src/lib/Tabs.ts18
-rw-r--r--frontend/src/lib/Toasts.ts2
-rw-r--r--frontend/src/lib/Transitions.ts6
-rw-r--r--frontend/src/lib/Update.svelte.ts (renamed from frontend/src/lib/Update.ts)19
-rw-r--r--frontend/src/lib/Utils.ts30
-rw-r--r--frontend/src/lib/components/AddButton.svelte13
-rw-r--r--frontend/src/lib/components/ArchiveCard.svelte39
-rw-r--r--frontend/src/lib/components/Badge.svelte4
-rw-r--r--frontend/src/lib/components/BookmarkButton.svelte10
-rw-r--r--frontend/src/lib/components/Card.svelte74
-rw-r--r--frontend/src/lib/components/Cardlet.svelte28
-rw-r--r--frontend/src/lib/components/ComicCard.svelte75
-rw-r--r--frontend/src/lib/components/DeleteButton.svelte18
-rw-r--r--frontend/src/lib/components/Dialog.svelte22
-rw-r--r--frontend/src/lib/components/Dropdown.svelte43
-rw-r--r--frontend/src/lib/components/Expander.svelte17
-rw-r--r--frontend/src/lib/components/Guard.svelte5
-rw-r--r--frontend/src/lib/components/Head.svelte3
-rw-r--r--frontend/src/lib/components/Labelled.svelte10
-rw-r--r--frontend/src/lib/components/LabelledBlock.svelte17
-rw-r--r--frontend/src/lib/components/OrganizedButton.svelte10
-rw-r--r--frontend/src/lib/components/RefreshButton.svelte10
-rw-r--r--frontend/src/lib/components/RemovePageButton.svelte8
-rw-r--r--frontend/src/lib/components/Select.svelte27
-rw-r--r--frontend/src/lib/components/Spinner.svelte27
-rw-r--r--frontend/src/lib/components/SubmitButton.svelte6
-rw-r--r--frontend/src/lib/components/Titlebar.svelte17
-rw-r--r--frontend/src/lib/containers/Cardlets.svelte6
-rw-r--r--frontend/src/lib/containers/Cards.svelte6
-rw-r--r--frontend/src/lib/containers/Carousel.svelte13
-rw-r--r--frontend/src/lib/containers/Column.svelte6
-rw-r--r--frontend/src/lib/containers/Grid.svelte8
-rw-r--r--frontend/src/lib/dialogs/AddArtist.svelte26
-rw-r--r--frontend/src/lib/dialogs/AddCharacter.svelte26
-rw-r--r--frontend/src/lib/dialogs/AddCircle.svelte26
-rw-r--r--frontend/src/lib/dialogs/AddNamespace.svelte26
-rw-r--r--frontend/src/lib/dialogs/AddTag.svelte26
-rw-r--r--frontend/src/lib/dialogs/AddWorld.svelte26
-rw-r--r--frontend/src/lib/dialogs/ConfirmDeletion.svelte35
-rw-r--r--frontend/src/lib/dialogs/EditArtist.svelte39
-rw-r--r--frontend/src/lib/dialogs/EditCharacter.svelte39
-rw-r--r--frontend/src/lib/dialogs/EditCircle.svelte39
-rw-r--r--frontend/src/lib/dialogs/EditNamespace.svelte39
-rw-r--r--frontend/src/lib/dialogs/EditTag.svelte37
-rw-r--r--frontend/src/lib/dialogs/EditWorld.svelte43
-rw-r--r--frontend/src/lib/dialogs/UpdateComics.svelte139
-rw-r--r--frontend/src/lib/dialogs/UpdateTags.svelte45
-rw-r--r--frontend/src/lib/dialogs/components/UpdateModeSelector.svelte12
-rw-r--r--frontend/src/lib/filter/ComicFilterForm.svelte97
-rw-r--r--frontend/src/lib/filter/TagFilterForm.svelte35
-rw-r--r--frontend/src/lib/filter/components/ComicFilterGroup.svelte27
-rw-r--r--frontend/src/lib/filter/components/Filter.svelte66
-rw-r--r--frontend/src/lib/filter/components/FilterForm.svelte46
-rw-r--r--frontend/src/lib/filter/components/TagFilterGroup.svelte14
-rw-r--r--frontend/src/lib/forms/ArtistForm.svelte34
-rw-r--r--frontend/src/lib/forms/CharacterForm.svelte34
-rw-r--r--frontend/src/lib/forms/CircleForm.svelte34
-rw-r--r--frontend/src/lib/forms/ComicForm.svelte169
-rw-r--r--frontend/src/lib/forms/NamespaceForm.svelte39
-rw-r--r--frontend/src/lib/forms/TagForm.svelte55
-rw-r--r--frontend/src/lib/forms/WorldForm.svelte34
-rw-r--r--frontend/src/lib/gallery/Gallery.svelte14
-rw-r--r--frontend/src/lib/gallery/GalleryPage.svelte55
-rw-r--r--frontend/src/lib/icons/Bookmark.svelte13
-rw-r--r--frontend/src/lib/icons/Female.svelte1
-rw-r--r--frontend/src/lib/icons/Location.svelte1
-rw-r--r--frontend/src/lib/icons/Male.svelte1
-rw-r--r--frontend/src/lib/icons/Organized.svelte23
-rw-r--r--frontend/src/lib/icons/Orphan.svelte15
-rw-r--r--frontend/src/lib/icons/Star.svelte17
-rw-r--r--frontend/src/lib/icons/Transgender.svelte1
-rw-r--r--frontend/src/lib/navigation/Link.svelte39
-rw-r--r--frontend/src/lib/navigation/Navigation.svelte8
-rw-r--r--frontend/src/lib/pagination/Pagination.svelte57
-rw-r--r--frontend/src/lib/pagination/Target.svelte28
-rw-r--r--frontend/src/lib/pills/AssociationPill.svelte30
-rw-r--r--frontend/src/lib/pills/ComicPills.svelte37
-rw-r--r--frontend/src/lib/pills/FooterPill.svelte15
-rw-r--r--frontend/src/lib/pills/Pill.svelte75
-rw-r--r--frontend/src/lib/pills/TagPill.svelte44
-rw-r--r--frontend/src/lib/reader/PageView.svelte105
-rw-r--r--frontend/src/lib/reader/Reader.svelte45
-rw-r--r--frontend/src/lib/reader/ReaderPage.svelte43
-rw-r--r--frontend/src/lib/reader/components/CloseReaderButton.svelte17
-rw-r--r--frontend/src/lib/reader/components/PageIndicator.svelte9
-rw-r--r--frontend/src/lib/reader/components/ReaderMenuButton.svelte13
-rw-r--r--frontend/src/lib/reader/components/SliderMargin.svelte11
-rw-r--r--frontend/src/lib/reader/components/SliderTooltip.svelte17
-rw-r--r--frontend/src/lib/reader/components/ToggleFullscreenButton.svelte34
-rw-r--r--frontend/src/lib/scraper/ComicScrapeForm.svelte128
-rw-r--r--frontend/src/lib/scraper/Scraper.svelte.ts (renamed from frontend/src/lib/Scraper.ts)28
-rw-r--r--frontend/src/lib/scraper/components/SelectorButton.svelte12
-rw-r--r--frontend/src/lib/scraper/components/SelectorGroup.svelte16
-rw-r--r--frontend/src/lib/scraper/components/SelectorItem.svelte5
-rw-r--r--frontend/src/lib/selection/Selectable.svelte48
-rw-r--r--frontend/src/lib/selection/Selection.svelte.ts121
-rw-r--r--frontend/src/lib/selection/SelectionOverlay.svelte14
-rw-r--r--frontend/src/lib/statistics/Stat.svelte31
-rw-r--r--frontend/src/lib/statistics/StatGroup.svelte14
-rw-r--r--frontend/src/lib/tabs/AddOverlay.svelte23
-rw-r--r--frontend/src/lib/tabs/ArchiveDelete.svelte25
-rw-r--r--frontend/src/lib/tabs/ArchiveDetails.svelte11
-rw-r--r--frontend/src/lib/tabs/ArchiveEdit.svelte32
-rw-r--r--frontend/src/lib/tabs/ComicDelete.svelte19
-rw-r--r--frontend/src/lib/tabs/ComicDetails.svelte40
-rw-r--r--frontend/src/lib/tabs/DetailsHeader.svelte6
-rw-r--r--frontend/src/lib/tabs/DetailsSection.svelte6
-rw-r--r--frontend/src/lib/tabs/Tab.svelte24
-rw-r--r--frontend/src/lib/tabs/Tabs.svelte50
-rw-r--r--frontend/src/lib/toolbar/DeleteSelection.svelte24
-rw-r--r--frontend/src/lib/toolbar/EditSelection.svelte22
-rw-r--r--frontend/src/lib/toolbar/FilterBookmarked.svelte17
-rw-r--r--frontend/src/lib/toolbar/FilterFavourites.svelte16
-rw-r--r--frontend/src/lib/toolbar/FilterOrganized.svelte22
-rw-r--r--frontend/src/lib/toolbar/FilterOrphaned.svelte24
-rw-r--r--frontend/src/lib/toolbar/MarkBookmark.svelte18
-rw-r--r--frontend/src/lib/toolbar/MarkFavourite.svelte18
-rw-r--r--frontend/src/lib/toolbar/MarkOrganized.svelte18
-rw-r--r--frontend/src/lib/toolbar/MarkSelection.svelte37
-rw-r--r--frontend/src/lib/toolbar/Search.svelte16
-rw-r--r--frontend/src/lib/toolbar/SelectItems.svelte23
-rw-r--r--frontend/src/lib/toolbar/SelectSort.svelte58
-rw-r--r--frontend/src/lib/toolbar/SelectionControls.svelte61
-rw-r--r--frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte36
-rw-r--r--frontend/src/lib/toolbar/Toolbar.svelte43
-rw-r--r--frontend/src/routes/+layout.svelte63
-rw-r--r--frontend/src/routes/+page.svelte49
-rw-r--r--frontend/src/routes/archives/+page.svelte120
-rw-r--r--frontend/src/routes/archives/[id]/+page.svelte64
-rw-r--r--frontend/src/routes/artists/+page.svelte93
-rw-r--r--frontend/src/routes/characters/+page.svelte93
-rw-r--r--frontend/src/routes/circles/+page.svelte93
-rw-r--r--frontend/src/routes/comics/+page.svelte112
-rw-r--r--frontend/src/routes/comics/[id]/+page.svelte178
-rw-r--r--frontend/src/routes/namespaces/+page.svelte93
-rw-r--r--frontend/src/routes/statistics/+page.svelte46
-rw-r--r--frontend/src/routes/statistics/+page.ts1
-rw-r--r--frontend/src/routes/tags/+page.svelte105
-rw-r--r--frontend/src/routes/worlds/+page.svelte96
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')}
>
&forall;
</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')}
>
&exist;
</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')}
>
&equals;
</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)}
>
&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}