diff options
Diffstat (limited to 'frontend/src/routes')
23 files changed, 1393 insertions, 0 deletions
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..0eefed1 --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,95 @@ +<script lang="ts"> + import { addShortcut, handleShortcuts } from '$lib/Shortcuts'; + import { fadeDefault } from '$lib/Transitions'; + import AddArtist from '$lib/dialogs/AddArtist.svelte'; + import AddCharacter from '$lib/dialogs/AddCharacter.svelte'; + import AddCircle from '$lib/dialogs/AddCircle.svelte'; + import AddNamespace from '$lib/dialogs/AddNamespace.svelte'; + import AddTag from '$lib/dialogs/AddTag.svelte'; + import AddWorld from '$lib/dialogs/AddWorld.svelte'; + import Link from '$lib/navigation/Link.svelte'; + import Navigation from '$lib/navigation/Navigation.svelte'; + import { cacheExchange, fetchExchange, initContextClient } from '@urql/svelte'; + import { SvelteToast } from '@zerodevx/svelte-toast'; + import { Modals, closeModal, openModal } from 'svelte-modals'; + import { fade } from 'svelte/transition'; + import '../app.css'; + + initContextClient({ + url: import.meta.env.VITE_GQL_ENDPOINT ?? '/graphql', + exchanges: [cacheExchange, fetchExchange] + }); + + addShortcut('na', () => openModal(AddArtist)); + addShortcut('nh', () => openModal(AddCharacter)); + addShortcut('ni', () => openModal(AddCircle)); + addShortcut('nn', () => openModal(AddNamespace)); + addShortcut('nt', () => openModal(AddTag)); + addShortcut('nw', () => openModal(AddWorld)); + + function keydown(event: KeyboardEvent) { + handleShortcuts(event); + } +</script> + +<svelte:document on:keydown={keydown} /> + +<Navigation> + <Link matchExact href="/" title="Home" accel="go"> + <span class="icon-base icon-[material-symbols--home]" /> + </Link> + <Link href="/comics/" title="Comics" accel="gc"> + <span class="icon-base icon-[material-symbols--menu-book]" /> + </Link> + <Link href="/namespaces/" title="Namespaces" accel="gn"> + <span class="icon-base icon-[material-symbols--inbox]" /> + </Link> + <Link href="/tags/" title="Tags" accel="gt"> + <span class="icon-base icon-[material-symbols--label]" /> + </Link> + <Link href="/artists/" title="Artists" accel="ga"> + <span class="icon-base icon-[material-symbols--person]" /> + </Link> + <Link href="/circles/" title="Circles" accel="gi"> + <span class="icon-base icon-[material-symbols--group]" /> + </Link> + <Link href="/characters/" title="Characters" accel="gh"> + <span class="icon-base icon-[material-symbols--face]" /> + </Link> + <Link href="/worlds/" title="Worlds" accel="gw"> + <span class="icon-base icon-[material-symbols--public]" /> + </Link> + <Link href="/archives/" title="Archives" accel="gz"> + <span class="icon-base icon-[material-symbols--folder-zip]" /> + </Link> + <div class="mb-auto" /> + <Link href="/help/" title="Help" accel="?" target="_blank"> + <span class="icon-base icon-[material-symbols--help]" /> + </Link> +</Navigation> + +<div class="min-w-[360px] overflow-auto p-4"> + <slot /> +</div> + +<Modals> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <div + slot="backdrop" + on:click={closeModal} + transition:fade={fadeDefault} + class="fixed bottom-0 left-0 right-0 top-0 z-20 bg-stone-800/80" + /> +</Modals> + +<SvelteToast options={{ reversed: true, intro: { y: 192 } }} /> + +<style> + :root { + --toastBarHeight: 0; + --toastContainerTop: auto; + --toastContainerLeft: 4rem; + --toastContainerBottom: 1rem; + } +</style> diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..97a7a60 --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + import { version } from '$app/environment'; + import { frontpageQuery } from '$gql/Queries'; + import { ComicSort, SortDirection } from '$gql/graphql'; + import { codename } from '$lib/Meta'; + import { href } from '$lib/Navigation'; + import { fadeDefault } from '$lib/Transitions'; + import logo from '$lib/assets/logo.webp'; + import Card, { comicCard } from '$lib/components/Card.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Carousel from '$lib/containers/Carousel.svelte'; + import { getContextClient } from '@urql/svelte'; + import { fade } from 'svelte/transition'; + + const bookmarkLink = href('comics', { filter: { include: { bookmarked: true } } }); + const recentLink = href('comics', { + sort: { on: ComicSort.CreatedAt, direction: SortDirection.Descending } + }); + const favouriteLink = href('comics', { filter: { include: { favourite: true } } }); + + $: query = frontpageQuery(getContextClient()); + $: recent = $query.data?.recent; + $: favourites = $query.data?.favourites; + $: bookmarked = $query.data?.bookmarked; +</script> + +<Head section="Home" /> + +<div class="flex flex-col justify-center gap-16 xl:flex-row"> + {#if $query.data} + <div class="flex flex-col items-center gap-1"> + <img src={logo} width="512" height="512" class="min-w-[400px]" alt="" /> + <h1 class="text-4xl font-medium"> + <span>hircine</span> + <span>{version}</span> + </h1> + <h2 class="text-2xl font-light text-zinc-400">{codename}</h2> + </div> + <div class="flex flex-col gap-8" in:fade={fadeDefault}> + {#if recent && recent.count > 0} + <Carousel title="Recently added" href={recentLink}> + {#each recent.edges as comic} + <Card coverOnly {...comicCard(comic)} /> + {/each} + </Carousel> + {/if} + {#if favourites && favourites.count > 0} + <Carousel title="Favourites" href={favouriteLink}> + {#each favourites.edges as comic} + <Card coverOnly {...comicCard(comic)} /> + {/each} + </Carousel> + {/if} + {#if bookmarked && bookmarked.count > 0} + <Carousel title="Bookmarks" href={bookmarkLink}> + {#each bookmarked.edges as comic} + <Card coverOnly {...comicCard(comic)} /> + {/each} + </Carousel> + {/if} + </div> + {:else} + <Guard result={query} /> + {/if} +</div> diff --git a/frontend/src/routes/archives/+page.svelte b/frontend/src/routes/archives/+page.svelte new file mode 100644 index 0000000..545058a --- /dev/null +++ b/frontend/src/routes/archives/+page.svelte @@ -0,0 +1,119 @@ +<script lang="ts"> + import { deleteArchives, updateArchives } from '$gql/Mutations'; + import { archivesQuery } from '$gql/Queries'; + import type { ArchiveFragment } from '$gql/graphql'; + import { ArchiveSortLabel } from '$lib/Enums'; + import { ArchiveFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import Card from '$lib/components/Card.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import RefreshButton from '$lib/components/RefreshButton.svelte'; + import Cards from '$lib/containers/Cards.svelte'; + import Column from '$lib/containers/Column.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Pill from '$lib/pills/Pill.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte'; + import MarkOrganized from '$lib/toolbar/MarkOrganized.svelte'; + import MarkSelection from '$lib/toolbar/MarkSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { filesize } from 'filesize'; + import type { PageData } from './$types'; + + let client = getContextClient(); + + export let data: PageData; + + $: result = archivesQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: archives = $result.data?.archives; + + const selection = initSelectionContext<ArchiveFragment>('Archive', (a) => a.name); + $: if (archives) { + $selection.view = archives.edges; + $pagination.total = archives.count; + } + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const filter = initFilterContext<ArchiveFilterContext>(); + $: $filter = new ArchiveFilterContext(data.filter); + + const sort = initSortContext(data.sort, ArchiveSortLabel); + $: $sort.update = data.sort; + + function refresh() { + result.reexecute({ requestPolicy: 'network-only' }); + } +</script> + +<Head section="Archives" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <MarkSelection> + <MarkOrganized mutation={updateArchives} /> + </MarkSelection> + <DeleteSelection + mutation={deleteArchives} + warning="Deleting an archive will also delete its archive file on disk as well as all comics that belong to it." + /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Archives" bind:field={$filter.include.controls.path.contains} /> + <FilterOrganized /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <RefreshButton slot="end" on:click={refresh} /> + </Toolbar> + {#if archives} + <Pagination /> + <main> + <Cards> + {#each archives.edges as { id, name, cover, size, pageCount }, index (id)} + <Selectable {index} {id} let:handle let:selected> + <Card + ellipsis={false} + href={id.toString()} + details={{ title: name, cover: cover }} + on:click={handle} + > + <SelectionOverlay position="left" {selected} slot="overlay" /> + <div class="flex gap-1 text-xs"> + <Pill name={`${pageCount} pages`}> + <span class="icon-[material-symbols--note] mr-0.5" slot="icon" /> + </Pill> + <Pill name={filesize(size, { base: 2 })}> + <span class="icon-[material-symbols--hard-drive] mr-0.5" slot="icon" /> + </Pill> + </div> + </Card> + </Selectable> + {:else} + <Empty /> + {/each} + </Cards> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/archives/+page.ts b/frontend/src/routes/archives/+page.ts new file mode 100644 index 0000000..88acade --- /dev/null +++ b/frontend/src/routes/archives/+page.ts @@ -0,0 +1,12 @@ +import { ArchiveSort, type ArchiveFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, ArchiveSort.Path), + filter: parseFilter<ArchiveFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams, 24) + }; +} diff --git a/frontend/src/routes/archives/[id]/+page.svelte b/frontend/src/routes/archives/[id]/+page.svelte new file mode 100644 index 0000000..50a2940 --- /dev/null +++ b/frontend/src/routes/archives/[id]/+page.svelte @@ -0,0 +1,99 @@ +<script lang="ts"> + import { updateArchives } from '$gql/Mutations'; + import { archiveQuery } from '$gql/Queries'; + import { Direction, Layout, type FullArchiveFragment, type PageFragment } from '$gql/graphql'; + import { initReaderContext } from '$lib/Reader'; + import { initSelectionContext } from '$lib/Selection'; + import { setTabContext } from '$lib/Tabs'; + import { toastFinally } from '$lib/Toasts'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Titlebar from '$lib/components/Titlebar.svelte'; + import Grid from '$lib/containers/Grid.svelte'; + import Gallery from '$lib/gallery/Gallery.svelte'; + import PageView from '$lib/reader/PageView.svelte'; + import Reader from '$lib/reader/Reader.svelte'; + import ArchiveDelete from '$lib/tabs/ArchiveDelete.svelte'; + import ArchiveDetails from '$lib/tabs/ArchiveDetails.svelte'; + import ArchiveEdit from '$lib/tabs/ArchiveEdit.svelte'; + import Tab from '$lib/tabs/Tab.svelte'; + import Tabs from '$lib/tabs/Tabs.svelte'; + import { getContextClient } from '@urql/svelte'; + import type { PageData } from './$types'; + + export let data: PageData; + const client = getContextClient(); + const reader = initReaderContext(); + setTabContext({ + tabs: { + details: { title: 'Details' }, + edit: { title: 'Edit' }, + deletion: { title: 'Delete' } + }, + current: 'details' + }); + + $: result = archiveQuery(client, { id: data.id }); + + function updateCover(event: CustomEvent<number>) { + updateArchives(client, { ids: archive.id, input: { cover: { id: event.detail } } }).catch( + toastFinally + ); + } + + let archive: FullArchiveFragment; + + $: $result, update(); + function update() { + if (!$result.stale && $result.data?.archive.__typename === 'FullArchive') { + archive = structuredClone($result.data.archive); + + $reader.pages = archive.pages; + } + } + + const selection = initSelectionContext<PageFragment>('Page', (p) => p.path); + $selection.selectable = (p) => p.comicId === null; + + $: if (archive) { + $selection.view = archive.pages; + } +</script> + +<Head section="Archive" title={archive?.name} /> + +{#if archive} + <Grid> + <header> + <Titlebar title={archive.name} /> + </header> + + <aside> + <Tabs> + <Tab id="details"> + <ArchiveDetails {archive} /> + </Tab> + <Tab id="edit"> + <ArchiveEdit {archive} /> + </Tab> + <Tab id="deletion"> + <ArchiveDelete {archive} /> + </Tab> + </Tabs> + </aside> + + <main class="overflow-auto"> + <Gallery + pages={archive.pages} + on:open={(e) => ($reader = $reader.open(e.detail))} + on:cover={updateCover} + /> + </main> + </Grid> +{:else} + <Guard {result} /> +{/if} + +<Reader> + <PageView layout={Layout.Single} direction={Direction.LeftToRight} /> +</Reader> diff --git a/frontend/src/routes/archives/[id]/+page.ts b/frontend/src/routes/archives/[id]/+page.ts new file mode 100644 index 0000000..d872ba2 --- /dev/null +++ b/frontend/src/routes/archives/[id]/+page.ts @@ -0,0 +1,5 @@ +export function load({ params }: { params: Record<string, string> }) { + return { + id: +params.id + }; +} diff --git a/frontend/src/routes/artists/+page.svelte b/frontend/src/routes/artists/+page.svelte new file mode 100644 index 0000000..e07338c --- /dev/null +++ b/frontend/src/routes/artists/+page.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import { deleteArtists } from '$gql/Mutations'; + import { artistsQuery, fetchArtist } from '$gql/Queries'; + import type { Artist } from '$gql/graphql'; + import { ArtistSortLabel } from '$lib/Enums'; + import { BasicFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Cardlet from '$lib/components/Cardlet.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cardlets from '$lib/containers/Cardlets.svelte'; + import Column from '$lib/containers/Column.svelte'; + import AddArtist from '$lib/dialogs/AddArtist.svelte'; + import EditArtist from '$lib/dialogs/EditArtist.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { openModal } from 'svelte-modals'; + import type { PageData } from './$types'; + + const client = getContextClient(); + export let data: PageData; + + $: result = artistsQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: artists = $result.data?.artists; + + const selection = initSelectionContext<Artist>('Artist', (a) => a.name); + $: if (artists) { + $selection.view = artists.edges; + $pagination.total = artists.count; + } + + const filter = initFilterContext<BasicFilterContext>(); + $: $filter = new BasicFilterContext(data.filter); + + const sort = initSortContext(data.sort, ArtistSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const edit = (id: number) => { + fetchArtist(client, id) + .then((artist) => openModal(EditArtist, { artist })) + .catch(toastFinally); + }; +</script> + +<Head section="artists" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <DeleteSelection mutation={deleteArtists} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Artists" bind:field={$filter.include.controls.name.contains} /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <svelte:fragment slot="end"> + <AddButton title="Add Artist" on:click={() => openModal(AddArtist)} /> + </svelte:fragment> + </Toolbar> + {#if artists} + <Pagination /> + <main> + <Cardlets> + {#each artists.edges as { id, name }, index (id)} + <Selectable {index} {id} {edit} let:handle let:selected> + <Cardlet {name} on:click={handle} filter="artists" {id}> + <SelectionOverlay slot="overlay" position="right" centered {selected} /> + </Cardlet> + </Selectable> + {:else} + <Empty /> + {/each} + </Cardlets> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/artists/+page.ts b/frontend/src/routes/artists/+page.ts new file mode 100644 index 0000000..5a76550 --- /dev/null +++ b/frontend/src/routes/artists/+page.ts @@ -0,0 +1,12 @@ +import { ArtistSort, type ArtistFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, ArtistSort.Name), + filter: parseFilter<ArtistFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams) + }; +} diff --git a/frontend/src/routes/characters/+page.svelte b/frontend/src/routes/characters/+page.svelte new file mode 100644 index 0000000..0934bab --- /dev/null +++ b/frontend/src/routes/characters/+page.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import { deleteCharacters } from '$gql/Mutations'; + import { charactersQuery, fetchCharacter } from '$gql/Queries'; + import type { Character } from '$gql/graphql'; + import { CharacterSortLabel } from '$lib/Enums'; + import { BasicFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Cardlet from '$lib/components/Cardlet.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cardlets from '$lib/containers/Cardlets.svelte'; + import Column from '$lib/containers/Column.svelte'; + import AddCharacter from '$lib/dialogs/AddCharacter.svelte'; + import EditCharacter from '$lib/dialogs/EditCharacter.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { openModal } from 'svelte-modals'; + import type { PageData } from './$types'; + + const client = getContextClient(); + export let data: PageData; + + $: result = charactersQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: characters = $result.data?.characters; + + const selection = initSelectionContext<Character>('Character', (c) => c.name); + $: if (characters) { + $selection.view = characters.edges; + $pagination.total = characters.count; + } + + const filter = initFilterContext<BasicFilterContext>(); + $: $filter = new BasicFilterContext(data.filter); + + const sort = initSortContext(data.sort, CharacterSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const edit = (id: number) => { + fetchCharacter(client, id) + .then((character) => openModal(EditCharacter, { character })) + .catch(toastFinally); + }; +</script> + +<Head section="characters" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <DeleteSelection mutation={deleteCharacters} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Characters" bind:field={$filter.include.controls.name.contains} /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <svelte:fragment slot="end"> + <AddButton title="Add Character" on:click={() => openModal(AddCharacter)} /> + </svelte:fragment> + </Toolbar> + {#if characters} + <Pagination /> + <main> + <Cardlets> + {#each characters.edges as { id, name }, index (id)} + <Selectable {index} {id} {edit} let:handle let:selected> + <Cardlet {name} on:click={handle} filter="characters" {id}> + <SelectionOverlay slot="overlay" position="right" centered {selected} /> + </Cardlet> + </Selectable> + {:else} + <Empty /> + {/each} + </Cardlets> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/characters/+page.ts b/frontend/src/routes/characters/+page.ts new file mode 100644 index 0000000..4f7a3cf --- /dev/null +++ b/frontend/src/routes/characters/+page.ts @@ -0,0 +1,12 @@ +import { CharacterSort, type CharacterFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, CharacterSort.Name), + filter: parseFilter<CharacterFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams) + }; +} diff --git a/frontend/src/routes/circles/+page.svelte b/frontend/src/routes/circles/+page.svelte new file mode 100644 index 0000000..14b0866 --- /dev/null +++ b/frontend/src/routes/circles/+page.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import { deleteCircles } from '$gql/Mutations'; + import { circlesQuery, fetchCircle } from '$gql/Queries'; + import type { Circle } from '$gql/graphql'; + import { CircleSortLabel } from '$lib/Enums'; + import { BasicFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Cardlet from '$lib/components/Cardlet.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cardlets from '$lib/containers/Cardlets.svelte'; + import Column from '$lib/containers/Column.svelte'; + import AddCircle from '$lib/dialogs/AddCircle.svelte'; + import EditCircle from '$lib/dialogs/EditCircle.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { openModal } from 'svelte-modals'; + import type { PageData } from './$types'; + + const client = getContextClient(); + export let data: PageData; + + $: result = circlesQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: circles = $result.data?.circles; + + const selection = initSelectionContext<Circle>('Circle', (c) => c.name); + $: if (circles) { + $selection.view = circles.edges; + $pagination.total = circles.count; + } + + const filter = initFilterContext<BasicFilterContext>(); + $: $filter = new BasicFilterContext(data.filter); + + const sort = initSortContext(data.sort, CircleSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const edit = (id: number) => { + fetchCircle(client, id) + .then((circle) => openModal(EditCircle, { circle })) + .catch(toastFinally); + }; +</script> + +<Head section="circles" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <DeleteSelection mutation={deleteCircles} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Circles" bind:field={$filter.include.controls.name.contains} /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <svelte:fragment slot="end"> + <AddButton title="Add Circle" on:click={() => openModal(AddCircle)} /> + </svelte:fragment> + </Toolbar> + {#if circles} + <Pagination /> + <main> + <Cardlets> + {#each circles.edges as { id, name }, index (id)} + <Selectable {index} {id} {edit} let:handle let:selected> + <Cardlet {name} on:click={handle} filter="circles" {id}> + <SelectionOverlay slot="overlay" position="right" centered {selected} /> + </Cardlet> + </Selectable> + {:else} + <Empty /> + {/each} + </Cardlets> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/circles/+page.ts b/frontend/src/routes/circles/+page.ts new file mode 100644 index 0000000..ea5c3df --- /dev/null +++ b/frontend/src/routes/circles/+page.ts @@ -0,0 +1,12 @@ +import { CircleSort, type CircleFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, CircleSort.Name), + filter: parseFilter<CircleFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams) + }; +} diff --git a/frontend/src/routes/comics/+page.svelte b/frontend/src/routes/comics/+page.svelte new file mode 100644 index 0000000..353d69c --- /dev/null +++ b/frontend/src/routes/comics/+page.svelte @@ -0,0 +1,116 @@ +<script lang="ts"> + import { deleteComics, updateComics } from '$gql/Mutations'; + import { comicsQuery } from '$gql/Queries'; + import { type ComicFragment } from '$gql/graphql'; + import { ComicSortLabel } from '$lib/Enums'; + import { ComicFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import Card, { comicCard } from '$lib/components/Card.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cards from '$lib/containers/Cards.svelte'; + import Column from '$lib/containers/Column.svelte'; + import UpdateComicsDialog from '$lib/dialogs/UpdateComics.svelte'; + import ComicFilterForm from '$lib/filter/ComicFilterForm.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import ComicPills from '$lib/pills/ComicPills.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import EditSelection from '$lib/toolbar/EditSelection.svelte'; + import FilterBookmarked from '$lib/toolbar/FilterBookmarked.svelte'; + import FilterFavourites from '$lib/toolbar/FilterFavourites.svelte'; + import FilterOrganized from '$lib/toolbar/FilterOrganized.svelte'; + import MarkBookmark from '$lib/toolbar/MarkBookmark.svelte'; + import MarkFavourite from '$lib/toolbar/MarkFavourite.svelte'; + import MarkOrganized from '$lib/toolbar/MarkOrganized.svelte'; + import MarkSelection from '$lib/toolbar/MarkSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import type { PageData } from './$types'; + + export let data: PageData; + + const client = getContextClient(); + + $: result = comicsQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: comics = $result.data?.comics; + + const selection = initSelectionContext<ComicFragment>('Comic', (c) => c.title); + $: if (comics) { + $selection.view = comics.edges; + $pagination.total = comics.count; + } + + const filter = initFilterContext<ComicFilterContext>(); + $: $filter = new ComicFilterContext(data.filter); + + const sort = initSortContext(data.sort, ComicSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; +</script> + +<Head section="Comics" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <MarkSelection> + <MarkFavourite mutation={updateComics} /> + <hr class="col-span-2 border-slate-600" /> + <MarkBookmark mutation={updateComics} /> + <hr class="col-span-2 border-slate-600" /> + <MarkOrganized mutation={updateComics} /> + </MarkSelection> + <EditSelection dialog={UpdateComicsDialog} /> + <DeleteSelection mutation={deleteComics} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Comics" bind:field={$filter.include.controls.title.contains} /> + <ToggleAdvancedFilters /> + <div class="rounded-group flex"> + <FilterFavourites /> + <FilterBookmarked /> + <FilterOrganized /> + </div> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <ComicFilterForm /> + </Toolbar> + {#if comics} + <Pagination /> + <main> + <Cards> + {#each comics.edges as comic, index (comic.id)} + <Selectable {index} id={comic.id} let:handle let:selected> + <Card {...comicCard(comic)} on:click={handle}> + <SelectionOverlay position="left" {selected} slot="overlay" /> + <ComicPills {comic} /> + </Card> + </Selectable> + {:else} + <Empty /> + {/each} + </Cards> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/comics/+page.ts b/frontend/src/routes/comics/+page.ts new file mode 100644 index 0000000..4558804 --- /dev/null +++ b/frontend/src/routes/comics/+page.ts @@ -0,0 +1,12 @@ +import { ComicSort, type ComicFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, ComicSort.Title), + filter: parseFilter<ComicFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams, 24) + }; +} diff --git a/frontend/src/routes/comics/[id]/+page.svelte b/frontend/src/routes/comics/[id]/+page.svelte new file mode 100644 index 0000000..cfc5840 --- /dev/null +++ b/frontend/src/routes/comics/[id]/+page.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import { beforeNavigate } from '$app/navigation'; + import { updateComics } from '$gql/Mutations'; + import { comicQuery } from '$gql/Queries'; + import { comicEquals } from '$gql/Utils'; + import { UpdateMode, type FullComicFragment, type UpdateComicInput } from '$gql/graphql'; + import { initReaderContext } from '$lib/Reader'; + import { initScraperContext } from '$lib/Scraper'; + import { initSelectionContext } from '$lib/Selection'; + import { setTabContext } from '$lib/Tabs'; + import { toastFinally } from '$lib/Toasts'; + import { preventOnPending } from '$lib/Utils'; + import BookmarkButton from '$lib/components/BookmarkButton.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import OrganizedButton from '$lib/components/OrganizedButton.svelte'; + import RemovePageButton from '$lib/components/RemovePageButton.svelte'; + import SubmitButton from '$lib/components/SubmitButton.svelte'; + import Titlebar from '$lib/components/Titlebar.svelte'; + import Grid from '$lib/containers/Grid.svelte'; + import ComicForm from '$lib/forms/ComicForm.svelte'; + import Gallery from '$lib/gallery/Gallery.svelte'; + import PageView from '$lib/reader/PageView.svelte'; + import Reader from '$lib/reader/Reader.svelte'; + import ComicScrapeForm from '$lib/scraper/ComicScrapeForm.svelte'; + import ComicDelete from '$lib/tabs/ComicDelete.svelte'; + import ComicDetails from '$lib/tabs/ComicDetails.svelte'; + import Tab from '$lib/tabs/Tab.svelte'; + import Tabs from '$lib/tabs/Tabs.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import { getContextClient } from '@urql/svelte'; + import type { PageData } from './$types'; + + const client = getContextClient(); + const reader = initReaderContext(); + const selection = initSelectionContext(); + const scraper = initScraperContext(); + const tabContext = setTabContext({ + tabs: { + details: { title: 'Details' }, + edit: { title: 'Edit' }, + scrape: { title: 'Scrape' }, + deletion: { title: 'Delete' } + }, + current: 'details' + }); + + export let data: PageData; + $: result = comicQuery(client, { id: data.id }); + + let comic: FullComicFragment; + let original: Readonly<FullComicFragment>; + let updatePartial = false; + + $: $result, update(); + $: pending = !comicEquals(comic, original); + $: $tabContext.tabs.edit.badge = pending; + + function update() { + if (!$result.stale && $result.data?.comic.__typename === 'FullComic') { + original = $result.data.comic; + if (updatePartial) { + comic.pages = structuredClone(original.pages); + comic.favourite = original.favourite; + comic.bookmarked = original.bookmarked; + comic.organized = original.organized; + comic.updatedAt = original.updatedAt; + updatePartial = false; + } else { + comic = structuredClone(original); + } + + $reader.pages = original.pages; + $selection.view = comic.pages; + $scraper.selector = undefined; + } + } + + function toggle(field: keyof Omit<UpdateComicInput, 'cover'>) { + updateComics(client, { ids: comic.id, input: { [field]: !comic[field] } }) + .then(() => (updatePartial = true)) + .catch(toastFinally); + } + + function updateComic(event: CustomEvent<UpdateComicInput>) { + updateComics(client, { ids: comic.id, input: event.detail }).catch(toastFinally); + } + + function updateCover(event: CustomEvent<number>) { + updateComics(client, { ids: comic.id, input: { cover: { id: event.detail } } }) + .then(() => (updatePartial = true)) + .catch(toastFinally); + } + + function removePages() { + updateComics(client, { + ids: comic.id, + input: { pages: { ids: $selection.ids, options: { mode: UpdateMode.Remove } } } + }) + .then(() => { + updatePartial = true; + $selection = $selection.clear(); + }) + .catch(toastFinally); + } + + beforeNavigate((navigation) => preventOnPending(navigation, pending)); +</script> + +<Head section="Comic" title={original?.title} /> + +{#if comic} + <Grid> + <header> + <Titlebar + title={original.title} + subtitle={original.originalTitle} + bind:favourite={comic.favourite} + on:favourite={() => toggle('favourite')} + /> + </header> + + <aside> + <Tabs> + <Tab id="details"> + <ComicDetails comic={original} /> + </Tab> + <Tab id="edit"> + <div class="flex flex-col gap-4"> + <div class="flex gap-2 text-sm"> + <SelectionControls page> + <RemovePageButton on:click={removePages} /> + </SelectionControls> + <div class="grow" /> + <BookmarkButton bookmarked={comic.bookmarked} on:click={() => toggle('bookmarked')} /> + <OrganizedButton organized={comic.organized} on:click={() => toggle('organized')} /> + </div> + <ComicForm bind:comic on:submit={updateComic}> + <div class="flex gap-2"> + <div class="grow" /> + <SubmitButton active={pending} /> + </div> + </ComicForm> + </div> + </Tab> + <Tab id="scrape"> + <ComicScrapeForm {comic} /> + </Tab> + <Tab id="deletion"> + <ComicDelete {comic} /> + </Tab> + </Tabs> + </aside> + + <main class="overflow-auto"> + <Gallery + pages={comic.pages} + on:open={(e) => ($reader = $reader.open(e.detail))} + on:cover={updateCover} + /> + </main> + </Grid> + + <Reader> + <PageView layout={comic.layout} direction={comic.direction} /> + <svelte:fragment slot="sidebar"> + <ComicForm bind:comic on:submit={updateComic}> + <div class="flex justify-end gap-2"> + <SubmitButton active={pending} /> + </div> + </ComicForm> + </svelte:fragment> + </Reader> +{:else} + <Guard {result} /> +{/if} diff --git a/frontend/src/routes/comics/[id]/+page.ts b/frontend/src/routes/comics/[id]/+page.ts new file mode 100644 index 0000000..d872ba2 --- /dev/null +++ b/frontend/src/routes/comics/[id]/+page.ts @@ -0,0 +1,5 @@ +export function load({ params }: { params: Record<string, string> }) { + return { + id: +params.id + }; +} diff --git a/frontend/src/routes/namespaces/+page.svelte b/frontend/src/routes/namespaces/+page.svelte new file mode 100644 index 0000000..f6568f9 --- /dev/null +++ b/frontend/src/routes/namespaces/+page.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import { deleteNamespaces } from '$gql/Mutations'; + import { fetchNamespace, namespacesQuery } from '$gql/Queries'; + import type { Namespace } from '$gql/graphql'; + import { NamespaceSortLabel } from '$lib/Enums'; + import { BasicFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Cardlet from '$lib/components/Cardlet.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cardlets from '$lib/containers/Cardlets.svelte'; + import Column from '$lib/containers/Column.svelte'; + import AddNamespace from '$lib/dialogs/AddNamespace.svelte'; + import EditNamespace from '$lib/dialogs/EditNamespace.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { openModal } from 'svelte-modals'; + import type { PageData } from './$types'; + + const client = getContextClient(); + export let data: PageData; + + $: result = namespacesQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: namespaces = $result.data?.namespaces; + + const selection = initSelectionContext<Namespace>('Namespace', (n) => n.name); + $: if (namespaces) { + $selection.view = namespaces.edges; + $pagination.total = namespaces.count; + } + + const filter = initFilterContext<BasicFilterContext>(); + $: $filter = new BasicFilterContext(data.filter); + + const sort = initSortContext(data.sort, NamespaceSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const edit = (id: number) => { + fetchNamespace(client, id) + .then((namespace) => openModal(EditNamespace, { namespace })) + .catch(toastFinally); + }; +</script> + +<Head section="Namespaces" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <DeleteSelection mutation={deleteNamespaces} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Namespaces" bind:field={$filter.include.controls.name.contains} /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <svelte:fragment slot="end"> + <AddButton title="Add Namespace" on:click={() => openModal(AddNamespace)} /> + </svelte:fragment> + </Toolbar> + {#if namespaces} + <Pagination /> + <main> + <Cardlets> + {#each namespaces.edges as { id, name }, index (id)} + <Selectable {index} {id} {edit} let:handle let:selected> + <Cardlet {name} on:click={handle} filter="tags" id={`${id}:`}> + <SelectionOverlay slot="overlay" position="right" centered {selected} /> + </Cardlet> + </Selectable> + {:else} + <Empty /> + {/each} + </Cardlets> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/namespaces/+page.ts b/frontend/src/routes/namespaces/+page.ts new file mode 100644 index 0000000..893b540 --- /dev/null +++ b/frontend/src/routes/namespaces/+page.ts @@ -0,0 +1,12 @@ +import { NamespaceSort, type NamespaceFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, NamespaceSort.Name), + filter: parseFilter<NamespaceFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams) + }; +} diff --git a/frontend/src/routes/tags/+page.svelte b/frontend/src/routes/tags/+page.svelte new file mode 100644 index 0000000..e0909ad --- /dev/null +++ b/frontend/src/routes/tags/+page.svelte @@ -0,0 +1,109 @@ +<script lang="ts"> + import { deleteTags } from '$gql/Mutations'; + import { fetchTag, tagsQuery } from '$gql/Queries'; + import { type Tag } from '$gql/graphql'; + import { TagSortLabel } from '$lib/Enums'; + import { TagFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Cardlet from '$lib/components/Cardlet.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cardlets from '$lib/containers/Cardlets.svelte'; + import Column from '$lib/containers/Column.svelte'; + import AddTag from '$lib/dialogs/AddTag.svelte'; + import EditTag from '$lib/dialogs/EditTag.svelte'; + import UpdateTagsDialog from '$lib/dialogs/UpdateTags.svelte'; + import TagFilterForm from '$lib/filter/TagFilterForm.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import EditSelection from '$lib/toolbar/EditSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import ToggleAdvancedFilters from '$lib/toolbar/ToggleAdvancedFilters.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { openModal } from 'svelte-modals'; + import type { PageData } from './$types'; + + const client = getContextClient(); + + export let data: PageData; + + $: result = tagsQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: tags = $result.data?.tags; + + const selection = initSelectionContext<Tag>('Tag', (t) => t.name); + $: if (tags) { + $selection.view = tags.edges; + $pagination.total = tags.count; + } + + const filter = initFilterContext<TagFilterContext>(); + $: $filter = new TagFilterContext(data.filter); + + const sort = initSortContext(data.sort, TagSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const edit = (id: number) => { + fetchTag(client, id) + .then((tag) => openModal(EditTag, { tag })) + .catch(toastFinally); + }; +</script> + +<Head section="Tags" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <EditSelection dialog={UpdateTagsDialog} /> + <DeleteSelection mutation={deleteTags} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Tags" bind:field={$filter.include.controls.name.contains} /> + <ToggleAdvancedFilters /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <svelte:fragment slot="end"> + <AddButton title="Add Tag" on:click={() => openModal(AddTag)} /> + </svelte:fragment> + <TagFilterForm /> + </Toolbar> + {#if tags} + <Pagination /> + <main> + <Cardlets> + {#each tags.edges as { id, name, description }, index (id)} + <Selectable {index} {id} {edit} let:handle let:selected> + <Cardlet {name} title={description} on:click={handle} filter="tags" id={`:${id}`}> + <SelectionOverlay slot="overlay" position="right" centered {selected} /> + </Cardlet> + </Selectable> + {:else} + <Empty /> + {/each} + </Cardlets> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/tags/+page.ts b/frontend/src/routes/tags/+page.ts new file mode 100644 index 0000000..f584b6f --- /dev/null +++ b/frontend/src/routes/tags/+page.ts @@ -0,0 +1,12 @@ +import { TagSort, type TagFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, TagSort.Name), + filter: parseFilter<TagFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams) + }; +} diff --git a/frontend/src/routes/worlds/+page.svelte b/frontend/src/routes/worlds/+page.svelte new file mode 100644 index 0000000..e0366e9 --- /dev/null +++ b/frontend/src/routes/worlds/+page.svelte @@ -0,0 +1,102 @@ +<script lang="ts"> + import { deleteWorlds } from '$gql/Mutations'; + import { fetchWorld, worldsQuery } from '$gql/Queries'; + import type { World } from '$gql/graphql'; + import { WorldSortLabel } from '$lib/Enums'; + import { BasicFilterContext, initFilterContext } from '$lib/Filter'; + import { initPaginationContext } from '$lib/Pagination'; + import { initSelectionContext } from '$lib/Selection'; + import { initSortContext } from '$lib/Sort'; + import { toastFinally } from '$lib/Toasts'; + import AddButton from '$lib/components/AddButton.svelte'; + import Cardlet from '$lib/components/Cardlet.svelte'; + import Empty from '$lib/components/Empty.svelte'; + import Guard from '$lib/components/Guard.svelte'; + import Head from '$lib/components/Head.svelte'; + import Cardlets from '$lib/containers/Cardlets.svelte'; + import Column from '$lib/containers/Column.svelte'; + import AddWorld from '$lib/dialogs/AddWorld.svelte'; + import EditWorld from '$lib/dialogs/EditWorld.svelte'; + import Pagination from '$lib/pagination/Pagination.svelte'; + import Selectable from '$lib/selection/Selectable.svelte'; + import SelectionOverlay from '$lib/selection/SelectionOverlay.svelte'; + import DeleteSelection from '$lib/toolbar/DeleteSelection.svelte'; + import Search from '$lib/toolbar/Search.svelte'; + import SelectItems from '$lib/toolbar/SelectItems.svelte'; + import SelectSort from '$lib/toolbar/SelectSort.svelte'; + import SelectionControls from '$lib/toolbar/SelectionControls.svelte'; + import Toolbar from '$lib/toolbar/Toolbar.svelte'; + import { getContextClient } from '@urql/svelte'; + import { openModal } from 'svelte-modals'; + import type { PageData } from './$types'; + + const client = getContextClient(); + + export let data: PageData; + + $: result = worldsQuery(client, { + pagination: data.pagination, + filter: data.filter, + sort: data.sort + }); + + $: worlds = $result.data?.worlds; + + const selection = initSelectionContext<World>('World', (w) => w.name); + $: if (worlds) { + $selection.view = worlds.edges; + $pagination.total = worlds.count; + } + + const filter = initFilterContext<BasicFilterContext>(); + $: $filter = new BasicFilterContext(data.filter); + + const sort = initSortContext(data.sort, WorldSortLabel); + $: $sort.update = data.sort; + + const pagination = initPaginationContext(); + $: $pagination.update = data.pagination; + + const edit = (id: number) => { + fetchWorld(client, id) + .then((world) => openModal(EditWorld, { world })) + .catch(toastFinally); + }; +</script> + +<Head section="Worlds" /> + +<Column> + <Toolbar> + <SelectionControls slot="start"> + <DeleteSelection mutation={deleteWorlds} /> + </SelectionControls> + <svelte:fragment slot="center"> + <Search name="Worlds" bind:field={$filter.include.controls.name.contains} /> + <SelectSort /> + <SelectItems /> + </svelte:fragment> + <svelte:fragment slot="end"> + <AddButton title="Add World" on:click={() => openModal(AddWorld)} /> + </svelte:fragment> + </Toolbar> + {#if worlds} + <Pagination /> + <main> + <Cardlets> + {#each worlds.edges as { id, name }, index (id)} + <Selectable {index} {id} {edit} let:handle let:selected> + <Cardlet {name} on:click={handle} filter="worlds" {id}> + <SelectionOverlay slot="overlay" position="right" centered {selected} /> + </Cardlet> + </Selectable> + {:else} + <Empty /> + {/each} + </Cardlets> + </main> + <Pagination /> + {:else} + <Guard {result} /> + {/if} +</Column> diff --git a/frontend/src/routes/worlds/+page.ts b/frontend/src/routes/worlds/+page.ts new file mode 100644 index 0000000..3b85f4c --- /dev/null +++ b/frontend/src/routes/worlds/+page.ts @@ -0,0 +1,12 @@ +import { WorldSort, type WorldFilterInput } from '$gql/graphql'; +import { parseFilter, parsePaginationData, parseSortData } from '$lib/Navigation'; + +export const trailingSlash = 'always'; + +export function load({ url }: { url: URL; params: Record<string, string> }) { + return { + sort: parseSortData(url.searchParams, WorldSort.Name), + filter: parseFilter<WorldFilterInput>(url.searchParams), + pagination: parsePaginationData(url.searchParams) + }; +} |