diff options
Diffstat (limited to 'frontend/src/lib/components')
21 files changed, 478 insertions, 0 deletions
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> |