summaryrefslogtreecommitdiffstatshomepage
path: root/frontend/src/lib/tabs
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/lib/tabs')
-rw-r--r--frontend/src/lib/tabs/AddOverlay.svelte36
-rw-r--r--frontend/src/lib/tabs/ArchiveDelete.svelte42
-rw-r--r--frontend/src/lib/tabs/ArchiveDetails.svelte50
-rw-r--r--frontend/src/lib/tabs/ArchiveEdit.svelte68
-rw-r--r--frontend/src/lib/tabs/ComicDelete.svelte34
-rw-r--r--frontend/src/lib/tabs/ComicDetails.svelte121
-rw-r--r--frontend/src/lib/tabs/DetailsHeader.svelte11
-rw-r--r--frontend/src/lib/tabs/DetailsSection.svelte10
-rw-r--r--frontend/src/lib/tabs/Tab.svelte14
-rw-r--r--frontend/src/lib/tabs/Tabs.svelte40
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>