From dc4db405d2991d3ec6a114f3b08d3fccd057d3ee Mon Sep 17 00:00:00 2001 From: Wolfgang Müller Date: Thu, 13 Feb 2025 17:52:16 +0100 Subject: frontend: Migrate to Svelte 5 --- frontend/src/lib/Actions.ts | 22 -- frontend/src/lib/Enums.ts | 5 +- frontend/src/lib/Filter.svelte.ts | 334 +++++++++++++++++++ frontend/src/lib/Filter.ts | 363 --------------------- frontend/src/lib/Form.ts | 76 +++++ frontend/src/lib/Navigation.ts | 44 ++- frontend/src/lib/Pagination.ts | 31 -- frontend/src/lib/Reader.ts | 62 ---- frontend/src/lib/Scraper.ts | 156 --------- frontend/src/lib/Selection.ts | 141 -------- frontend/src/lib/Shortcuts.ts | 9 +- frontend/src/lib/Sort.ts | 42 --- frontend/src/lib/Tabs.ts | 18 - frontend/src/lib/Update.svelte.ts | 94 ++++++ frontend/src/lib/Update.ts | 95 ------ frontend/src/lib/Utils.ts | 27 +- frontend/src/lib/components/AddButton.svelte | 6 +- frontend/src/lib/components/Badge.svelte | 2 +- frontend/src/lib/components/BookmarkButton.svelte | 10 +- frontend/src/lib/components/Card.svelte | 35 +- frontend/src/lib/components/Cardlet.svelte | 29 +- frontend/src/lib/components/DeleteButton.svelte | 15 +- frontend/src/lib/components/Dialog.svelte | 19 +- frontend/src/lib/components/Dropdown.svelte | 43 ++- frontend/src/lib/components/Expander.svelte | 22 +- frontend/src/lib/components/Guard.svelte | 5 +- frontend/src/lib/components/Head.svelte | 3 +- frontend/src/lib/components/Labelled.svelte | 10 - frontend/src/lib/components/LabelledBlock.svelte | 17 +- frontend/src/lib/components/OrganizedButton.svelte | 10 +- frontend/src/lib/components/RefreshButton.svelte | 10 +- .../src/lib/components/RemovePageButton.svelte | 8 +- frontend/src/lib/components/Select.svelte | 27 +- frontend/src/lib/components/Spinner.svelte | 4 +- frontend/src/lib/components/SubmitButton.svelte | 6 +- frontend/src/lib/components/Titlebar.svelte | 15 +- frontend/src/lib/containers/Cardlets.svelte | 4 +- frontend/src/lib/containers/Cards.svelte | 4 +- frontend/src/lib/containers/Carousel.svelte | 13 +- frontend/src/lib/containers/Column.svelte | 6 +- frontend/src/lib/containers/Grid.svelte | 8 +- frontend/src/lib/dialogs/AddArtist.svelte | 26 +- frontend/src/lib/dialogs/AddCharacter.svelte | 26 +- frontend/src/lib/dialogs/AddCircle.svelte | 26 +- frontend/src/lib/dialogs/AddNamespace.svelte | 26 +- frontend/src/lib/dialogs/AddTag.svelte | 26 +- frontend/src/lib/dialogs/AddWorld.svelte | 26 +- frontend/src/lib/dialogs/ConfirmDeletion.svelte | 31 +- frontend/src/lib/dialogs/EditArtist.svelte | 39 +-- frontend/src/lib/dialogs/EditCharacter.svelte | 39 +-- frontend/src/lib/dialogs/EditCircle.svelte | 39 +-- frontend/src/lib/dialogs/EditNamespace.svelte | 39 +-- frontend/src/lib/dialogs/EditTag.svelte | 37 +-- frontend/src/lib/dialogs/EditWorld.svelte | 43 +-- frontend/src/lib/dialogs/UpdateComics.svelte | 139 ++++---- frontend/src/lib/dialogs/UpdateTags.svelte | 45 +-- .../dialogs/components/UpdateModeSelector.svelte | 4 +- frontend/src/lib/filter/ComicFilterForm.svelte | 85 +++-- frontend/src/lib/filter/TagFilterForm.svelte | 32 +- .../lib/filter/components/ComicFilterGroup.svelte | 27 -- frontend/src/lib/filter/components/Filter.svelte | 26 +- .../src/lib/filter/components/FilterForm.svelte | 36 +- .../lib/filter/components/TagFilterGroup.svelte | 14 - frontend/src/lib/forms/ArtistForm.svelte | 34 +- frontend/src/lib/forms/CharacterForm.svelte | 34 +- frontend/src/lib/forms/CircleForm.svelte | 34 +- frontend/src/lib/forms/ComicForm.svelte | 169 +++++----- frontend/src/lib/forms/NamespaceForm.svelte | 39 ++- frontend/src/lib/forms/TagForm.svelte | 55 ++-- frontend/src/lib/forms/WorldForm.svelte | 34 +- frontend/src/lib/gallery/Gallery.svelte | 10 +- frontend/src/lib/gallery/GalleryPage.svelte | 51 ++- frontend/src/lib/icons/Bookmark.svelte | 13 +- frontend/src/lib/icons/Female.svelte | 2 +- frontend/src/lib/icons/Location.svelte | 2 +- frontend/src/lib/icons/Male.svelte | 2 +- frontend/src/lib/icons/Organized.svelte | 23 +- frontend/src/lib/icons/Star.svelte | 15 +- frontend/src/lib/icons/Transgender.svelte | 2 +- frontend/src/lib/navigation/Link.svelte | 29 +- frontend/src/lib/navigation/Navigation.svelte | 8 +- frontend/src/lib/pagination/Pagination.svelte | 57 ++-- frontend/src/lib/pagination/Target.svelte | 23 +- frontend/src/lib/pills/AssociationPill.svelte | 7 +- frontend/src/lib/pills/ComicPills.svelte | 2 +- frontend/src/lib/pills/Pill.svelte | 17 +- frontend/src/lib/pills/TagPill.svelte | 15 +- frontend/src/lib/reader/PageView.svelte | 26 +- frontend/src/lib/reader/Reader.svelte | 76 ++++- frontend/src/lib/reader/ReaderPage.svelte | 14 +- .../lib/reader/components/CloseReaderButton.svelte | 15 +- .../src/lib/reader/components/PageIndicator.svelte | 4 +- .../lib/reader/components/ReaderMenuButton.svelte | 11 +- frontend/src/lib/scraper/ComicScrapeForm.svelte | 125 +++---- frontend/src/lib/scraper/Scraper.svelte.ts | 164 ++++++++++ .../lib/scraper/components/SelectorButton.svelte | 10 +- .../lib/scraper/components/SelectorGroup.svelte | 13 +- .../src/lib/scraper/components/SelectorItem.svelte | 5 +- frontend/src/lib/selection/Selectable.svelte | 26 +- frontend/src/lib/selection/Selection.svelte.ts | 121 +++++++ frontend/src/lib/selection/SelectionOverlay.svelte | 12 +- frontend/src/lib/statistics/Stat.svelte | 12 +- frontend/src/lib/statistics/StatGroup.svelte | 6 +- frontend/src/lib/tabs/AddOverlay.svelte | 21 +- frontend/src/lib/tabs/ArchiveDelete.svelte | 4 +- frontend/src/lib/tabs/ArchiveDetails.svelte | 2 +- frontend/src/lib/tabs/ArchiveEdit.svelte | 22 +- frontend/src/lib/tabs/ComicDelete.svelte | 6 +- frontend/src/lib/tabs/ComicDetails.svelte | 20 +- frontend/src/lib/tabs/DetailsHeader.svelte | 6 +- frontend/src/lib/tabs/DetailsSection.svelte | 6 +- frontend/src/lib/tabs/Tab.svelte | 22 +- frontend/src/lib/tabs/Tabs.svelte | 38 ++- frontend/src/lib/toolbar/DeleteSelection.svelte | 24 +- frontend/src/lib/toolbar/EditSelection.svelte | 22 +- frontend/src/lib/toolbar/FilterBookmarked.svelte | 15 +- frontend/src/lib/toolbar/FilterFavourites.svelte | 14 +- frontend/src/lib/toolbar/FilterOrganized.svelte | 18 +- frontend/src/lib/toolbar/MarkBookmark.svelte | 18 +- frontend/src/lib/toolbar/MarkFavourite.svelte | 18 +- frontend/src/lib/toolbar/MarkOrganized.svelte | 18 +- frontend/src/lib/toolbar/MarkSelection.svelte | 37 +-- frontend/src/lib/toolbar/Search.svelte | 14 +- frontend/src/lib/toolbar/SelectItems.svelte | 21 +- frontend/src/lib/toolbar/SelectSort.svelte | 58 ++-- frontend/src/lib/toolbar/SelectionControls.svelte | 61 ++-- .../src/lib/toolbar/ToggleAdvancedFilters.svelte | 33 +- frontend/src/lib/toolbar/Toolbar.svelte | 38 ++- 128 files changed, 2357 insertions(+), 2197 deletions(-) create mode 100644 frontend/src/lib/Filter.svelte.ts delete mode 100644 frontend/src/lib/Filter.ts create mode 100644 frontend/src/lib/Form.ts delete mode 100644 frontend/src/lib/Pagination.ts delete mode 100644 frontend/src/lib/Reader.ts delete mode 100644 frontend/src/lib/Scraper.ts delete mode 100644 frontend/src/lib/Selection.ts delete mode 100644 frontend/src/lib/Sort.ts delete mode 100644 frontend/src/lib/Tabs.ts create mode 100644 frontend/src/lib/Update.svelte.ts delete mode 100644 frontend/src/lib/Update.ts delete mode 100644 frontend/src/lib/components/Labelled.svelte delete mode 100644 frontend/src/lib/filter/components/ComicFilterGroup.svelte delete mode 100644 frontend/src/lib/filter/components/TagFilterGroup.svelte create mode 100644 frontend/src/lib/scraper/Scraper.svelte.ts create mode 100644 frontend/src/lib/selection/Selection.svelte.ts (limited to 'frontend/src/lib') 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..3264de4 100644 --- a/frontend/src/lib/Enums.ts +++ b/frontend/src/lib/Enums.ts @@ -15,6 +15,7 @@ import { UpdateMode, WorldSort } from '$gql/graphql'; +import type { Key } from './Utils'; export interface EnumOption { id: T; @@ -318,8 +319,6 @@ export const censorships: EnumOption[] = optionsFromLabel(Censorship export const categories: EnumOption[] = optionsFromLabel(CategoryLabel); export const languages: EnumOption[] = optionsFromLabel(LanguageLabel); -function optionsFromLabel( - labels: Record -): EnumOption[] { +function optionsFromLabel(labels: Record): EnumOption[] { return Object.entries(labels).map(([k, v]) => ({ id: k as T, name: v as string })); } diff --git a/frontend/src/lib/Filter.svelte.ts b/frontend/src/lib/Filter.svelte.ts new file mode 100644 index 0000000..8c0fa82 --- /dev/null +++ b/frontend/src/lib/Filter.svelte.ts @@ -0,0 +1,334 @@ +import { + type ArchiveFilter, + type ArchiveFilterInput, + type ComicFilter, + type ComicFilterInput, + type StringFilter, + type TagFilter, + type TagFilterInput +} from '$gql/graphql'; +import { navigate } from './Navigation'; +import { numKeys, type Key } from './Utils'; + +interface FilterInput { + include?: T | null; + exclude?: T | null; +} + +interface BasicFilter { + name?: { contains?: string | null } | null; +} + +export type FilterType = 'include' | 'exclude'; + +type FilterMode = 'any' | 'all' | 'exact'; + +type Filter = Partial>; + +type AssocFilter = Filter< + { + any?: T[] | null; + all?: T[] | null; + exact?: T[] | null; + empty?: boolean | null; + }, + K +>; + +type EnumFilter = Filter< + { + any?: string[] | null; + empty?: boolean | null; + }, + K +>; + +interface Integrateable { + integrate(filter: F): void; +} + +class ComplexMember { + values: unknown[] = $state([]); + key: K; + mode: FilterMode = $state('all'); + empty?: boolean | null = $state(null); + + constructor(key: K, mode: FilterMode) { + this.key = key; + this.mode = mode; + } + + integrate(filter: AssocFilter) { + 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 extends ComplexMember { + values: (string | number)[] = $state([]); + + constructor(key: K, mode: FilterMode, filter?: AssocFilter | null) { + super(key, mode); + + if (!filter) { + return; + } + + const prop = filter[key]; + this.empty = prop?.empty; + + if (prop?.all && prop.all.length > 0) { + this.mode = 'all'; + this.values = prop.all; + } else if (prop?.any && prop.any.length > 0) { + this.mode = 'any'; + this.values = prop.any; + } else if (prop?.exact && prop.exact.length > 0) { + this.mode = 'exact'; + this.values = prop.exact; + } + } +} + +export class Enum extends ComplexMember { + values: string[] = $state([]); + + constructor(key: K, filter?: EnumFilter | null) { + super(key, 'any'); + + if (!filter) { + return; + } + + this.empty = filter[key]?.empty; + + const prop = filter[key]; + if (prop?.any) { + this.values = prop.any; + } + } +} + +class Bool { + key: K; + value?: boolean = $state(undefined); + + constructor(key: K, filter?: Filter | null) { + this.key = key; + + if (filter) { + this.value = filter[key] ?? undefined; + } + } + + integrate(filter: Filter) { + if (this.value !== undefined) { + filter[this.key] = this.value; + } + } +} + +class Str { + key: K; + contains = $state(''); + + constructor(key: K, filter?: Filter | null) { + this.key = key; + + if (filter) { + this.contains = filter[key]?.contains ?? ''; + } + } + + integrate(filter: Filter) { + if (this.contains) { + filter[this.key] = { contains: this.contains }; + } + } +} + +abstract class Controls { + buildFilter() { + const filter = {} as F; + Object.values(this).forEach((v: Integrateable) => v.integrate(filter)); + return filter; + } +} + +export class ArchiveFilterControls extends Controls { + path: Str<'path'>; + organized: Bool<'organized'>; + + constructor(filter: ArchiveFilter | null | undefined) { + super(); + + this.path = new Str('path', filter); + this.organized = new Bool('organized', filter); + } +} + +export class ComicFilterControls extends Controls { + title: Str<'title'>; + categories: Enum<'category'>; + censorships: Enum<'censorship'>; + ratings: Enum<'rating'>; + tags: Association<'tags'>; + languages: Enum<'language'>; + artists: Association<'artists'>; + circles: Association<'circles'>; + characters: Association<'characters'>; + worlds: Association<'worlds'>; + favourite: Bool<'favourite'>; + organized: Bool<'organized'>; + bookmarked: Bool<'bookmarked'>; + + constructor(filter: ComicFilter | null | undefined, mode: FilterMode); + constructor(filter: ComicFilter | null | undefined, mode: FilterMode); + constructor(filter: ComicFilter | null | undefined, mode: FilterMode) { + super(); + + this.title = new Str('title', filter); + this.favourite = new Bool('favourite', filter); + this.organized = new Bool('organized', filter); + this.bookmarked = new Bool('bookmarked', filter); + this.tags = new Association('tags', mode, filter); + this.languages = new Enum('language', filter); + this.categories = new Enum('category', filter); + this.censorships = new Enum('censorship', filter); + this.ratings = new Enum('rating', filter); + this.artists = new Association('artists', mode, filter); + this.circles = new Association('circles', mode, filter); + this.characters = new Association('characters', mode, filter); + this.worlds = new Association('worlds', mode, filter); + } +} + +export class BasicFilterControls extends Controls { + name: Str<'name'>; + + constructor(filter?: BasicFilter | null) { + super(); + + this.name = new Str('name', filter); + } +} + +export class TagFilterControls extends BasicFilterControls { + namespaces: Association<'namespaces'>; + + constructor(filter: TagFilter | null | undefined, mode: FilterMode) { + super(filter); + + this.namespaces = new Association('namespaces', mode, filter); + } +} + +function buildFilterInput(include?: F, exclude?: F) { + const input: FilterInput = {}; + + if (include && Object.keys(include).length > 0) { + input.include = include; + } + + if (exclude && Object.keys(exclude).length > 0) { + input.exclude = exclude; + } + + return input; +} + +abstract class FilterContext { + include!: Controls; + exclude!: Controls; + includes = 0; + excludes = 0; + + apply = (params: URLSearchParams) => { + navigate( + { + filter: buildFilterInput(this.include.buildFilter(), this.exclude.buildFilter()) + }, + params + ); + }; +} + +export class ArchiveFilterContext extends FilterContext { + include: ArchiveFilterControls; + exclude: ArchiveFilterControls; + private static ignore = ['organized']; + + constructor(filter: ArchiveFilterInput) { + super(); + + 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 { + include: ComicFilterControls; + exclude: ComicFilterControls; + private static ignore = ['title', 'favourite', 'organized', 'bookmarked']; + + constructor(filter: ComicFilterInput) { + super(); + + 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 { + include: BasicFilterControls; + exclude: BasicFilterControls; + + constructor(filter: FilterInput) { + super(); + + this.include = new BasicFilterControls(filter.include); + this.exclude = new BasicFilterControls(); + } +} + +export class TagFilterContext extends FilterContext { + include: TagFilterControls; + exclude: TagFilterControls; + private static ignore = ['name']; + + constructor(filter: TagFilterInput) { + super(); + + 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 cycleBooleanFilter(value: boolean | undefined, tristate = true) { + if (tristate) { + if (value === undefined) { + return true; + } else if (value) { + return false; + } else { + return undefined; + } + } else { + if (value) { + return undefined; + } else { + return true; + } + } +} diff --git a/frontend/src/lib/Filter.ts b/frontend/src/lib/Filter.ts deleted file mode 100644 index 1340eaf..0000000 --- a/frontend/src/lib/Filter.ts +++ /dev/null @@ -1,363 +0,0 @@ -import { - type ArchiveFilter, - type ArchiveFilterInput, - type ComicFilter, - type ComicFilterInput, - type StringFilter, - type TagFilter, - type TagFilterInput -} from '$gql/graphql'; -import { getContext, setContext } from 'svelte'; -import { writable, type Writable } from 'svelte/store'; -import { navigate } from './Navigation'; -import { numKeys } from './Utils'; - -interface FilterInput { - include?: T | null; - exclude?: T | null; -} - -interface BasicFilter { - name?: { contains?: string | null } | null; -} - -type FilterMode = 'any' | 'all' | 'exact'; - -type Key = string | number | symbol; - -type Filter = Partial>; - -type AssocFilter = Filter< - { - any?: T[] | null; - all?: T[] | null; - exact?: T[] | null; - empty?: boolean | null; - }, - K ->; - -type EnumFilter = Filter< - { - any?: string[] | null; - empty?: boolean | null; - }, - K ->; - -interface Integrateable { - integrate(filter: F): void; -} - -class ComplexMember { - values: unknown[] = []; - key: K; - mode: FilterMode; - empty?: boolean | null; - - constructor(key: K, mode: FilterMode) { - this.key = key; - this.mode = mode; - } - - integrate(filter: AssocFilter) { - 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 extends ComplexMember { - values: (string | number)[] = []; - - constructor(key: K, mode: FilterMode, filter?: AssocFilter | null) { - super(key, mode); - - if (!filter) { - return; - } - - const prop = filter[key]; - this.empty = prop?.empty; - - if (prop?.all && prop.all.length > 0) { - this.mode = 'all'; - this.values = prop.all; - } else if (prop?.any && prop.any.length > 0) { - this.mode = 'any'; - this.values = prop.any; - } else if (prop?.exact && prop.exact.length > 0) { - this.mode = 'exact'; - this.values = prop.exact; - } - } -} - -export class Enum extends ComplexMember { - values: string[] = []; - - constructor(key: K, filter?: EnumFilter | null) { - super(key, 'any'); - - if (!filter) { - return; - } - - this.empty = filter[key]?.empty; - - const prop = filter[key]; - if (prop?.any) { - this.values = prop.any; - } - } -} - -class Bool { - key: K; - value?: boolean = undefined; - - constructor(key: K, filter?: Filter | null) { - this.key = key; - - if (filter) { - this.value = filter[key] ?? undefined; - } - } - - integrate(filter: Filter) { - if (this.value !== undefined) { - filter[this.key] = this.value; - } - } -} - -class Str { - key: K; - contains = ''; - - constructor(key: K, filter?: Filter | null) { - this.key = key; - - if (filter) { - this.contains = filter[key]?.contains ?? ''; - } - } - - integrate(filter: Filter) { - if (this.contains) { - filter[this.key] = { contains: this.contains }; - } - } -} - -abstract class Controls { - buildFilter() { - const filter = {} as F; - Object.values(this).forEach((v: Integrateable) => v.integrate(filter)); - return filter; - } -} - -export class ArchiveFilterControls extends Controls { - path: Str<'path'>; - organized: Bool<'organized'>; - - constructor(filter: ArchiveFilter | null | undefined) { - super(); - - this.path = new Str('path', filter); - this.organized = new Bool('organized', filter); - } -} - -export class ComicFilterControls extends Controls { - title: Str<'title'>; - categories: Enum<'category'>; - censorships: Enum<'censorship'>; - ratings: Enum<'rating'>; - tags: Association<'tags'>; - languages: Enum<'language'>; - artists: Association<'artists'>; - circles: Association<'circles'>; - characters: Association<'characters'>; - worlds: Association<'worlds'>; - favourite: Bool<'favourite'>; - organized: Bool<'organized'>; - bookmarked: Bool<'bookmarked'>; - - constructor(filter: ComicFilter | null | undefined, mode: FilterMode); - constructor(filter: ComicFilter | null | undefined, mode: FilterMode); - constructor(filter: ComicFilter | null | undefined, mode: FilterMode) { - super(); - - this.title = new Str('title', filter); - this.favourite = new Bool('favourite', filter); - this.organized = new Bool('organized', filter); - this.bookmarked = new Bool('bookmarked', filter); - this.tags = new Association('tags', mode, filter); - this.languages = new Enum('language', filter); - this.categories = new Enum('category', filter); - this.censorships = new Enum('censorship', filter); - this.ratings = new Enum('rating', filter); - this.artists = new Association('artists', mode, filter); - this.circles = new Association('circles', mode, filter); - this.characters = new Association('characters', mode, filter); - this.worlds = new Association('worlds', mode, filter); - } -} - -export class BasicFilterControls extends Controls { - name: Str<'name'>; - - constructor(filter?: BasicFilter | null) { - super(); - - this.name = new Str('name', filter); - } -} - -export class TagFilterControls extends BasicFilterControls { - namespaces: Association<'namespaces'>; - - constructor(filter: TagFilter | null | undefined, mode: FilterMode) { - super(filter); - - this.namespaces = new Association('namespaces', mode, filter); - } -} - -function buildFilterInput(include?: F, exclude?: F) { - const input: FilterInput = {}; - - if (include && Object.keys(include).length > 0) { - input.include = include; - } - - if (exclude && Object.keys(exclude).length > 0) { - input.exclude = exclude; - } - - return input; -} - -abstract class FilterContext { - include!: { controls: Controls; size: number }; - exclude!: { controls: Controls; size: number }; - - apply(params: URLSearchParams) { - navigate( - { - filter: buildFilterInput( - this.include.controls.buildFilter(), - this.exclude.controls.buildFilter() - ) - }, - params - ); - } -} - -export class ArchiveFilterContext extends FilterContext { - include: { controls: ArchiveFilterControls; size: number }; - exclude: { controls: ArchiveFilterControls; size: number }; - private static ignore = ['organized']; - - constructor(filter: ArchiveFilterInput) { - super(); - - this.include = { - controls: new ArchiveFilterControls(filter.include), - size: numKeys(filter.include, ArchiveFilterContext.ignore) - }; - this.exclude = { - controls: new ArchiveFilterControls(filter.exclude), - size: numKeys(filter.exclude, ArchiveFilterContext.ignore) - }; - } -} - -export class ComicFilterContext extends FilterContext { - include: { controls: ComicFilterControls; size: number }; - exclude: { controls: ComicFilterControls; size: number }; - private static ignore = ['title', 'favourite', 'organized', 'bookmarked']; - - constructor(filter: ComicFilterInput) { - super(); - - this.include = { - controls: new ComicFilterControls(filter.include, 'all'), - size: numKeys(filter.include, ComicFilterContext.ignore) - }; - this.exclude = { - controls: new ComicFilterControls(filter.exclude, 'any'), - size: numKeys(filter.exclude, ComicFilterContext.ignore) - }; - } -} - -export class BasicFilterContext extends FilterContext { - include: { controls: BasicFilterControls; size: number }; - exclude: { controls: BasicFilterControls; size: number }; - - constructor(filter: FilterInput) { - super(); - - this.include = { - controls: new BasicFilterControls(filter.include), - size: numKeys(filter.include) - }; - this.exclude = { - controls: new BasicFilterControls(), - size: 0 - }; - } -} - -export class TagFilterContext extends FilterContext { - include: { controls: TagFilterControls; size: number }; - exclude: { controls: TagFilterControls; size: number }; - private static ignore = ['name']; - - constructor(filter: TagFilterInput) { - super(); - - this.include = { - controls: new TagFilterControls(filter.include, 'all'), - size: numKeys(filter.include, TagFilterContext.ignore) - }; - this.exclude = { - controls: new TagFilterControls(filter.exclude, 'any'), - size: numKeys(filter.exclude, TagFilterContext.ignore) - }; - } -} - -export function initFilterContext>() { - return setContext>('filter', writable()); -} - -export function getFilterContext>() { - return getContext>('filter'); -} - -export function cycleBooleanFilter(value: boolean | undefined, tristate = true) { - if (tristate) { - if (value === undefined) { - return true; - } else if (value) { - return false; - } else { - return undefined; - } - } else { - if (value) { - return undefined; - } else { - return true; - } - } -} diff --git a/frontend/src/lib/Form.ts b/frontend/src/lib/Form.ts new file mode 100644 index 0000000..ab0f4f7 --- /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 { + initial: OmitIdentifiers; + 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, current: OmitIdentifiers) { + return stringPending(initial.name, current.name); +} + +export function namespacePending( + initial: OmitIdentifiers, + current: OmitIdentifiers +) { + return itemPending(initial, current) || stringPending(initial.sortName, current.sortName); +} + +export function tagPending(a: OmitIdentifiers, b: OmitIdentifiers) { + return ( + itemPending(a, b) || + stringPending(a.description, b.description) || + associationPending(a.namespaces, b.namespaces) + ); +} + +export function comicPending(a?: FullComicFragment, b?: OmitIdentifiers) { + 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/Navigation.ts b/frontend/src/lib/Navigation.ts index 5ed3ec5..f3bc413 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 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(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 { + on: T; + direction: SortDirection; + seed: number | undefined; +} + +function number(value: string | null, fallback: T) { + if (!value) return fallback; + + const number = +value; - return number; + if (Number.isNaN(number) || number < 0) { + return fallback; } - return fallback; + return number; } -export function parseSortData(params: URLSearchParams, fallback: T): SortData { +export function parseSortData(params: URLSearchParams, fallback: T): SortData { 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) }; } @@ -62,7 +70,7 @@ interface NavigationParameters { pagination?: Partial; } -function paramsFrom( +function parametersFrom( { pagination, filter, sort }: NavigationParameters, current?: URLSearchParams ) { @@ -102,13 +110,13 @@ function paramsFrom( return params; } -export function navigate(parameters: NavigationParameters, current?: URLSearchParams) { +export function navigate(params: NavigationParameters, current?: URLSearchParams) { goto({ - params: paramsFrom(parameters, current), + params: parametersFrom(params, current), options: { noScroll: false, keepFocus: true, replaceState: true } }); } export function href(base: string, params: NavigationParameters) { - return `/${base}/?${paramsFrom(params).toString()}`; + return `/${base}/?${parametersFrom(params).toString()}`; } 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>('pagination', writable(new PaginationContext())); -} - -export function getPaginationContext() { - return getContext>('pagination'); -} diff --git a/frontend/src/lib/Reader.ts b/frontend/src/lib/Reader.ts deleted file mode 100644 index 8777b9b..0000000 --- a/frontend/src/lib/Reader.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Layout, type PageFragment } from '$gql/graphql'; -import { getContext, setContext } from 'svelte'; -import { writable, type Writable } from 'svelte/store'; - -export interface Chunk { - main: PageFragment; - secondary?: PageFragment; - index: number; -} - -class ReaderContext { - visible = false; - sidebar = false; - pages: PageFragment[] = []; - page = 0; - - open(page: number) { - this.page = page; - this.visible = true; - - return this; - } -} - -export function initReaderContext() { - return setContext>('reader', writable(new ReaderContext())); -} - -export function getReaderContext() { - return getContext>('reader'); -} - -export function partition(pages: PageFragment[], layout: Layout): [Chunk[], number[]] { - const single = layout === Layout.Single; - const offset = layout === Layout.DoubleOffset; - - const chunks: Chunk[] = []; - const lookup: number[] = Array(pages.length); - - for (let chunkIndex = 0, pageIndex = 0; pageIndex < pages.length; chunkIndex++) { - const wide = () => pages[pageIndex].image.aspectRatio > 1; - - const nextPage = () => { - lookup[pageIndex] = chunkIndex; - return pages[pageIndex++]; - }; - - const offsetFirst = pageIndex === 0 && offset; - const full = single || wide() || offsetFirst; - - const chunk: Chunk = { index: pageIndex, main: nextPage() }; - - if (!full && pageIndex < pages.length) { - if (!wide()) { - chunk.secondary = nextPage(); - } - } - - chunks.push(chunk); - } - return [chunks, lookup]; -} diff --git a/frontend/src/lib/Scraper.ts b/frontend/src/lib/Scraper.ts deleted file mode 100644 index 4baf370..0000000 --- a/frontend/src/lib/Scraper.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - Category, - Censorship, - Direction, - Language, - Layout, - OnMissing, - Rating, - type FullComicFragment, - type ScrapedComic, - type UpsertComicInput, - type UpsertOptions -} from '$gql/graphql'; -import { - CategoryLabel, - CensorshipLabel, - DirectionLabel, - LanguageLabel, - LayoutLabel, - RatingLabel -} from '$lib/Enums'; -import { getContext, setContext } from 'svelte'; -import { writable, type Writable } from 'svelte/store'; - -interface ScraperContext { - scraper: string; - warnings: string[]; - selector?: ScrapedComicSelector; -} - -export function initScraperContext() { - return setContext>('scraper', writable({ scraper: '', warnings: [] })); -} - -export function getScraperContext() { - return getContext>('scraper'); -} - -export class Selector { - keep = true; - value: T; - display: string | undefined; - - constructor(value: T, display?: string) { - this.value = value; - this.display = display; - } - - toString() { - return this.display ?? this.value; - } - - static from( - scraped: T | undefined | null, - have: string | undefined | null, - label?: Record - ) { - if (scraped && have !== scraped) { - return new Selector(scraped, label ? label[scraped] : undefined); - } - return undefined; - } - - static fromList(scraped: string[], have: { name: string }[]) { - const haves = new Set(have.map((i) => i.name)); - - return scraped.filter((i) => !haves.has(i)).map((i) => new Selector(i)); - } -} - -function keepItem(selector?: Selector): T | undefined | null { - if (selector?.keep) { - return selector.value; - } - return undefined; -} - -function keepList( - selectorList: Selector[], - onMissing: OnMissing -): { names: T[]; options: UpsertOptions } { - return { - names: selectorList.filter((v) => v.keep).map((v) => v.value), - options: { onMissing } - }; -} - -export class ScrapedComicSelector { - title?: Selector; - originalTitle?: Selector; - url?: Selector; - date?: Selector; - category?: Selector; - censorship?: Selector; - rating?: Selector; - language?: Selector; - direction?: Selector; - layout?: Selector; - artists: Selector[]; - circles: Selector[]; - characters: Selector[]; - worlds: Selector[]; - tags: Selector[]; - - constructor(scraped: ScrapedComic, comic: FullComicFragment) { - this.title = Selector.from(scraped.title, comic.title); - this.originalTitle = Selector.from(scraped.originalTitle, comic.originalTitle); - this.url = Selector.from(scraped.url, comic.url); - this.date = Selector.from(scraped.date, comic.date); - this.category = Selector.from(scraped.category, comic.category, CategoryLabel); - this.censorship = Selector.from(scraped.censorship, comic.censorship, CensorshipLabel); - this.rating = Selector.from(scraped.rating, comic.rating, RatingLabel); - this.language = Selector.from(scraped.language, comic.language, LanguageLabel); - this.direction = Selector.from(scraped.direction, comic.direction, DirectionLabel); - this.layout = Selector.from(scraped.layout, comic.layout, LayoutLabel); - - this.artists = Selector.fromList(scraped.artists, comic.artists); - this.circles = Selector.fromList(scraped.circles, comic.circles); - this.characters = Selector.fromList(scraped.characters, comic.characters); - this.tags = Selector.fromList(scraped.tags, comic.tags); - this.worlds = Selector.fromList(scraped.worlds, comic.worlds); - } - - hasData() { - return ( - Object.values(this).filter((i) => { - if (i === undefined) { - return false; - } else if (Array.isArray(i) && i.length === 0) { - return false; - } - return true; - }).length > 0 - ); - } - - toInput(onMissing: OnMissing): UpsertComicInput { - return { - title: keepItem(this.title), - originalTitle: keepItem(this.originalTitle), - url: keepItem(this.url), - date: keepItem(this.date), - category: keepItem(this.category), - censorship: keepItem(this.censorship), - rating: keepItem(this.rating), - language: keepItem(this.language), - direction: keepItem(this.direction), - layout: keepItem(this.layout), - artists: keepList(this.artists, onMissing), - circles: keepList(this.circles, onMissing), - characters: keepList(this.characters, onMissing), - worlds: keepList(this.worlds, onMissing), - tags: keepList(this.tags, onMissing) - }; - } -} diff --git a/frontend/src/lib/Selection.ts b/frontend/src/lib/Selection.ts 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() { - return getContext>>('selection'); -} - -export function initSelectionContext( - typename?: string, - toName?: (item: T) => string -) { - return setContext>>( - 'selection', - writable(new ItemSelection(typename, toName)) - ); -} - -export class ItemSelection { - active = false; - typename: string; - #toName: (item: T) => string; - - #view: T[] = []; - selectable: (item: T) => boolean = () => true; - - #ids = new Set(); - #masked = new Set(); - - 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 300ddcb..1ff7679 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' @@ -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 { - on: T; - direction: SortDirection; - seed: number | undefined; -} - -export class SortContext { - on: T; - direction: SortDirection; - seed: number | undefined; - labels: Record; - - constructor({ on, direction, seed }: SortData, labels: Record) { - this.on = on; - this.direction = direction; - this.seed = seed; - this.labels = labels; - } - - set update({ on, direction, seed }: SortData) { - 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(sort: SortData, labels: Record) { - return setContext>>('sort', writable(new SortContext(sort, labels))); -} - -export function getSortContext() { - return getContext>>('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; - -interface TabContext { - tabs: Tabs; - current: Tab; -} - -export function setTabContext(context: TabContext) { - return setContext>('tabs', writable(context)); -} - -export function getTabContext() { - return getContext>('tabs'); -} diff --git a/frontend/src/lib/Update.svelte.ts b/frontend/src/lib/Update.svelte.ts new file mode 100644 index 0000000..1d684d5 --- /dev/null +++ b/frontend/src/lib/Update.svelte.ts @@ -0,0 +1,94 @@ +import { + UpdateMode, + type UpdateComicInput, + type UpdateOptions, + type UpdateTagInput +} from '$gql/graphql'; +import type { Key } from './Utils'; + +interface AssociationUpdate { + ids?: number[] | string[] | null; + options?: UpdateOptions | null; +} + +type Input = Partial>; + +abstract class Entry { + key: K; + + constructor(key: K) { + this.key = key; + } + + abstract integrate(input: Input): void; + abstract hasInput(): boolean; +} + +class Association extends Entry { + ids = $state([]); + options = $state({ + mode: UpdateMode.Add + }); + + constructor(key: K) { + super(key); + } + + integrate(input: Input) { + if (this.hasInput()) { + input[this.key] = { ids: this.ids, options: this.options }; + } + } + + hasInput() { + return this.ids.length > 0; + } +} + +class Enum extends Entry { + value?: string = $state(undefined); + + constructor(key: K) { + super(key); + } + + integrate(input: Input): void { + if (this.hasInput()) { + input[this.key] = this.value; + } + } + + hasInput() { + return this.value !== undefined && this.value !== null; + } +} + +abstract class Controls { + input() { + const input = {} as I; + Object.values(this).forEach((v: Entry) => v.integrate(input)); + return input; + } + + pending() { + return Object.values(this).some((i: Entry) => i.hasInput()); + } +} + +export class UpdateTagsControls extends Controls { + namespaces = new Association('namespaces'); +} + +export class UpdateComicsControls extends Controls { + artists = new Association('artists'); + category = new Enum('category'); + censorship = new Enum('censorship'); + direction = new Enum('direction'); + layout = new Enum('layout'); + characters = new Association('characters'); + circles = new Association('circles'); + language = new Enum('language'); + rating = new Enum('rating'); + tags = new Association('tags'); + worlds = new Association('worlds'); +} diff --git a/frontend/src/lib/Update.ts b/frontend/src/lib/Update.ts deleted file mode 100644 index 13aec61..0000000 --- a/frontend/src/lib/Update.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { - UpdateMode, - type UpdateComicInput, - type UpdateOptions, - type UpdateTagInput -} from '$gql/graphql'; - -type Key = string | number | symbol; - -interface AssociationUpdate { - ids?: number[] | string[] | null; - options?: UpdateOptions | null; -} - -type Input = Partial>; - -abstract class Entry { - key: K; - - constructor(key: K) { - this.key = key; - } - - abstract integrate(input: Input): void; - abstract hasInput(): boolean; -} - -class Association extends Entry { - ids = []; - options = { - mode: UpdateMode.Add - }; - - constructor(key: K) { - super(key); - } - - integrate(input: Input) { - if (this.hasInput()) { - input[this.key] = { ids: this.ids, options: this.options }; - } - } - - hasInput() { - return this.ids.length > 0; - } -} - -class Enum extends Entry { - value?: string = undefined; - - constructor(key: K) { - super(key); - } - - integrate(input: Input): void { - if (this.hasInput()) { - input[this.key] = this.value; - } - } - - hasInput() { - return this.value !== undefined && this.value !== null; - } -} - -abstract class Controls { - toInput() { - const input = {} as I; - Object.values(this).forEach((v: Entry) => v.integrate(input)); - return input; - } - - hasInput() { - return Object.values(this).some((i: Entry) => i.hasInput()); - } -} - -export class UpdateTagsControls extends Controls { - namespaces = new Association('namespaces'); -} - -export class UpdateComicsControls extends Controls { - artists = new Association('artists'); - category = new Enum('category'); - censorship = new Enum('censorship'); - direction = new Enum('direction'); - layout = new Enum('layout'); - characters = new Association('characters'); - circles = new Association('circles'); - language = new Enum('language'); - rating = new Enum('rating'); - tags = new Association('tags'); - worlds = new Association('worlds'); -} diff --git a/frontend/src/lib/Utils.ts b/frontend/src/lib/Utils.ts index 1a07be1..c0e5b6c 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; @@ -68,11 +71,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) { @@ -106,3 +112,12 @@ export function preventOnPending({ to, cancel }: BeforeNavigate, pending: boolea cancel(); } +export function omit(obj: T, ...props: K[]): Omit { + 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..f07eafd 100644 --- a/frontend/src/lib/components/AddButton.svelte +++ b/frontend/src/lib/components/AddButton.svelte @@ -1,7 +1,7 @@ - diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte index 7ad3173..6f8198a 100644 --- a/frontend/src/lib/components/Badge.svelte +++ b/frontend/src/lib/components/Badge.svelte @@ -2,7 +2,7 @@ import { fadeDefault } from '$lib/Transitions'; import { fade } from 'svelte/transition'; - export let number: number; + let { number }: { number: number } = $props(); {#if number > 0} 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 @@ - diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte index d209517..21181dc 100644 --- a/frontend/src/lib/components/Card.svelte +++ b/frontend/src/lib/components/Card.svelte @@ -1,4 +1,4 @@ - - + {@render overlay?.()} {#if details.cover}
- + {@render children?.()}
{/if} diff --git a/frontend/src/lib/components/Cardlet.svelte b/frontend/src/lib/components/Cardlet.svelte index 04d8599..d249cc8 100644 --- a/frontend/src/lib/components/Cardlet.svelte +++ b/frontend/src/lib/components/Cardlet.svelte @@ -1,14 +1,27 @@ diff --git a/frontend/src/lib/components/Dialog.svelte b/frontend/src/lib/components/Dialog.svelte index a0bbe5e..d300369 100644 --- a/frontend/src/lib/components/Dialog.svelte +++ b/frontend/src/lib/components/Dialog.svelte @@ -1,10 +1,16 @@ {#if isOpen} @@ -18,18 +24,19 @@ class="pointer-events-auto flex flex-col rounded-md bg-slate-800 shadow-md shadow-slate-900" >
- +

{title}

- + {@render children?.()}
diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte index 9e935e4..ddd20a0 100644 --- a/frontend/src/lib/components/Dropdown.svelte +++ b/frontend/src/lib/components/Dropdown.svelte @@ -1,18 +1,37 @@ -{#if visible} -
(visible = false), ignore: parent }} - > - -
-{/if} +
+ {@render button(() => (visible = !visible))} + {#if visible} +
+ {@render children?.()} +
+ {/if} +
diff --git a/frontend/src/lib/components/Expander.svelte b/frontend/src/lib/components/Expander.svelte index a382658..8f23042 100644 --- a/frontend/src/lib/components/Expander.svelte +++ b/frontend/src/lib/components/Expander.svelte @@ -1,17 +1,21 @@ - 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 @@ {#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 @@ - - - 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 @@ @@ -9,10 +16,10 @@
- {#if $$slots.controls} -
- + {#if side} +
+ {@render side?.()} {/if}
- + {@render children?.({ id })}
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 @@ - 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 @@ - 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 @@ 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(); {#if options !== null && options !== undefined} diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte index 946329c..1a471a7 100644 --- a/frontend/src/lib/components/Spinner.svelte +++ b/frontend/src/lib/components/Spinner.svelte @@ -1,7 +1,7 @@ - + diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte index 2cdfa70..fe28cfe 100644 --- a/frontend/src/lib/components/Titlebar.svelte +++ b/frontend/src/lib/components/Titlebar.svelte @@ -1,12 +1,15 @@
@@ -16,7 +19,7 @@ type="button" class="focus-background mr-1 flex items-center" title="Toggle favourite" - on:click={() => dispatch('favourite')} + onclick={onfavourite} > diff --git a/frontend/src/lib/containers/Cardlets.svelte b/frontend/src/lib/containers/Cardlets.svelte index 129da61..5997a69 100644 --- a/frontend/src/lib/containers/Cardlets.svelte +++ b/frontend/src/lib/containers/Cardlets.svelte @@ -1,11 +1,13 @@
- + {@render children?.()}
diff --git a/frontend/src/lib/containers/Cards.svelte b/frontend/src/lib/containers/Cards.svelte index a19e8be..36a4b86 100644 --- a/frontend/src/lib/containers/Cards.svelte +++ b/frontend/src/lib/containers/Cards.svelte @@ -1,8 +1,10 @@
- + {@render children?.()}
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 @@
@@ -10,6 +17,6 @@
- + {@render children?.()}
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 @@ + +
- + {@render children?.()}
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 @@ -
- + {@render children?.()}