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