summaryrefslogtreecommitdiffstatshomepage
diff options
context:
space:
mode:
authorWolfgang Müller2025-01-19 18:30:28 +0100
committerWolfgang Müller2025-01-19 23:36:36 +0100
commit91cfd5d306aedb4bdcc1c4045611bee7d2270462 (patch)
tree1e88be6e7757169a9cae7651bc7c4d558e7f7604
parent261ceaa057742fc70c52885021221d7a89c28af7 (diff)
downloadhircine-91cfd5d306aedb4bdcc1c4045611bee7d2270462.tar.gz
frontend: Add basic statistics page
-rw-r--r--frontend/operations.graphql25
-rw-r--r--frontend/src/gql/Queries.ts4
-rw-r--r--frontend/src/gql/graphql.ts37
-rw-r--r--frontend/src/lib/statistics/Stat.svelte27
-rw-r--r--frontend/src/lib/statistics/StatGroup.svelte12
-rw-r--r--frontend/src/routes/+layout.svelte3
-rw-r--r--frontend/src/routes/statistics/+page.svelte43
-rw-r--r--frontend/src/routes/statistics/+page.ts1
8 files changed, 152 insertions, 0 deletions
diff --git a/frontend/operations.graphql b/frontend/operations.graphql
index 5c41080..4ca665b 100644
--- a/frontend/operations.graphql
+++ b/frontend/operations.graphql
@@ -430,6 +430,31 @@ query frontpage {
}
}
+query statistics {
+ statistics {
+ total {
+ archives
+ artists
+ characters
+ circles
+ comic {
+ artists
+ characters
+ circles
+ tags
+ worlds
+ }
+ comics
+ images
+ scrapers
+ pages
+ namespaces
+ tags
+ worlds
+ }
+ }
+}
+
mutation addComic($input: AddComicInput!) {
addComic(input: $input) {
... on AddComicSuccess {
diff --git a/frontend/src/gql/Queries.ts b/frontend/src/gql/Queries.ts
index cc9dd4c..28bbf63 100644
--- a/frontend/src/gql/Queries.ts
+++ b/frontend/src/gql/Queries.ts
@@ -200,6 +200,10 @@ export function frontpageQuery(client: Client) {
});
}
+export function statisticsQuery(client: Client) {
+ return queryStore({ client: client, query: gql.StatisticsDocument });
+}
+
export function fetchArtist(client: Client, id: number) {
return client
.query(gql.ArtistDocument, { id }, { requestPolicy: 'cache-and-network' })
diff --git a/frontend/src/gql/graphql.ts b/frontend/src/gql/graphql.ts
index 4d09e75..d09592b 100644
--- a/frontend/src/gql/graphql.ts
+++ b/frontend/src/gql/graphql.ts
@@ -387,6 +387,15 @@ export type ComicTagsUpsertInput = {
options?: InputMaybe<UpsertOptions>;
};
+export type ComicTotals = {
+ __typename?: 'ComicTotals';
+ artists: Scalars['Int']['output'];
+ characters: Scalars['Int']['output'];
+ circles: Scalars['Int']['output'];
+ tags: Scalars['Int']['output'];
+ worlds: Scalars['Int']['output'];
+};
+
export type CoverInput = {
id: Scalars['Int']['input'];
};
@@ -940,6 +949,7 @@ export type Query = {
namespace: NamespaceResponse;
namespaces: NamespaceFilterResult;
scrapeComic: ScrapeComicResponse;
+ statistics: Statistics;
tag: TagResponse;
tags: TagFilterResult;
world: WorldResponse;
@@ -1120,6 +1130,11 @@ export enum SortDirection {
Descending = 'DESCENDING'
}
+export type Statistics = {
+ __typename?: 'Statistics';
+ total: Totals;
+};
+
export type StringFilter = {
contains?: InputMaybe<Scalars['String']['input']>;
};
@@ -1173,6 +1188,22 @@ export type TagSortInput = {
seed?: InputMaybe<Scalars['Int']['input']>;
};
+export type Totals = {
+ __typename?: 'Totals';
+ archives: Scalars['Int']['output'];
+ artists: Scalars['Int']['output'];
+ characters: Scalars['Int']['output'];
+ circles: Scalars['Int']['output'];
+ comic: ComicTotals;
+ comics: Scalars['Int']['output'];
+ images: Scalars['Int']['output'];
+ namespaces: Scalars['Int']['output'];
+ pages: Scalars['Int']['output'];
+ scrapers: Scalars['Int']['output'];
+ tags: Scalars['Int']['output'];
+ worlds: Scalars['Int']['output'];
+};
+
export type UniquePagesInput = {
ids: Array<Scalars['Int']['input']>;
};
@@ -1528,6 +1559,11 @@ export type FrontpageQueryVariables = Exact<{ [key: string]: never; }>;
export type FrontpageQuery = { __typename?: 'Query', recent: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> }, favourites: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> }, bookmarked: { __typename?: 'ComicFilterResult', count: number, edges: Array<{ __typename?: 'Comic', id: number, title: string, originalTitle?: string | null, favourite: boolean, cover: { __typename?: 'Image', hash: string, width: number, height: number }, tags: Array<{ __typename?: 'ComicTag', name: string, description?: string | null }>, artists: Array<{ __typename?: 'Artist', name: string }>, characters: Array<{ __typename?: 'Character', name: string }>, worlds: Array<{ __typename?: 'World', name: string }>, circles: Array<{ __typename?: 'Circle', name: string }> }> } };
+export type StatisticsQueryVariables = Exact<{ [key: string]: never; }>;
+
+
+export type StatisticsQuery = { __typename?: 'Query', statistics: { __typename?: 'Statistics', total: { __typename?: 'Totals', archives: number, artists: number, characters: number, circles: number, comics: number, images: number, scrapers: number, pages: number, namespaces: number, tags: number, worlds: number, comic: { __typename?: 'ComicTotals', artists: number, characters: number, circles: number, tags: number, worlds: number } } } };
+
export type AddComicMutationVariables = Exact<{
input: AddComicInput;
}>;
@@ -1738,6 +1774,7 @@ export const WorldDocument = {"kind":"Document","definitions":[{"kind":"Operatio
export const ComicScrapersDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"comicScrapers"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"comicScrapers"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<ComicScrapersQuery, ComicScrapersQueryVariables>;
export const ScrapeComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"scrapeComic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"scraper"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"scrapeComic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"Argument","name":{"kind":"Name","value":"scraper"},"value":{"kind":"Variable","name":{"kind":"Name","value":"scraper"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ScrapeComicResult"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ScrapeComicResult"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ScrapeComicResult"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"category"}},{"kind":"Field","name":{"kind":"Name","value":"censorship"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"date"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"language"}},{"kind":"Field","name":{"kind":"Name","value":"layout"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rating"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"warnings"}}]}}]} as unknown as DocumentNode<ScrapeComicQuery, ScrapeComicQueryVariables>;
export const FrontpageDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"frontpage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","alias":{"kind":"Name","value":"recent"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"CREATED_AT"}},{"kind":"ObjectField","name":{"kind":"Name","value":"direction"},"value":{"kind":"EnumValue","value":"DESCENDING"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"favourites"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"include"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"favourite"},"value":{"kind":"BooleanValue","value":true}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"RANDOM"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}},{"kind":"Field","alias":{"kind":"Name","value":"bookmarked"},"name":{"kind":"Name","value":"comics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"pagination"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"items"},"value":{"kind":"IntValue","value":"6"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"include"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"bookmarked"},"value":{"kind":"BooleanValue","value":true}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"sort"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"on"},"value":{"kind":"EnumValue","value":"RANDOM"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Comic"}}]}},{"kind":"Field","name":{"kind":"Name","value":"count"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Image"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Image"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hash"}},{"kind":"Field","name":{"kind":"Name","value":"width"}},{"kind":"Field","name":{"kind":"Name","value":"height"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"Comic"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Comic"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"originalTitle"}},{"kind":"Field","name":{"kind":"Name","value":"favourite"}},{"kind":"Field","name":{"kind":"Name","value":"cover"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"Image"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}}]}},{"kind":"Field","name":{"kind":"Name","value":"artists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"characters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"worlds"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"circles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode<FrontpageQuery, FrontpageQueryVariables>;
+export const StatisticsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"statistics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"statistics"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archives"}},{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"comic"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"artists"}},{"kind":"Field","name":{"kind":"Name","value":"characters"}},{"kind":"Field","name":{"kind":"Name","value":"circles"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}},{"kind":"Field","name":{"kind":"Name","value":"comics"}},{"kind":"Field","name":{"kind":"Name","value":"images"}},{"kind":"Field","name":{"kind":"Name","value":"scrapers"}},{"kind":"Field","name":{"kind":"Name","value":"pages"}},{"kind":"Field","name":{"kind":"Name","value":"namespaces"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}},{"kind":"Field","name":{"kind":"Name","value":"worlds"}}]}}]}}]}}]} as unknown as DocumentNode<StatisticsQuery, StatisticsQueryVariables>;
export const AddComicDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"addComic"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addComic"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"AddComicSuccess"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"archivePagesRemaining"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<AddComicMutation, AddComicMutationVariables>;
export const UpdateArchivesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateArchives"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateArchiveInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateArchives"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateArchivesMutation, UpdateArchivesMutationVariables>;
export const UpdateComicsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"updateComics"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"ids"}},"type":{"kind":"NonNullType","type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateComicInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateComics"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"ids"},"value":{"kind":"Variable","name":{"kind":"Name","value":"ids"}}},{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Success"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}},{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]} as unknown as DocumentNode<UpdateComicsMutation, UpdateComicsMutationVariables>;
diff --git a/frontend/src/lib/statistics/Stat.svelte b/frontend/src/lib/statistics/Stat.svelte
new file mode 100644
index 0000000..c657526
--- /dev/null
+++ b/frontend/src/lib/statistics/Stat.svelte
@@ -0,0 +1,27 @@
+<script lang="ts">
+ export let title: string;
+ export let value: number;
+ export let precision = 0;
+ export let unit = '';
+
+ function format(value: number) {
+ if (Number.isNaN(value) || !Number.isFinite(value)) {
+ return 0;
+ }
+
+ if (Number.isInteger(value)) {
+ return value;
+ } else {
+ return value.toFixed(precision);
+ }
+ }
+</script>
+
+<div class="flex flex-col">
+ <h2 class="text-lg font-medium">
+ {title}
+ </h2>
+ <span class="text-base font-medium">
+ {format(value)}{unit}
+ </span>
+</div>
diff --git a/frontend/src/lib/statistics/StatGroup.svelte b/frontend/src/lib/statistics/StatGroup.svelte
new file mode 100644
index 0000000..e1b97da
--- /dev/null
+++ b/frontend/src/lib/statistics/StatGroup.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ export let title;
+</script>
+
+<section
+ class="flex flex-col gap-2 rounded bg-slate-900 p-2 font-medium shadow-md shadow-slate-950/30"
+>
+ <h2 class="text-2xl">{title}</h2>
+ <div class="flex flex-row flex-wrap gap-10">
+ <slot />
+ </div>
+</section>
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 9a5f3d2..6af3b88 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -63,6 +63,9 @@
<span class="icon-base icon-[material-symbols--folder-zip]" />
</Link>
<div class="mb-auto" />
+ <Link href="/statistics/" title="Statistics" accel="gs">
+ <span class="icon-base icon-[material-symbols--bar-chart]" />
+ </Link>
<Link href="/help/" title="Help" accel="?" target="_blank">
<span class="icon-base icon-[material-symbols--help]" />
</Link>
diff --git a/frontend/src/routes/statistics/+page.svelte b/frontend/src/routes/statistics/+page.svelte
new file mode 100644
index 0000000..7497bcf
--- /dev/null
+++ b/frontend/src/routes/statistics/+page.svelte
@@ -0,0 +1,43 @@
+<script lang="ts">
+ import { statisticsQuery } from '$gql/Queries';
+ import Head from '$lib/components/Head.svelte';
+ import Stat from '$lib/statistics/Stat.svelte';
+ import StatGroup from '$lib/statistics/StatGroup.svelte';
+ import { getContextClient } from '@urql/svelte';
+
+ $: query = statisticsQuery(getContextClient());
+ $: totals = $query.data?.statistics.total;
+</script>
+
+<Head section="Statistics" />
+
+{#if $query.data && totals}
+ <div class="flex flex-row flex-wrap gap-8">
+ <StatGroup title="Content">
+ <Stat title="Archives" value={totals.archives} />
+ <Stat title="Comics" value={totals.comics} />
+ <Stat title="Images" value={totals.images} />
+ <Stat title="Pages" value={totals.pages} />
+ <Stat title="Dedup" value={(1 - totals.images / totals.pages) * 100} precision={2} unit="%" />
+ </StatGroup>
+ <StatGroup title="Metadata">
+ <Stat title="Namespaces" value={totals.namespaces} />
+ <Stat title="Tags" value={totals.tags} />
+ <Stat title="Artists" value={totals.artists} />
+ <Stat title="Circles" value={totals.circles} />
+ <Stat title="Characters" value={totals.characters} />
+ <Stat title="Worlds" value={totals.worlds} />
+ </StatGroup>
+ <StatGroup title="Average per Comic">
+ <Stat title="Tags" value={totals.comic.tags / totals.comics} precision={2} />
+ <Stat title="Artists" value={totals.comic.artists / totals.comics} precision={2} />
+ <Stat title="Circles" value={totals.comic.circles / totals.comics} precision={2} />
+ <Stat title="Characters" value={totals.comic.characters / totals.comics} precision={2} />
+ <Stat title="Worlds" value={totals.comic.worlds / totals.comics} precision={2} />
+ </StatGroup>
+ <StatGroup title="Average per Archive">
+ <Stat title="Comics" value={totals.comics / totals.archives} precision={2} />
+ <Stat title="Images" value={totals.images / totals.archives} precision={2} />
+ </StatGroup>
+ </div>
+{/if}
diff --git a/frontend/src/routes/statistics/+page.ts b/frontend/src/routes/statistics/+page.ts
new file mode 100644
index 0000000..d3c3250
--- /dev/null
+++ b/frontend/src/routes/statistics/+page.ts
@@ -0,0 +1 @@
+export const trailingSlash = 'always';