diff options
Diffstat (limited to 'frontend/src/lib/tabs')
-rw-r--r-- | frontend/src/lib/tabs/AddOverlay.svelte | 36 | ||||
-rw-r--r-- | frontend/src/lib/tabs/ArchiveDelete.svelte | 42 | ||||
-rw-r--r-- | frontend/src/lib/tabs/ArchiveDetails.svelte | 50 | ||||
-rw-r--r-- | frontend/src/lib/tabs/ArchiveEdit.svelte | 68 | ||||
-rw-r--r-- | frontend/src/lib/tabs/ComicDelete.svelte | 34 | ||||
-rw-r--r-- | frontend/src/lib/tabs/ComicDetails.svelte | 121 | ||||
-rw-r--r-- | frontend/src/lib/tabs/DetailsHeader.svelte | 11 | ||||
-rw-r--r-- | frontend/src/lib/tabs/DetailsSection.svelte | 10 | ||||
-rw-r--r-- | frontend/src/lib/tabs/Tab.svelte | 14 | ||||
-rw-r--r-- | frontend/src/lib/tabs/Tabs.svelte | 40 |
10 files changed, 426 insertions, 0 deletions
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> |