diff options
Diffstat (limited to 'frontend/src/lib')
125 files changed, 5252 insertions, 0 deletions
diff --git a/frontend/src/lib/Actions.ts b/frontend/src/lib/Actions.ts new file mode 100644 index 0000000..7231c2f --- /dev/null +++ b/frontend/src/lib/Actions.ts @@ -0,0 +1,109 @@ +export function debounce( + node: HTMLInputElement, + { callback, timeout = 500 }: { callback: () => void; timeout?: number } +) { + let timer: NodeJS.Timeout; + + function trigger(event: KeyboardEvent) { + clearTimeout(timer); + if (event.key !== 'Enter') { + timer = setTimeout(callback, timeout); + } else { + callback(); + } + } + + node.addEventListener('keyup', trigger); + + return { + destroy() { + clearTimeout(timer); + node.removeEventListener('keyup', trigger); + } + }; +} + +export function clickOutside( + node: HTMLElement, + { handler, ignore }: { handler: () => void; ignore?: HTMLElement } +) { + const handle = (event: Event) => { + const target = event.target as HTMLElement; + if (!target || target === ignore) return; + + if (node && !node.contains(target) && !event.defaultPrevented) { + handler(); + } + }; + + document.addEventListener('click', handle, true); + + return { + destroy() { + document.removeEventListener('click', handle, true); + } + }; +} + +export const focusableElements = [ + 'a[href]', + 'area[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + 'iframe', + 'object', + 'embed', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])' +]; + +let trapped: HTMLElement[] = []; + +export function trapFocus(node: HTMLElement) { + function handler(event: KeyboardEvent) { + if (event.target === window) return; + + // return if we're not the topmost node to handle + if (trapped.at(0) !== node) return; + + const focusable = node.querySelectorAll<HTMLElement>(focusableElements.join()); + + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + + if (event.key === 'Tab') { + if (!node.contains(document.activeElement)) { + first.focus(); + event.preventDefault(); + } + + if (event.shiftKey && event.target === first) { + last.focus(); + event.preventDefault(); + } else if (!event.shiftKey && event.target === last) { + first.focus(); + event.preventDefault(); + } + } + } + + if (document.activeElement instanceof HTMLElement) { + // if we trap focus, make sure to blur any previously selected external + // item such that focus does not remain outside of the node + if (!node.contains(document.activeElement)) { + document.activeElement.blur(); + } + } + + document.addEventListener('keydown', handler); + trapped.unshift(node); + + return { + destroy() { + document.removeEventListener('keydown', handler); + trapped = trapped.filter((i) => i !== node); + } + }; +} diff --git a/frontend/src/lib/Enums.ts b/frontend/src/lib/Enums.ts new file mode 100644 index 0000000..876aec8 --- /dev/null +++ b/frontend/src/lib/Enums.ts @@ -0,0 +1,325 @@ +import { + ArchiveSort, + ArtistSort, + Category, + Censorship, + CharacterSort, + CircleSort, + ComicSort, + Direction, + Language, + Layout, + NamespaceSort, + Rating, + TagSort, + UpdateMode, + WorldSort +} from '$gql/graphql'; + +export interface EnumOption<T> { + id: T; + name: string; +} + +export const DirectionLabel: Record<Direction, string> = { + [Direction.LeftToRight]: 'Left to Right', + [Direction.RightToLeft]: 'Right to Left' +}; + +export const LayoutLabel: Record<Layout, string> = { + [Layout.Single]: 'Single Page', + [Layout.Double]: 'Double Page', + [Layout.DoubleOffset]: 'Double Page, offset' +}; + +export const RatingLabel: Record<Rating, string> = { + [Rating.Safe]: 'Safe', + [Rating.Questionable]: 'Questionable', + [Rating.Explicit]: 'Explicit' +}; + +export const CensorshipLabel: Record<Censorship, string> = { + [Censorship.None]: 'None', + [Censorship.Bar]: 'Bars', + [Censorship.Mosaic]: 'Mosaic', + [Censorship.Full]: 'Full' +}; + +export const CategoryLabel: Record<Category, string> = { + [Category.Manga]: 'Manga', + [Category.Doujinshi]: 'Doujinshi', + [Category.Comic]: 'Comic', + [Category.Artbook]: 'Artbook', + [Category.GameCg]: 'Game CG', + [Category.ImageSet]: 'Image Set', + [Category.VariantSet]: 'Variant Set', + [Category.Webtoon]: 'Webtoon' +}; + +export const ArchiveSortLabel: Record<ArchiveSort, string> = { + [ArchiveSort.Path]: 'Path', + [ArchiveSort.Size]: 'File Size', + [ArchiveSort.CreatedAt]: 'Created At', + [ArchiveSort.PageCount]: 'Page Count', + [ArchiveSort.Random]: 'Random' +}; + +export const ComicSortLabel: Record<ComicSort, string> = { + [ComicSort.Title]: 'Title', + [ComicSort.OriginalTitle]: 'Original Title', + [ComicSort.Date]: 'Date', + [ComicSort.CreatedAt]: 'Created At', + [ComicSort.UpdatedAt]: 'Updated At', + [ComicSort.TagCount]: 'Tag Count', + [ComicSort.PageCount]: 'Page Count', + [ComicSort.Random]: 'Random' +}; + +export const ArtistSortLabel: Record<ArtistSort, string> = { + [ArtistSort.Name]: 'Name', + [ArtistSort.CreatedAt]: 'Created At', + [ArtistSort.UpdatedAt]: 'Updated At', + [ArchiveSort.Random]: 'Random' +}; + +export const CharacterSortLabel: Record<CharacterSort, string> = { + [CharacterSort.Name]: 'Name', + [CharacterSort.CreatedAt]: 'Created At', + [CharacterSort.UpdatedAt]: 'Updated At', + [ArchiveSort.Random]: 'Random' +}; + +export const CircleSortLabel: Record<CircleSort, string> = { + [CircleSort.Name]: 'Name', + [CircleSort.CreatedAt]: 'Created At', + [CircleSort.UpdatedAt]: 'Updated At', + [ArchiveSort.Random]: 'Random' +}; + +export const NamespaceSortLabel: Record<NamespaceSort, string> = { + [NamespaceSort.Name]: 'Name', + [NamespaceSort.SortName]: 'Sort Name', + [NamespaceSort.CreatedAt]: 'Created At', + [NamespaceSort.UpdatedAt]: 'Updated At', + [ArchiveSort.Random]: 'Random' +}; + +export const TagSortLabel: Record<TagSort, string> = { + [TagSort.Name]: 'Name', + [TagSort.CreatedAt]: 'Created At', + [TagSort.UpdatedAt]: 'Updated At', + [ArchiveSort.Random]: 'Random' +}; + +export const WorldSortLabel: Record<WorldSort, string> = { + [WorldSort.Name]: 'Name', + [WorldSort.CreatedAt]: 'Created At', + [WorldSort.UpdatedAt]: 'Updated At', + [ArchiveSort.Random]: 'Random' +}; + +export const UpdateModeLabel: Record<UpdateMode, string> = { + [UpdateMode.Add]: 'Add', + [UpdateMode.Remove]: 'Remove', + [UpdateMode.Replace]: 'Replace' +}; + +export const LanguageLabel: Record<Language, string> = { + [Language.Ab]: 'Abkhazian', + [Language.Aa]: 'Afar', + [Language.Af]: 'Afrikaans', + [Language.Ak]: 'Akan', + [Language.Sq]: 'Albanian', + [Language.Am]: 'Amharic', + [Language.Ar]: 'Arabic', + [Language.An]: 'Aragonese', + [Language.Hy]: 'Armenian', + [Language.As]: 'Assamese', + [Language.Av]: 'Avaric', + [Language.Ae]: 'Avestan', + [Language.Ay]: 'Aymara', + [Language.Az]: 'Azerbaijani', + [Language.Bm]: 'Bambara', + [Language.Ba]: 'Bashkir', + [Language.Eu]: 'Basque', + [Language.Be]: 'Belarusian', + [Language.Bn]: 'Bengali', + [Language.Bh]: 'Bihari languages', + [Language.Bi]: 'Bislama', + [Language.Bs]: 'Bosnian', + [Language.Br]: 'Breton', + [Language.Bg]: 'Bulgarian', + [Language.My]: 'Burmese', + [Language.Ca]: 'Catalan', + [Language.Km]: 'Central Khmer', + [Language.Ch]: 'Chamorro', + [Language.Ce]: 'Chechen', + [Language.Ny]: 'Chichewa', + [Language.Zh]: 'Chinese', + [Language.Cu]: 'Church Slavic', + [Language.Cv]: 'Chuvash', + [Language.Kw]: 'Cornish', + [Language.Co]: 'Corsican', + [Language.Cr]: 'Cree', + [Language.Hr]: 'Croatian', + [Language.Cs]: 'Czech', + [Language.Da]: 'Danish', + [Language.Dv]: 'Divehi', + [Language.Nl]: 'Dutch', + [Language.Dz]: 'Dzongkha', + [Language.En]: 'English', + [Language.Eo]: 'Esperanto', + [Language.Et]: 'Estonian', + [Language.Ee]: 'Ewe', + [Language.Fo]: 'Faroese', + [Language.Fj]: 'Fijian', + [Language.Fi]: 'Finnish', + [Language.Fr]: 'French', + [Language.Ff]: 'Fulah', + [Language.Gd]: 'Gaelic', + [Language.Gl]: 'Galician', + [Language.Lg]: 'Ganda', + [Language.Ka]: 'Georgian', + [Language.De]: 'German', + [Language.Gn]: 'Guarani', + [Language.Gu]: 'Gujarati', + [Language.Ht]: 'Haitian', + [Language.Ha]: 'Hausa', + [Language.He]: 'Hebrew', + [Language.Hz]: 'Herero', + [Language.Hi]: 'Hindi', + [Language.Ho]: 'Hiri Motu', + [Language.Hu]: 'Hungarian', + [Language.Is]: 'Icelandic', + [Language.Io]: 'Ido', + [Language.Ig]: 'Igbo', + [Language.Id]: 'Indonesian', + [Language.Ia]: 'Interlingua', + [Language.Ie]: 'Interlingue', + [Language.Iu]: 'Inuktitut', + [Language.Ik]: 'Inupiaq', + [Language.Ga]: 'Irish', + [Language.It]: 'Italian', + [Language.Ja]: 'Japanese', + [Language.Jv]: 'Javanese', + [Language.Kl]: 'Kalaallisut', + [Language.Kn]: 'Kannada', + [Language.Kr]: 'Kanuri', + [Language.Ks]: 'Kashmiri', + [Language.Kk]: 'Kazakh', + [Language.Ki]: 'Kikuyu', + [Language.Rw]: 'Kinyarwanda', + [Language.Ky]: 'Kirghiz', + [Language.Kv]: 'Komi', + [Language.Kg]: 'Kongo', + [Language.Ko]: 'Korean', + [Language.Kj]: 'Kuanyama', + [Language.Ku]: 'Kurdish', + [Language.Lo]: 'Lao', + [Language.La]: 'Latin', + [Language.Lv]: 'Latvian', + [Language.Li]: 'Limburgan', + [Language.Ln]: 'Lingala', + [Language.Lt]: 'Lithuanian', + [Language.Lu]: 'Luba-Katanga', + [Language.Lb]: 'Luxembourgish', + [Language.Mk]: 'Macedonian', + [Language.Mg]: 'Malagasy', + [Language.Ms]: 'Malay', + [Language.Ml]: 'Malayalam', + [Language.Mt]: 'Maltese', + [Language.Gv]: 'Manx', + [Language.Mi]: 'Maori', + [Language.Mr]: 'Marathi', + [Language.Mh]: 'Marshallese', + [Language.El]: 'Modern Greek', + [Language.Mn]: 'Mongolian', + [Language.Na]: 'Nauru', + [Language.Nv]: 'Navajo', + [Language.Ng]: 'Ndonga', + [Language.Ne]: 'Nepali', + [Language.Se]: 'Northern Sami', + [Language.Nd]: 'North Ndebele', + [Language.No]: 'Norwegian', + [Language.Nb]: 'Norwegian BokmÃ¥l', + [Language.Nn]: 'Norwegian Nynorsk', + [Language.Oc]: 'Occitan', + [Language.Oj]: 'Ojibwa', + [Language.Or]: 'Oriya', + [Language.Om]: 'Oromo', + [Language.Os]: 'Ossetian', + [Language.Pi]: 'Pali', + [Language.Pa]: 'Panjabi', + [Language.Fa]: 'Persian', + [Language.Pl]: 'Polish', + [Language.Pt]: 'Portuguese', + [Language.Ps]: 'Pushto', + [Language.Qu]: 'Quechua', + [Language.Ro]: 'Romanian', + [Language.Rm]: 'Romansh', + [Language.Rn]: 'Rundi', + [Language.Ru]: 'Russian', + [Language.Sm]: 'Samoan', + [Language.Sg]: 'Sango', + [Language.Sa]: 'Sanskrit', + [Language.Sc]: 'Sardinian', + [Language.Sr]: 'Serbian', + [Language.Sn]: 'Shona', + [Language.Ii]: 'Sichuan Yi', + [Language.Sd]: 'Sindhi', + [Language.Si]: 'Sinhala', + [Language.Sk]: 'Slovak', + [Language.Sl]: 'Slovenian', + [Language.So]: 'Somali', + [Language.St]: 'Southern Sotho', + [Language.Nr]: 'South Ndebele', + [Language.Es]: 'Spanish', + [Language.Su]: 'Sundanese', + [Language.Sw]: 'Swahili', + [Language.Ss]: 'Swati', + [Language.Sv]: 'Swedish', + [Language.Tl]: 'Tagalog', + [Language.Ty]: 'Tahitian', + [Language.Tg]: 'Tajik', + [Language.Ta]: 'Tamil', + [Language.Tt]: 'Tatar', + [Language.Te]: 'Telugu', + [Language.Th]: 'Thai', + [Language.Bo]: 'Tibetan', + [Language.Ti]: 'Tigrinya', + [Language.To]: 'Tonga', + [Language.Ts]: 'Tsonga', + [Language.Tn]: 'Tswana', + [Language.Tr]: 'Turkish', + [Language.Tk]: 'Turkmen', + [Language.Tw]: 'Twi', + [Language.Ug]: 'Uighur', + [Language.Uk]: 'Ukrainian', + [Language.Ur]: 'Urdu', + [Language.Uz]: 'Uzbek', + [Language.Ve]: 'Venda', + [Language.Vi]: 'Vietnamese', + [Language.Vo]: 'Volapük', + [Language.Wa]: 'Walloon', + [Language.Cy]: 'Welsh', + [Language.Fy]: 'Western Frisian', + [Language.Wo]: 'Wolof', + [Language.Xh]: 'Xhosa', + [Language.Yi]: 'Yiddish', + [Language.Yo]: 'Yoruba', + [Language.Za]: 'Zhuang', + [Language.Zu]: 'Zulu' +}; + +export const directions: EnumOption<Direction>[] = optionsFromLabel(DirectionLabel); +export const layouts: EnumOption<Layout>[] = optionsFromLabel(LayoutLabel); +export const ratings: EnumOption<Rating>[] = optionsFromLabel(RatingLabel); +export const censorships: EnumOption<Censorship>[] = optionsFromLabel(CensorshipLabel); +export const categories: EnumOption<Category>[] = optionsFromLabel(CategoryLabel); +export const languages: EnumOption<Language>[] = optionsFromLabel(LanguageLabel); + +function optionsFromLabel<T extends string | number | symbol>( + labels: Record<T, string> +): EnumOption<T>[] { + return Object.entries(labels).map(([k, v]) => ({ id: k as T, name: v as string })); +} diff --git a/frontend/src/lib/Filter.ts b/frontend/src/lib/Filter.ts new file mode 100644 index 0000000..8e419f3 --- /dev/null +++ b/frontend/src/lib/Filter.ts @@ -0,0 +1,365 @@ +import { + type ArchiveFilter, + type ArchiveFilterInput, + type ComicFilter, + type ComicFilterInput, + type StringFilter, + type TagFilter, + type TagFilterInput +} from '$gql/graphql'; +import { getContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; +import { navigate } from './Navigation'; +import { numKeys } from './Utils'; + +interface FilterInput<T> { + include?: T | null; + exclude?: T | null; +} + +interface BasicFilter { + name?: { contains?: string | null } | null; +} + +type FilterMode = 'any' | 'all' | 'exact'; + +type Key = string | number | symbol; + +type Filter<T, K extends Key> = { + [Property in K]?: T | null; +}; + +type AssocFilter<T, K extends Key> = Filter< + { + any?: T[] | null; + all?: T[] | null; + exact?: T[] | null; + empty?: boolean | null; + }, + K +>; + +type EnumFilter<K extends Key> = Filter< + { + any?: string[] | null; + empty?: boolean | null; + }, + K +>; + +interface Integrateable<F> { + integrate(filter: F): void; +} + +class ComplexMember<K extends Key> { + values: unknown[] = []; + key: K; + mode: FilterMode; + empty?: boolean | null; + + constructor(key: K, mode: FilterMode) { + this.key = key; + this.mode = mode; + } + + integrate(filter: AssocFilter<unknown, K>) { + if (this.values.length > 0) { + filter[this.key] = { [this.mode]: this.values }; + } + + if (this.empty) { + filter[this.key] = { ...filter[this.key], empty: this.empty }; + } + } +} + +export class Association<K extends Key> extends ComplexMember<K> { + values: (string | number)[] = []; + + constructor(key: K, mode: FilterMode, filter?: AssocFilter<string | number, K> | null) { + super(key, mode); + + if (!filter) { + return; + } + + const prop = filter[key]; + this.empty = prop?.empty; + + if (prop?.all && prop.all.length > 0) { + this.mode = 'all'; + this.values = prop.all; + } else if (prop?.any && prop.any.length > 0) { + this.mode = 'any'; + this.values = prop.any; + } else if (prop?.exact && prop.exact.length > 0) { + this.mode = 'exact'; + this.values = prop.exact; + } + } +} + +export class Enum<K extends Key> extends ComplexMember<K> { + values: string[] = []; + + constructor(key: K, filter?: EnumFilter<K> | null) { + super(key, 'any'); + + if (!filter) { + return; + } + + this.empty = filter[key]?.empty; + + const prop = filter[key]; + if (prop?.any) { + this.values = prop.any; + } + } +} + +class Bool<K extends Key> { + key: K; + value?: boolean = undefined; + + constructor(key: K, filter?: Filter<boolean, K> | null) { + this.key = key; + + if (filter) { + this.value = filter[key] ?? undefined; + } + } + + integrate(filter: Filter<boolean, K>) { + if (this.value !== undefined) { + filter[this.key] = this.value; + } + } +} + +class Str<K extends Key> { + key: K; + contains = ''; + + constructor(key: K, filter?: Filter<StringFilter, K> | null) { + this.key = key; + + if (filter) { + this.contains = filter[key]?.contains ?? ''; + } + } + + integrate(filter: Filter<StringFilter, K>) { + if (this.contains) { + filter[this.key] = { contains: this.contains }; + } + } +} + +abstract class Controls<F> { + buildFilter() { + const filter = {} as F; + Object.values(this).forEach((v: Integrateable<F>) => v.integrate(filter)); + return filter; + } +} + +export class ArchiveFilterControls extends Controls<ArchiveFilter> { + path: Str<'path'>; + organized: Bool<'organized'>; + + constructor(filter: ArchiveFilter | null | undefined) { + super(); + + this.path = new Str('path', filter); + this.organized = new Bool('organized', filter); + } +} + +export class ComicFilterControls extends Controls<ComicFilter> { + title: Str<'title'>; + categories: Enum<'category'>; + censorships: Enum<'censorship'>; + ratings: Enum<'rating'>; + tags: Association<'tags'>; + languages: Enum<'language'>; + artists: Association<'artists'>; + circles: Association<'circles'>; + characters: Association<'characters'>; + worlds: Association<'worlds'>; + favourite: Bool<'favourite'>; + organized: Bool<'organized'>; + bookmarked: Bool<'bookmarked'>; + + constructor(filter: ComicFilter | null | undefined, mode: FilterMode); + constructor(filter: ComicFilter | null | undefined, mode: FilterMode); + constructor(filter: ComicFilter | null | undefined, mode: FilterMode) { + super(); + + this.title = new Str('title', filter); + this.favourite = new Bool('favourite', filter); + this.organized = new Bool('organized', filter); + this.bookmarked = new Bool('bookmarked', filter); + this.tags = new Association('tags', mode, filter); + this.languages = new Enum('language', filter); + this.categories = new Enum('category', filter); + this.censorships = new Enum('censorship', filter); + this.ratings = new Enum('rating', filter); + this.artists = new Association('artists', mode, filter); + this.circles = new Association('circles', mode, filter); + this.characters = new Association('characters', mode, filter); + this.worlds = new Association('worlds', mode, filter); + } +} + +export class BasicFilterControls extends Controls<BasicFilter> { + name: Str<'name'>; + + constructor(filter?: BasicFilter | null) { + super(); + + this.name = new Str('name', filter); + } +} + +export class TagFilterControls extends BasicFilterControls { + namespaces: Association<'namespaces'>; + + constructor(filter: TagFilter | null | undefined, mode: FilterMode) { + super(filter); + + this.namespaces = new Association('namespaces', mode, filter); + } +} + +function buildFilterInput<F>(include?: F, exclude?: F) { + const input: FilterInput<F> = {}; + + if (include && Object.keys(include).length > 0) { + input.include = include; + } + + if (exclude && Object.keys(exclude).length > 0) { + input.exclude = exclude; + } + + return input; +} + +abstract class FilterContext<F> { + include!: { controls: Controls<F>; size: number }; + exclude!: { controls: Controls<F>; size: number }; + + apply(params: URLSearchParams) { + navigate( + { + filter: buildFilterInput( + this.include.controls.buildFilter(), + this.exclude.controls.buildFilter() + ) + }, + params + ); + } +} + +export class ArchiveFilterContext extends FilterContext<ArchiveFilter> { + include: { controls: ArchiveFilterControls; size: number }; + exclude: { controls: ArchiveFilterControls; size: number }; + private static ignore = ['organized']; + + constructor(filter: ArchiveFilterInput) { + super(); + + this.include = { + controls: new ArchiveFilterControls(filter.include), + size: numKeys(filter.include, ArchiveFilterContext.ignore) + }; + this.exclude = { + controls: new ArchiveFilterControls(filter.exclude), + size: numKeys(filter.exclude, ArchiveFilterContext.ignore) + }; + } +} + +export class ComicFilterContext extends FilterContext<ComicFilter> { + include: { controls: ComicFilterControls; size: number }; + exclude: { controls: ComicFilterControls; size: number }; + private static ignore = ['title', 'favourite', 'organized', 'bookmarked']; + + constructor(filter: ComicFilterInput) { + super(); + + this.include = { + controls: new ComicFilterControls(filter.include, 'all'), + size: numKeys(filter.include, ComicFilterContext.ignore) + }; + this.exclude = { + controls: new ComicFilterControls(filter.exclude, 'any'), + size: numKeys(filter.exclude, ComicFilterContext.ignore) + }; + } +} + +export class BasicFilterContext extends FilterContext<BasicFilter> { + include: { controls: BasicFilterControls; size: number }; + exclude: { controls: BasicFilterControls; size: number }; + + constructor(filter: FilterInput<BasicFilter>) { + super(); + + this.include = { + controls: new BasicFilterControls(filter.include), + size: numKeys(filter.include) + }; + this.exclude = { + controls: new BasicFilterControls(), + size: 0 + }; + } +} + +export class TagFilterContext extends FilterContext<TagFilter> { + include: { controls: TagFilterControls; size: number }; + exclude: { controls: TagFilterControls; size: number }; + private static ignore = ['name']; + + constructor(filter: TagFilterInput) { + super(); + + this.include = { + controls: new TagFilterControls(filter.include, 'all'), + size: numKeys(filter.include, TagFilterContext.ignore) + }; + this.exclude = { + controls: new TagFilterControls(filter.exclude, 'any'), + size: numKeys(filter.exclude, TagFilterContext.ignore) + }; + } +} + +export function initFilterContext<F extends FilterContext<unknown>>() { + return setContext<Writable<F>>('filter', writable()); +} + +export function getFilterContext<F extends FilterContext<unknown>>() { + return getContext<Writable<F>>('filter'); +} + +export function cycleBooleanFilter(value: boolean | undefined, tristate = true) { + if (tristate) { + if (value === undefined) { + return true; + } else if (value) { + return false; + } else { + return undefined; + } + } else { + if (value) { + return undefined; + } else { + return true; + } + } +} diff --git a/frontend/src/lib/Meta.ts b/frontend/src/lib/Meta.ts new file mode 100644 index 0000000..8cfad6b --- /dev/null +++ b/frontend/src/lib/Meta.ts @@ -0,0 +1 @@ +export const codename = 'Satanic Satyr'; diff --git a/frontend/src/lib/Navigation.ts b/frontend/src/lib/Navigation.ts new file mode 100644 index 0000000..e6b17cd --- /dev/null +++ b/frontend/src/lib/Navigation.ts @@ -0,0 +1,114 @@ +import { goto as svelteGoto } from '$app/navigation'; +import { SortDirection } from '$gql/graphql'; +import JsonURL from '@jsonurl/jsonurl'; +import { type PaginationData } from './Pagination'; +import { type SortData } from './Sort'; +import { toastError } from './Toasts'; + +function paramToNum<T>(value: string | null, fallback: T) { + if (value) { + const number = +value; + + if (Number.isNaN(number) || number < 0) { + return fallback; + } + + return number; + } + + return fallback; +} + +export function parseSortData<T>(params: URLSearchParams, fallback: T): SortData<T> { + return { + on: (params.get('s') as T) || fallback, + direction: (params.get('d') as SortDirection) || SortDirection.Ascending, + seed: paramToNum(params.get('r'), undefined) + }; +} + +export function parsePaginationData(params: URLSearchParams, defaultItems = 120): PaginationData { + return { + page: paramToNum(params.get('p'), 1), + items: paramToNum(params.get('i'), defaultItems) + }; +} + +export function parseFilter<T>(params: URLSearchParams): T { + const param = params.get('f'); + + if (!param) return {} as T; + + try { + return JsonURL.parse(param, { AQF: true, impliedObject: {} }) as T; + } catch (e) { + return {} as T; + } +} + +interface NavigationOptions { + to?: string; + params: URLSearchParams; + options?: Parameters<typeof svelteGoto>[1]; +} + +export function goto({ to = '', params, options }: NavigationOptions) { + svelteGoto(`${to}?${params.toString()}`, options).catch(() => toastError('Navigation failed')); +} + +interface NavigationParameters<T> { + filter?: T; + sort?: Partial<SortData<string>>; + pagination?: Partial<PaginationData>; +} + +function paramsFrom<T>( + { pagination, filter, sort }: NavigationParameters<T>, + current?: URLSearchParams +) { + const params = new URLSearchParams(current); + + if (filter !== undefined) { + const json = JsonURL.stringify(filter, { AQF: true, impliedObject: true }); + if (json) { + params.set('f', json); + } else { + params.delete('f'); + } + } + + if (sort !== undefined) { + if (sort.on !== undefined) { + params.set('s', sort.on); + } + if (sort.direction !== undefined) { + params.set('d', sort.direction); + } + if (sort.seed !== undefined) { + params.set('r', sort.seed.toString()); + } + } + + params.delete('p'); + + if (pagination?.items) { + params.set('i', pagination.items.toString()); + } + + if (pagination?.page) { + params.set('p', pagination.page.toString()); + } + + return params; +} + +export function navigate(parameters: NavigationParameters<object>, current?: URLSearchParams) { + goto({ + params: paramsFrom(parameters, current), + options: { noScroll: false, keepFocus: true, replaceState: true } + }); +} + +export function href<T>(base: string, params: NavigationParameters<T>) { + return `/${base}/?${paramsFrom(params).toString()}`; +} diff --git a/frontend/src/lib/Pagination.ts b/frontend/src/lib/Pagination.ts new file mode 100644 index 0000000..f05492b --- /dev/null +++ b/frontend/src/lib/Pagination.ts @@ -0,0 +1,31 @@ +import { navigate } from '$lib/Navigation'; +import { getContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; + +export interface PaginationData { + page: number; + items: number; +} + +export class PaginationContext { + page = 0; + items = 0; + total = 0; + + set update({ page, items }: PaginationData) { + this.page = page; + this.items = items; + } + + apply(params: URLSearchParams) { + navigate({ pagination: { items: this.items } }, params); + } +} + +export function initPaginationContext() { + return setContext<Writable<PaginationContext>>('pagination', writable(new PaginationContext())); +} + +export function getPaginationContext() { + return getContext<Writable<PaginationContext>>('pagination'); +} diff --git a/frontend/src/lib/Reader.ts b/frontend/src/lib/Reader.ts new file mode 100644 index 0000000..8777b9b --- /dev/null +++ b/frontend/src/lib/Reader.ts @@ -0,0 +1,62 @@ +import { Layout, type PageFragment } from '$gql/graphql'; +import { getContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; + +export interface Chunk { + main: PageFragment; + secondary?: PageFragment; + index: number; +} + +class ReaderContext { + visible = false; + sidebar = false; + pages: PageFragment[] = []; + page = 0; + + open(page: number) { + this.page = page; + this.visible = true; + + return this; + } +} + +export function initReaderContext() { + return setContext<Writable<ReaderContext>>('reader', writable(new ReaderContext())); +} + +export function getReaderContext() { + return getContext<Writable<ReaderContext>>('reader'); +} + +export function partition(pages: PageFragment[], layout: Layout): [Chunk[], number[]] { + const single = layout === Layout.Single; + const offset = layout === Layout.DoubleOffset; + + const chunks: Chunk[] = []; + const lookup: number[] = Array<number>(pages.length); + + for (let chunkIndex = 0, pageIndex = 0; pageIndex < pages.length; chunkIndex++) { + const wide = () => pages[pageIndex].image.aspectRatio > 1; + + const nextPage = () => { + lookup[pageIndex] = chunkIndex; + return pages[pageIndex++]; + }; + + const offsetFirst = pageIndex === 0 && offset; + const full = single || wide() || offsetFirst; + + const chunk: Chunk = { index: pageIndex, main: nextPage() }; + + if (!full && pageIndex < pages.length) { + if (!wide()) { + chunk.secondary = nextPage(); + } + } + + chunks.push(chunk); + } + return [chunks, lookup]; +} diff --git a/frontend/src/lib/Scraper.ts b/frontend/src/lib/Scraper.ts new file mode 100644 index 0000000..4baf370 --- /dev/null +++ b/frontend/src/lib/Scraper.ts @@ -0,0 +1,156 @@ +import { + Category, + Censorship, + Direction, + Language, + Layout, + OnMissing, + Rating, + type FullComicFragment, + type ScrapedComic, + type UpsertComicInput, + type UpsertOptions +} from '$gql/graphql'; +import { + CategoryLabel, + CensorshipLabel, + DirectionLabel, + LanguageLabel, + LayoutLabel, + RatingLabel +} from '$lib/Enums'; +import { getContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; + +interface ScraperContext { + scraper: string; + warnings: string[]; + selector?: ScrapedComicSelector; +} + +export function initScraperContext() { + return setContext<Writable<ScraperContext>>('scraper', writable({ scraper: '', warnings: [] })); +} + +export function getScraperContext() { + return getContext<Writable<ScraperContext>>('scraper'); +} + +export class Selector<T extends string> { + keep = true; + value: T; + display: string | undefined; + + constructor(value: T, display?: string) { + this.value = value; + this.display = display; + } + + toString() { + return this.display ?? this.value; + } + + static from<T extends string>( + scraped: T | undefined | null, + have: string | undefined | null, + label?: Record<string, string> + ) { + if (scraped && have !== scraped) { + return new Selector(scraped, label ? label[scraped] : undefined); + } + return undefined; + } + + static fromList(scraped: string[], have: { name: string }[]) { + const haves = new Set(have.map((i) => i.name)); + + return scraped.filter((i) => !haves.has(i)).map((i) => new Selector(i)); + } +} + +function keepItem<T extends string>(selector?: Selector<T>): T | undefined | null { + if (selector?.keep) { + return selector.value; + } + return undefined; +} + +function keepList<T extends string>( + selectorList: Selector<T>[], + onMissing: OnMissing +): { names: T[]; options: UpsertOptions } { + return { + names: selectorList.filter((v) => v.keep).map((v) => v.value), + options: { onMissing } + }; +} + +export class ScrapedComicSelector { + title?: Selector<string>; + originalTitle?: Selector<string>; + url?: Selector<string>; + date?: Selector<string>; + category?: Selector<Category>; + censorship?: Selector<Censorship>; + rating?: Selector<Rating>; + language?: Selector<Language>; + direction?: Selector<Direction>; + layout?: Selector<Layout>; + artists: Selector<string>[]; + circles: Selector<string>[]; + characters: Selector<string>[]; + worlds: Selector<string>[]; + tags: Selector<string>[]; + + constructor(scraped: ScrapedComic, comic: FullComicFragment) { + this.title = Selector.from(scraped.title, comic.title); + this.originalTitle = Selector.from(scraped.originalTitle, comic.originalTitle); + this.url = Selector.from(scraped.url, comic.url); + this.date = Selector.from(scraped.date, comic.date); + this.category = Selector.from(scraped.category, comic.category, CategoryLabel); + this.censorship = Selector.from(scraped.censorship, comic.censorship, CensorshipLabel); + this.rating = Selector.from(scraped.rating, comic.rating, RatingLabel); + this.language = Selector.from(scraped.language, comic.language, LanguageLabel); + this.direction = Selector.from(scraped.direction, comic.direction, DirectionLabel); + this.layout = Selector.from(scraped.layout, comic.layout, LayoutLabel); + + this.artists = Selector.fromList(scraped.artists, comic.artists); + this.circles = Selector.fromList(scraped.circles, comic.circles); + this.characters = Selector.fromList(scraped.characters, comic.characters); + this.tags = Selector.fromList(scraped.tags, comic.tags); + this.worlds = Selector.fromList(scraped.worlds, comic.worlds); + } + + hasData() { + return ( + Object.values(this).filter((i) => { + if (i === undefined) { + return false; + } else if (Array.isArray(i) && i.length === 0) { + return false; + } + return true; + }).length > 0 + ); + } + + toInput(onMissing: OnMissing): UpsertComicInput { + return { + title: keepItem(this.title), + originalTitle: keepItem(this.originalTitle), + url: keepItem(this.url), + date: keepItem(this.date), + category: keepItem(this.category), + censorship: keepItem(this.censorship), + rating: keepItem(this.rating), + language: keepItem(this.language), + direction: keepItem(this.direction), + layout: keepItem(this.layout), + artists: keepList(this.artists, onMissing), + circles: keepList(this.circles, onMissing), + characters: keepList(this.characters, onMissing), + worlds: keepList(this.worlds, onMissing), + tags: keepList(this.tags, onMissing) + }; + } +} diff --git a/frontend/src/lib/Selection.ts b/frontend/src/lib/Selection.ts new file mode 100644 index 0000000..0ea85cc --- /dev/null +++ b/frontend/src/lib/Selection.ts @@ -0,0 +1,141 @@ +import { getContext, hasContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; +import { range } from './Utils'; + +interface Item { + id: number; +} + +export const hasSelectionContext = () => hasContext('selection'); + +export function getSelectionContext<T extends Item>() { + return getContext<Writable<ItemSelection<T>>>('selection'); +} + +export function initSelectionContext<T extends Item>( + typename?: string, + toName?: (item: T) => string +) { + return setContext<Writable<ItemSelection<T>>>( + 'selection', + writable(new ItemSelection(typename, toName)) + ); +} + +export class ItemSelection<T extends Item> { + active = false; + typename: string; + #toName: (item: T) => string; + + #view: T[] = []; + selectable: (item: T) => boolean = () => true; + + #ids = new Set<number>(); + #masked = new Set<number>(); + + constructor(typename?: string, toName?: (item: T) => string) { + this.typename = typename ?? 'unknown'; + this.#toName = toName ?? (() => 'unknown'); + } + + set view(view: T[]) { + this.#view = view; + this.#updateMasked(); + } + + #indexOf = (id: number) => this.#view.findIndex((v) => v.id === id); + + update(index: number, shift: boolean) { + const id = this.#view[index].id; + + const selectableRange = (first: number, last: number) => + range(first, last) + .filter((i) => this.selectable(this.#view[i])) + .map((i) => this.#view[i].id); + + if (shift) { + const indices = this.indices; + + const first = indices.at(0); + const last = indices.at(-1); + + if (first === undefined || last === undefined) { + this.#ids.add(id); + } else if (index === first || index === last) { + this.#ids.clear(); + } else if (index > last) { + this.#ids = new Set([...this.#ids, ...selectableRange(last, index)]); + } else if (index < last) { + this.#ids = new Set([...this.#ids, ...selectableRange(index, last)]); + } + } else { + if (this.#ids.has(id)) { + this.#ids.delete(id); + } else { + this.#ids.add(id); + } + } + + this.#updateMasked(); + + return this; + } + + toggle() { + this.active = !this.active; + + if (!this.active) { + return this.none(); + } + + return this; + } + + all() { + this.#ids = new Set(this.#view.filter(this.selectable).map((i) => i.id)); + this.#updateMasked(); + + return this; + } + + none() { + this.#ids.clear(); + this.#masked.clear(); + + return this; + } + + clear() { + this.active = false; + + return this.none(); + } + + contains(id: number) { + return this.#masked.has(id); + } + + #updateMasked() { + this.#masked = new Set([...this.#ids].filter((i) => this.#indexOf(i) >= 0)); + } + + get ids() { + return [...this.#masked]; + } + + get size() { + return this.#masked.size; + } + + get indices() { + return [...this.#ids].map(this.#indexOf).filter((i) => i >= 0); + } + + get items() { + return this.indices.map((i) => this.#view[i]); + } + + get names() { + return this.items.map(this.#toName); + } +} diff --git a/frontend/src/lib/Shortcuts.ts b/frontend/src/lib/Shortcuts.ts new file mode 100644 index 0000000..063bd40 --- /dev/null +++ b/frontend/src/lib/Shortcuts.ts @@ -0,0 +1,153 @@ +import { closeModal, modals } from 'svelte-modals'; +import { get } from 'svelte/store'; + +type LowercaseLetter = + | 'a' + | 'b' + | 'c' + | 'd' + | 'e' + | 'f' + | 'g' + | 'h' + | 'i' + | 'j' + | 'l' + | 'm' + | 'n' + | 'o' + | 'p' + | 'q' + | 'r' + | 's' + | 't' + | 'u' + | 'v' + | 'w' + | 'x' + | 'y' + | 'z'; + +type UppercaseLetter = Uppercase<LowercaseLetter>; +type Letter = LowercaseLetter | UppercaseLetter; +type Special = '?' | 'Enter' | 'Escape' | 'Delete'; + +const modeSwitches = ['n', 'g', 'i'] as const; +type ModeSwitch = (typeof modeSwitches)[number]; + +function isModeSwitch(s: string): s is ModeSwitch { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument + return modeSwitches.indexOf(s as any) !== -1; +} + +type Key = Letter | Special; +type KeyCombo = `${ModeSwitch}${Letter}`; +export type Shortcut = Key | KeyCombo; + +type EventAction = (event: KeyboardEvent) => void; +type FocusAction = HTMLInputElement; +type ClickAction = HTMLElement; + +type Action = EventAction | FocusAction | ClickAction; + +const handlers = new Map<string, Action>(); +let mode: ModeSwitch | undefined; + +export function handleShortcuts(event: KeyboardEvent) { + if (isInputElement(event.target)) { + if (event.key === 'Escape') { + event.target.blur(); + event.preventDefault(); + event.stopImmediatePropagation(); + } + return; + } + + if (event.ctrlKey) { + return; + } + + if (event.key === 'Escape') { + if (get(modals).length > 0) { + closeModal(); + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + } + + if (isModeSwitch(event.key) && mode === undefined) { + mode = event.key; + event.preventDefault(); + return; + } + + const handler = handlers.get(mode === undefined ? event.key : `${mode}${event.key}`); + + if (!handler || get(modals).length > 0) { + mode = undefined; + return; + } + + if (handler instanceof HTMLInputElement) { + handler.focus(); + } else if (handler instanceof HTMLElement) { + handler.click(); + } else { + handler(event); + } + + mode = undefined; + event.preventDefault(); +} + +export function accelerator(node: HTMLElement | HTMLInputElement, sc: Shortcut) { + handlers.set(sc, node); + + return { + destroy() { + handlers.delete(sc); + } + }; +} + +export function binds(node: Document, scs: [string, EventAction][]) { + const handlers = new Map<string, EventAction>(); + + for (const [k, a] of scs) { + handlers.set(k, a); + } + + function keydown(event: KeyboardEvent) { + if (isInputElement(event.target)) return; + + const handler = handlers.get(event.key); + + if (!handler) return; + + handler(event); + event.preventDefault(); + } + + node.addEventListener('keydown', keydown); + + return { + destroy() { + node.removeEventListener('keydown', keydown); + } + }; +} + +export function addShortcut(sc: Shortcut, action: EventAction) { + handlers.set(sc, action); +} + +function isInputElement(target: EventTarget | null): target is HTMLElement { + return ( + target instanceof HTMLElement && + (target instanceof HTMLInputElement || + target instanceof HTMLSelectElement || + target instanceof HTMLTextAreaElement || + target.isContentEditable) + ); +} diff --git a/frontend/src/lib/Sort.ts b/frontend/src/lib/Sort.ts new file mode 100644 index 0000000..4c9a353 --- /dev/null +++ b/frontend/src/lib/Sort.ts @@ -0,0 +1,42 @@ +import { SortDirection } from '$gql/graphql'; +import { getContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; +import { navigate } from './Navigation'; + +export interface SortData<T> { + on: T; + direction: SortDirection; + seed: number | undefined; +} + +export class SortContext<T extends string> { + on: T; + direction: SortDirection; + seed: number | undefined; + labels: Record<T, string>; + + constructor({ on, direction, seed }: SortData<T>, labels: Record<T, string>) { + this.on = on; + this.direction = direction; + this.seed = seed; + this.labels = labels; + } + + set update({ on, direction, seed }: SortData<T>) { + this.on = on; + this.direction = direction; + this.seed = seed; + } + + apply(params: URLSearchParams) { + navigate({ sort: { on: this.on, direction: this.direction, seed: this.seed } }, params); + } +} + +export function initSortContext<T extends string>(sort: SortData<T>, labels: Record<T, string>) { + return setContext<Writable<SortContext<T>>>('sort', writable(new SortContext(sort, labels))); +} + +export function getSortContext<T extends string>() { + return getContext<Writable<SortContext<T>>>('sort'); +} diff --git a/frontend/src/lib/Tabs.ts b/frontend/src/lib/Tabs.ts new file mode 100644 index 0000000..1c43068 --- /dev/null +++ b/frontend/src/lib/Tabs.ts @@ -0,0 +1,18 @@ +import { getContext, setContext } from 'svelte'; +import { writable, type Writable } from 'svelte/store'; + +type Tab = string; +type Tabs = Record<Tab, { title: string; badge?: boolean }>; + +interface TabContext { + tabs: Tabs; + current: Tab; +} + +export function setTabContext(context: TabContext) { + return setContext<Writable<TabContext>>('tabs', writable(context)); +} + +export function getTabContext() { + return getContext<Writable<TabContext>>('tabs'); +} diff --git a/frontend/src/lib/Toasts.ts b/frontend/src/lib/Toasts.ts new file mode 100644 index 0000000..abc9a7d --- /dev/null +++ b/frontend/src/lib/Toasts.ts @@ -0,0 +1,19 @@ +import { toast } from '@zerodevx/svelte-toast'; + +export function toastSuccess(message: string) { + toast.push(message, { + theme: { '--toastBackground': 'rgba(72, 187, 120, 0.9)', '--toastColor': 'mintcream' }, + duration: 1000 + }); +} + +export function toastError(message: string) { + toast.push(message, { + theme: { '--toastBackground': 'rgba(187, 72, 72, 0.9)', '--toastColor': 'lavenderblush' }, + duration: 5000, + pausable: true + }); +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any +export const toastFinally = (reason: any) => toastError(reason); diff --git a/frontend/src/lib/Transitions.ts b/frontend/src/lib/Transitions.ts new file mode 100644 index 0000000..59ebaf2 --- /dev/null +++ b/frontend/src/lib/Transitions.ts @@ -0,0 +1,10 @@ +import { quartInOut } from 'svelte/easing'; +import type { FadeParams, SlideParams } from 'svelte/transition'; + +export const fadeFast: FadeParams = { duration: 60 }; +export const fadeDefault: FadeParams = { duration: 100 }; + +export const slideYDefault: SlideParams = { axis: 'y', duration: 300, easing: quartInOut }; + +export const slideXDefault: SlideParams = { axis: 'x', duration: 300, easing: quartInOut }; +export const slideXFast: SlideParams = { axis: 'x', duration: 200 }; diff --git a/frontend/src/lib/Update.ts b/frontend/src/lib/Update.ts new file mode 100644 index 0000000..507dd52 --- /dev/null +++ b/frontend/src/lib/Update.ts @@ -0,0 +1,97 @@ +import { + UpdateMode, + type UpdateComicInput, + type UpdateOptions, + type UpdateTagInput +} from '$gql/graphql'; + +type Key = string | number | symbol; + +interface AssociationUpdate { + ids?: number[] | string[] | null; + options?: UpdateOptions | null; +} + +type Input<T, K extends Key> = { + [Property in K]?: T | null; +}; + +abstract class Entry<K extends Key> { + key: K; + + constructor(key: K) { + this.key = key; + } + + abstract integrate(input: Input<unknown, K>): void; + abstract hasInput(): boolean; +} + +class Association<K extends Key> extends Entry<K> { + ids = []; + options = { + mode: UpdateMode.Add + }; + + constructor(key: K) { + super(key); + } + + integrate(input: Input<AssociationUpdate, K>) { + if (this.hasInput()) { + input[this.key] = { ids: this.ids, options: this.options }; + } + } + + hasInput() { + return this.ids.length > 0; + } +} + +class Enum<K extends Key> extends Entry<K> { + value?: string = undefined; + + constructor(key: K) { + super(key); + } + + integrate(input: Input<string, K>): void { + if (this.hasInput()) { + input[this.key] = this.value; + } + } + + hasInput() { + return this.value !== undefined && this.value !== null; + } +} + +abstract class Controls<I> { + toInput() { + const input = {} as I; + Object.values(this).forEach((v: Entry<keyof I>) => v.integrate(input)); + return input; + } + + hasInput() { + return Object.values(this).some((i: Entry<keyof I>) => i.hasInput()); + } +} + +export class UpdateTagsControls extends Controls<UpdateTagInput> { + namespaces = new Association('namespaces'); +} + +export class UpdateComicsControls extends Controls<UpdateComicInput> { + artists = new Association('artists'); + category = new Enum('category'); + censorship = new Enum('censorship'); + direction = new Enum('direction'); + layout = new Enum('layout'); + characters = new Association('characters'); + circles = new Association('circles'); + language = new Enum('language'); + rating = new Enum('rating'); + tags = new Association('tags'); + worlds = new Association('worlds'); +} diff --git a/frontend/src/lib/Utils.ts b/frontend/src/lib/Utils.ts new file mode 100644 index 0000000..1a07be1 --- /dev/null +++ b/frontend/src/lib/Utils.ts @@ -0,0 +1,108 @@ +import { isError } from '$gql/Utils'; +import type { ImageFragment } from '$gql/graphql'; +import type { BeforeNavigate } from '@sveltejs/kit'; +import type { OperationResultState } from '@urql/svelte'; +import { openModal } from 'svelte-modals'; +import ConfirmDeletion from './dialogs/ConfirmDeletion.svelte'; + +export function range(from: number, to: number) { + return Array.from({ length: to - from + 1 }, (_, k) => k + from); +} + +export function getRandomInt(min: number, max: number) { + const minCeiled = Math.ceil(min); + const maxFloored = Math.floor(max); + + return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled); +} + +export interface ListItem { + id: number | string; + name: string; +} + +export interface ResultState { + fetching: boolean; + message?: string; +} + +export function getResultState(state: OperationResultState): ResultState { + let message: string | undefined; + + if (state.error) { + message = `${state.error.name}: ${state.error.message}`; + } else if (state.data) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const obj = Object.values(state.data)[0]; + if (isError(obj)) { + message = obj.message; + } + } + + return { fetching: state.fetching, message: message }; +} + +export function src(image: ImageFragment, type: 'full' | 'thumb' = 'thumb') { + const dir = image.hash.slice(0, 2); + const file = image.hash.slice(2); + + return `/objects/${dir}/${file}_${type}.webp`; +} + +export function numKeys(obj?: object | null, ignore: string[] = []) { + if (!obj) return 0; + + const len = Object.keys(obj).length; + let ignored = 0; + + for (const i of ignore) { + if (Object.hasOwn(obj, i)) ignored++; + } + + return len - ignored; +} + +export function confirmDeletion( + typename: string, + names: string | string[], + callback: () => void, + warning?: string +) { + openModal( + ConfirmDeletion, + { names: Array.isArray(names) ? names : [names], typename, callback: callback, warning }, + { replace: true } + ); +} + +export function idFromLabel(label: string) { + return label.toLowerCase().replaceAll(' ', '-'); +} + +export function pluralize(singular: string, size: number) { + return `${singular}${size > 1 ? 's' : ''}`; +} + +export function formatListSize(word: string, size: number) { + return `${size} ${pluralize(word, size)}`; +} + +export function joinText(items: string[], separator = ', ') { + return items.filter((i) => i).join(separator); +} + +export function confirmPending() { + return confirm('There are pending changes. Click Cancel to keep editing or OK to dismiss them.'); +} + +export function preventOnPending({ to, cancel }: BeforeNavigate, pending: boolean) { + if (!pending) return; + + if (to) { + if (confirmPending()) { + return; + } + } + + cancel(); +} diff --git a/frontend/src/lib/assets/logo.webp b/frontend/src/lib/assets/logo.webp Binary files differnew file mode 100644 index 0000000..e41cbb0 --- /dev/null +++ b/frontend/src/lib/assets/logo.webp diff --git a/frontend/src/lib/components/AddButton.svelte b/frontend/src/lib/components/AddButton.svelte new file mode 100644 index 0000000..9c0ab29 --- /dev/null +++ b/frontend/src/lib/components/AddButton.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + export let title: string; +</script> + +<button class="btn-blue" {title} on:click> + <span class="icon-base icon-[material-symbols--add]" /> +</button> diff --git a/frontend/src/lib/components/Badge.svelte b/frontend/src/lib/components/Badge.svelte new file mode 100644 index 0000000..7ad3173 --- /dev/null +++ b/frontend/src/lib/components/Badge.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + import { fadeDefault } from '$lib/Transitions'; + import { fade } from 'svelte/transition'; + + export let number: number; +</script> + +{#if number > 0} + <span + class="absolute -right-[3px] -top-[6px] z-[1] rounded-lg bg-teal-600 px-1 text-xs" + transition:fade={fadeDefault} + > + {number} + </span> +{/if} diff --git a/frontend/src/lib/components/BookmarkButton.svelte b/frontend/src/lib/components/BookmarkButton.svelte new file mode 100644 index 0000000..89570e6 --- /dev/null +++ b/frontend/src/lib/components/BookmarkButton.svelte @@ -0,0 +1,9 @@ +<script lang="ts"> + import Bookmark from '$lib/icons/Bookmark.svelte'; + + export let bookmarked: boolean; +</script> + +<button type="button" title="Toggle bookmark" class="flex text-base" on:click> + <Bookmark hoverable {bookmarked} /> +</button> diff --git a/frontend/src/lib/components/Card.svelte b/frontend/src/lib/components/Card.svelte new file mode 100644 index 0000000..2384799 --- /dev/null +++ b/frontend/src/lib/components/Card.svelte @@ -0,0 +1,106 @@ +<script lang="ts" context="module"> + import type { ComicFragment, ImageFragment } from '$gql/graphql'; + + interface CardDetails { + title: string; + favourite?: boolean; + subtitle?: string | null; + cover?: ImageFragment; + } + + export function comicCard(comic: ComicFragment) { + return { + href: `/comics/${comic.id.toString()}`, + details: { + title: comic.title, + subtitle: comic.originalTitle, + favourite: comic.favourite, + cover: comic.cover + } + }; + } +</script> + +<script lang="ts"> + import { src } from '$lib/Utils'; + import Star from '$lib/icons/Star.svelte'; + + export let href: string; + export let details: CardDetails; + export let compact = false; + export let coverOnly = false; + export let ellipsis = true; +</script> + +<a + {href} + class="grid-card-v sm:grid-card-h relative grid overflow-hidden rounded bg-slate-900 shadow-md shadow-slate-950/30" + class:compact + class:grid-card-cover-only={coverOnly} + on:click +> + <slot name="overlay" /> + {#if details.cover} + <img + class="h-full w-full object-cover object-[center_top]" + width={details.cover.width} + height={details.cover.height} + src={src(details.cover)} + alt="" + title={details.title} + /> + {/if} + {#if !coverOnly} + <article class="flex h-full flex-col gap-2 p-2"> + <header> + <h2 + class:ellipsis-nowrap={ellipsis} + class="self-center text-sm font-medium [grid-area:title]" + title={details.title} + > + {details.title} + </h2> + {#if details.subtitle} + <h3 + class="ellipsis-nowrap text-xs opacity-60 [grid-area:subtitle]" + title={details.subtitle} + > + {details.subtitle} + </h3> + {/if} + {#if details.favourite} + <div class="flex items-center text-lg [grid-area:fav]"> + <Star favourite /> + </div> + {/if} + </header> + + <section class="max-h-full grow overflow-auto border-t border-slate-800/80 pt-2 text-xs"> + <slot /> + </section> + </article> + {/if} +</a> + +<style> + a.compact { + grid-template-columns: 175px 1fr; + grid-template-rows: 250px; + } + + img { + border-start-start-radius: inherit; + border-end-start-radius: inherit; + } + + article > header { + display: grid; + + grid-template-columns: 1fr auto; + grid-template-rows: auto; + + grid-template-areas: + 'title fav' + 'subtitle fav'; + } +</style> diff --git a/frontend/src/lib/components/Cardlet.svelte b/frontend/src/lib/components/Cardlet.svelte new file mode 100644 index 0000000..04d8599 --- /dev/null +++ b/frontend/src/lib/components/Cardlet.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { ComicFilter } from '$gql/graphql'; + import { href } from '$lib/Navigation'; + + export let name: string; + export let title: string | null | undefined = undefined; + + export let filter: keyof ComicFilter | undefined = undefined; + export let id: number | string | undefined = undefined; + + const handleAux = (e: MouseEvent) => { + if (filter === undefined || id === undefined || e.button !== 1) return; + window.open(href('comics', { filter: { include: { [filter]: { all: [id] } } } })); + }; +</script> + +<button + type="button" + class="relative flex overflow-hidden rounded bg-slate-900 text-left shadow-md shadow-slate-950/20" + {title} + on:click + on:auxclick={handleAux} +> + <slot name="overlay" /> + <article class="group h-full grow items-center gap-2 p-2 text-xs"> + <h2 class="ellipsis-nowrap text-sm font-medium">{name}</h2> + </article> +</button> + +<style> + article { + display: grid; + + grid-template-columns: 1fr auto; + grid-template-rows: 2em; + } +</style> diff --git a/frontend/src/lib/components/DeleteButton.svelte b/frontend/src/lib/components/DeleteButton.svelte new file mode 100644 index 0000000..8f5f116 --- /dev/null +++ b/frontend/src/lib/components/DeleteButton.svelte @@ -0,0 +1,15 @@ +<script> + import { accelerator } from '$lib/Shortcuts'; + + export let prominent = false; +</script> + +<button + type="button" + class={prominent ? 'btn-rose' : 'btn-slate hover:bg-rose-700'} + title="Delete forever" + on:click + use:accelerator={'Delete'} +> + <span class="icon-base icon-[material-symbols--delete-forever]" /> +</button> diff --git a/frontend/src/lib/components/Dialog.svelte b/frontend/src/lib/components/Dialog.svelte new file mode 100644 index 0000000..a0bbe5e --- /dev/null +++ b/frontend/src/lib/components/Dialog.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { trapFocus } from '$lib/Actions'; + import { fadeDefault } from '$lib/Transitions'; + import { closeModal } from 'svelte-modals'; + import { fade } from 'svelte/transition'; + + export let isOpen: boolean; +</script> + +{#if isOpen} + <div + role="dialog" + class="pointer-events-none fixed bottom-0 left-0 right-0 top-0 z-30 flex items-center justify-center" + transition:fade|global={fadeDefault} + use:trapFocus + > + <div + class="pointer-events-auto flex flex-col rounded-md bg-slate-800 shadow-md shadow-slate-900" + > + <header class="flex items-center gap-1 border-b-2 border-slate-700/50 p-2"> + <slot name="header" /> + <button + type="button" + class="ml-auto flex items-center text-white/30 hover:text-white" + title="Cancel" + on:click={closeModal} + > + <span class="icon-base icon-[material-symbols--close]" /> + </button> + </header> + <main class="m-3 w-80 sm:w-[34rem]"> + <slot /> + </main> + </div> + </div> +{/if} diff --git a/frontend/src/lib/components/Dropdown.svelte b/frontend/src/lib/components/Dropdown.svelte new file mode 100644 index 0000000..9e935e4 --- /dev/null +++ b/frontend/src/lib/components/Dropdown.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { clickOutside } from '$lib/Actions'; + import { fadeFast } from '$lib/Transitions'; + import { fade } from 'svelte/transition'; + + export let visible: boolean; + export let parent: HTMLElement; +</script> + +{#if visible} + <div + class="absolute z-[1] mt-1 w-max rounded bg-slate-700 p-1 shadow-sm shadow-slate-900" + transition:fade={fadeFast} + use:clickOutside={{ handler: () => (visible = false), ignore: parent }} + > + <slot /> + </div> +{/if} diff --git a/frontend/src/lib/components/Empty.svelte b/frontend/src/lib/components/Empty.svelte new file mode 100644 index 0000000..7f9557c --- /dev/null +++ b/frontend/src/lib/components/Empty.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import logo from '$lib/assets/logo.webp'; +</script> + +<div class="col-span-full flex flex-col items-center text-4xl font-medium text-gray-600"> + <img src={logo} class="w-1/5 opacity-60 grayscale" alt="" /> + <div class="flex items-center gap-2"> + <h2>There is nothing here...</h2> + </div> +</div> diff --git a/frontend/src/lib/components/Expander.svelte b/frontend/src/lib/components/Expander.svelte new file mode 100644 index 0000000..a382658 --- /dev/null +++ b/frontend/src/lib/components/Expander.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + export let expanded: boolean; + export let title: string; +</script> + +<button + class="flex items-center text-base hover:text-white" + type="button" + on:click={() => (expanded = !expanded)} +> + {#if expanded} + <span class="icon-base icon-[material-symbols--expand-less]" /> + {:else} + <span class="icon-base icon-[material-symbols--expand-more]" /> + {/if} + {title} +</button> diff --git a/frontend/src/lib/components/Guard.svelte b/frontend/src/lib/components/Guard.svelte new file mode 100644 index 0000000..fd7ded4 --- /dev/null +++ b/frontend/src/lib/components/Guard.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import { getResultState } from '$lib/Utils'; + import Spinner from './Spinner.svelte'; + + export let result; + $: state = getResultState($result); +</script> + +{#if state.fetching} + <Spinner /> +{:else} + <p>{state.message}</p> +{/if} diff --git a/frontend/src/lib/components/Head.svelte b/frontend/src/lib/components/Head.svelte new file mode 100644 index 0000000..b4aed5b --- /dev/null +++ b/frontend/src/lib/components/Head.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + export let section: string; + export let title = ''; + + function formatTitle(section: string, title?: string) { + return [title, section, 'hircine'].filter((i) => i).join(' · '); + } +</script> + +<svelte:head> + <title>{formatTitle(section, title)}</title> +</svelte:head> diff --git a/frontend/src/lib/components/Labelled.svelte b/frontend/src/lib/components/Labelled.svelte new file mode 100644 index 0000000..4b36ad6 --- /dev/null +++ b/frontend/src/lib/components/Labelled.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import { idFromLabel } from '$lib/Utils'; + + export let label: string; + + const id = idFromLabel(label); +</script> + +<label class="self-center" for={id}>{label}</label> +<slot {id} /> diff --git a/frontend/src/lib/components/LabelledBlock.svelte b/frontend/src/lib/components/LabelledBlock.svelte new file mode 100644 index 0000000..feb563e --- /dev/null +++ b/frontend/src/lib/components/LabelledBlock.svelte @@ -0,0 +1,18 @@ +<script lang="ts"> + import { idFromLabel } from '$lib/Utils'; + + export let label: string; + + const id = idFromLabel(label); +</script> + +<div class="flex flex-col"> + <div class="flex"> + <label for={id}>{label}</label> + {#if $$slots.controls} + <div class="grow" /> + <slot name="controls" /> + {/if} + </div> + <slot {id} /> +</div> diff --git a/frontend/src/lib/components/OrganizedButton.svelte b/frontend/src/lib/components/OrganizedButton.svelte new file mode 100644 index 0000000..9be985c --- /dev/null +++ b/frontend/src/lib/components/OrganizedButton.svelte @@ -0,0 +1,9 @@ +<script lang="ts"> + import Organized from '$lib/icons/Organized.svelte'; + + export let organized: boolean; +</script> + +<button type="button" title="Toggle organized" class="flex text-base" on:click> + <Organized hoverable {organized} /> +</button> diff --git a/frontend/src/lib/components/RefreshButton.svelte b/frontend/src/lib/components/RefreshButton.svelte new file mode 100644 index 0000000..afab640 --- /dev/null +++ b/frontend/src/lib/components/RefreshButton.svelte @@ -0,0 +1,3 @@ +<button class="btn-blue" title="Refresh" on:click> + <span class="icon-base icon-[material-symbols--sync]" /> +</button> diff --git a/frontend/src/lib/components/RemovePageButton.svelte b/frontend/src/lib/components/RemovePageButton.svelte new file mode 100644 index 0000000..e23c079 --- /dev/null +++ b/frontend/src/lib/components/RemovePageButton.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + import { accelerator } from '$lib/Shortcuts'; +</script> + +<button + type="button" + class="btn-rose" + title="Remove selected pages" + on:click + use:accelerator={'Delete'} +> + <span class="icon-base icon-[material-symbols--scan-delete]" /> +</button> diff --git a/frontend/src/lib/components/Select.svelte b/frontend/src/lib/components/Select.svelte new file mode 100644 index 0000000..83f026c --- /dev/null +++ b/frontend/src/lib/components/Select.svelte @@ -0,0 +1,55 @@ +<script lang="ts"> + import type { ListItem } from '$lib/Utils'; + /* eslint-disable @typescript-eslint/ban-ts-comment, @typescript-eslint/no-explicit-any */ + + // @ts-ignore + import Svelecte from 'svelecte'; + + let inputId: string; + let valueAsObject = false; + let multiple = false; + + type Value = (number | string | ListItem)[] | number | string | ListItem | undefined | null; + + export let clearable = false; + export let placeholder = 'Select...'; + export let options: ListItem[] | undefined; + export let value: Value; + + export { inputId as id, valueAsObject as object, multiple as multi }; + + function optionsPlaceholder(from: Value) { + if (from === undefined || from === null) return []; + + return Array.isArray(from) ? value : [value]; + } +</script> + +{#if options !== null && options !== undefined} + <Svelecte + virtualList + valueField="id" + labelField="name" + {options} + {multiple} + {clearable} + {inputId} + {valueAsObject} + {placeholder} + bind:value + /> +{:else} + <Svelecte + virtualList + valueField="id" + labelField="name" + disabled + options={optionsPlaceholder(value)} + {multiple} + {clearable} + {inputId} + {valueAsObject} + {placeholder} + {value} + /> +{/if} diff --git a/frontend/src/lib/components/Spinner.svelte b/frontend/src/lib/components/Spinner.svelte new file mode 100644 index 0000000..946329c --- /dev/null +++ b/frontend/src/lib/components/Spinner.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { onDestroy } from 'svelte'; + + let show = false; + const timeout = setTimeout(() => (show = true), 150); + + onDestroy(() => clearTimeout(timeout)); +</script> + +{#if show} + <div class="flex h-full w-full items-center justify-center"> + <span class="spinner" /> + </div> +{/if} + +<style lang="postcss"> + .spinner { + width: 64px; + height: 64px; + border: 5px solid theme(colors.gray.200); + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +</style> diff --git a/frontend/src/lib/components/SubmitButton.svelte b/frontend/src/lib/components/SubmitButton.svelte new file mode 100644 index 0000000..8ac90b9 --- /dev/null +++ b/frontend/src/lib/components/SubmitButton.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + export let active = false; + + $: title = active ? 'Save pending changes' : 'Save (no changes pending)'; +</script> + +<button type="submit" class:active class="btn-slate [&.active]:btn-blue" {title}>Save</button> diff --git a/frontend/src/lib/components/Titlebar.svelte b/frontend/src/lib/components/Titlebar.svelte new file mode 100644 index 0000000..8aab2dd --- /dev/null +++ b/frontend/src/lib/components/Titlebar.svelte @@ -0,0 +1,32 @@ +<script lang="ts"> + import Star from '$lib/icons/Star.svelte'; + import { createEventDispatcher } from 'svelte'; + + export let title: string; + export let subtitle: string | null = ''; + export let favourite: boolean | undefined = undefined; + + const dispatch = createEventDispatcher<{ favourite: null }>(); +</script> + +<div class="flex flex-wrap gap-x-4"> + <div class="flex overflow-hidden"> + {#if favourite !== undefined} + <button + type="button" + class="mr-1 flex items-center" + title="Toggle favourite" + on:click={() => dispatch('favourite')} + > + <Star large hoverable {favourite} /> + </button> + {/if} + <h1 class="xl:ellipsis-nowrap text-2xl font-semibold">{title}</h1> + </div> + + {#if subtitle} + <h2 class="xl:ellipsis-nowrap self-end text-lg font-light text-gray-400"> + {subtitle} + </h2> + {/if} +</div> diff --git a/frontend/src/lib/containers/Cardlets.svelte b/frontend/src/lib/containers/Cardlets.svelte new file mode 100644 index 0000000..129da61 --- /dev/null +++ b/frontend/src/lib/containers/Cardlets.svelte @@ -0,0 +1,11 @@ +<script> + import { fadeDefault } from '$lib/Transitions'; + import { fade } from 'svelte/transition'; +</script> + +<div + class="grid gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 min-[1600px]:grid-cols-8 min-[1920px]:grid-cols-10" + in:fade={fadeDefault} +> + <slot /> +</div> diff --git a/frontend/src/lib/containers/Cards.svelte b/frontend/src/lib/containers/Cards.svelte new file mode 100644 index 0000000..a19e8be --- /dev/null +++ b/frontend/src/lib/containers/Cards.svelte @@ -0,0 +1,8 @@ +<script> + import { fadeDefault } from '$lib/Transitions'; + import { fade } from 'svelte/transition'; +</script> + +<div class="grid gap-4 xl:grid-cols-2 min-[1920px]:grid-cols-3" in:fade|global={fadeDefault}> + <slot /> +</div> diff --git a/frontend/src/lib/containers/Carousel.svelte b/frontend/src/lib/containers/Carousel.svelte new file mode 100644 index 0000000..1268a78 --- /dev/null +++ b/frontend/src/lib/containers/Carousel.svelte @@ -0,0 +1,15 @@ +<script lang="ts"> + export let title: string; + export let href: string; +</script> + +<div class="flex flex-col gap-1"> + <h2 class="flex text-2xl font-medium"> + <a class="hover:text-white" {href}> + {title} + </a> + </h2> + <div class="flex flex-wrap gap-5"> + <slot /> + </div> +</div> diff --git a/frontend/src/lib/containers/Column.svelte b/frontend/src/lib/containers/Column.svelte new file mode 100644 index 0000000..05daece --- /dev/null +++ b/frontend/src/lib/containers/Column.svelte @@ -0,0 +1,3 @@ +<div class="flex flex-col gap-4"> + <slot /> +</div> diff --git a/frontend/src/lib/containers/Grid.svelte b/frontend/src/lib/containers/Grid.svelte new file mode 100644 index 0000000..1224156 --- /dev/null +++ b/frontend/src/lib/containers/Grid.svelte @@ -0,0 +1,23 @@ +<script> + import { fadeDefault } from '$lib/Transitions'; + + import { fade } from 'svelte/transition'; +</script> + +<div + class="flex flex-col gap-1 lg:grid lg:h-full lg:max-h-full lg:overflow-auto" + in:fade|global={fadeDefault} +> + <slot /> +</div> + +<style> + div { + grid-template-columns: auto 1fr; + grid-template-rows: auto 1fr; + + grid-template-areas: + 'header header' + 'sidebar main'; + } +</style> diff --git a/frontend/src/lib/dialogs/AddArtist.svelte b/frontend/src/lib/dialogs/AddArtist.svelte new file mode 100644 index 0000000..6ec93c5 --- /dev/null +++ b/frontend/src/lib/dialogs/AddArtist.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { addArtist, type ArtistInput } from '$gql/Mutations'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import ArtistForm from '$lib/forms/ArtistForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + let artist = { name: '' }; + + function add(event: CustomEvent<ArtistInput>) { + addArtist(client, { input: event.detail }).then(closeModal).catch(toastFinally); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Add Artist</h2> + </svelte:fragment> + <ArtistForm bind:artist on:submit={add}> + <div class="flex justify-end gap-4"> + <SubmitButton active={artist.name.length > 0} /> + </div> + </ArtistForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/AddCharacter.svelte b/frontend/src/lib/dialogs/AddCharacter.svelte new file mode 100644 index 0000000..23fea08 --- /dev/null +++ b/frontend/src/lib/dialogs/AddCharacter.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { addCharacter, type CharacterInput } from '$gql/Mutations'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import CharacterForm from '$lib/forms/CharacterForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + let character = { name: '' }; + + function add(event: CustomEvent<CharacterInput>) { + addCharacter(client, { input: event.detail }).then(closeModal).catch(toastFinally); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Add Character</h2> + </svelte:fragment> + <CharacterForm bind:character on:submit={add}> + <div class="flex justify-end gap-4"> + <SubmitButton active={character.name.length > 0} /> + </div> + </CharacterForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/AddCircle.svelte b/frontend/src/lib/dialogs/AddCircle.svelte new file mode 100644 index 0000000..f0ef014 --- /dev/null +++ b/frontend/src/lib/dialogs/AddCircle.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { addCircle, type CircleInput } from '$gql/Mutations'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import CircleForm from '$lib/forms/CircleForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + let circle = { name: '' }; + + function add(event: CustomEvent<CircleInput>) { + addCircle(client, { input: event.detail }).then(closeModal).catch(toastFinally); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Add Circle</h2> + </svelte:fragment> + <CircleForm bind:circle on:submit={add}> + <div class="flex justify-end gap-4"> + <SubmitButton active={circle.name.length > 0} /> + </div> + </CircleForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/AddNamespace.svelte b/frontend/src/lib/dialogs/AddNamespace.svelte new file mode 100644 index 0000000..e81b22a --- /dev/null +++ b/frontend/src/lib/dialogs/AddNamespace.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { addNamespace, type NamespaceInput } from '$gql/Mutations'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import NamespaceForm from '$lib/forms/NamespaceForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + let namespace = { name: '' }; + + function add(event: CustomEvent<NamespaceInput>) { + addNamespace(client, { input: event.detail }).then(closeModal).catch(toastFinally); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Add Namespace</h2> + </svelte:fragment> + <NamespaceForm bind:namespace on:submit={add}> + <div class="flex justify-end gap-4"> + <SubmitButton active={namespace.name.length > 0} /> + </div> + </NamespaceForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/AddTag.svelte b/frontend/src/lib/dialogs/AddTag.svelte new file mode 100644 index 0000000..00d3a03 --- /dev/null +++ b/frontend/src/lib/dialogs/AddTag.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { addTag, type TagInput } from '$gql/Mutations'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import TagForm from '$lib/forms/TagForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + let tag = { name: '', namespaces: [] }; + + function add(event: CustomEvent<TagInput>) { + addTag(client, { input: event.detail }).then(closeModal).catch(toastFinally); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Add Tag</h2> + </svelte:fragment> + <TagForm bind:tag on:submit={add}> + <div class="flex justify-end gap-4"> + <SubmitButton active={tag.name.length > 0} /> + </div> + </TagForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/AddWorld.svelte b/frontend/src/lib/dialogs/AddWorld.svelte new file mode 100644 index 0000000..ceb946e --- /dev/null +++ b/frontend/src/lib/dialogs/AddWorld.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { addWorld, type WorldInput } from '$gql/Mutations'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import WorldForm from '$lib/forms/WorldForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + let world = { name: '' }; + + function add(event: CustomEvent<WorldInput>) { + addWorld(client, { input: event.detail }).then(closeModal).catch(toastFinally); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Add World</h2> + </svelte:fragment> + <WorldForm bind:world on:submit={add}> + <div class="flex justify-end gap-4"> + <SubmitButton active={world.name.length > 0} /> + </div> + </WorldForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/ConfirmDeletion.svelte b/frontend/src/lib/dialogs/ConfirmDeletion.svelte new file mode 100644 index 0000000..6b0cbf8 --- /dev/null +++ b/frontend/src/lib/dialogs/ConfirmDeletion.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import { accelerator } from '$lib/Shortcuts'; + import Dialog from '$lib/components/Dialog.svelte'; + import { closeModal } from 'svelte-modals'; + + export let isOpen: boolean; + export let callback: () => void; + + export let names: string[]; + export let typename: string; + export let warning: string | undefined = undefined; + const multiple = names.length > 1; + const formattedTypename = multiple ? `${typename}s` : typename; + const formattedNames = multiple ? `${names.length} ${formattedTypename}` : names[0]; + + function confirm() { + callback(); + closeModal(); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Delete {formattedTypename}</h2> + </svelte:fragment> + <form on:submit|preventDefault={confirm}> + <div class="flex flex-col"> + <p class="mb-3"> + Are you sure you want to delete <span class="font-semibold">{formattedNames}</span>? + </p> + {#if multiple} + <ul class="mb-3 ml-8 list-disc"> + {#each names.slice(0, 10) as name} + <li>{name}</li> + {/each} + </ul> + {#if names.length - 10 > 0} + <p>... and {names.length - 10} more.</p> + {/if} + {/if} + {#if warning} + <p class="font-medium text-red-600">Warning: {warning}</p> + {/if} + </div> + + <div class="flex justify-end gap-4"> + <button type="submit" class="btn-rose" use:accelerator={'Enter'}>Delete</button> + <button type="button" on:click={closeModal} class="btn-slate">Cancel</button> + </div> + </form> +</Dialog> diff --git a/frontend/src/lib/dialogs/EditArtist.svelte b/frontend/src/lib/dialogs/EditArtist.svelte new file mode 100644 index 0000000..dd08bc6 --- /dev/null +++ b/frontend/src/lib/dialogs/EditArtist.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { deleteArtists, updateArtists, type ArtistInput } from '$gql/Mutations'; + import { itemEquals } from '$gql/Utils'; + import { type Artist } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import ArtistForm from '$lib/forms/ArtistForm.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + export let artist: Artist; + const original = structuredClone(artist); + $: pending = !itemEquals(artist, original); + + function save(event: CustomEvent<ArtistInput>) { + updateArtists(client, { ids: artist.id, input: event.detail }) + .then(closeModal) + .catch(toastFinally); + } + + function deleteArtist() { + confirmDeletion('Artist', artist.name, () => { + deleteArtists(client, { ids: artist.id }).then(closeModal).catch(toastFinally); + }); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Artist</h2> + </svelte:fragment> + <ArtistForm bind:artist on:submit={save}> + <div class="flex gap-4"> + <DeleteButton on:click={deleteArtist} /> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </ArtistForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/EditCharacter.svelte b/frontend/src/lib/dialogs/EditCharacter.svelte new file mode 100644 index 0000000..3b45e78 --- /dev/null +++ b/frontend/src/lib/dialogs/EditCharacter.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { deleteCharacters, updateCharacters, type CharacterInput } from '$gql/Mutations'; + import { itemEquals } from '$gql/Utils'; + import { type Character } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import CharacterForm from '$lib/forms/CharacterForm.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + export let character: Character; + const original = structuredClone(character); + $: pending = !itemEquals(original, character); + + function save(event: CustomEvent<CharacterInput>) { + updateCharacters(client, { ids: character.id, input: event.detail }) + .then(closeModal) + .catch(toastFinally); + } + + function deleteCharacter() { + confirmDeletion('Character', character.name, () => { + deleteCharacters(client, { ids: character.id }).then(closeModal).catch(toastFinally); + }); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Character</h2> + </svelte:fragment> + <CharacterForm bind:character on:submit={save}> + <div class="flex gap-4"> + <DeleteButton on:click={deleteCharacter} /> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </CharacterForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/EditCircle.svelte b/frontend/src/lib/dialogs/EditCircle.svelte new file mode 100644 index 0000000..bdc1217 --- /dev/null +++ b/frontend/src/lib/dialogs/EditCircle.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { deleteCircles, updateCircles, type CircleInput } from '$gql/Mutations'; + import { itemEquals } from '$gql/Utils'; + import { type Circle } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import CircleForm from '$lib/forms/CircleForm.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + export let circle: Circle; + const original = structuredClone(circle); + $: pending = !itemEquals(original, circle); + + function save(event: CustomEvent<CircleInput>) { + updateCircles(client, { ids: circle.id, input: event.detail }) + .then(closeModal) + .catch(toastFinally); + } + + function deleteCircle() { + confirmDeletion('Circle', circle.name, () => { + deleteCircles(client, { ids: circle.id }).then(closeModal).catch(toastFinally); + }); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Circle</h2> + </svelte:fragment> + <CircleForm bind:circle on:submit={save}> + <div class="flex gap-4"> + <DeleteButton on:click={deleteCircle} /> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </CircleForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/EditNamespace.svelte b/frontend/src/lib/dialogs/EditNamespace.svelte new file mode 100644 index 0000000..f398b21 --- /dev/null +++ b/frontend/src/lib/dialogs/EditNamespace.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { deleteNamespaces, updateNamespaces, type NamespaceInput } from '$gql/Mutations'; + import { itemEquals } from '$gql/Utils'; + import { type Namespace } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import NamespaceForm from '$lib/forms/NamespaceForm.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + export let namespace: Namespace; + const original = structuredClone(namespace); + $: pending = !itemEquals(original, namespace); + + function save(event: CustomEvent<NamespaceInput>) { + updateNamespaces(client, { ids: namespace.id, input: event.detail }) + .then(closeModal) + .catch(toastFinally); + } + + function deleteNamespace() { + confirmDeletion('Namespace', namespace.name, () => { + deleteNamespaces(client, { ids: namespace.id }).then(closeModal).catch(toastFinally); + }); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Namespace</h2> + </svelte:fragment> + <NamespaceForm bind:namespace on:submit={save}> + <div class="flex gap-4"> + <DeleteButton on:click={deleteNamespace} /> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </NamespaceForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/EditTag.svelte b/frontend/src/lib/dialogs/EditTag.svelte new file mode 100644 index 0000000..d2d0013 --- /dev/null +++ b/frontend/src/lib/dialogs/EditTag.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + import { deleteTags, updateTags, type TagInput } from '$gql/Mutations'; + import { tagEquals } from '$gql/Utils'; + import { type FullTag } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import TagForm from '$lib/forms/TagForm.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + export let tag: FullTag; + const original = structuredClone(tag); + $: pending = !tagEquals(original, tag); + + function save(event: CustomEvent<TagInput>) { + updateTags(client, { ids: tag.id, input: event.detail }).then(closeModal).catch(toastFinally); + } + + function deleteTag() { + confirmDeletion('Tag', tag.name, () => { + deleteTags(client, { ids: tag.id }).then(closeModal).catch(toastFinally); + }); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Tag</h2> + </svelte:fragment> + <TagForm bind:tag on:submit={save}> + <div class="flex gap-4"> + <DeleteButton on:click={deleteTag} /> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </TagForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/EditWorld.svelte b/frontend/src/lib/dialogs/EditWorld.svelte new file mode 100644 index 0000000..82afe6a --- /dev/null +++ b/frontend/src/lib/dialogs/EditWorld.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import { type World } from '$gql/graphql'; + import { deleteWorlds, updateWorlds, type WorldInput } from '$gql/Mutations'; + import { itemEquals } from '$gql/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import Dialog from '$lib/components/Dialog.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import WorldForm from '$lib/forms/WorldForm.svelte'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + + const client = getContextClient(); + + export let isOpen: boolean; + + export let world: World; + const original = structuredClone(world); + $: pending = !itemEquals(original, world); + + function save(event: CustomEvent<WorldInput>) { + updateWorlds(client, { ids: world.id, input: event.detail }) + .then(closeModal) + .catch(toastFinally); + } + + function deleteWorld() { + confirmDeletion('World', world.name, () => { + deleteWorlds(client, { ids: world.id }).then(closeModal).catch(toastFinally); + }); + } +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit World</h2> + </svelte:fragment> + <WorldForm bind:world on:submit={save}> + <div class="flex gap-4"> + <DeleteButton on:click={deleteWorld} /> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </WorldForm> +</Dialog> diff --git a/frontend/src/lib/dialogs/UpdateComics.svelte b/frontend/src/lib/dialogs/UpdateComics.svelte new file mode 100644 index 0000000..8de9622 --- /dev/null +++ b/frontend/src/lib/dialogs/UpdateComics.svelte @@ -0,0 +1,96 @@ +<script lang="ts"> + import { updateComics } from '$gql/Mutations'; + import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries'; + import { categories, censorships, directions, languages, layouts, ratings } from '$lib/Enums'; + import { toastFinally } from '$lib/Toasts'; + import { UpdateComicsControls } from '$lib/Update'; + import Dialog from '$lib/components/Dialog.svelte'; + import Labelled from '$lib/components/Labelled.svelte'; + import LabelledBlock from '$lib/components/LabelledBlock.svelte'; + import Select from '$lib/components/Select.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + import UpdateModeSelector from './components/UpdateModeSelector.svelte'; + + const client = getContextClient(); + + export let isOpen: boolean; + export let ids: number[]; + + $: tagsQuery = comicTagList(client); + $: artistsQuery = artistList(client); + $: charactersQuery = characterList(client); + $: circlesQuery = circleList(client); + $: worldsQuery = worldList(client); + + $: tags = $tagsQuery.data?.comicTags.edges; + $: artists = $artistsQuery.data?.artists.edges; + $: characters = $charactersQuery.data?.characters.edges; + $: circles = $circlesQuery.data?.circles.edges; + $: worlds = $worldsQuery.data?.worlds.edges; + + const controls = new UpdateComicsControls(); + + const update = () => { + updateComics(client, { + ids: ids, + input: controls.toInput() + }) + .then(closeModal) + .catch(toastFinally); + }; +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Comics</h2> + </svelte:fragment> + <form on:submit|preventDefault={update}> + <div class="grid-labels"> + <Labelled label="Category" let:id> + <Select clearable {id} options={categories} bind:value={controls.category.value} /> + </Labelled> + <Labelled label="Rating" let:id> + <Select clearable {id} options={ratings} bind:value={controls.rating.value} /> + </Labelled> + <Labelled label="Censorship" let:id> + <Select clearable {id} options={censorships} bind:value={controls.censorship.value} /> + </Labelled> + <Labelled label="Language" let:id> + <Select clearable {id} options={languages} bind:value={controls.language.value} /> + </Labelled> + <Labelled label="Direction" let:id> + <Select clearable {id} options={directions} bind:value={controls.direction.value} /> + </Labelled> + <Labelled label="Layout" let:id> + <Select clearable {id} options={layouts} bind:value={controls.layout.value} /> + </Labelled> + </div> + + <LabelledBlock label="Artists" let:id> + <Select multi {id} options={artists} bind:value={controls.artists.ids} /> + <UpdateModeSelector bind:mode={controls.artists.options.mode} slot="controls" /> + </LabelledBlock> + <LabelledBlock label="Circles" let:id> + <Select multi {id} options={circles} bind:value={controls.circles.ids} /> + <UpdateModeSelector bind:mode={controls.circles.options.mode} slot="controls" /> + </LabelledBlock> + <LabelledBlock label="Characters" let:id> + <Select multi {id} options={characters} bind:value={controls.characters.ids} /> + <UpdateModeSelector bind:mode={controls.characters.options.mode} slot="controls" /> + </LabelledBlock> + <LabelledBlock label="Worlds" let:id> + <Select multi {id} options={worlds} bind:value={controls.worlds.ids} /> + <UpdateModeSelector bind:mode={controls.worlds.options.mode} slot="controls" /> + </LabelledBlock> + <LabelledBlock label="Tags" let:id> + <Select multi {id} options={tags} bind:value={controls.tags.ids} /> + <UpdateModeSelector bind:mode={controls.tags.options.mode} slot="controls" /> + </LabelledBlock> + + <div class="flex justify-end gap-4"> + <SubmitButton active={controls.hasInput()} /> + </div> + </form> +</Dialog> diff --git a/frontend/src/lib/dialogs/UpdateTags.svelte b/frontend/src/lib/dialogs/UpdateTags.svelte new file mode 100644 index 0000000..f753c7f --- /dev/null +++ b/frontend/src/lib/dialogs/UpdateTags.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + import { updateTags } from '$gql/Mutations'; + import { namespaceList } from '$gql/Queries'; + import { toastFinally } from '$lib/Toasts'; + import { UpdateTagsControls } from '$lib/Update'; + import Dialog from '$lib/components/Dialog.svelte'; + import LabelledBlock from '$lib/components/LabelledBlock.svelte'; + import Select from '$lib/components/Select.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import { getContextClient } from '@urql/svelte'; + import { closeModal } from 'svelte-modals'; + import UpdateModeSelector from './components/UpdateModeSelector.svelte'; + + const client = getContextClient(); + + $: namespaceQuery = namespaceList(client); + $: namespaces = $namespaceQuery.data?.namespaces.edges; + + export let isOpen: boolean; + export let ids: number[]; + + const controls = new UpdateTagsControls(); + + const update = () => { + updateTags(client, { ids: ids, input: controls.toInput() }) + .then(closeModal) + .catch(toastFinally); + }; +</script> + +<Dialog {isOpen}> + <svelte:fragment slot="header"> + <h2>Edit Tags</h2> + </svelte:fragment> + <form on:submit|preventDefault={update}> + <LabelledBlock label="Namespaces" let:id> + <Select multi {id} options={namespaces} bind:value={controls.namespaces.ids} /> + <UpdateModeSelector bind:mode={controls.namespaces.options.mode} slot="controls" /> + </LabelledBlock> + + <div class="flex justify-end gap-4"> + <SubmitButton active={controls.hasInput()} /> + </div> + </form> +</Dialog> diff --git a/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte new file mode 100644 index 0000000..e4b4479 --- /dev/null +++ b/frontend/src/lib/dialogs/components/UpdateModeSelector.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { UpdateMode } from '$gql/graphql'; + import { UpdateModeLabel } from '$lib/Enums'; + + export let mode: UpdateMode; + + function select(e: string) { + mode = e as UpdateMode; + } +</script> + +<div class="flex gap-1 pb-1 text-xs"> + {#each Object.entries(UpdateModeLabel) as [e, label]} + <button + type="button" + class:active={mode === e} + class:dangerous={mode !== UpdateMode.Add} + class="btn btn-xs hover:bg-slate-700 [&.active.dangerous]:bg-rose-800 [&.active]:bg-indigo-700" + on:click={() => select(e)} + > + {label} + </button> + {/each} +</div> diff --git a/frontend/src/lib/filter/ComicFilterForm.svelte b/frontend/src/lib/filter/ComicFilterForm.svelte new file mode 100644 index 0000000..13b5320 --- /dev/null +++ b/frontend/src/lib/filter/ComicFilterForm.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries'; + import { ComicFilterContext, getFilterContext } from '$lib/Filter'; + import { getContextClient } from '@urql/svelte'; + import ComicFilterGroup from './components/ComicFilterGroup.svelte'; + import FilterForm from './components/FilterForm.svelte'; + + const client = getContextClient(); + + $: tagsQuery = comicTagList(client, { forFilter: true }); + $: artistsQuery = artistList(client); + $: charactersQuery = characterList(client); + $: circlesQuery = circleList(client); + $: worldsQuery = worldList(client); + + $: tags = $tagsQuery.data?.comicTags.edges; + $: artists = $artistsQuery.data?.artists.edges; + $: characters = $charactersQuery.data?.characters.edges; + $: circles = $circlesQuery.data?.circles.edges; + $: worlds = $worldsQuery.data?.worlds.edges; + + const filter = getFilterContext<ComicFilterContext>(); + const apply = () => $filter.apply($page.url.searchParams); +</script> + +<FilterForm type="grid" on:submit={apply}> + <ComicFilterGroup + slot="include" + type="include" + bind:controls={$filter.include.controls} + {tags} + {artists} + {characters} + {circles} + {worlds} + /> + <ComicFilterGroup + slot="exclude" + type="exclude" + bind:controls={$filter.exclude.controls} + {tags} + {artists} + {characters} + {circles} + {worlds} + /> +</FilterForm> diff --git a/frontend/src/lib/filter/TagFilterForm.svelte b/frontend/src/lib/filter/TagFilterForm.svelte new file mode 100644 index 0000000..be5996e --- /dev/null +++ b/frontend/src/lib/filter/TagFilterForm.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { namespaceList } from '$gql/Queries'; + import { TagFilterContext, getFilterContext } from '$lib/Filter'; + import { getContextClient } from '@urql/svelte'; + import FilterForm from './components/FilterForm.svelte'; + import TagFilterGroup from './components/TagFilterGroup.svelte'; + + const client = getContextClient(); + + $: namespaceQuery = namespaceList(client); + $: namespaces = $namespaceQuery.data?.namespaces.edges; + + const filter = getFilterContext<TagFilterContext>(); + const apply = () => $filter.apply($page.url.searchParams); +</script> + +<FilterForm on:submit={apply}> + <TagFilterGroup + slot="include" + type="include" + bind:controls={$filter.include.controls} + {namespaces} + /> + <TagFilterGroup + slot="exclude" + type="exclude" + bind:controls={$filter.exclude.controls} + {namespaces} + /> +</FilterForm> diff --git a/frontend/src/lib/filter/components/ComicFilterGroup.svelte b/frontend/src/lib/filter/components/ComicFilterGroup.svelte new file mode 100644 index 0000000..d302de4 --- /dev/null +++ b/frontend/src/lib/filter/components/ComicFilterGroup.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { categories, censorships, languages, ratings } from '$lib/Enums'; + import { ComicFilterControls } from '$lib/Filter'; + import type { ListItem } from '$lib/Utils'; + import { setContext } from 'svelte'; + import Filter from './Filter.svelte'; + + export let tags: ListItem[] | undefined; + export let artists: ListItem[] | undefined; + export let circles: ListItem[] | undefined; + export let characters: ListItem[] | undefined; + export let worlds: ListItem[] | undefined; + export let controls: ComicFilterControls; + export let type: 'include' | 'exclude'; + + setContext('filter-type', type); +</script> + +<Filter title="Tags" options={tags} bind:filter={controls.tags} --grid-column="span 2" /> +<Filter title="Artists" options={artists} bind:filter={controls.artists} /> +<Filter title="Circles" options={circles} bind:filter={controls.circles} /> +<Filter title="Characters" options={characters} bind:filter={controls.characters} /> +<Filter title="Worlds" options={worlds} bind:filter={controls.worlds} /> +<Filter title="Categories" options={categories} bind:filter={controls.categories} /> +<Filter title="Ratings" options={ratings} bind:filter={controls.ratings} /> +<Filter title="Censorship" options={censorships} bind:filter={controls.censorships} /> +<Filter title="Languages" options={languages} bind:filter={controls.languages} /> diff --git a/frontend/src/lib/filter/components/Filter.svelte b/frontend/src/lib/filter/components/Filter.svelte new file mode 100644 index 0000000..ead5c4d --- /dev/null +++ b/frontend/src/lib/filter/components/Filter.svelte @@ -0,0 +1,77 @@ +<script lang="ts"> + import { Association, Enum } from '$lib/Filter'; + import type { ListItem } from '$lib/Utils'; + import Select from '$lib/components/Select.svelte'; + import { getContext } from 'svelte'; + + export let title: string; + const context: 'include' | 'exclude' = getContext('filter-type'); + $: exclude = context === 'exclude'; + + const id = `${context}-${title.toLowerCase()}`; + + export let options: ListItem[] | undefined; + export let filter: Association<string> | Enum<string>; +</script> + +<div class:exclude class="filter-container"> + <div class="flex gap-2"> + <label for={id}>{title}</label> + <div class="ml-auto flex items-center gap-1 self-center text-xs"> + {#if filter instanceof Association} + <button + type="button" + title="matches all" + class:active={filter.mode === 'all'} + class="btn btn-xs" + on:click={() => (filter.mode = 'all')} + > + ∀ + </button> + <button + type="button" + title="matches any of" + class:active={filter.mode === 'any'} + class="btn btn-xs" + on:click={() => (filter.mode = 'any')} + > + ∃ + </button> + <button + type="button" + title="matches exactly" + class:active={filter.mode === 'exact'} + class="btn btn-xs" + on:click={() => (filter.mode = 'exact')} + > + = + </button> + <hr class="border-px border-slate-600" /> + {/if} + <button + type="button" + title="empty" + class:active={filter.empty} + class="btn btn-xs" + on:click={() => (filter.empty = !filter.empty)} + > + ∅ + </button> + </div> + </div> + <Select multi clearable {options} {id} bind:value={filter.values} /> +</div> + +<style lang="postcss"> + button:hover { + @apply bg-slate-700; + } + + button.active { + @apply bg-indigo-800; + } + + .filter-container { + grid-column: var(--grid-column); + } +</style> diff --git a/frontend/src/lib/filter/components/FilterForm.svelte b/frontend/src/lib/filter/components/FilterForm.svelte new file mode 100644 index 0000000..6fc4c90 --- /dev/null +++ b/frontend/src/lib/filter/components/FilterForm.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import Expander from '$lib/components/Expander.svelte'; + import { getFilterContext } from '$lib/Filter'; + + const filter = getFilterContext(); + export let type: 'grid' | 'row' = 'row'; + + let exclude = false; + + $: if ($filter.exclude.size > 0) { + exclude = true; + } +</script> + +<form on:submit|preventDefault class="gap-0"> + {#if type === 'grid'} + <div class="flex flex-col gap-4 px-2 md:grid md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6"> + <slot name="include" /> + </div> + <div class="my-2 flex justify-start"> + <Expander title="Exclude" bind:expanded={exclude} /> + </div> + {#if exclude} + <div + class="flex flex-col gap-4 bg-rose-950/50 p-2 md:grid md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6" + > + <slot name="exclude" /> + </div> + {/if} + {:else} + <div + class="flex flex-wrap justify-center gap-2 [&>*]:basis-full xl:[&>*]:basis-1/3 2xl:[&>*]:basis-1/5" + > + <div class="p-2"> + <slot name="include" /> + </div> + <div class="bg-rose-950/50 p-2"> + <slot name="exclude" /> + </div> + </div> + {/if} + <div class=" mt-4 flex items-center"> + <hr class="flex-1 border-slate-700/70" /> + <button type="submit" class="btn-blue mx-2">Apply</button> + <hr class="flex-1 border-slate-700/70" /> + </div> +</form> diff --git a/frontend/src/lib/filter/components/TagFilterGroup.svelte b/frontend/src/lib/filter/components/TagFilterGroup.svelte new file mode 100644 index 0000000..83b6997 --- /dev/null +++ b/frontend/src/lib/filter/components/TagFilterGroup.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import { TagFilterControls } from '$lib/Filter'; + import type { ListItem } from '$lib/Utils'; + import { setContext } from 'svelte'; + import Filter from './Filter.svelte'; + + export let namespaces: ListItem[] | undefined; + export let controls: TagFilterControls; + export let type: 'include' | 'exclude'; + + setContext('filter-type', type); +</script> + +<Filter title="Namespaces" options={namespaces} bind:filter={controls.namespaces} /> diff --git a/frontend/src/lib/forms/ArtistForm.svelte b/frontend/src/lib/forms/ArtistForm.svelte new file mode 100644 index 0000000..7df5e8b --- /dev/null +++ b/frontend/src/lib/forms/ArtistForm.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import { type ArtistInput } from '$gql/Mutations'; + import { type OmitIdentifiers } from '$gql/Utils'; + import { type Artist } from '$gql/graphql'; + import Labelled from '$lib/components/Labelled.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher<{ submit: ArtistInput }>(); + + export let artist: OmitIdentifiers<Artist>; + + function submit() { + dispatch('submit', { name: artist.name }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Name" let:id> + <!-- svelte-ignore a11y-autofocus --> + <input autofocus required {id} bind:value={artist.name} /> + </Labelled> + </div> + <slot /> +</form> diff --git a/frontend/src/lib/forms/CharacterForm.svelte b/frontend/src/lib/forms/CharacterForm.svelte new file mode 100644 index 0000000..4cec37c --- /dev/null +++ b/frontend/src/lib/forms/CharacterForm.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import { type CharacterInput } from '$gql/Mutations'; + import { type OmitIdentifiers } from '$gql/Utils'; + import { type Character } from '$gql/graphql'; + import Labelled from '$lib/components/Labelled.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher<{ submit: CharacterInput }>(); + + export let character: OmitIdentifiers<Character>; + + function submit() { + dispatch('submit', { name: character.name }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Name" let:id> + <!-- svelte-ignore a11y-autofocus --> + <input autofocus required {id} bind:value={character.name} /> + </Labelled> + </div> + <slot /> +</form> diff --git a/frontend/src/lib/forms/CircleForm.svelte b/frontend/src/lib/forms/CircleForm.svelte new file mode 100644 index 0000000..b71256c --- /dev/null +++ b/frontend/src/lib/forms/CircleForm.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import { type CircleInput } from '$gql/Mutations'; + import { type OmitIdentifiers } from '$gql/Utils'; + import { type Circle } from '$gql/graphql'; + import Labelled from '$lib/components/Labelled.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher<{ submit: CircleInput }>(); + + export let circle: OmitIdentifiers<Circle>; + + function submit() { + dispatch('submit', { name: circle.name }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Name" let:id> + <!-- svelte-ignore a11y-autofocus --> + <input required autofocus {id} bind:value={circle.name} /> + </Labelled> + </div> + <slot /> +</form> diff --git a/frontend/src/lib/forms/ComicForm.svelte b/frontend/src/lib/forms/ComicForm.svelte new file mode 100644 index 0000000..74051c8 --- /dev/null +++ b/frontend/src/lib/forms/ComicForm.svelte @@ -0,0 +1,100 @@ +<script lang="ts"> + import { artistList, characterList, circleList, comicTagList, worldList } from '$gql/Queries'; + import { type OmitIdentifiers } from '$gql/Utils'; + import type { FullComicFragment, UpdateComicInput } from '$gql/graphql'; + import { categories, censorships, directions, languages, layouts, ratings } from '$lib/Enums'; + import Labelled from '$lib/components/Labelled.svelte'; + import LabelledBlock from '$lib/components/LabelledBlock.svelte'; + import Select from '$lib/components/Select.svelte'; + import { getContextClient } from '@urql/svelte'; + import { createEventDispatcher } from 'svelte'; + + const client = getContextClient(); + const dispatch = createEventDispatcher<{ submit: UpdateComicInput }>(); + + export let comic: OmitIdentifiers<FullComicFragment>; + + $: tagsQuery = comicTagList(client); + $: artistsQuery = artistList(client); + $: charactersQuery = characterList(client); + $: circlesQuery = circleList(client); + $: worldsQuery = worldList(client); + + $: tags = $tagsQuery.data?.comicTags.edges; + $: artists = $artistsQuery.data?.artists.edges; + $: characters = $charactersQuery.data?.characters.edges; + $: circles = $circlesQuery.data?.circles.edges; + $: worlds = $worldsQuery.data?.worlds.edges; + + function submit() { + dispatch('submit', { + direction: comic.direction, + layout: comic.layout, + rating: comic.rating, + category: comic.category, + censorship: comic.censorship, + title: comic.title, + originalTitle: comic.originalTitle, + url: comic.url, + date: comic.date === '' ? null : comic.date, + language: comic.language, + tags: { ids: comic.tags.map((t) => t.id) }, + artists: { ids: comic.artists.map((a) => a.id) }, + characters: { ids: comic.characters.map((c) => c.id) }, + circles: { ids: comic.circles.map((c) => c.id) }, + worlds: { ids: comic.worlds.map((w) => w.id) } + }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Title" let:id> + <input required {id} bind:value={comic.title} title={comic.title} /> + </Labelled> + <Labelled label="Original Title" let:id> + <input {id} bind:value={comic.originalTitle} title={comic.originalTitle} /> + </Labelled> + <Labelled label="URL" let:id> + <input {id} bind:value={comic.url} /> + </Labelled> + <Labelled label="Date" let:id> + <input {id} type="date" bind:value={comic.date} pattern={'d{4}-d{2}-d{2}'} /> + </Labelled> + <Labelled label="Category" let:id> + <Select {id} options={categories} bind:value={comic.category} /> + </Labelled> + <Labelled label="Rating" let:id> + <Select {id} options={ratings} bind:value={comic.rating} /> + </Labelled> + <Labelled label="Censorship" let:id> + <Select {id} options={censorships} bind:value={comic.censorship} /> + </Labelled> + <Labelled label="Language" let:id> + <Select {id} options={languages} bind:value={comic.language} /> + </Labelled> + <Labelled label="Direction" let:id> + <Select {id} options={directions} bind:value={comic.direction} /> + </Labelled> + <Labelled label="Layout" let:id> + <Select {id} options={layouts} bind:value={comic.layout} /> + </Labelled> + </div> + + <LabelledBlock label="Artists" let:id> + <Select multi object {id} options={artists} bind:value={comic.artists} /> + </LabelledBlock> + <LabelledBlock label="Circles" let:id> + <Select multi object {id} options={circles} bind:value={comic.circles} /> + </LabelledBlock> + <LabelledBlock label="Characters" let:id> + <Select multi object {id} options={characters} bind:value={comic.characters} /> + </LabelledBlock> + <LabelledBlock label="Worlds" let:id> + <Select multi object {id} options={worlds} bind:value={comic.worlds} /> + </LabelledBlock> + <LabelledBlock label="Tags" let:id> + <Select multi object {id} options={tags} bind:value={comic.tags} /> + </LabelledBlock> + <slot /> +</form> diff --git a/frontend/src/lib/forms/NamespaceForm.svelte b/frontend/src/lib/forms/NamespaceForm.svelte new file mode 100644 index 0000000..c05b6d8 --- /dev/null +++ b/frontend/src/lib/forms/NamespaceForm.svelte @@ -0,0 +1,28 @@ +<script lang="ts"> + import { type NamespaceInput } from '$gql/Mutations'; + import { type OmitIdentifiers } from '$gql/Utils'; + import { type Namespace } from '$gql/graphql'; + import Labelled from '$lib/components/Labelled.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher<{ submit: NamespaceInput }>(); + + export let namespace: OmitIdentifiers<Namespace>; + + function submit() { + dispatch('submit', { name: namespace.name, sortName: namespace.sortName }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Name" let:id> + <!-- svelte-ignore a11y-autofocus --> + <input required autofocus {id} bind:value={namespace.name} /> + </Labelled> + <Labelled label="Sort name" let:id> + <input {id} bind:value={namespace.sortName} /> + </Labelled> + </div> + <slot /> +</form> diff --git a/frontend/src/lib/forms/TagForm.svelte b/frontend/src/lib/forms/TagForm.svelte new file mode 100644 index 0000000..6cc2227 --- /dev/null +++ b/frontend/src/lib/forms/TagForm.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import type { TagInput } from '$gql/Mutations'; + import { namespaceList } from '$gql/Queries'; + import type { OmitIdentifiers } from '$gql/Utils'; + import type { FullTag } from '$gql/graphql'; + import Labelled from '$lib/components/Labelled.svelte'; + import Select from '$lib/components/Select.svelte'; + import { getContextClient } from '@urql/svelte'; + import { createEventDispatcher } from 'svelte'; + + const client = getContextClient(); + const dispatch = createEventDispatcher<{ submit: TagInput }>(); + + export let tag: OmitIdentifiers<FullTag>; + + $: namespaceQuery = namespaceList(client); + $: namespaces = $namespaceQuery.data?.namespaces.edges; + + function submit() { + dispatch('submit', { + name: tag.name, + description: tag.description, + namespaces: { ids: tag.namespaces.map((n) => n.id) } + }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Name" let:id> + <!-- svelte-ignore a11y-autofocus --> + <input autofocus required {id} bind:value={tag.name} /> + </Labelled> + <Labelled label="Description" let:id> + <textarea rows={3} {id} bind:value={tag.description} /> + </Labelled> + <Labelled label="Namespaces" let:id> + <Select multi object {id} options={namespaces} bind:value={tag.namespaces} /> + </Labelled> + </div> + <slot /> +</form> diff --git a/frontend/src/lib/forms/WorldForm.svelte b/frontend/src/lib/forms/WorldForm.svelte new file mode 100644 index 0000000..103dd5b --- /dev/null +++ b/frontend/src/lib/forms/WorldForm.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import { type WorldInput } from '$gql/Mutations'; + import { type OmitIdentifiers } from '$gql/Utils'; + import { type World } from '$gql/graphql'; + import Labelled from '$lib/components/Labelled.svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher<{ submit: WorldInput }>(); + + export let world: OmitIdentifiers<World>; + + function submit() { + dispatch('submit', { name: world.name }); + } +</script> + +<form on:submit|preventDefault={submit}> + <div class="grid-labels"> + <Labelled label="Name" let:id> + <!-- svelte-ignore a11y-autofocus --> + <input autofocus required {id} bind:value={world.name} /> + </Labelled> + </div> + <slot /> +</form> diff --git a/frontend/src/lib/gallery/Gallery.svelte b/frontend/src/lib/gallery/Gallery.svelte new file mode 100644 index 0000000..c3b6386 --- /dev/null +++ b/frontend/src/lib/gallery/Gallery.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import type { PageFragment } from '$gql/graphql'; + import GalleryPage from './GalleryPage.svelte'; + + export let pages: PageFragment[]; +</script> + +<div class="max-h-full gap-2 overflow-auto p-1 pr-3"> + {#each pages as page, index} + <GalleryPage {page} {index} on:open on:cover /> + {/each} +</div> + +<style> + :root { + --gallery-image-size: 100px; + } + + @media (min-width: 1280px) { + :root { + --gallery-image-size: 180px; + } + } + + @media (min-width: 1600px) { + :root { + --gallery-image-size: 200px; + } + } + + @media (min-width: 1920px) { + :root { + --gallery-image-size: 240px; + } + } + + div { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--gallery-image-size), 1fr)); + grid-auto-rows: fit-content(400px); + } +</style> diff --git a/frontend/src/lib/gallery/GalleryPage.svelte b/frontend/src/lib/gallery/GalleryPage.svelte new file mode 100644 index 0000000..449321c --- /dev/null +++ b/frontend/src/lib/gallery/GalleryPage.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import type { PageFragment } from '$gql/graphql'; + import { getSelectionContext } from '$lib/Selection'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import { src } from '$lib/Utils'; + import { createEventDispatcher } from 'svelte'; + + export let page: PageFragment; + export let index: number; + + const selection = getSelectionContext<PageFragment>(); + + let span: 'single' | 'double' | 'triple'; + + $: page.image.aspectRatio, updateSpan(); + + function updateSpan() { + const aspectRatio = page.image.aspectRatio; + + if (aspectRatio <= 1) { + span = 'single'; + } else if (aspectRatio > 1 && aspectRatio <= 2) { + span = 'double'; + } else if (aspectRatio > 2) { + span = 'triple'; + } + } + + const dispatch = createEventDispatcher<{ open: number; cover: number }>(); + + function press(event: MouseEvent | KeyboardEvent) { + if (event instanceof KeyboardEvent && event.key !== 'Enter') { + return; + } + + if ($selection.active) { + if (event.ctrlKey) { + dispatch('open', index); + } else if (selectable) { + $selection = $selection.update(index, event.shiftKey); + } + } else if (event.ctrlKey) { + dispatch('cover', page.id); + } else { + dispatch('open', index); + } + + event.preventDefault(); + } + + $: selectable = $selection.selectable(page); + $: dim = $selection.active && !selectable; + $: selected = $selection.contains(page.id); +</script> + +<div + class:dim + role="button" + tabindex="0" + class="{span} relative overflow-hidden rounded" + on:click={press} + on:keydown={press} +> + <SelectionOverlay position="top" {selected} /> + <img + class="h-full w-full object-cover object-[center_top] transition-opacity" + loading="lazy" + alt="" + width={page.image.width} + height={page.image.height} + src={src(page.image)} + title={`${page.path} (${page.image.width} x ${page.image.height})`} + /> +</div> + +<style> + .dim { + cursor: not-allowed; + } + + .dim > img { + opacity: 0.2; + filter: grayscale(1); + } + + .double { + grid-column: span 2; + } + + .triple { + grid-column: span 3; + } +</style> diff --git a/frontend/src/lib/icons/Bookmark.svelte b/frontend/src/lib/icons/Bookmark.svelte new file mode 100644 index 0000000..6f8e192 --- /dev/null +++ b/frontend/src/lib/icons/Bookmark.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + export let bookmarked: boolean | undefined = undefined; + export let hoverable = false; +</script> + +{#if bookmarked} + <span class:hoverable class="icon-gray icon-base icon-[material-symbols--bookmark]" /> +{:else} + <span class:hoverable class="icon-gray icon-base dim icon-[material-symbols--bookmark-outline]" /> +{/if} diff --git a/frontend/src/lib/icons/Female.svelte b/frontend/src/lib/icons/Female.svelte new file mode 100644 index 0000000..c772a6a --- /dev/null +++ b/frontend/src/lib/icons/Female.svelte @@ -0,0 +1 @@ +<span class="icon-xs icon-[material-symbols--female] -mx-[3px]" /> diff --git a/frontend/src/lib/icons/Location.svelte b/frontend/src/lib/icons/Location.svelte new file mode 100644 index 0000000..e345f83 --- /dev/null +++ b/frontend/src/lib/icons/Location.svelte @@ -0,0 +1 @@ +<span class="icon-xs icon-[material-symbols--location-on-outline]" /> diff --git a/frontend/src/lib/icons/Male.svelte b/frontend/src/lib/icons/Male.svelte new file mode 100644 index 0000000..e3578b7 --- /dev/null +++ b/frontend/src/lib/icons/Male.svelte @@ -0,0 +1 @@ +<span class="icon-xs icon-[material-symbols--male] -mx-px" /> diff --git a/frontend/src/lib/icons/Organized.svelte b/frontend/src/lib/icons/Organized.svelte new file mode 100644 index 0000000..66b5b00 --- /dev/null +++ b/frontend/src/lib/icons/Organized.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + export let organized: boolean | undefined = undefined; + export let hoverable = false; + export let tristate = false; + export let dim = false; +</script> + +{#if organized} + <span class:hoverable class="icon-gray icon-base icon-[material-symbols--check-circle]" /> +{:else if organized === undefined || !tristate} + <span + class:hoverable + class="icon-gray dim icon-base icon-[material-symbols--check-circle-outline]" + /> +{:else} + <span + class:hoverable + class:dim + class="icon-gray icon-base icon-[material-symbols--unpublished]" + /> +{/if} diff --git a/frontend/src/lib/icons/Star.svelte b/frontend/src/lib/icons/Star.svelte new file mode 100644 index 0000000..7613c55 --- /dev/null +++ b/frontend/src/lib/icons/Star.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + export let large = false; + export let favourite: boolean | undefined = undefined; + export let hoverable = false; +</script> + +{#if favourite} + <span class:hoverable class:large class="icon-yellow icon-[material-symbols--star-rounded]" /> +{:else} + <span + class:hoverable + class:large + class="icon-yellow dim icon-[material-symbols--star-outline-rounded]" + /> +{/if} + +<style lang="postcss"> + span { + @apply -m-px -translate-y-px text-[26px]; + } + + span.large { + @apply text-[34px]; + } +</style> diff --git a/frontend/src/lib/icons/Transgender.svelte b/frontend/src/lib/icons/Transgender.svelte new file mode 100644 index 0000000..7d9adc6 --- /dev/null +++ b/frontend/src/lib/icons/Transgender.svelte @@ -0,0 +1 @@ +<span class="icon-xs icon-[material-symbols--transgender]" /> diff --git a/frontend/src/lib/navigation/Link.svelte b/frontend/src/lib/navigation/Link.svelte new file mode 100644 index 0000000..7297a69 --- /dev/null +++ b/frontend/src/lib/navigation/Link.svelte @@ -0,0 +1,20 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { accelerator, type Shortcut } from '$lib/Shortcuts'; + import type { HTMLAttributeAnchorTarget } from 'svelte/elements'; + + export let href: string; + export let title: string; + export let accel: Shortcut; + export let matchExact = false; + export let target: HTMLAttributeAnchorTarget | undefined = undefined; + $: active = matchExact ? $page.url.pathname === href : $page.url.pathname.startsWith(href); +</script> + +<li class:active class="items-center hover:bg-indigo-700 [&.active]:bg-indigo-700"> + <a class="flex items-center" {target} {title} {href} use:accelerator={accel}> + <div class="flex p-3"> + <slot /> + </div> + </a> +</li> diff --git a/frontend/src/lib/navigation/Navigation.svelte b/frontend/src/lib/navigation/Navigation.svelte new file mode 100644 index 0000000..76096c8 --- /dev/null +++ b/frontend/src/lib/navigation/Navigation.svelte @@ -0,0 +1,5 @@ +<nav> + <ul class="flex h-full flex-col bg-slate-700/70 font-medium"> + <slot /> + </ul> +</nav> diff --git a/frontend/src/lib/pagination/Pagination.svelte b/frontend/src/lib/pagination/Pagination.svelte new file mode 100644 index 0000000..51612f4 --- /dev/null +++ b/frontend/src/lib/pagination/Pagination.svelte @@ -0,0 +1,45 @@ +<script lang="ts"> + import { getPaginationContext } from '$lib/Pagination'; + import Target from './Target.svelte'; + + const pagination = getPaginationContext(); + export let context = 2; + + $: totalPages = Math.ceil($pagination.total / $pagination.items); + $: rightBoundary = $pagination.page - context; + $: leftBoundary = $pagination.page + context; + + $: shiftRight = leftBoundary - totalPages; + $: shiftLeft = 1 - rightBoundary; + + $: containedLeft = leftBoundary <= totalPages; + $: containedRight = rightBoundary > 0; + + $: start = Math.max(1, containedLeft ? rightBoundary : rightBoundary - shiftRight); + $: end = Math.min(totalPages, containedRight ? leftBoundary : leftBoundary + shiftLeft); + + $: leftmost = $pagination.page <= 1; + $: rightmost = $pagination.page >= totalPages; +</script> + +{#if totalPages > 1} + <div class="flex justify-center gap-2"> + <Target disabled={leftmost} page={1}> + <span class="icon-base icon-[material-symbols--keyboard-double-arrow-left]" /> + </Target> + <Target disabled={leftmost} page={$pagination.page - 1}> + <span class="icon-base icon-[material-symbols--keyboard-arrow-left]" /> + </Target> + {#each Array.from({ length: end + 1 - start }, (_, i) => i + start) as page} + <Target active={$pagination.page === page} {page}> + <p>{page.toString()}</p> + </Target> + {/each} + <Target disabled={rightmost} page={$pagination.page + 1}> + <span class="icon-base icon-[material-symbols--keyboard-arrow-right]" /> + </Target> + <Target disabled={rightmost} page={totalPages}> + <span class="icon-base icon-[material-symbols--keyboard-double-arrow-right]" /> + </Target> + </div> +{/if} diff --git a/frontend/src/lib/pagination/Target.svelte b/frontend/src/lib/pagination/Target.svelte new file mode 100644 index 0000000..9044bb9 --- /dev/null +++ b/frontend/src/lib/pagination/Target.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { page as pageStore } from '$app/stores'; + import { navigate } from '$lib/Navigation'; + + export let active = false; + + export let disabled = false; + export let page: number; +</script> + +<button + on:click={() => { + navigate({ pagination: { page: page } }, $pageStore.url.searchParams); + }} + class:bg-slate-700={active} + class:bg-slate-800={!active} + class="flex h-8 w-8 items-center justify-center rounded-sm p-0 text-base hover:text-white disabled:text-slate-600" + {disabled} +> + <slot /> +</button> diff --git a/frontend/src/lib/pills/AssociationPill.svelte b/frontend/src/lib/pills/AssociationPill.svelte new file mode 100644 index 0000000..85dbe39 --- /dev/null +++ b/frontend/src/lib/pills/AssociationPill.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import Pill from './Pill.svelte'; + + type Association = 'artist' | 'circle' | 'world' | 'character'; + + export let name: string; + export let type: Association; +</script> + +<Pill {name}> + <span class={`${type} icon-xs`} slot="icon" /> +</Pill> + +<style lang="postcss"> + .artist { + @apply icon-[material-symbols--person] -mx-px; + } + + .character { + @apply icon-[material-symbols--face]; + } + + .circle { + @apply icon-[material-symbols--group] mx-px; + } + + .world { + @apply icon-[material-symbols--public]; + } +</style> diff --git a/frontend/src/lib/pills/ComicPills.svelte b/frontend/src/lib/pills/ComicPills.svelte new file mode 100644 index 0000000..671bbf2 --- /dev/null +++ b/frontend/src/lib/pills/ComicPills.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { ComicFragment } from '$gql/graphql'; + import AssociationPill from '$lib/pills/AssociationPill.svelte'; + import TagPill from '$lib/pills/TagPill.svelte'; + + export let comic: ComicFragment; +</script> + +<div class="flex flex-col gap-1"> + {#if comic.artists.length || comic.circles.length} + <div class="flex flex-wrap gap-1"> + {#each comic.artists as { name } (name)} + <AssociationPill {name} type="artist" /> + {/each} + {#each comic.circles as { name } (name)} + <AssociationPill {name} type="circle" /> + {/each} + </div> + {/if} + {#if comic.characters.length || comic.worlds.length} + <div class="flex flex-wrap gap-1"> + {#each comic.worlds as { name } (name)} + <AssociationPill {name} type="world" /> + {/each} + {#each comic.characters as { name } (name)} + <AssociationPill {name} type="character" /> + {/each} + </div> + {/if} + {#if comic.tags.length} + <div class="flex flex-wrap gap-1"> + {#each comic.tags as { name, description } (name)} + <TagPill {name} {description} /> + {/each} + </div> + {/if} +</div> diff --git a/frontend/src/lib/pills/Pill.svelte b/frontend/src/lib/pills/Pill.svelte new file mode 100644 index 0000000..7aa9670 --- /dev/null +++ b/frontend/src/lib/pills/Pill.svelte @@ -0,0 +1,40 @@ +<script lang="ts" context="module"> + export type PillColour = 'pink' | 'blue' | 'violet' | 'amber' | 'zinc' | 'sky'; +</script> + +<script lang="ts"> + export let name: string; + export let tooltip: string | null | undefined = undefined; + export let colour: PillColour = 'zinc'; +</script> + +<div class="flex items-center rounded border p-0.5 {colour}" title={tooltip}> + <slot name="icon" /> + <span>{name}</span> +</div> + +<style lang="postcss"> + .pink { + @apply border-pink-800 bg-pink-800/20 text-pink-200; + } + + .blue { + @apply border-blue-800 bg-blue-800/20 text-blue-200; + } + + .violet { + @apply border-violet-800 bg-violet-800/20 text-violet-200; + } + + .amber { + @apply border-amber-800 bg-amber-800/20 text-amber-200; + } + + .sky { + @apply border-sky-800 bg-sky-800/20 text-sky-200; + } + + .zinc { + @apply border-zinc-700 bg-zinc-700/20 text-zinc-300; + } +</style> diff --git a/frontend/src/lib/pills/TagPill.svelte b/frontend/src/lib/pills/TagPill.svelte new file mode 100644 index 0000000..60221bd --- /dev/null +++ b/frontend/src/lib/pills/TagPill.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import Female from '$lib/icons/Female.svelte'; + import Location from '$lib/icons/Location.svelte'; + import Male from '$lib/icons/Male.svelte'; + import Transgender from '$lib/icons/Transgender.svelte'; + import { SvelteComponent } from 'svelte'; + import Pill, { type PillColour } from './Pill.svelte'; + + export let name: string; + export let description: string | undefined | null = undefined; + + let [namespace, tag] = name.split(':'); + + const styles: Record<string, PillColour> = { + female: 'pink', + male: 'blue', + trans: 'violet', + mixed: 'amber', + location: 'sky', + rest: 'zinc' + }; + + const icons: Record<string, typeof SvelteComponent<Record<string, unknown>>> = { + female: Female, + male: Male, + trans: Transgender, + location: Location + }; + + const colour = styles[namespace] ?? styles.rest; + const icon = icons[namespace]; + + function formatTooltip() { + return [name, description].filter((v) => v).join('\n\n'); + } +</script> + +<Pill name={tag} tooltip={formatTooltip()} {colour}> + <svelte:component this={icon} slot="icon" /> +</Pill> diff --git a/frontend/src/lib/reader/PageView.svelte b/frontend/src/lib/reader/PageView.svelte new file mode 100644 index 0000000..cc4d10e --- /dev/null +++ b/frontend/src/lib/reader/PageView.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { Direction, Layout, type PageFragment } from '$gql/graphql'; + import { getReaderContext, partition, type Chunk } from '$lib/Reader'; + import { binds } from '$lib/Shortcuts'; + import ReaderPage from './ReaderPage.svelte'; + + const reader = getReaderContext(); + + export let direction: Direction; + export let layout: Layout; + + let chunks: Chunk[] = []; + let lookup: number[] = []; + + let main: PageFragment; + let secondary: PageFragment | undefined; + + function gotoChunk(to: number) { + if (to < 0 || to >= chunks.length) return; + + $reader.page = chunks[to].index; + } + + const next = () => gotoChunk(lookup[$reader.page] + 1); + const prev = () => gotoChunk(lookup[$reader.page] - 1); + + const clickLeft = () => (direction === Direction.LeftToRight ? prev() : next()); + const clickRight = () => (direction === Direction.RightToLeft ? prev() : next()); + + function clickMain(event: MouseEvent & { currentTarget: EventTarget | null }) { + if (event.currentTarget instanceof Element) { + const rect = event.currentTarget.getBoundingClientRect(); + + if (event.clientX - rect.left < rect.width / 2) { + clickLeft(); + } else { + clickRight(); + } + } + } + + $: [chunks, lookup] = partition($reader.pages, layout); + $: layout, ({ main, secondary } = chunks[lookup[$reader.page]]); +</script> + +<svelte:document + use:binds={[ + ['ArrowLeft', clickLeft], + ['ArrowRight', clickRight], + ['ArrowUp', prev], + ['ArrowDown', next], + ['PageUp', prev], + ['PageDown', next], + [' ', next], + ['Backspace', prev] + ]} +/> + +{#if !secondary} + <ReaderPage page={main} on:click={clickMain} --justify="center" /> +{:else if direction === Direction.LeftToRight} + <ReaderPage page={main} on:click={prev} --justify="flex-end" /> + <ReaderPage page={secondary} on:click={next} --justify="flex-start" /> +{:else} + <ReaderPage page={secondary} on:click={next} --justify="flex-end" /> + <ReaderPage page={main} on:click={prev} --justify="flex-start" /> +{/if} diff --git a/frontend/src/lib/reader/Reader.svelte b/frontend/src/lib/reader/Reader.svelte new file mode 100644 index 0000000..0b1450a --- /dev/null +++ b/frontend/src/lib/reader/Reader.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import { trapFocus } from '$lib/Actions'; + import { getReaderContext } from '$lib/Reader'; + import { fadeDefault, slideXDefault } from '$lib/Transitions'; + import { fade, slide } from 'svelte/transition'; + import CloseReaderButton from './components/CloseReaderButton.svelte'; + import ReaderMenuButton from './components/ReaderMenuButton.svelte'; + + const reader = getReaderContext(); +</script> + +{#if $reader.visible} + <div + role="dialog" + class="fixed bottom-0 left-0 right-0 top-0 z-10 flex h-full w-full bg-black" + transition:fade={fadeDefault} + use:trapFocus + > + {#if $$slots.sidebar && $reader.sidebar} + <aside class="w-[36rem] shrink-0 bg-slate-800" transition:slide={slideXDefault}> + <div class="flex h-full min-w-[36rem] flex-col gap-4 overflow-auto p-4"> + <slot name="sidebar" /> + </div> + </aside> + {/if} + <main class="relative flex grow"> + <div class="absolute flex w-full p-1 text-lg [&>*:last-child]:ml-auto"> + {#if $$slots.sidebar} + <ReaderMenuButton /> + {/if} + <CloseReaderButton /> + </div> + + <div class="flex grow"> + <slot /> + </div> + </main> + </div> +{/if} diff --git a/frontend/src/lib/reader/ReaderPage.svelte b/frontend/src/lib/reader/ReaderPage.svelte new file mode 100644 index 0000000..fb3e780 --- /dev/null +++ b/frontend/src/lib/reader/ReaderPage.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import type { PageFragment } from '$gql/graphql'; + import { src } from '$lib/Utils'; + + export let page: PageFragment; +</script> + +<!-- svelte-ignore a11y-click-events-have-key-events --> +<!-- svelte-ignore a11y-no-static-element-interactions --> +<div class="flex grow" on:click> + <img + class="h-auto w-auto object-contain" + width={page.image.width} + height={page.image.height} + src={src(page.image, 'full')} + alt={page.path} + /> +</div> + +<style> + div { + justify-content: var(--justify); + } +</style> diff --git a/frontend/src/lib/reader/components/CloseReaderButton.svelte b/frontend/src/lib/reader/components/CloseReaderButton.svelte new file mode 100644 index 0000000..0c88323 --- /dev/null +++ b/frontend/src/lib/reader/components/CloseReaderButton.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { getReaderContext } from '$lib/Reader'; + import { accelerator } from '$lib/Shortcuts'; + + const reader = getReaderContext(); +</script> + +<button + type="button" + class="btn floating" + title="Close reader" + on:click={() => { + $reader.visible = false; + $reader.sidebar = false; + }} + use:accelerator={'Escape'} +> + <span class="icon-lg icon-[material-symbols--close]" /> +</button> diff --git a/frontend/src/lib/reader/components/ReaderMenuButton.svelte b/frontend/src/lib/reader/components/ReaderMenuButton.svelte new file mode 100644 index 0000000..aa20206 --- /dev/null +++ b/frontend/src/lib/reader/components/ReaderMenuButton.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import { getReaderContext } from '$lib/Reader'; + import { accelerator } from '$lib/Shortcuts'; + + const reader = getReaderContext(); +</script> + +<button + type="button" + class="btn floating invisible xl:visible" + title={`${$reader.sidebar ? 'Hide' : 'Show'} menu`} + on:click={() => ($reader.sidebar = !$reader.sidebar)} + use:accelerator={'z'} +> + <span class="icon-lg icon-[material-symbols--dock-to-right]" /> +</button> diff --git a/frontend/src/lib/scraper/ComicScrapeForm.svelte b/frontend/src/lib/scraper/ComicScrapeForm.svelte new file mode 100644 index 0000000..30ad89b --- /dev/null +++ b/frontend/src/lib/scraper/ComicScrapeForm.svelte @@ -0,0 +1,138 @@ +<script lang="ts"> + import { upsertComics } from '$gql/Mutations'; + import { comicScrapersQuery, scrapeComic } from '$gql/Queries'; + import { isError } from '$gql/Utils'; + import { OnMissing, type FullComicFragment } from '$gql/graphql'; + import { ScrapedComicSelector, getScraperContext } from '$lib/Scraper'; + import { toastError, toastFinally } from '$lib/Toasts'; + import Select from '$lib/components/Select.svelte'; + import Spinner from '$lib/components/Spinner.svelte'; + import { getContextClient } from '@urql/svelte'; + import SelectorGroup from './components/SelectorGroup.svelte'; + import SelectorItem from './components/SelectorItem.svelte'; + + let client = getContextClient(); + const context = getScraperContext(); + + export let comic: FullComicFragment; + let createMissing = false; + let loading = false; + + $: scrapersResult = comicScrapersQuery(client, { id: comic.id }); + $: scrapers = $scrapersResult.data?.comicScrapers; + + function scrape() { + loading = true; + scrapeComic(client, { id: comic.id, scraper: $context.scraper }) + .then((result) => { + if (result.error) { + toastError(result.error.message); + return; + } + + if (result.data) { + if (isError(result.data.scrapeComic)) { + toastError(result.data.scrapeComic.message); + return; + } + + if (result.data.scrapeComic.__typename === 'ScrapeComicResult') { + $context.selector = new ScrapedComicSelector(result.data.scrapeComic.data, comic); + $context.warnings = result.data.scrapeComic.warnings; + } + } + }) + .catch(toastFinally) + .finally(() => (loading = false)); + } + + function updateFromScrape(createMissing: boolean) { + if (!$context.selector) return; + + upsertComics(client, { + ids: comic.id, + input: $context.selector.toInput(createMissing ? OnMissing.Create : OnMissing.Ignore) + }) + .then(() => { + $context.selector = undefined; + $context.warnings = []; + }) + .catch(toastFinally); + } +</script> + +<div class="flex flex-col gap-4 text-sm"> + {#if scrapers && scrapers.length === 0} + <h2 class="text-base">No scrapers available.</h2> + {:else} + <form on:submit|preventDefault={scrape}> + <div class="grid grid-cols-6 gap-2"> + <div class="col-span-5"> + <Select + id="scrapers" + options={scrapers} + placeholder={'Select scraper...'} + bind:value={$context.scraper} + /> + </div> + <button type="submit" disabled={!$context.scraper} class="btn-blue">Scrape</button> + </div> + </form> + {/if} + + {#if loading} + <Spinner /> + {:else if $context.selector} + {#if $context.warnings.length > 0} + <div class="flex flex-col gap-2"> + <h2 class="flex gap-1 border-b border-slate-700 text-base font-medium">Warnings</h2> + <ul class="ml-2 list-inside list-disc"> + {#each $context.warnings as warning} + <li>{warning}</li> + {/each} + </ul> + </div> + {/if} + {#if !$context.selector.hasData()} + <h2 class="text-base">No data to merge.</h2> + {:else} + <div class="flex flex-col gap-2"> + <h2 class="border-b border-slate-700 text-base font-medium">Results</h2> + <form on:submit|preventDefault={() => updateFromScrape(createMissing)}> + <div class="grid grid-cols-6 gap-4 pb-2"> + <SelectorItem title="Title" selector={$context.selector.title} /> + <SelectorItem title="Original Title" selector={$context.selector.originalTitle} /> + <SelectorItem title="URL" selector={$context.selector.url} /> + <SelectorItem title="Date" selector={$context.selector.date} --span="2" /> + <SelectorItem title="Category" selector={$context.selector.category} --span="2" /> + <SelectorItem title="Language" selector={$context.selector.language} --span="2" /> + <SelectorItem title="Rating" selector={$context.selector.rating} --span="2" /> + <SelectorItem title="Censorship" selector={$context.selector.censorship} --span="2" /> + <SelectorItem title="Direction" selector={$context.selector.direction} --span="2" /> + <SelectorItem title="Layout" selector={$context.selector.layout} --span="2" /> + <SelectorGroup title="Artists" selectors={$context.selector.artists} /> + <SelectorGroup title="Circles" selectors={$context.selector.circles} /> + <SelectorGroup title="Characters" selectors={$context.selector.characters} /> + <SelectorGroup title="Worlds" selectors={$context.selector.worlds} /> + <SelectorGroup title="Tags" selectors={$context.selector.tags} /> + </div> + <div class="flex flex-col gap-2"> + <h2 class="border-b border-slate-700 text-base font-medium">Options</h2> + <div class="flex items-center gap-1"> + <input + class="h-4 w-4" + type="checkbox" + id="create-missing" + bind:checked={createMissing} + /> + <label class="shrink-0" for="create-missing">Create missing items</label> + </div> + </div> + <div class="flex gap-4"> + <button type="submit" class="btn-blue">Merge</button> + </div> + </form> + </div> + {/if} + {/if} +</div> diff --git a/frontend/src/lib/scraper/components/SelectorButton.svelte b/frontend/src/lib/scraper/components/SelectorButton.svelte new file mode 100644 index 0000000..b786f89 --- /dev/null +++ b/frontend/src/lib/scraper/components/SelectorButton.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import { Selector } from '$lib/Scraper'; + + export let selector: Selector<string>; +</script> + +<button + type="button" + class="ml-1 flex rounded-sm border-slate-700 bg-slate-900 hover:brightness-110" + on:click={() => (selector.keep = !selector.keep)} +> + <div class="flex self-center pl-1"> + {#if selector.keep} + <span class="icon-base icon-[material-symbols--check] text-green-400" /> + {:else} + <span class="icon-base icon-[material-symbols--close] text-red-400" /> + {/if} + </div> + <p class:opacity-50={!selector.keep} class="p-1 text-left"> + {selector} + </p> +</button> diff --git a/frontend/src/lib/scraper/components/SelectorGroup.svelte b/frontend/src/lib/scraper/components/SelectorGroup.svelte new file mode 100644 index 0000000..ae7287a --- /dev/null +++ b/frontend/src/lib/scraper/components/SelectorGroup.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import { Selector } from '$lib/Scraper'; + import SelectorButton from './SelectorButton.svelte'; + + export let title: string; + export let selectors: Selector<string>[]; + + function invert() { + for (let selector of selectors) { + selector.keep = !selector.keep; + } + selectors = selectors; + } +</script> + +{#if selectors.length > 0} + <div class="group col-span-6 flex flex-col gap-1"> + <div class="flex gap-2"> + <h2>{title}</h2> + <button + type="button" + class="flex items-end opacity-0 brightness-75 transition-opacity hover:brightness-110 group-hover:opacity-100" + on:click={invert} + title="Invert selection" + > + <span class="icon-xs icon-[material-symbols--compare-arrows]"></span> + </button> + </div> + <div class="flex flex-wrap gap-y-1"> + {#each selectors as selector} + <SelectorButton {selector} /> + {/each} + </div> + </div> +{/if} diff --git a/frontend/src/lib/scraper/components/SelectorItem.svelte b/frontend/src/lib/scraper/components/SelectorItem.svelte new file mode 100644 index 0000000..dd3f5b4 --- /dev/null +++ b/frontend/src/lib/scraper/components/SelectorItem.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { Selector } from '$lib/Scraper'; + import SelectorButton from './SelectorButton.svelte'; + + export let title: string; + export let selector: Selector<string> | undefined; +</script> + +{#if selector} + <div class="flex flex-col gap-1"> + <h2>{title}</h2> + <SelectorButton {selector} /> + </div> +{/if} + +<style> + :root { + --span: 6; + } + + div { + grid-column: span var(--span) / span var(--span); + } +</style> diff --git a/frontend/src/lib/selection/Selectable.svelte b/frontend/src/lib/selection/Selectable.svelte new file mode 100644 index 0000000..48b6ac7 --- /dev/null +++ b/frontend/src/lib/selection/Selectable.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { getSelectionContext } from '$lib/Selection'; + + export let id: number; + export let index: number; + + export let edit: ((id: number) => void) | undefined = undefined; + + const selection = getSelectionContext(); + + $: selected = $selection.contains(id); + + const handle = (event: MouseEvent) => { + if ($selection.active) { + $selection = $selection.update(index, event.shiftKey); + event.preventDefault(); + } else if (edit) { + edit(id); + event.preventDefault(); + } + }; +</script> + +<slot {handle} {selected} /> diff --git a/frontend/src/lib/selection/SelectionOverlay.svelte b/frontend/src/lib/selection/SelectionOverlay.svelte new file mode 100644 index 0000000..04ff382 --- /dev/null +++ b/frontend/src/lib/selection/SelectionOverlay.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + export let selected: boolean; + export let position: 'top' | 'right' | 'left' | 'bottom'; + export let centered = false; +</script> + +{#if selected} + <div + class:items-center={centered} + class="{position} pointer-events-none absolute z-[1] flex bg-emerald-700/95" + > + <span class="icon-base icon-[material-symbols--check] text-[2rem]" /> + </div> +{/if} + +<style lang="postcss"> + .top, + .bottom { + width: 100%; + } + + .left, + .right { + height: 100%; + } + + .bottom { + bottom: 0; + } + + .right { + right: 0; + } +</style> diff --git a/frontend/src/lib/tabs/AddOverlay.svelte b/frontend/src/lib/tabs/AddOverlay.svelte new file mode 100644 index 0000000..b1c98bf --- /dev/null +++ b/frontend/src/lib/tabs/AddOverlay.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import { updateComics } from '$gql/Mutations'; + import { UpdateMode } from '$gql/graphql'; + import { getSelectionContext } from '$lib/Selection'; + import { toastFinally } from '$lib/Toasts'; + import { fadeDefault } from '$lib/Transitions'; + import { getContextClient } from '@urql/svelte'; + import { fade } from 'svelte/transition'; + + const client = getContextClient(); + const selection = getSelectionContext(); + + export let id: number; + + function addPages() { + updateComics(client, { + ids: id, + input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Add } } } + }) + .then(() => ($selection = $selection.none())) + .catch(toastFinally); + } +</script> + +{#if $selection.size > 0} + <div class="absolute left-1 top-1" transition:fade={fadeDefault}> + <button + type="button" + class="btn-blue rounded-full shadow-sm shadow-black" + title="Add to this comic" + on:click|preventDefault={addPages} + > + <span class="icon-base icon-[material-symbols--note-add]" /> + </button> + </div> +{/if} diff --git a/frontend/src/lib/tabs/ArchiveDelete.svelte b/frontend/src/lib/tabs/ArchiveDelete.svelte new file mode 100644 index 0000000..b0e3c58 --- /dev/null +++ b/frontend/src/lib/tabs/ArchiveDelete.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { goto } from '$app/navigation'; + import { deleteArchives } from '$gql/Mutations'; + import type { FullArchiveFragment } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import { getContextClient } from '@urql/svelte'; + + const client = getContextClient(); + + export let archive: FullArchiveFragment; + + function deleteArchive() { + confirmDeletion('Archive', archive.name, () => { + deleteArchives(client, { ids: archive.id }) + .then(() => goto('/archives/')) + .catch(toastFinally); + }); + } +</script> + +<div class="flex flex-col gap-2"> + <div> + <p> + Deleting this archive will remove the + <span class="cursor-help font-medium underline" title={archive.path}>archive file</span> on disk. + </p> + {#if archive.comics.length > 0} + <p>The following comics will also be deleted:</p> + <ul class="ml-8 list-disc"> + {#each archive.comics as comic} + <li><a href="/comics/{comic.id}" class="underline">{comic.title}</a></li> + {/each} + </ul> + {/if} + <p class="mt-2 font-medium">This action is irrevocable.</p> + </div> + <div class="flex"> + <DeleteButton prominent on:click={deleteArchive} /> + </div> +</div> diff --git a/frontend/src/lib/tabs/ArchiveDetails.svelte b/frontend/src/lib/tabs/ArchiveDetails.svelte new file mode 100644 index 0000000..9554557 --- /dev/null +++ b/frontend/src/lib/tabs/ArchiveDetails.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import type { FullArchiveFragment } from '$gql/graphql'; + import { formatListSize, joinText } from '$lib/Utils'; + import Card, { comicCard } from '$lib/components/Card.svelte'; + import ComicPills from '$lib/pills/ComicPills.svelte'; + import { formatDistance, formatISO9075 } from 'date-fns'; + import { filesize } from 'filesize'; + import Header from './DetailsHeader.svelte'; + import Section from './DetailsSection.svelte'; + + export let archive: FullArchiveFragment; + + const now = Date.now(); + const modifiedDate = new Date(archive.mtime); + const createdDate = new Date(archive.createdAt); + + const title = joinText(['Archive', formatListSize('image', archive.pageCount)]); +</script> + +<div class="flex flex-col gap-4 text-sm"> + <Header {title} /> + <div class="grid grid-cols-3 gap-4"> + <Section title="Size"> + <span>{filesize(archive.size, { base: 2 })}</span> + </Section> + <Section title="Created"> + <span title={formatISO9075(createdDate)}> + {formatDistance(createdDate, now, { addSuffix: true })} + </span> + </Section> + <Section title="File last modified"> + <span title={formatISO9075(modifiedDate)}> + {formatDistance(modifiedDate, now, { addSuffix: true })} + </span> + </Section> + </div> + + {#if archive.comics.length > 0} + <div class="flex flex-col gap-1"> + <h2 class="text-base font-medium">Comics</h2> + <div class="flex shrink-0 flex-col gap-4"> + {#each archive.comics as comic} + <Card compact {...comicCard(comic)}> + <ComicPills {comic} /> + </Card> + {/each} + </div> + </div> + {/if} +</div> diff --git a/frontend/src/lib/tabs/ArchiveEdit.svelte b/frontend/src/lib/tabs/ArchiveEdit.svelte new file mode 100644 index 0000000..80efaed --- /dev/null +++ b/frontend/src/lib/tabs/ArchiveEdit.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import { addComic, updateArchives } from '$gql/Mutations'; + import { type FullArchiveFragment } from '$gql/graphql'; + import { getSelectionContext } from '$lib/Selection'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Card, { comicCard } from '$lib/components/Card.svelte'; + import OrganizedButton from '$lib/components/OrganizedButton.svelte'; + import ComicPills from '$lib/pills/ComicPills.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import { getContextClient } from '@urql/svelte'; + import AddOverlay from './AddOverlay.svelte'; + + const client = getContextClient(); + const selection = getSelectionContext(); + + export let archive: FullArchiveFragment; + + function addNew() { + addComic(client, { + input: { + archive: { id: archive.id }, + title: archive.name, + pages: { ids: $selection.ids }, + cover: { id: archive.pages[$selection.indices.toSorted((a, b) => a - b)[0]].id } + } + }) + .then((mutatation) => { + const data = mutatation.addComic; + if (data.__typename === 'AddComicSuccess' && !data.archivePagesRemaining) { + $selection = $selection.clear(); + } else { + $selection = $selection.none(); + } + }) + .catch(toastFinally); + } + + function toggleOrganized() { + updateArchives(client, { ids: archive.id, input: { organized: !archive.organized } }).catch( + toastFinally + ); + } +</script> + +<div class="flex flex-col gap-4"> + <div class="flex gap-2 text-sm"> + <SelectionControls page> + <AddButton title="Add Comic from selected" on:click={addNew} /> + </SelectionControls> + <div class="grow" /> + <OrganizedButton organized={archive.organized} on:click={toggleOrganized} /> + </div> + + {#if archive.comics.length > 0} + <div class="flex flex-col gap-1"> + <h2 class="text-base font-medium">Comics</h2> + <div class="flex shrink-0 flex-col gap-4"> + {#each archive.comics as comic} + <Card compact {...comicCard(comic)}> + <AddOverlay slot="overlay" id={comic.id} /> + <ComicPills {comic} /> + </Card> + {/each} + </div> + </div> + {/if} +</div> diff --git a/frontend/src/lib/tabs/ComicDelete.svelte b/frontend/src/lib/tabs/ComicDelete.svelte new file mode 100644 index 0000000..a10f6b2 --- /dev/null +++ b/frontend/src/lib/tabs/ComicDelete.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import { goto } from '$app/navigation'; + import { deleteComics } from '$gql/Mutations'; + import type { FullComicFragment } from '$gql/graphql'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import { getContextClient } from '@urql/svelte'; + + const client = getContextClient(); + + export let comic: FullComicFragment; + + function deleteComic() { + confirmDeletion('Comic', comic.title, () => { + deleteComics(client, { ids: comic.id }) + .then(() => goto('/comics/')) + .catch(toastFinally); + }); + } +</script> + +<div class="flex flex-col gap-2"> + <div> + <p> + Deleting this comic will make all of its pages available again for allocation. All of its + metadata will be lost. + </p> + <p class="mt-2 font-medium">This action is irrevocable.</p> + </div> + <div class="flex"> + <DeleteButton prominent on:click={deleteComic} /> + </div> +</div> diff --git a/frontend/src/lib/tabs/ComicDetails.svelte b/frontend/src/lib/tabs/ComicDetails.svelte new file mode 100644 index 0000000..0a131af --- /dev/null +++ b/frontend/src/lib/tabs/ComicDetails.svelte @@ -0,0 +1,121 @@ +<script lang="ts"> + import type { ComicFilter, FullComicFragment } from '$gql/graphql'; + import { CategoryLabel, CensorshipLabel, LanguageLabel, RatingLabel } from '$lib/Enums'; + import { href } from '$lib/Navigation'; + import { formatListSize, joinText } from '$lib/Utils'; + import AssociationPill from '$lib/pills/AssociationPill.svelte'; + import TagPill from '$lib/pills/TagPill.svelte'; + import { formatDistance, formatISO9075 } from 'date-fns'; + import Header from './DetailsHeader.svelte'; + import Section from './DetailsSection.svelte'; + + export let comic: FullComicFragment; + + const now = Date.now(); + const updatedDate = new Date(comic.updatedAt); + const createdDate = new Date(comic.createdAt); + + const title = joinText([ + comic.category ? CategoryLabel[comic.category] : '', + formatListSize('page', comic.pages.length) + ]); + + function filterFor(filter: keyof ComicFilter, id: number | string) { + return href('comics', { filter: { include: { [filter]: { all: [id] } } } }); + } +</script> + +<div class="flex flex-col gap-4 text-sm"> + <Header {title}> + {#if comic.url} + <a href={comic.url} target="_blank" rel="noreferrer" class="btn-slate" title="Open URL"> + <span class="icon-base icon-[material-symbols--link]" /> + </a> + {/if} + <a href={`/archives/${comic.archive.id}`} class="btn-slate" title="Go to Archive"> + <span class="icon-base icon-[material-symbols--folder-zip]" /> + </a> + </Header> + + <div class="grid grid-cols-3 gap-4"> + {#if comic.language} + <Section title="Language"> + <span>{LanguageLabel[comic.language]}</span> + </Section> + {/if} + {#if comic.censorship} + <Section title="Censorship"> + <span>{CensorshipLabel[comic.censorship]}</span> + </Section> + {/if} + {#if comic.rating} + <Section title="Rating"> + <span>{RatingLabel[comic.rating]}</span> + </Section> + {/if} + </div> + + <div class="grid grid-cols-3 gap-4"> + {#if comic.date} + <Section title="Released"> + <span>{formatISO9075(new Date(comic.date), { representation: 'date' })}</span> + </Section> + {/if} + <Section title="Created"> + <span title={formatISO9075(createdDate)}> + {formatDistance(createdDate, now, { addSuffix: true })} + </span> + </Section> + <Section title="Updated"> + <span title={formatISO9075(updatedDate)}> + {formatDistance(updatedDate, now, { addSuffix: true })} + </span> + </Section> + </div> + + {#if comic.artists.length} + <Section title="Artists"> + {#each comic.artists as { id, name } (id)} + <a href={filterFor('artists', id)}> + <AssociationPill {name} type="artist" /> + </a> + {/each} + </Section> + {/if} + {#if comic.circles.length} + <Section title="Circles"> + {#each comic.circles as { id, name } (id)} + <a href={filterFor('circles', id)}> + <AssociationPill {name} type="circle" /> + </a> + {/each} + </Section> + {/if} + {#if comic.characters.length} + <Section title="Characters"> + {#each comic.characters as { id, name } (id)} + <a href={filterFor('characters', id)}> + <AssociationPill {name} type="character" /> + </a> + {/each} + </Section> + {/if} + {#if comic.worlds.length} + <Section title="Worlds"> + {#each comic.worlds as { id, name } (id)} + <a href={filterFor('worlds', id)}> + <AssociationPill {name} type="world" /> + </a> + {/each} + </Section> + {/if} + {#if comic.tags.length} + <Section title="Tags"> + {#each comic.tags as { id, name, description } (id)} + <a href={filterFor('tags', id)}> + <TagPill {name} {description} /> + </a> + {/each} + </Section> + {/if} +</div> diff --git a/frontend/src/lib/tabs/DetailsHeader.svelte b/frontend/src/lib/tabs/DetailsHeader.svelte new file mode 100644 index 0000000..f980f75 --- /dev/null +++ b/frontend/src/lib/tabs/DetailsHeader.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + export let title: string; +</script> + +<div class="flex items-center gap-2"> + <h2 class="flex text-base"> + {title} + </h2> + <div class="grow"></div> + <slot /> +</div> diff --git a/frontend/src/lib/tabs/DetailsSection.svelte b/frontend/src/lib/tabs/DetailsSection.svelte new file mode 100644 index 0000000..9a6ad51 --- /dev/null +++ b/frontend/src/lib/tabs/DetailsSection.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + export let title: string; +</script> + +<section class="flex flex-col gap-1"> + <h2 class="text-base font-medium">{title}</h2> + <div class="flex flex-wrap gap-1 text-gray-300"> + <slot /> + </div> +</section> diff --git a/frontend/src/lib/tabs/Tab.svelte b/frontend/src/lib/tabs/Tab.svelte new file mode 100644 index 0000000..0a6be57 --- /dev/null +++ b/frontend/src/lib/tabs/Tab.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import { getTabContext } from '$lib/Tabs'; + import { fadeDefault } from '$lib/Transitions'; + import { fade } from 'svelte/transition'; + + const context = getTabContext(); + export let id: string; +</script> + +{#if $context.current === id} + <div class="h-full overflow-auto py-2 pe-3" in:fade={fadeDefault}> + <slot /> + </div> +{/if} diff --git a/frontend/src/lib/tabs/Tabs.svelte b/frontend/src/lib/tabs/Tabs.svelte new file mode 100644 index 0000000..09cdbdd --- /dev/null +++ b/frontend/src/lib/tabs/Tabs.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { getTabContext } from '$lib/Tabs'; + import { fadeFast } from '$lib/Transitions'; + import { fade } from 'svelte/transition'; + + const context = getTabContext(); +</script> + +<div class="flex h-full max-h-full flex-col"> + <nav> + <ul class="me-3 flex border-b-2 border-slate-700 text-sm"> + {#each Object.entries($context.tabs) as [id, { title, badge }]} + <li class="-mb-0.5"> + <button + type="button" + class:active={$context.current === id} + class="relative flex gap-1 p-1 px-3 hover:border-b-2 hover:border-slate-200" + on:click={() => ($context.current = id)} + > + {#if badge} + <div + class="absolute right-0 top-1 h-2 w-2 rounded-full bg-emerald-400" + title="There are pending changes" + transition:fade={fadeFast} + /> + {/if} + <span>{title}</span> + </button> + </li> + {/each} + </ul> + </nav> + <slot /> +</div> + +<style lang="postcss"> + button.active { + @apply border-b-2 border-indigo-500; + } +</style> diff --git a/frontend/src/lib/toolbar/DeleteSelection.svelte b/frontend/src/lib/toolbar/DeleteSelection.svelte new file mode 100644 index 0000000..7459a87 --- /dev/null +++ b/frontend/src/lib/toolbar/DeleteSelection.svelte @@ -0,0 +1,26 @@ +<script lang="ts"> + import type { DeleteMutation } from '$gql/Mutations'; + import { getSelectionContext } from '$lib/Selection'; + import { toastFinally } from '$lib/Toasts'; + import { confirmDeletion } from '$lib/Utils'; + import DeleteButton from '$lib/components/DeleteButton.svelte'; + import { getContextClient } from '@urql/svelte'; + + const client = getContextClient(); + const selection = getSelectionContext(); + + export let mutation: DeleteMutation; + export let warning: string | undefined = undefined; + + function remove() { + const mutate = () => { + mutation(client, { ids: $selection.ids }) + .then(() => ($selection = $selection.clear())) + .catch(toastFinally); + }; + + confirmDeletion($selection.typename, $selection.names, mutate, warning); + } +</script> + +<DeleteButton on:click={remove} /> diff --git a/frontend/src/lib/toolbar/EditSelection.svelte b/frontend/src/lib/toolbar/EditSelection.svelte new file mode 100644 index 0000000..50e6656 --- /dev/null +++ b/frontend/src/lib/toolbar/EditSelection.svelte @@ -0,0 +1,29 @@ +<script lang="ts"> + import { getSelectionContext } from '$lib/Selection'; + import { accelerator } from '$lib/Shortcuts'; + import type { SvelteComponent } from 'svelte'; + import { openModal } from 'svelte-modals'; + + const selection = getSelectionContext(); + + export let dialog: typeof SvelteComponent<{ + isOpen: boolean; + ids: number[]; + }>; + + function edit() { + openModal(dialog, { + ids: $selection.ids + }); + } +</script> + +<button + type="button" + class="btn-slate hover:bg-blue-700" + title="Edit selection" + on:click={edit} + use:accelerator={'e'} +> + <span class="icon-base icon-[material-symbols--edit]" /> +</button> diff --git a/frontend/src/lib/toolbar/FilterBookmarked.svelte b/frontend/src/lib/toolbar/FilterBookmarked.svelte new file mode 100644 index 0000000..bcbe295 --- /dev/null +++ b/frontend/src/lib/toolbar/FilterBookmarked.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter'; + import { accelerator } from '$lib/Shortcuts'; + import Bookmark from '$lib/icons/Bookmark.svelte'; + + const filter = getFilterContext<ComicFilterContext>(); + $: bookmarked = $filter.include.controls.bookmarked.value; + + const toggle = () => { + $filter.include.controls.bookmarked.value = cycleBooleanFilter(bookmarked, false); + $filter.apply($page.url.searchParams); + }; +</script> + +<button + class:toggled={bookmarked} + class="btn-slate" + title="Filter bookmarked" + on:click={toggle} + use:accelerator={'b'} +> + <Bookmark {bookmarked} /> +</button> diff --git a/frontend/src/lib/toolbar/FilterFavourites.svelte b/frontend/src/lib/toolbar/FilterFavourites.svelte new file mode 100644 index 0000000..6591cef --- /dev/null +++ b/frontend/src/lib/toolbar/FilterFavourites.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { ComicFilterContext, cycleBooleanFilter, getFilterContext } from '$lib/Filter'; + import { accelerator } from '$lib/Shortcuts'; + import Star from '$lib/icons/Star.svelte'; + + const filter = getFilterContext<ComicFilterContext>(); + $: favourite = $filter.include.controls.favourite.value; + + const toggle = () => { + $filter.include.controls.favourite.value = cycleBooleanFilter(favourite, false); + $filter.apply($page.url.searchParams); + }; +</script> + +<button + class:toggled={favourite} + class="btn-slate" + title="Filter favourites" + on:click={toggle} + use:accelerator={'f'} +> + <Star {favourite} /> +</button> diff --git a/frontend/src/lib/toolbar/FilterOrganized.svelte b/frontend/src/lib/toolbar/FilterOrganized.svelte new file mode 100644 index 0000000..754e663 --- /dev/null +++ b/frontend/src/lib/toolbar/FilterOrganized.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { + ArchiveFilterContext, + ComicFilterContext, + cycleBooleanFilter, + getFilterContext + } from '$lib/Filter'; + import { accelerator } from '$lib/Shortcuts'; + import Organized from '$lib/icons/Organized.svelte'; + + const filter = getFilterContext<ArchiveFilterContext | ComicFilterContext>(); + $: organized = $filter.include.controls.organized.value; + + const toggle = () => { + $filter.include.controls.organized.value = cycleBooleanFilter(organized); + $filter.apply($page.url.searchParams); + }; +</script> + +<button + type="button" + class:toggled={organized !== undefined} + class="btn-slate" + title="Filter organized" + on:click={toggle} + use:accelerator={'o'} +> + <Organized tristate {organized} /> +</button> diff --git a/frontend/src/lib/toolbar/MarkBookmark.svelte b/frontend/src/lib/toolbar/MarkBookmark.svelte new file mode 100644 index 0000000..792b84f --- /dev/null +++ b/frontend/src/lib/toolbar/MarkBookmark.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { getSelectionContext } from '$lib/Selection'; + import { toastFinally } from '$lib/Toasts'; + import Bookmark from '$lib/icons/Bookmark.svelte'; + import { Client, getContextClient } from '@urql/svelte'; + + const client = getContextClient(); + const selection = getSelectionContext(); + + export let mutation: ( + client: Client, + args: { ids: number[]; input: { bookmarked: boolean } } + ) => Promise<unknown>; + + function mutate(bookmarked: boolean) { + mutation(client, { ids: $selection.ids, input: { bookmarked } }).catch(toastFinally); + } +</script> + +<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(true)}> + <Bookmark bookmarked={true} /> + <span>Bookmark</span> +</button> +<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}> + <Bookmark bookmarked={false} /> + <span>Unbookmark</span> +</button> diff --git a/frontend/src/lib/toolbar/MarkFavourite.svelte b/frontend/src/lib/toolbar/MarkFavourite.svelte new file mode 100644 index 0000000..42eaa39 --- /dev/null +++ b/frontend/src/lib/toolbar/MarkFavourite.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { getSelectionContext } from '$lib/Selection'; + import { toastFinally } from '$lib/Toasts'; + import Star from '$lib/icons/Star.svelte'; + import { Client, getContextClient } from '@urql/svelte'; + + const client = getContextClient(); + const selection = getSelectionContext(); + + export let mutation: ( + client: Client, + args: { ids: number[]; input: { favourite: boolean } } + ) => Promise<unknown>; + + function mutate(favourite: boolean) { + mutation(client, { ids: $selection.ids, input: { favourite } }).catch(toastFinally); + } +</script> + +<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(true)}> + <Star favourite={true} /> + <span>Favourite</span> +</button> +<button type="button" class="btn-slate justify-start gap-1" on:click={() => mutate(false)}> + <Star favourite={false} /> + <span>Unfavourite</span> +</button> diff --git a/frontend/src/lib/toolbar/MarkOrganized.svelte b/frontend/src/lib/toolbar/MarkOrganized.svelte new file mode 100644 index 0000000..4dc3a83 --- /dev/null +++ b/frontend/src/lib/toolbar/MarkOrganized.svelte @@ -0,0 +1,27 @@ +<script lang="ts"> + import { getSelectionContext } from '$lib/Selection'; + import { toastFinally } from '$lib/Toasts'; + import Organized from '$lib/icons/Organized.svelte'; + import { Client, getContextClient } from '@urql/svelte'; + + const client = getContextClient(); + const selection = getSelectionContext(); + + export let mutation: ( + client: Client, + args: { ids: number[]; input: { organized: boolean } } + ) => Promise<unknown>; + + function mutate(organized: boolean) { + mutation(client, { ids: $selection.ids, input: { organized } }).catch(toastFinally); + } +</script> + +<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(true)}> + <Organized tristate organized={true} /> + <span>Organized</span> +</button> +<button type="button" class="btn-slate flex justify-start gap-1" on:click={() => mutate(false)}> + <Organized dim tristate organized={false} /> + <span>Unorganized</span> +</button> diff --git a/frontend/src/lib/toolbar/MarkSelection.svelte b/frontend/src/lib/toolbar/MarkSelection.svelte new file mode 100644 index 0000000..27eb2c7 --- /dev/null +++ b/frontend/src/lib/toolbar/MarkSelection.svelte @@ -0,0 +1,24 @@ +<script lang="ts"> + import Dropdown from '$lib/components/Dropdown.svelte'; + + let visible = false; + let button: HTMLElement; +</script> + +<div class="relative"> + <button + type="button" + class="btn-slate rounded-inherit relative hover:bg-blue-700 [&:not(:only-child)]:bg-blue-700" + title="Set flag..." + bind:this={button} + on:click={() => (visible = !visible)} + > + <span class="icon-base icon-[material-symbols--flag] pointer-events-none" /> + </button> + + <Dropdown parent={button} bind:visible> + <div class="grid grid-cols-[min-content_min-content] gap-1"> + <slot /> + </div> + </Dropdown> +</div> diff --git a/frontend/src/lib/toolbar/Search.svelte b/frontend/src/lib/toolbar/Search.svelte new file mode 100644 index 0000000..f033258 --- /dev/null +++ b/frontend/src/lib/toolbar/Search.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { debounce } from '$lib/Actions'; + import { BasicFilterContext, getFilterContext } from '$lib/Filter'; + import { accelerator } from '$lib/Shortcuts'; + + const filter = getFilterContext<BasicFilterContext>(); + + export let name: string; + export let field: string; +</script> + +<input + type="text" + size={25} + class="btn-slate w-min" + placeholder="Search {name}..." + bind:value={field} + use:debounce={{ callback: () => $filter.apply($page.url.searchParams) }} + use:accelerator={'F'} +/> diff --git a/frontend/src/lib/toolbar/SelectItems.svelte b/frontend/src/lib/toolbar/SelectItems.svelte new file mode 100644 index 0000000..7ff339e --- /dev/null +++ b/frontend/src/lib/toolbar/SelectItems.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { getPaginationContext } from '$lib/Pagination'; + + const pagination = getPaginationContext(); + + $: values = new Set([24, 48, 72, 90, 120, 150, 180, $pagination.items].sort((a, b) => a - b)); +</script> + +<select + class="btn-slate" + bind:value={$pagination.items} + on:change={() => $pagination.apply($page.url.searchParams)} + title="Limit displayed items to..." +> + {#each values as value} + <option {value}>{value}</option> + {/each} +</select> diff --git a/frontend/src/lib/toolbar/SelectSort.svelte b/frontend/src/lib/toolbar/SelectSort.svelte new file mode 100644 index 0000000..fdcb057 --- /dev/null +++ b/frontend/src/lib/toolbar/SelectSort.svelte @@ -0,0 +1,61 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { SortDirection } from '$gql/graphql'; + + import { getSortContext } from '$lib/Sort'; + import { slideXFast } from '$lib/Transitions'; + import { getRandomInt } from '$lib/Utils'; + import { slide } from 'svelte/transition'; + + const sort = getSortContext(); + + function toggle() { + if ($sort.direction === SortDirection.Ascending) { + $sort.direction = SortDirection.Descending; + } else { + $sort.direction = SortDirection.Ascending; + } + + apply(); + } + + function apply() { + if ($sort.on === 'RANDOM' && $sort.seed === undefined) { + $sort.seed = getRandomInt(0, 1000000000); + } + $sort.apply($page.url.searchParams); + } + + function reshuffle() { + $sort.seed = undefined; + apply(); + } +</script> + +<div class="rounded-group flex flex-row"> + <select class="btn-slate" bind:value={$sort.on} on:change={apply} title="Sort on..."> + {#each Object.entries($sort.labels) as [value, label]} + <option {value}>{label}</option> + {/each} + </select> + <button type="button" class="btn-slate" title="Toggle sort direction" on:click={toggle}> + {#if $sort.direction === SortDirection.Ascending} + <span class="icon-base icon-[material-symbols--sort] -scale-y-100" /> + {:else} + <span class="icon-base icon-[material-symbols--sort]" /> + {/if} + </button> + {#if $sort.on === 'RANDOM'} + <button + type="button" + class="btn-slate" + title="Reshuffle" + on:click={reshuffle} + transition:slide={slideXFast} + > + <div class="flex"> + <span class="icon-base icon-[material-symbols--shuffle]" /> + </div> + </button> + {/if} +</div> diff --git a/frontend/src/lib/toolbar/SelectionControls.svelte b/frontend/src/lib/toolbar/SelectionControls.svelte new file mode 100644 index 0000000..4d309df --- /dev/null +++ b/frontend/src/lib/toolbar/SelectionControls.svelte @@ -0,0 +1,57 @@ +<script lang="ts"> + import { getSelectionContext } from '$lib/Selection'; + import { accelerator } from '$lib/Shortcuts'; + import { fadeDefault, slideXFast } from '$lib/Transitions'; + import Badge from '$lib/components/Badge.svelte'; + import { onDestroy } from 'svelte'; + import { fade, slide } from 'svelte/transition'; + + const selection = getSelectionContext(); + + export let page = false; + + const toggle = () => ($selection = $selection.toggle()); + const all = () => ($selection = $selection.all()); + const none = () => ($selection = $selection.none()); + + onDestroy(() => ($selection = $selection.clear())); +</script> + +<div class="rounded-group flex"> + <button + type="button" + class="btn-slate relative" + class:toggled={$selection.active} + title={`${$selection.active ? 'Exit' : 'Enter'} ${page ? 'page ' : ' '}selection mode`} + on:click={toggle} + use:accelerator={'s'} + > + {#if $selection.active} + {#if page} + <span class="icon-base icon-[material-symbols--edit-document]" /> + {:else} + <span class="icon-base icon-[material-symbols--remove-selection]" /> + {/if} + {:else if page} + <span class="icon-base icon-[material-symbols--edit-document-outline]" /> + {:else} + <span class="icon-base icon-[material-symbols--select]" /> + {/if} + <Badge number={$selection.size} /> + </button> + {#if $selection.active} + <div class="rounded-group-end flex" transition:slide={slideXFast}> + <button type="button" class="btn-slate" title="Select all" on:click={all}> + <span class="icon-base icon-[material-symbols--select-all]" /> + </button> + <button type="button" class="btn-slate" title="Select none" on:click={none}> + <span class="icon-base icon-[material-symbols--deselect]" /> + </button> + </div> + {/if} +</div> +{#if $selection.size > 0} + <div class="rounded-group flex" transition:fade={fadeDefault}> + <slot /> + </div> +{/if} diff --git a/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte new file mode 100644 index 0000000..2e7869f --- /dev/null +++ b/frontend/src/lib/toolbar/ToggleAdvancedFilters.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + import { page } from '$app/stores'; + import { getFilterContext } from '$lib/Filter'; + import { navigate } from '$lib/Navigation'; + import { slideXFast } from '$lib/Transitions'; + import Badge from '$lib/components/Badge.svelte'; + import { slide } from 'svelte/transition'; + import { getToolbarContext } from './Toolbar.svelte'; + + const toolbar = getToolbarContext(); + const filter = getFilterContext(); +</script> + +<div class="rounded-group flex"> + <button + class:toggled={$toolbar.expand} + class="btn-slate relative" + title={`${$toolbar.expand ? 'Hide' : 'Show'} filters`} + on:click={() => ($toolbar.expand = !$toolbar.expand)} + > + {#if $toolbar.expand} + <span class="icon-base icon-[material-symbols--filter-alt]" /> + {:else} + <span class="icon-base icon-[material-symbols--filter-alt-outline]" /> + {/if} + <Badge number={$filter.include.size + $filter.exclude.size} /> + </button> + {#if $filter.include.size + $filter.exclude.size > 0} + <button + class="btn-slate relative hover:bg-rose-700" + on:click={() => navigate({ filter: {} }, $page.url.searchParams)} + transition:slide={slideXFast} + title="Reset filters" + > + <div class="flex"> + <span class="icon-base icon-[material-symbols--filter-alt-off]" /> + </div> + </button> + {/if} +</div> diff --git a/frontend/src/lib/toolbar/Toolbar.svelte b/frontend/src/lib/toolbar/Toolbar.svelte new file mode 100644 index 0000000..e87d731 --- /dev/null +++ b/frontend/src/lib/toolbar/Toolbar.svelte @@ -0,0 +1,42 @@ +<script lang="ts" context="module"> + import { writable, type Writable } from 'svelte/store'; + + interface ToolbarContext { + expand: boolean; + } + + function initToolbarContext() { + return setContext<Writable<ToolbarContext>>('toolbar', writable({ expand: false })); + } + + export function getToolbarContext() { + return getContext<Writable<ToolbarContext>>('toolbar'); + } +</script> + +<script lang="ts"> + import { getContext, setContext } from 'svelte'; + + const toolbar = initToolbarContext(); +</script> + +<div class="flex flex-col"> + <div + class="flex flex-row flex-wrap gap-4 text-sm xl:grid xl:grid-flow-col xl:grid-cols-[1fr_2fr_1fr]" + > + <div class="flex flex-row justify-start gap-2"> + <slot name="start" /> + </div> + <div class="flex flex-row flex-wrap justify-start gap-2 xl:flex-nowrap xl:justify-center"> + <slot name="center" /> + </div> + <div class="flex flex-row justify-end gap-2"> + <slot name="end" /> + </div> + </div> + {#if $toolbar.expand} + <div class="mt-4"> + <slot /> + </div> + {/if} +</div> |