aboutsummaryrefslogtreecommitdiff
path: root/src/lib/Tools
diff options
context:
space:
mode:
authorFuwn <[email protected]>2024-10-09 00:41:20 -0700
committerFuwn <[email protected]>2024-10-09 00:41:43 -0700
commit998b63a35256ac985a5a2714dd1ca451af4dfd8a (patch)
tree50796121a9d5ab0330fdc5d7e098bda2860d9726 /src/lib/Tools
parentfeat(graphql): add badgeCount field (diff)
downloaddue.moe-998b63a35256ac985a5a2714dd1ca451af4dfd8a.tar.xz
due.moe-998b63a35256ac985a5a2714dd1ca451af4dfd8a.zip
chore(prettier): use spaces instead of tabs
Diffstat (limited to 'src/lib/Tools')
-rw-r--r--src/lib/Tools/ActivityHistory/Grid.svelte130
-rw-r--r--src/lib/Tools/ActivityHistory/Tool.svelte220
-rw-r--r--src/lib/Tools/Birthdays.svelte310
-rw-r--r--src/lib/Tools/DumpProfile.svelte80
-rw-r--r--src/lib/Tools/EpisodeDiscussionCollector.svelte104
-rw-r--r--src/lib/Tools/FollowFix.svelte82
-rw-r--r--src/lib/Tools/Hayai.svelte144
-rw-r--r--src/lib/Tools/HololiveBirthdays.svelte152
-rw-r--r--src/lib/Tools/InputTemplate.svelte114
-rw-r--r--src/lib/Tools/Likes.svelte100
-rw-r--r--src/lib/Tools/Picker.svelte32
-rw-r--r--src/lib/Tools/RandomFollower.svelte46
-rw-r--r--src/lib/Tools/SequelCatcher/List.svelte126
-rw-r--r--src/lib/Tools/SequelCatcher/Tool.svelte132
-rw-r--r--src/lib/Tools/SequelSpy/Prequels.svelte56
-rw-r--r--src/lib/Tools/SequelSpy/Tool.svelte112
-rw-r--r--src/lib/Tools/UmaMusumeBirthdays.svelte222
-rw-r--r--src/lib/Tools/Wrapped/ActivityHistory.svelte28
-rw-r--r--src/lib/Tools/Wrapped/Media.svelte184
-rw-r--r--src/lib/Tools/Wrapped/MediaExtras.svelte136
-rw-r--r--src/lib/Tools/Wrapped/Tool.svelte1530
-rw-r--r--src/lib/Tools/Wrapped/Top/Activity.svelte70
-rw-r--r--src/lib/Tools/Wrapped/Top/Anime.svelte28
-rw-r--r--src/lib/Tools/Wrapped/Top/Manga.svelte32
-rw-r--r--src/lib/Tools/Wrapped/Watermark.svelte6
-rw-r--r--src/lib/Tools/Wrapped/wrapped.css84
-rw-r--r--src/lib/Tools/tools.ts174
27 files changed, 2217 insertions, 2217 deletions
diff --git a/src/lib/Tools/ActivityHistory/Grid.svelte b/src/lib/Tools/ActivityHistory/Grid.svelte
index 22d90d33..db9f3839 100644
--- a/src/lib/Tools/ActivityHistory/Grid.svelte
+++ b/src/lib/Tools/ActivityHistory/Grid.svelte
@@ -1,82 +1,82 @@
<script lang="ts">
- import {
- fillMissingDays,
- type ActivityHistoryEntry,
- activityHistory
- } from '$lib/Data/AniList/activity';
- import { onMount } from 'svelte';
- import userIdentity from '$stores/identity';
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import { clearAllParameters } from '../../Utility/parameters';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import tooltip from '$lib/Tooltip/tooltip';
- import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
+ import {
+ fillMissingDays,
+ type ActivityHistoryEntry,
+ activityHistory
+ } from '$lib/Data/AniList/activity';
+ import { onMount } from 'svelte';
+ import userIdentity from '$stores/identity';
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import { clearAllParameters } from '../../Utility/parameters';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import tooltip from '$lib/Tooltip/tooltip';
+ import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
- export let user: AniListAuthorisation;
- export let activityData: ActivityHistoryEntry[] | null = null;
- export let currentYear = new Date().getFullYear();
+ export let user: AniListAuthorisation;
+ export let activityData: ActivityHistoryEntry[] | null = null;
+ export let currentYear = new Date().getFullYear();
- let activityHistoryData: ActivityHistoryEntry[];
- let baseHue = Math.floor(Math.random() * 360);
+ let activityHistoryData: ActivityHistoryEntry[];
+ let baseHue = Math.floor(Math.random() * 360);
- onMount(async () => {
- clearAllParameters();
+ onMount(async () => {
+ clearAllParameters();
- activityHistoryData = activityData || (await activityHistory($userIdentity));
- });
+ activityHistoryData = activityData || (await activityHistory($userIdentity));
+ });
- const gradientColour = (amount: number, maxAmount: number, baseHue: number) => {
- const lightness = 100 - Math.round((amount / maxAmount) * 50);
+ const gradientColour = (amount: number, maxAmount: number, baseHue: number) => {
+ const lightness = 100 - Math.round((amount / maxAmount) * 50);
- return `hsl(${baseHue}, 100%, ${lightness}%)`;
- };
+ return `hsl(${baseHue}, 100%, ${lightness}%)`;
+ };
</script>
{#if user === undefined}
- <LogInRestricted />
+ <LogInRestricted />
{:else if activityHistoryData === undefined}
- <Skeleton card={false} count={1} height="150px" />
+ <Skeleton card={false} count={1} height="150px" />
{:else}
- {@const filledActivities = fillMissingDays(activityHistoryData, false, currentYear)}
- {@const highestActivity = Math.max(...filledActivities.map((activity) => activity.amount))}
+ {@const filledActivities = fillMissingDays(activityHistoryData, false, currentYear)}
+ {@const highestActivity = Math.max(...filledActivities.map((activity) => activity.amount))}
- <div class="grid">
- {#each filledActivities as activity}
- <div
- class="grid-item"
- style="background-color: {gradientColour(activity.amount, highestActivity, baseHue)}"
- on:click={() => (baseHue = Math.floor(Math.random() * 360))}
- on:keydown={() => {
- return;
- }}
- role="button"
- tabindex="0"
- use:tooltip
- title={`Date: ${new Date(activity.date * 1000).toLocaleDateString()}\nAmount: ${
- activity.amount
- }`}
- />
- {/each}
- </div>
+ <div class="grid">
+ {#each filledActivities as activity}
+ <div
+ class="grid-item"
+ style="background-color: {gradientColour(activity.amount, highestActivity, baseHue)}"
+ on:click={() => (baseHue = Math.floor(Math.random() * 360))}
+ on:keydown={() => {
+ return;
+ }}
+ role="button"
+ tabindex="0"
+ use:tooltip
+ title={`Date: ${new Date(activity.date * 1000).toLocaleDateString()}\nAmount: ${
+ activity.amount
+ }`}
+ />
+ {/each}
+ </div>
{/if}
<style>
- .grid {
- display: grid;
- grid-template-columns: repeat(52, 1fr);
- grid-template-rows: repeat(7, 1fr);
- /* gap: 2px; */
- grid-auto-flow: column;
- width: min-content;
- background-color: white;
- border-radius: 3px;
- overflow: hidden;
- }
- .grid-item {
- width: 20px;
- height: 20px;
- /* width: 3.25vw; */
- /* height: 3.25vw; */
- /* border: 1px solid #ccc; */
- }
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(52, 1fr);
+ grid-template-rows: repeat(7, 1fr);
+ /* gap: 2px; */
+ grid-auto-flow: column;
+ width: min-content;
+ background-color: white;
+ border-radius: 3px;
+ overflow: hidden;
+ }
+ .grid-item {
+ width: 20px;
+ height: 20px;
+ /* width: 3.25vw; */
+ /* height: 3.25vw; */
+ /* border: 1px solid #ccc; */
+ }
</style>
diff --git a/src/lib/Tools/ActivityHistory/Tool.svelte b/src/lib/Tools/ActivityHistory/Tool.svelte
index aafac40d..b6e66a5e 100644
--- a/src/lib/Tools/ActivityHistory/Tool.svelte
+++ b/src/lib/Tools/ActivityHistory/Tool.svelte
@@ -1,116 +1,116 @@
<script lang="ts">
- import {
- activityHistory,
- fillMissingDays,
- type ActivityHistoryEntry
- } from '$lib/Data/AniList/activity';
- import { onMount } from 'svelte';
- import userIdentity from '$stores/identity';
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import { clearAllParameters } from '../../Utility/parameters';
- import { domToBlob } from 'modern-screenshot';
- import ActivityHistoryGrid from './Grid.svelte';
- import SettingHint from '$lib/Settings/SettingHint.svelte';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
-
- export let user: AniListAuthorisation;
-
- let activityHistoryData: Promise<ActivityHistoryEntry[]>;
- let generated = false;
-
- onMount(async () => {
- clearAllParameters();
-
- if (user !== undefined) activityHistoryData = activityHistory($userIdentity);
- });
-
- // const incrementDate = (date: Date): Date => {
- // date.setDate(date.getDate() + 1);
-
- // return date;
- // };
-
- const screenshot = async () => {
- let element = document.querySelector('.grid') as HTMLElement;
-
- if (element !== null) {
- domToBlob(element, {
- quality: 1,
- scale: 2
- }).then((blob) => {
- const downloadWrapper = document.createElement('a');
- const image = document.createElement('img');
- const object = (window.URL || window.webkitURL || window || {}).createObjectURL(blob);
-
- downloadWrapper.href = object;
- downloadWrapper.target = '_blank';
- image.src = object;
-
- downloadWrapper.appendChild(image);
-
- const gridFinal = document.getElementById('grid-final');
-
- if (gridFinal !== null) {
- gridFinal.innerHTML = '';
-
- gridFinal.appendChild(downloadWrapper);
-
- generated = true;
- }
-
- downloadWrapper.click();
- });
- }
- };
+ import {
+ activityHistory,
+ fillMissingDays,
+ type ActivityHistoryEntry
+ } from '$lib/Data/AniList/activity';
+ import { onMount } from 'svelte';
+ import userIdentity from '$stores/identity';
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import { clearAllParameters } from '../../Utility/parameters';
+ import { domToBlob } from 'modern-screenshot';
+ import ActivityHistoryGrid from './Grid.svelte';
+ import SettingHint from '$lib/Settings/SettingHint.svelte';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
+
+ export let user: AniListAuthorisation;
+
+ let activityHistoryData: Promise<ActivityHistoryEntry[]>;
+ let generated = false;
+
+ onMount(async () => {
+ clearAllParameters();
+
+ if (user !== undefined) activityHistoryData = activityHistory($userIdentity);
+ });
+
+ // const incrementDate = (date: Date): Date => {
+ // date.setDate(date.getDate() + 1);
+
+ // return date;
+ // };
+
+ const screenshot = async () => {
+ let element = document.querySelector('.grid') as HTMLElement;
+
+ if (element !== null) {
+ domToBlob(element, {
+ quality: 1,
+ scale: 2
+ }).then((blob) => {
+ const downloadWrapper = document.createElement('a');
+ const image = document.createElement('img');
+ const object = (window.URL || window.webkitURL || window || {}).createObjectURL(blob);
+
+ downloadWrapper.href = object;
+ downloadWrapper.target = '_blank';
+ image.src = object;
+
+ downloadWrapper.appendChild(image);
+
+ const gridFinal = document.getElementById('grid-final');
+
+ if (gridFinal !== null) {
+ gridFinal.innerHTML = '';
+
+ gridFinal.appendChild(downloadWrapper);
+
+ generated = true;
+ }
+
+ downloadWrapper.click();
+ });
+ }
+ };
</script>
{#if user === undefined}
- <LogInRestricted />
+ <LogInRestricted />
{:else}
- {#await activityHistoryData}
- <Skeleton card={false} count={5} height="0.9rem" list />
- {:then activities}
- {#if activities === undefined}
- <Skeleton card={false} count={5} height="0.9rem" list />
- {:else}
- {@const filledActivities = fillMissingDays(activities)}
-
- <div class="card">
- <ActivityHistoryGrid {user} />
-
- <p />
-
- <div id="grid-final" />
-
- {#if generated}
- <p />
- {/if}
-
- <button on:click={screenshot}>Generate grid image</button>
- </div>
-
- <p />
-
- <details open>
- <summary>Days in risk of developing an activity history hole</summary>
-
- <SettingHint>
- Days in which you did not log any activity or only have one activity logged.
- </SettingHint>
-
- <ul>
- {#each filledActivities as activity}
- {#if activity.amount === 0}
- <li>
- {new Date(
- activity.date * 1000 + new Date().getTimezoneOffset() * 60 * 1000
- ).toDateString()}
- </li>
- {/if}
- {/each}
- </ul>
- </details>
- {/if}
- {/await}
+ {#await activityHistoryData}
+ <Skeleton card={false} count={5} height="0.9rem" list />
+ {:then activities}
+ {#if activities === undefined}
+ <Skeleton card={false} count={5} height="0.9rem" list />
+ {:else}
+ {@const filledActivities = fillMissingDays(activities)}
+
+ <div class="card">
+ <ActivityHistoryGrid {user} />
+
+ <p />
+
+ <div id="grid-final" />
+
+ {#if generated}
+ <p />
+ {/if}
+
+ <button on:click={screenshot}>Generate grid image</button>
+ </div>
+
+ <p />
+
+ <details open>
+ <summary>Days in risk of developing an activity history hole</summary>
+
+ <SettingHint>
+ Days in which you did not log any activity or only have one activity logged.
+ </SettingHint>
+
+ <ul>
+ {#each filledActivities as activity}
+ {#if activity.amount === 0}
+ <li>
+ {new Date(
+ activity.date * 1000 + new Date().getTimezoneOffset() * 60 * 1000
+ ).toDateString()}
+ </li>
+ {/if}
+ {/each}
+ </ul>
+ </details>
+ {/if}
+ {/await}
{/if}
diff --git a/src/lib/Tools/Birthdays.svelte b/src/lib/Tools/Birthdays.svelte
index 15e2d2fb..97ff40d8 100644
--- a/src/lib/Tools/Birthdays.svelte
+++ b/src/lib/Tools/Birthdays.svelte
@@ -1,166 +1,166 @@
<script lang="ts">
- import { browser } from '$app/environment';
- import { page } from '$app/stores';
- import { ACDBBirthdays, type ACDBBirthday } from '$lib/Data/Birthday/secondary';
- import { aniSearchBirthdays, type aniSearchBirthday } from '$lib/Data/Birthday/primary';
- import Error from '$lib/Error/RateLimited.svelte';
- import { onMount } from 'svelte';
- import { clearAllParameters, parseOrDefault } from '../Utility/parameters';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import Message from '$lib/Loading/Message.svelte';
- import tooltip from '$lib/Tooltip/tooltip';
-
- interface Birthday {
- name: string;
- image: string;
- origin?: string;
- }
-
- const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
- let date = new Date();
- let month = parseOrDefault(urlParameters, 'month', date.getMonth() + 1);
- let day = parseOrDefault(urlParameters, 'day', date.getDate());
- let anisearchBirthdays: Promise<aniSearchBirthday[]>;
- let acdbBirthdays: Promise<ACDBBirthday[]>;
-
- $: {
- month = Math.min(month, 12);
- month = Math.max(month, 1);
- day = Math.min(day, new Date(new Date().getFullYear(), month, 0).getDate());
- day = Math.max(day, 1);
-
- if (browser) anisearchBirthdays = aniSearchBirthdays(month, day);
-
- acdbBirthdays = ACDBBirthdays(month, day);
-
- if (browser) {
- $page.url.searchParams.set('month', month.toString());
- $page.url.searchParams.set('day', day.toString());
- clearAllParameters(['month', 'day']);
- history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
- }
- }
-
- onMount(() => clearAllParameters(['month', 'day']));
-
- const normaliseName = (name: string): string => name.toLowerCase().split(' ').sort().join(' ');
-
- const fixName = (name: string): string => {
- const split = name.split(' ');
- const last = split[split.length - 1];
-
- if (last === last.toUpperCase()) {
- split[split.length - 1] = last[0] + last.slice(1).toLowerCase();
- return split.join(' ');
- }
-
- const bracketIndex = name.indexOf('[');
-
- if (bracketIndex !== -1) return name.slice(0, bracketIndex).trim();
-
- return name;
- };
-
- const combineBirthdaySources = (
- acdb: ACDBBirthday[],
- aniSearch: aniSearchBirthday[]
- ): Birthday[] => {
- const nameMap = new Map<string, Birthday>();
-
- for (const entry of aniSearch.map((entry) => ({
- ...entry,
- normalisedName: normaliseName(fixName(entry.name))
- }))) {
- if (!nameMap.has(entry.normalisedName))
- nameMap.set(entry.normalisedName, {
- name: fixName(entry.name),
- image: entry.image
- });
- }
-
- for (const entry of acdb) {
- const normalisedName = normaliseName(fixName(entry.name));
-
- if (!nameMap.has(normalisedName))
- nameMap.set(normalisedName, {
- name: entry.name,
- image: entry.character_image,
- origin: entry.origin
- });
- }
-
- return Array.from(nameMap.values());
- };
+ import { browser } from '$app/environment';
+ import { page } from '$app/stores';
+ import { ACDBBirthdays, type ACDBBirthday } from '$lib/Data/Birthday/secondary';
+ import { aniSearchBirthdays, type aniSearchBirthday } from '$lib/Data/Birthday/primary';
+ import Error from '$lib/Error/RateLimited.svelte';
+ import { onMount } from 'svelte';
+ import { clearAllParameters, parseOrDefault } from '../Utility/parameters';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import Message from '$lib/Loading/Message.svelte';
+ import tooltip from '$lib/Tooltip/tooltip';
+
+ interface Birthday {
+ name: string;
+ image: string;
+ origin?: string;
+ }
+
+ const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
+ let date = new Date();
+ let month = parseOrDefault(urlParameters, 'month', date.getMonth() + 1);
+ let day = parseOrDefault(urlParameters, 'day', date.getDate());
+ let anisearchBirthdays: Promise<aniSearchBirthday[]>;
+ let acdbBirthdays: Promise<ACDBBirthday[]>;
+
+ $: {
+ month = Math.min(month, 12);
+ month = Math.max(month, 1);
+ day = Math.min(day, new Date(new Date().getFullYear(), month, 0).getDate());
+ day = Math.max(day, 1);
+
+ if (browser) anisearchBirthdays = aniSearchBirthdays(month, day);
+
+ acdbBirthdays = ACDBBirthdays(month, day);
+
+ if (browser) {
+ $page.url.searchParams.set('month', month.toString());
+ $page.url.searchParams.set('day', day.toString());
+ clearAllParameters(['month', 'day']);
+ history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
+ }
+ }
+
+ onMount(() => clearAllParameters(['month', 'day']));
+
+ const normaliseName = (name: string): string => name.toLowerCase().split(' ').sort().join(' ');
+
+ const fixName = (name: string): string => {
+ const split = name.split(' ');
+ const last = split[split.length - 1];
+
+ if (last === last.toUpperCase()) {
+ split[split.length - 1] = last[0] + last.slice(1).toLowerCase();
+ return split.join(' ');
+ }
+
+ const bracketIndex = name.indexOf('[');
+
+ if (bracketIndex !== -1) return name.slice(0, bracketIndex).trim();
+
+ return name;
+ };
+
+ const combineBirthdaySources = (
+ acdb: ACDBBirthday[],
+ aniSearch: aniSearchBirthday[]
+ ): Birthday[] => {
+ const nameMap = new Map<string, Birthday>();
+
+ for (const entry of aniSearch.map((entry) => ({
+ ...entry,
+ normalisedName: normaliseName(fixName(entry.name))
+ }))) {
+ if (!nameMap.has(entry.normalisedName))
+ nameMap.set(entry.normalisedName, {
+ name: fixName(entry.name),
+ image: entry.image
+ });
+ }
+
+ for (const entry of acdb) {
+ const normalisedName = normaliseName(fixName(entry.name));
+
+ if (!nameMap.has(normalisedName))
+ nameMap.set(normalisedName, {
+ name: entry.name,
+ image: entry.character_image,
+ origin: entry.origin
+ });
+ }
+
+ return Array.from(nameMap.values());
+ };
</script>
{#await acdbBirthdays}
- <Message message="Loading birthday set one ..." />
+ <Message message="Loading birthday set one ..." />
- <Skeleton grid={true} count={100} width="150px" height="170px" />
+ <Skeleton grid={true} count={100} width="150px" height="170px" />
{:then acdbBirthdays}
- {#await anisearchBirthdays}
- <Message message="Loading birthday set two ..." />
-
- <Skeleton grid={true} count={100} width="150px" height="170px" />
- {:then anisearch}
- {@const birthdays = combineBirthdaySources(acdbBirthdays, anisearch)}
-
- <p>
- <select bind:value={month}>
- {#each Array.from({ length: 12 }, (_, i) => i + 1) as month}
- <option value={month}>
- {new Date(0, month - 1).toLocaleString('default', { month: 'long' })}
- </option>
- {/each}
- </select>
-
- <select bind:value={day}>
- {#each Array.from({ length: new Date(new Date().getFullYear(), month, 0).getDate() }, (_, i) => i + 1) as day}
- <option value={day}>{day}</option>
- {/each}
- </select>
- </p>
-
- <div class="characters">
- {#each birthdays as birthday}
- <div class="card card-small">
- <a
- href={`https://anilist.co/search/characters?search=${encodeURIComponent(
- birthday.name
- ).replace(/%20/g, '+')}`}
- target="_blank"
- title={birthday.origin}
- use:tooltip
- data-tooltip-disable={birthday.origin === undefined}
- >
- {birthday.name}
- <img src={birthday.image} alt="Character (Large)" class="character-image" />
- </a>
- </div>
- {/each}
- </div>
- {:catch}
- <Error type="Character" card />
- {/await}
+ {#await anisearchBirthdays}
+ <Message message="Loading birthday set two ..." />
+
+ <Skeleton grid={true} count={100} width="150px" height="170px" />
+ {:then anisearch}
+ {@const birthdays = combineBirthdaySources(acdbBirthdays, anisearch)}
+
+ <p>
+ <select bind:value={month}>
+ {#each Array.from({ length: 12 }, (_, i) => i + 1) as month}
+ <option value={month}>
+ {new Date(0, month - 1).toLocaleString('default', { month: 'long' })}
+ </option>
+ {/each}
+ </select>
+
+ <select bind:value={day}>
+ {#each Array.from({ length: new Date(new Date().getFullYear(), month, 0).getDate() }, (_, i) => i + 1) as day}
+ <option value={day}>{day}</option>
+ {/each}
+ </select>
+ </p>
+
+ <div class="characters">
+ {#each birthdays as birthday}
+ <div class="card card-small">
+ <a
+ href={`https://anilist.co/search/characters?search=${encodeURIComponent(
+ birthday.name
+ ).replace(/%20/g, '+')}`}
+ target="_blank"
+ title={birthday.origin}
+ use:tooltip
+ data-tooltip-disable={birthday.origin === undefined}
+ >
+ {birthday.name}
+ <img src={birthday.image} alt="Character (Large)" class="character-image" />
+ </a>
+ </div>
+ {/each}
+ </div>
+ {:catch}
+ <Error type="Character" card />
+ {/await}
{:catch}
- <Error type="Character" card />
+ <Error type="Character" card />
{/await}
<style lang="scss">
- .characters {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
- gap: 1rem;
- grid-row-gap: 1rem;
- align-items: start;
-
- img {
- width: 100%;
- height: auto;
- object-fit: cover;
- border-radius: 8px;
- margin-top: 0.5rem;
- box-shadow: 0 4px 30px var(--base01);
- }
- }
+ .characters {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
+ gap: 1rem;
+ grid-row-gap: 1rem;
+ align-items: start;
+
+ img {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+ box-shadow: 0 4px 30px var(--base01);
+ }
+ }
</style>
diff --git a/src/lib/Tools/DumpProfile.svelte b/src/lib/Tools/DumpProfile.svelte
index a6adba6d..45d4ffc9 100644
--- a/src/lib/Tools/DumpProfile.svelte
+++ b/src/lib/Tools/DumpProfile.svelte
@@ -1,52 +1,52 @@
<script lang="ts">
- import { dumpUser } from '$lib/Data/AniList/user';
- import RateLimited from '$lib/Error/RateLimited.svelte';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import InputTemplate from './InputTemplate.svelte';
- import LZString from 'lz-string';
-
- let submission = '';
-
- // Credit: @hoh
- const decodeJSON = (about: string): JSON | null => {
- const match = (about || '').match(/^\[\]\(json([A-Za-z0-9+/=]+)\)/);
-
- if (match)
- try {
- return JSON.parse(atob(match[1]));
- } catch {
- try {
- return JSON.parse(LZString.decompressFromBase64(match[1]));
- } catch {
- return null;
- }
- }
-
- return null;
- };
+ import { dumpUser } from '$lib/Data/AniList/user';
+ import RateLimited from '$lib/Error/RateLimited.svelte';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import InputTemplate from './InputTemplate.svelte';
+ import LZString from 'lz-string';
+
+ let submission = '';
+
+ // Credit: @hoh
+ const decodeJSON = (about: string): JSON | null => {
+ const match = (about || '').match(/^\[\]\(json([A-Za-z0-9+/=]+)\)/);
+
+ if (match)
+ try {
+ return JSON.parse(atob(match[1]));
+ } catch {
+ try {
+ return JSON.parse(LZString.decompressFromBase64(match[1]));
+ } catch {
+ return null;
+ }
+ }
+
+ return null;
+ };
</script>
<!-- svelte-ignore missing-declaration -->
<InputTemplate field="Username" bind:submission event="Dump User" submitText="Dump">
- {#await dumpUser(submission)}
- <Skeleton card={false} count={1} height="500px" />
- {:then dump}
- {@const decoded = decodeJSON(dump.about)}
+ {#await dumpUser(submission)}
+ <Skeleton card={false} count={1} height="500px" />
+ {:then dump}
+ {@const decoded = decodeJSON(dump.about)}
- <pre>{JSON.stringify(dump, null, 2)}</pre>
+ <pre>{JSON.stringify(dump, null, 2)}</pre>
- {#if decoded && (dump.about || '').includes('[](json')}
- <p />
+ {#if decoded && (dump.about || '').includes('[](json')}
+ <p />
- <pre>{JSON.stringify(decoded, null, 2).replaceAll(/\\n/g, '\n')}</pre>
- {/if}
- {:catch}
- <RateLimited type="User" list={false} />
- {/await}
+ <pre>{JSON.stringify(decoded, null, 2).replaceAll(/\\n/g, '\n')}</pre>
+ {/if}
+ {:catch}
+ <RateLimited type="User" list={false} />
+ {/await}
</InputTemplate>
<style>
- pre {
- margin: 0;
- }
+ pre {
+ margin: 0;
+ }
</style>
diff --git a/src/lib/Tools/EpisodeDiscussionCollector.svelte b/src/lib/Tools/EpisodeDiscussionCollector.svelte
index d213949f..4c61f3cf 100644
--- a/src/lib/Tools/EpisodeDiscussionCollector.svelte
+++ b/src/lib/Tools/EpisodeDiscussionCollector.svelte
@@ -1,61 +1,61 @@
<script lang="ts">
- import { threads } from '$lib/Data/AniList/forum';
- import { onMount } from 'svelte';
- import { clearAllParameters } from '../Utility/parameters';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import InputTemplate from './InputTemplate.svelte';
- import tooltip from '$lib/Tooltip/tooltip';
+ import { threads } from '$lib/Data/AniList/forum';
+ import { onMount } from 'svelte';
+ import { clearAllParameters } from '../Utility/parameters';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import InputTemplate from './InputTemplate.svelte';
+ import tooltip from '$lib/Tooltip/tooltip';
- let submission = '';
+ let submission = '';
- onMount(clearAllParameters);
+ onMount(clearAllParameters);
</script>
<InputTemplate
- field="Username"
- bind:submission
- event="Collect Episode Discussions"
- submitText="Search"
+ field="Username"
+ bind:submission
+ event="Collect Episode Discussions"
+ submitText="Search"
>
- {#if submission !== ''}
- {#await threads(submission)}
- <Skeleton card={false} count={5} height="0.9rem" list />
- {:then threads}
- <ul>
- {#each threads
- .filter((thread) => thread.title.includes('[Spoilers]') && thread.title.includes('Episode'))
- .sort((a, b) => b.createdAt - a.createdAt) as thread}
- <li>
- <span class="opaque" style="white-space: pre;">
- {new Date(thread.createdAt * 1000).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric'
- })}
- </span>
- <a
- href={`https://anilist.co/forum/thread/${thread.id}`}
- target="_blank"
- title={`<img src="${thread.mediaCategories[0].coverImage.extraLarge}" style="width: 250px; object-fit: cover; border-radius: 8px;" />`}
- use:tooltip
- >
- {thread.title.replace('[Spoilers]', '')}
- </a>
- </li>
- {/each}
- </ul>
- {:catch}
- <p>Threads could not be loaded. You might have been rate-limited.</p>
- <p>
- Try again in a few minutes. If the problem persists, please contact <a
- href="https://anilist.co/user/fuwn"
- target="_blank">@fuwn</a
- > on AniList.
- </p>
- {/await}
- {:else}
- <p />
+ {#if submission !== ''}
+ {#await threads(submission)}
+ <Skeleton card={false} count={5} height="0.9rem" list />
+ {:then threads}
+ <ul>
+ {#each threads
+ .filter((thread) => thread.title.includes('[Spoilers]') && thread.title.includes('Episode'))
+ .sort((a, b) => b.createdAt - a.createdAt) as thread}
+ <li>
+ <span class="opaque" style="white-space: pre;">
+ {new Date(thread.createdAt * 1000).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ })}
+ </span>
+ <a
+ href={`https://anilist.co/forum/thread/${thread.id}`}
+ target="_blank"
+ title={`<img src="${thread.mediaCategories[0].coverImage.extraLarge}" style="width: 250px; object-fit: cover; border-radius: 8px;" />`}
+ use:tooltip
+ >
+ {thread.title.replace('[Spoilers]', '')}
+ </a>
+ </li>
+ {/each}
+ </ul>
+ {:catch}
+ <p>Threads could not be loaded. You might have been rate-limited.</p>
+ <p>
+ Try again in a few minutes. If the problem persists, please contact <a
+ href="https://anilist.co/user/fuwn"
+ target="_blank">@fuwn</a
+ > on AniList.
+ </p>
+ {/await}
+ {:else}
+ <p />
- Enter a username to search for to continue.
- {/if}
+ Enter a username to search for to continue.
+ {/if}
</InputTemplate>
diff --git a/src/lib/Tools/FollowFix.svelte b/src/lib/Tools/FollowFix.svelte
index 434fb746..b9fddeff 100644
--- a/src/lib/Tools/FollowFix.svelte
+++ b/src/lib/Tools/FollowFix.svelte
@@ -1,52 +1,52 @@
<script lang="ts">
- import { toggleFollow } from '$lib/Data/AniList/follow';
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
+ import { toggleFollow } from '$lib/Data/AniList/follow';
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
- export let user: AniListAuthorisation;
+ export let user: AniListAuthorisation;
- let input = '';
- let submit = '';
+ let input = '';
+ let submit = '';
</script>
{#if user === undefined}
- <LogInRestricted />
+ <LogInRestricted />
{:else}
- <p>
- <!-- svelte-ignore missing-declaration -->
- <input
- type="text"
- minlength="1"
- placeholder="Username"
- bind:value={input}
- on:keypress={(e) => {
- if (e.key === 'Enter') {
- submit = input;
+ <p>
+ <!-- svelte-ignore missing-declaration -->
+ <input
+ type="text"
+ minlength="1"
+ placeholder="Username"
+ bind:value={input}
+ on:keypress={(e) => {
+ if (e.key === 'Enter') {
+ submit = input;
- // eslint-disable-next-line no-undef
- umami.track('Fix Follow');
- }
- }}
- />
- <a href={'#'} on:click={() => (submit = input)}>
- Toggle follow for {input.length === 0 ? '...' : input}
- </a>
- </p>
+ // eslint-disable-next-line no-undef
+ umami.track('Fix Follow');
+ }
+ }}
+ />
+ <a href={'#'} on:click={() => (submit = input)}>
+ Toggle follow for {input.length === 0 ? '...' : input}
+ </a>
+ </p>
- {#if submit.length > 0}
- {#await toggleFollow(user, submit)}
- Toggling follow ...
- {:then response}
- <p>
- Successfully toggled follow for {submit}.
+ {#if submit.length > 0}
+ {#await toggleFollow(user, submit)}
+ Toggling follow ...
+ {:then response}
+ <p>
+ Successfully toggled follow for {submit}.
- {submit}
- {response.isFollower ? 'is' : 'is not'} following you, {response.isFollowing
- ? 'but'
- : 'and'} you {response.isFollowing ? 'are' : 'are not'} following them.
- </p>
- {:catch}
- <p>Failed to toggle follow for {submit}.</p>
- {/await}
- {/if}
+ {submit}
+ {response.isFollower ? 'is' : 'is not'} following you, {response.isFollowing
+ ? 'but'
+ : 'and'} you {response.isFollowing ? 'are' : 'are not'} following them.
+ </p>
+ {:catch}
+ <p>Failed to toggle follow for {submit}.</p>
+ {/await}
+ {/if}
{/if}
diff --git a/src/lib/Tools/Hayai.svelte b/src/lib/Tools/Hayai.svelte
index f1eddb1c..1790af53 100644
--- a/src/lib/Tools/Hayai.svelte
+++ b/src/lib/Tools/Hayai.svelte
@@ -1,102 +1,102 @@
<script lang="ts">
- import { onMount } from 'svelte';
- import JSZip from 'jszip';
+ import { onMount } from 'svelte';
+ import JSZip from 'jszip';
- let fileInput: HTMLInputElement | null = null;
+ let fileInput: HTMLInputElement | null = null;
- const handleFileUpload = async () => {
- if (!fileInput || !fileInput.files || fileInput.files.length === 0) return;
+ const handleFileUpload = async () => {
+ if (!fileInput || !fileInput.files || fileInput.files.length === 0) return;
- const file = fileInput.files[0];
- const reader = new FileReader();
+ const file = fileInput.files[0];
+ const reader = new FileReader();
- reader.onload = async (event) => {
- const zip = await JSZip.loadAsync((event.target as FileReader).result as ArrayBuffer);
- const newZip = new JSZip();
+ reader.onload = async (event) => {
+ const zip = await JSZip.loadAsync((event.target as FileReader).result as ArrayBuffer);
+ const newZip = new JSZip();
- for (const relativePath in zip.files) {
- const zipEntry = zip.files[relativePath];
+ for (const relativePath in zip.files) {
+ const zipEntry = zip.files[relativePath];
- if (zipEntry.dir) {
- newZip.folder(relativePath);
- } else if (relativePath.endsWith('.xhtml') || relativePath.endsWith('.html')) {
- newZip.file(relativePath, applyBionicReadingToHTML(await zipEntry.async('text')));
- } else {
- newZip.file(relativePath, await zipEntry.async('arraybuffer'));
- }
- }
+ if (zipEntry.dir) {
+ newZip.folder(relativePath);
+ } else if (relativePath.endsWith('.xhtml') || relativePath.endsWith('.html')) {
+ newZip.file(relativePath, applyBionicReadingToHTML(await zipEntry.async('text')));
+ } else {
+ newZip.file(relativePath, await zipEntry.async('arraybuffer'));
+ }
+ }
- downloadEPUB(
- await newZip.generateAsync({ type: 'blob' }),
- `${file.name.split('.epub')[0]}_hayai.epub`
- );
- };
+ downloadEPUB(
+ await newZip.generateAsync({ type: 'blob' }),
+ `${file.name.split('.epub')[0]}_hayai.epub`
+ );
+ };
- reader.readAsArrayBuffer(file);
- };
+ reader.readAsArrayBuffer(file);
+ };
- const applyBionicReadingToHTML = (content: string) => {
- const contentParser = new DOMParser().parseFromString(content, 'text/html');
+ const applyBionicReadingToHTML = (content: string) => {
+ const contentParser = new DOMParser().parseFromString(content, 'text/html');
- for (const paragraph of contentParser.getElementsByTagName('p'))
- paragraph.innerHTML = applyBionicReadingToString(paragraph.textContent ?? '');
+ for (const paragraph of contentParser.getElementsByTagName('p'))
+ paragraph.innerHTML = applyBionicReadingToString(paragraph.textContent ?? '');
- return contentParser.documentElement.outerHTML;
- };
+ return contentParser.documentElement.outerHTML;
+ };
- const applyBionicReadingToString = (text: string) =>
- text
- .split(/\s+/)
- .map((word) => {
- if (/^\W+$/.test(word) || word.length <= 2) return word;
+ const applyBionicReadingToString = (text: string) =>
+ text
+ .split(/\s+/)
+ .map((word) => {
+ if (/^\W+$/.test(word) || word.length <= 2) return word;
- let boldLength;
+ let boldLength;
- if (word.length <= 4) {
- boldLength = 2;
- } else if (word.length <= 7) {
- boldLength = 3;
- } else if (word.length <= 10) {
- boldLength = 4;
- } else {
- boldLength = Math.ceil(word.length * 0.5);
- }
+ if (word.length <= 4) {
+ boldLength = 2;
+ } else if (word.length <= 7) {
+ boldLength = 3;
+ } else if (word.length <= 10) {
+ boldLength = 4;
+ } else {
+ boldLength = Math.ceil(word.length * 0.5);
+ }
- return `<strong>${word.slice(0, boldLength)}</strong>${word.slice(boldLength)}`;
- })
- .join(' ');
+ return `<strong>${word.slice(0, boldLength)}</strong>${word.slice(boldLength)}`;
+ })
+ .join(' ');
- const downloadEPUB = (blob: Blob | MediaSource, fileName: string) => {
- const link = document.createElement('a');
+ const downloadEPUB = (blob: Blob | MediaSource, fileName: string) => {
+ const link = document.createElement('a');
- link.href = URL.createObjectURL(blob);
- link.download = fileName;
+ link.href = URL.createObjectURL(blob);
+ link.download = fileName;
- link.click();
- URL.revokeObjectURL(link.href);
- };
+ link.click();
+ URL.revokeObjectURL(link.href);
+ };
- onMount(() => (fileInput = document.getElementById('epub-file') as HTMLInputElement));
+ onMount(() => (fileInput = document.getElementById('epub-file') as HTMLInputElement));
</script>
<div class="card">
- {@html applyBionicReadingToString('Upload an EPUB; receive a "bionic" EPUB.')}
+ {@html applyBionicReadingToString('Upload an EPUB; receive a "bionic" EPUB.')}
- <br />
+ <br />
- <small>
- {@html applyBionicReadingToString(
- "Bionic reading is a method of displaying text that aims to enhance readability and reading speed by guiding the reader's eyes using artificial fixation points."
- )}
- </small>
+ <small>
+ {@html applyBionicReadingToString(
+ "Bionic reading is a method of displaying text that aims to enhance readability and reading speed by guiding the reader's eyes using artificial fixation points."
+ )}
+ </small>
- <p />
+ <p />
- {@html applyBionicReadingToString(
- `After selecting an EPUB file, 早い will apply a bionic reading filter over any and all words, and return the newly created "bionic" EPUB file.`
- )}
+ {@html applyBionicReadingToString(
+ `After selecting an EPUB file, 早い will apply a bionic reading filter over any and all words, and return the newly created "bionic" EPUB file.`
+ )}
- <p />
+ <p />
- <input type="file" id="epub-file" accept=".epub" on:change={handleFileUpload} />
+ <input type="file" id="epub-file" accept=".epub" on:change={handleFileUpload} />
</div>
diff --git a/src/lib/Tools/HololiveBirthdays.svelte b/src/lib/Tools/HololiveBirthdays.svelte
index 059a1f91..68a591de 100644
--- a/src/lib/Tools/HololiveBirthdays.svelte
+++ b/src/lib/Tools/HololiveBirthdays.svelte
@@ -1,95 +1,95 @@
<script lang="ts">
- import { browser } from '$app/environment';
- import { page } from '$app/stores';
- import { onMount } from 'svelte';
- import { clearAllParameters, parseOrDefault } from '../Utility/parameters';
- import Message from '$lib/Loading/Message.svelte';
- import locale from '$stores/locale';
- import birthdays from '$lib/Data/Static/hololiveBirthdays.json';
+ import { browser } from '$app/environment';
+ import { page } from '$app/stores';
+ import { onMount } from 'svelte';
+ import { clearAllParameters, parseOrDefault } from '../Utility/parameters';
+ import Message from '$lib/Loading/Message.svelte';
+ import locale from '$stores/locale';
+ import birthdays from '$lib/Data/Static/hololiveBirthdays.json';
- const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
- let date = new Date();
- let month = parseOrDefault(urlParameters, 'month', date.getMonth() + 1);
- let day = parseOrDefault(urlParameters, 'day', date.getDate());
+ const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
+ let date = new Date();
+ let month = parseOrDefault(urlParameters, 'month', date.getMonth() + 1);
+ let day = parseOrDefault(urlParameters, 'day', date.getDate());
- $: todaysBirthdays = birthdays.filter(
- (birthday) => birthday.month === month && birthday.day === day
- );
+ $: todaysBirthdays = birthdays.filter(
+ (birthday) => birthday.month === month && birthday.day === day
+ );
- $: {
- month = Math.min(month, 12);
- month = Math.max(month, 1);
- day = Math.min(day, new Date(new Date().getFullYear(), month, 0).getDate());
- day = Math.max(day, 1);
+ $: {
+ month = Math.min(month, 12);
+ month = Math.max(month, 1);
+ day = Math.min(day, new Date(new Date().getFullYear(), month, 0).getDate());
+ day = Math.max(day, 1);
- if (browser) {
- $page.url.searchParams.set('month', month.toString());
- $page.url.searchParams.set('day', day.toString());
- clearAllParameters(['month', 'day']);
- history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
- }
- }
+ if (browser) {
+ $page.url.searchParams.set('month', month.toString());
+ $page.url.searchParams.set('day', day.toString());
+ clearAllParameters(['month', 'day']);
+ history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
+ }
+ }
- onMount(() => clearAllParameters(['month', 'day']));
+ onMount(() => clearAllParameters(['month', 'day']));
</script>
<p>
- <select bind:value={month}>
- {#each Array.from({ length: 12 }, (_, i) => i + 1) as month}
- <option value={month}>
- {new Date(0, month - 1).toLocaleString('default', { month: 'long' })}
- </option>
- {/each}
- </select>
+ <select bind:value={month}>
+ {#each Array.from({ length: 12 }, (_, i) => i + 1) as month}
+ <option value={month}>
+ {new Date(0, month - 1).toLocaleString('default', { month: 'long' })}
+ </option>
+ {/each}
+ </select>
- <select bind:value={day}>
- {#each Array.from({ length: new Date(new Date().getFullYear(), month, 0).getDate() }, (_, i) => i + 1) as day}
- <option value={day}>{day}</option>
- {/each}
- </select>
+ <select bind:value={day}>
+ {#each Array.from({ length: new Date(new Date().getFullYear(), month, 0).getDate() }, (_, i) => i + 1) as day}
+ <option value={day}>{day}</option>
+ {/each}
+ </select>
</p>
{#if todaysBirthdays.length === 0}
- <Message
- message={`No birthdays for ${$locale().dayFormatter(
- new Date(new Date().getFullYear(), month - 1, day)
- )}.`}
- fullscreen={false}
- loader="ripple"
- />
+ <Message
+ message={`No birthdays for ${$locale().dayFormatter(
+ new Date(new Date().getFullYear(), month - 1, day)
+ )}.`}
+ fullscreen={false}
+ loader="ripple"
+ />
{:else}
- <div class="characters">
- {#each todaysBirthdays as birthday}
- <div class="card card-small">
- <a
- href={`https://anilist.co/search/characters?search=${encodeURIComponent(
- birthday.name
- ).replace(/%20/g, '+')}`}
- target="_blank"
- >
- {birthday.name}
- <img src={birthday.image} alt="Character" class="character-image" />
- </a>
- </div>
- {/each}
- </div>
+ <div class="characters">
+ {#each todaysBirthdays as birthday}
+ <div class="card card-small">
+ <a
+ href={`https://anilist.co/search/characters?search=${encodeURIComponent(
+ birthday.name
+ ).replace(/%20/g, '+')}`}
+ target="_blank"
+ >
+ {birthday.name}
+ <img src={birthday.image} alt="Character" class="character-image" />
+ </a>
+ </div>
+ {/each}
+ </div>
{/if}
<style lang="scss">
- .characters {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
- gap: 1rem;
- grid-row-gap: 1rem;
- align-items: start;
+ .characters {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
+ gap: 1rem;
+ grid-row-gap: 1rem;
+ align-items: start;
- img {
- width: 100%;
- height: auto;
- object-fit: cover;
- border-radius: 8px;
- margin-top: 0.5rem;
- box-shadow: 0 4px 30px var(--base01);
- }
- }
+ img {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+ box-shadow: 0 4px 30px var(--base01);
+ }
+ }
</style>
diff --git a/src/lib/Tools/InputTemplate.svelte b/src/lib/Tools/InputTemplate.svelte
index c81c12d5..72e2f807 100644
--- a/src/lib/Tools/InputTemplate.svelte
+++ b/src/lib/Tools/InputTemplate.svelte
@@ -1,71 +1,71 @@
<script lang="ts">
- import { clearAllParameters } from '$lib/Utility/parameters';
- import { onMount } from 'svelte';
- import SettingHint from '$lib/Settings/SettingHint.svelte';
+ import { clearAllParameters } from '$lib/Utility/parameters';
+ import { onMount } from 'svelte';
+ import SettingHint from '$lib/Settings/SettingHint.svelte';
- export let field: string;
- export let submission: string;
- export let event: string | undefined = undefined;
- export let submitText: string;
- export let saveParameters: string[] = [];
- export let onSubmit = () => {
- return;
- };
- export let preserveCase = false;
- export let prompt = `Enter a ${
- preserveCase ? field : field.toLowerCase()
- } to search for to continue.`;
- export let hint: string | undefined = undefined;
+ export let field: string;
+ export let submission: string;
+ export let event: string | undefined = undefined;
+ export let submitText: string;
+ export let saveParameters: string[] = [];
+ export let onSubmit = () => {
+ return;
+ };
+ export let preserveCase = false;
+ export let prompt = `Enter a ${
+ preserveCase ? field : field.toLowerCase()
+ } to search for to continue.`;
+ export let hint: string | undefined = undefined;
- let input = '';
+ let input = '';
- onMount(() => clearAllParameters(saveParameters));
+ onMount(() => clearAllParameters(saveParameters));
</script>
<div class="card">
- <p>
- <!-- svelte-ignore missing-declaration -->
- <input
- type="text"
- placeholder={field}
- bind:value={input}
- on:keypress={(e) => {
- if (e.key === 'Enter') {
- submission = input;
+ <p>
+ <!-- svelte-ignore missing-declaration -->
+ <input
+ type="text"
+ placeholder={field}
+ bind:value={input}
+ on:keypress={(e) => {
+ if (e.key === 'Enter') {
+ submission = input;
- onSubmit();
+ onSubmit();
- // eslint-disable-next-line no-undef
- if (event) umami.track(event);
- }
- }}
- />
- <button
- class="button-lined"
- on:click={() => {
- submission = input;
+ // eslint-disable-next-line no-undef
+ if (event) umami.track(event);
+ }
+ }}
+ />
+ <button
+ class="button-lined"
+ on:click={() => {
+ submission = input;
- onSubmit();
- }}
- title="Or click your Enter key"
- data-umami-event={event}
- >
- {submitText}
- </button>
+ onSubmit();
+ }}
+ title="Or click your Enter key"
+ data-umami-event={event}
+ >
+ {submitText}
+ </button>
- {#if hint !== undefined}
- <br />
- <div style="margin-top: .25rem;">
- <SettingHint>{hint}</SettingHint>
- </div>
- {/if}
- </p>
+ {#if hint !== undefined}
+ <br />
+ <div style="margin-top: .25rem;">
+ <SettingHint>{hint}</SettingHint>
+ </div>
+ {/if}
+ </p>
- {#if submission !== ''}
- <slot />
- {:else}
- <p />
+ {#if submission !== ''}
+ <slot />
+ {:else}
+ <p />
- {prompt}
- {/if}
+ {prompt}
+ {/if}
</div>
diff --git a/src/lib/Tools/Likes.svelte b/src/lib/Tools/Likes.svelte
index fbbc30af..7b626c94 100644
--- a/src/lib/Tools/Likes.svelte
+++ b/src/lib/Tools/Likes.svelte
@@ -1,58 +1,58 @@
<script lang="ts">
- import { activityLikes } from '$lib/Data/AniList/activity';
- import { threadLikes } from '$lib/Data/AniList/forum';
- import RateLimited from '$lib/Error/RateLimited.svelte';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import tooltip from '$lib/Tooltip/tooltip';
- import settings from '$stores/settings';
- import InputTemplate from './InputTemplate.svelte';
+ import { activityLikes } from '$lib/Data/AniList/activity';
+ import { threadLikes } from '$lib/Data/AniList/forum';
+ import RateLimited from '$lib/Error/RateLimited.svelte';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import tooltip from '$lib/Tooltip/tooltip';
+ import settings from '$stores/settings';
+ import InputTemplate from './InputTemplate.svelte';
- let submission = '';
+ let submission = '';
- $: normalisedSubmission = submission.replace(/.*\/(activity|thread)\/(\d+).*/, '$2');
- $: submissionType = submission.replace(/.*\/(activity|thread)\/(\d+).*/, '$1');
- $: likesPromise =
- submissionType === 'activity'
- ? activityLikes(Number(normalisedSubmission))
- : submissionType === 'thread'
- ? threadLikes(Number(normalisedSubmission))
- : Promise.resolve(null);
+ $: normalisedSubmission = submission.replace(/.*\/(activity|thread)\/(\d+).*/, '$2');
+ $: submissionType = submission.replace(/.*\/(activity|thread)\/(\d+).*/, '$1');
+ $: likesPromise =
+ submissionType === 'activity'
+ ? activityLikes(Number(normalisedSubmission))
+ : submissionType === 'thread'
+ ? threadLikes(Number(normalisedSubmission))
+ : Promise.resolve(null);
</script>
<InputTemplate
- field="Activity or Thread URL"
- bind:submission
- event="Get All Likes"
- submitText="Get All Likes"
+ field="Activity or Thread URL"
+ bind:submission
+ event="Get All Likes"
+ submitText="Get All Likes"
>
- {#if submission.length > 5 && submission.match(/https:\/\/anilist.co\/(activity|forum\/thread)\/\d+/)}
- {#await likesPromise}
- <Skeleton card={false} count={5} height="0.9rem" list />
- {:then likes}
- {#if likes && likes.length > 0}
- <ul>
- {#each likes as like}
- <li>
- <a
- href={`https://anilist.co/user/${like.name}`}
- target="_blank"
- title={`<img src="${
- $settings.displayDataSaver ? like.avatar?.medium : like.avatar?.large
- }" style="width: 150px; object-fit: cover; border-radius: 8px;" />`}
- use:tooltip
- >
- {like.name}
- </a>
- </li>
- {/each}
- </ul>
- {:else}
- No likes were found for that {submissionType}.
- {/if}
- {:catch}
- <RateLimited type="Likes" list={false} />
- {/await}
- {:else}
- Please enter a valid Activity or Thread URL.
- {/if}
+ {#if submission.length > 5 && submission.match(/https:\/\/anilist.co\/(activity|forum\/thread)\/\d+/)}
+ {#await likesPromise}
+ <Skeleton card={false} count={5} height="0.9rem" list />
+ {:then likes}
+ {#if likes && likes.length > 0}
+ <ul>
+ {#each likes as like}
+ <li>
+ <a
+ href={`https://anilist.co/user/${like.name}`}
+ target="_blank"
+ title={`<img src="${
+ $settings.displayDataSaver ? like.avatar?.medium : like.avatar?.large
+ }" style="width: 150px; object-fit: cover; border-radius: 8px;" />`}
+ use:tooltip
+ >
+ {like.name}
+ </a>
+ </li>
+ {/each}
+ </ul>
+ {:else}
+ No likes were found for that {submissionType}.
+ {/if}
+ {:catch}
+ <RateLimited type="Likes" list={false} />
+ {/await}
+ {:else}
+ Please enter a valid Activity or Thread URL.
+ {/if}
</InputTemplate>
diff --git a/src/lib/Tools/Picker.svelte b/src/lib/Tools/Picker.svelte
index 8b2d183f..583a7a0d 100644
--- a/src/lib/Tools/Picker.svelte
+++ b/src/lib/Tools/Picker.svelte
@@ -1,23 +1,23 @@
<script lang="ts">
- import { browser } from '$app/environment';
- import { goto } from '$app/navigation';
- import root from '$lib/Utility/root';
- import { tools } from './tools';
+ import { browser } from '$app/environment';
+ import { goto } from '$app/navigation';
+ import root from '$lib/Utility/root';
+ import { tools } from './tools';
- export let tool: string;
+ export let tool: string;
</script>
<blockquote>
- <select
- bind:value={tool}
- on:change={() => {
- if (browser) goto(root(`/tools/${tool}`));
- }}
- >
- <option value="default" selected disabled hidden>Select a tool to continue</option>
+ <select
+ bind:value={tool}
+ on:change={() => {
+ if (browser) goto(root(`/tools/${tool}`));
+ }}
+ >
+ <option value="default" selected disabled hidden>Select a tool to continue</option>
- {#each Object.keys(tools).filter((t) => t !== 'default') as t}
- <option value={t}>{tools[t].short || tools[t].name()}</option>
- {/each}
- </select>
+ {#each Object.keys(tools).filter((t) => t !== 'default') as t}
+ <option value={t}>{tools[t].short || tools[t].name()}</option>
+ {/each}
+ </select>
</blockquote>
diff --git a/src/lib/Tools/RandomFollower.svelte b/src/lib/Tools/RandomFollower.svelte
index a8fe7141..acb5a33a 100644
--- a/src/lib/Tools/RandomFollower.svelte
+++ b/src/lib/Tools/RandomFollower.svelte
@@ -1,32 +1,32 @@
<script lang="ts">
- import { followers } from '$lib/Data/AniList/following';
- import RateLimited from '$lib/Error/RateLimited.svelte';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import TextSwap from '$lib/Layout/TextTransition.svelte';
- import InputTemplate from './InputTemplate.svelte';
+ import { followers } from '$lib/Data/AniList/following';
+ import RateLimited from '$lib/Error/RateLimited.svelte';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import TextSwap from '$lib/Layout/TextTransition.svelte';
+ import InputTemplate from './InputTemplate.svelte';
- let submission = '';
- let randomSeed = 0;
+ let submission = '';
+ let randomSeed = 0;
</script>
<InputTemplate
- field="Username"
- bind:submission
- event="Random Follower"
- submitText="Generate"
- onSubmit={() => (randomSeed = Math.random())}
+ field="Username"
+ bind:submission
+ event="Random Follower"
+ submitText="Generate"
+ onSubmit={() => (randomSeed = Math.random())}
>
- {#await followers(submission)}
- <Skeleton card={false} count={1} height="0.9rem" list />
- {:then users}
- {@const user = users[Math.floor(randomSeed * users.length)]}
+ {#await followers(submission)}
+ <Skeleton card={false} count={1} height="0.9rem" list />
+ {:then users}
+ {@const user = users[Math.floor(randomSeed * users.length)]}
- <p />
+ <p />
- <a href={`https://anilist.co/user/${user.id}`} target="_blank">
- <TextSwap text={user.name} />
- </a>
- {:catch}
- <RateLimited type="Followers" list={false} />
- {/await}
+ <a href={`https://anilist.co/user/${user.id}`} target="_blank">
+ <TextSwap text={user.name} />
+ </a>
+ {:catch}
+ <RateLimited type="Followers" list={false} />
+ {/await}
</InputTemplate>
diff --git a/src/lib/Tools/SequelCatcher/List.svelte b/src/lib/Tools/SequelCatcher/List.svelte
index 8205b8c7..009df219 100644
--- a/src/lib/Tools/SequelCatcher/List.svelte
+++ b/src/lib/Tools/SequelCatcher/List.svelte
@@ -1,27 +1,27 @@
<script lang="ts">
- import { filterRelations, type Media } from '$lib/Data/AniList/media';
- import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte';
- import { outboundLink } from '$lib/Media/links';
- import settings from '$stores/settings';
+ import { filterRelations, type Media } from '$lib/Data/AniList/media';
+ import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte';
+ import { outboundLink } from '$lib/Media/links';
+ import settings from '$stores/settings';
- export let mediaListUnchecked: Media[];
+ export let mediaListUnchecked: Media[];
- let includeCurrent = false;
- let includeSideStories = false;
+ let includeCurrent = false;
+ let includeSideStories = false;
- const matchCheck = (media: Media | undefined, swap = false) =>
- (media &&
- media.mediaListEntry &&
- media.mediaListEntry?.status !== 'CURRENT' &&
- media.mediaListEntry?.status !== 'REPEATING' &&
- media.mediaListEntry?.status !== 'PAUSED') ||
- !media
- ? swap
- ? undefined
- : media
- : swap
- ? media
- : undefined;
+ const matchCheck = (media: Media | undefined, swap = false) =>
+ (media &&
+ media.mediaListEntry &&
+ media.mediaListEntry?.status !== 'CURRENT' &&
+ media.mediaListEntry?.status !== 'REPEATING' &&
+ media.mediaListEntry?.status !== 'PAUSED') ||
+ !media
+ ? swap
+ ? undefined
+ : media
+ : swap
+ ? media
+ : undefined;
</script>
<input type="checkbox" bind:checked={includeCurrent} /> Include current (watching, rewatching,
@@ -33,57 +33,57 @@ specials, etc.)
<p />
<ol class="media-list">
- {#each filterRelations( mediaListUnchecked.filter((media) => media.mediaListEntry?.status === 'COMPLETED'), includeSideStories ) as { media, unwatchedRelations }}
- {#if unwatchedRelations.filter( (relation) => matchCheck(mediaListUnchecked.find((media) => media.id === relation.node.id)) ).length !== 0 || includeCurrent}
- <li>
- <a href={outboundLink(media, 'anime', $settings.displayOutboundLinksTo)} target="_blank">
- <MediaTitleDisplay title={media.title} />
- </a>
+ {#each filterRelations( mediaListUnchecked.filter((media) => media.mediaListEntry?.status === 'COMPLETED'), includeSideStories ) as { media, unwatchedRelations }}
+ {#if unwatchedRelations.filter( (relation) => matchCheck(mediaListUnchecked.find((media) => media.id === relation.node.id)) ).length !== 0 || includeCurrent}
+ <li>
+ <a href={outboundLink(media, 'anime', $settings.displayOutboundLinksTo)} target="_blank">
+ <MediaTitleDisplay title={media.title} />
+ </a>
- <span class="opaque">
- ({media.startDate.year})
- </span>
+ <span class="opaque">
+ ({media.startDate.year})
+ </span>
- <ol class="unwatched-relations-list">
- {#each unwatchedRelations as relation}
- {@const hit = matchCheck(
- mediaListUnchecked.find((media) => media.id === relation.node.id),
- true
- )}
+ <ol class="unwatched-relations-list">
+ {#each unwatchedRelations as relation}
+ {@const hit = matchCheck(
+ mediaListUnchecked.find((media) => media.id === relation.node.id),
+ true
+ )}
- {#if matchCheck(mediaListUnchecked.find((media) => media.id === relation.node.id)) || includeCurrent}
- <li>
- <a
- href={outboundLink(relation.node, 'anime', $settings.displayOutboundLinksTo)}
- target="_blank"
- >
- <MediaTitleDisplay title={relation.node.title} />
- </a>
+ {#if matchCheck(mediaListUnchecked.find((media) => media.id === relation.node.id)) || includeCurrent}
+ <li>
+ <a
+ href={outboundLink(relation.node, 'anime', $settings.displayOutboundLinksTo)}
+ target="_blank"
+ >
+ <MediaTitleDisplay title={relation.node.title} />
+ </a>
- {#if hit && hit.mediaListEntry && hit.mediaListEntry.progress > 0}
- <span class="opaque">|</span>
+ {#if hit && hit.mediaListEntry && hit.mediaListEntry.progress > 0}
+ <span class="opaque">|</span>
- {hit.mediaListEntry.progress}/{#if hit.episodes}{hit.episodes}{:else}?{/if}
- {/if}
+ {hit.mediaListEntry.progress}/{#if hit.episodes}{hit.episodes}{:else}?{/if}
+ {/if}
- <span class="opaque">
- ({relation.node.startDate.year})
- </span>
- </li>
- {/if}
- {/each}
- </ol>
- </li>
- {/if}
- {/each}
+ <span class="opaque">
+ ({relation.node.startDate.year})
+ </span>
+ </li>
+ {/if}
+ {/each}
+ </ol>
+ </li>
+ {/if}
+ {/each}
</ol>
<style>
- .media-list li {
- margin-bottom: 1rem;
- }
+ .media-list li {
+ margin-bottom: 1rem;
+ }
- .unwatched-relations-list li:not(:last-child) {
- margin-bottom: 0 !important;
- }
+ .unwatched-relations-list li:not(:last-child) {
+ margin-bottom: 0 !important;
+ }
</style>
diff --git a/src/lib/Tools/SequelCatcher/Tool.svelte b/src/lib/Tools/SequelCatcher/Tool.svelte
index 06d635b3..a954b4d7 100644
--- a/src/lib/Tools/SequelCatcher/Tool.svelte
+++ b/src/lib/Tools/SequelCatcher/Tool.svelte
@@ -1,80 +1,80 @@
<script lang="ts">
- import List from './List.svelte';
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import userIdentity from '$stores/identity';
- import { type Media, mediaListCollection, Type } from '$lib/Data/AniList/media';
- import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
- import anime from '$stores/anime';
- import identity from '$stores/identity';
- import { onMount } from 'svelte';
- import lastPruneTimes from '$stores/lastPruneTimes';
- import Message from '$lib/Loading/Message.svelte';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import Username from '$lib/Layout/Username.svelte';
+ import List from './List.svelte';
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import userIdentity from '$stores/identity';
+ import { type Media, mediaListCollection, Type } from '$lib/Data/AniList/media';
+ import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
+ import anime from '$stores/anime';
+ import identity from '$stores/identity';
+ import { onMount } from 'svelte';
+ import lastPruneTimes from '$stores/lastPruneTimes';
+ import Message from '$lib/Loading/Message.svelte';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import Username from '$lib/Layout/Username.svelte';
- export let user: AniListAuthorisation;
+ export let user: AniListAuthorisation;
- let mediaList: Promise<Media[]>;
+ let mediaList: Promise<Media[]>;
- onMount(async () => {
- if (user === undefined || $identity.id === -2) return;
+ onMount(async () => {
+ if (user === undefined || $identity.id === -2) return;
- mediaList = mediaListCollection(
- user,
- $userIdentity,
- Type.Anime,
- $anime,
- $lastPruneTimes.anime,
- {
- forcePrune: true,
- includeCompleted: true,
- all: true,
- includeRelations: true
- }
- );
- });
+ mediaList = mediaListCollection(
+ user,
+ $userIdentity,
+ Type.Anime,
+ $anime,
+ $lastPruneTimes.anime,
+ {
+ forcePrune: true,
+ includeCompleted: true,
+ all: true,
+ includeRelations: true
+ }
+ );
+ });
</script>
{#if user === undefined || $identity.id === -2}
- <LogInRestricted />
+ <LogInRestricted />
{:else}
- <div class="card">
- {#await mediaList}
- <Message message="Cross-checking media ..." />
+ <div class="card">
+ {#await mediaList}
+ <Message message="Cross-checking media ..." />
- <Skeleton
- card={false}
- count={8}
- pad={false}
- height={'0.9rem'}
- width={'100%'}
- list
- grid={false}
- />
- {:then mediaListUnchecked}
- {#if mediaListUnchecked}
- <List {mediaListUnchecked} />
- {:else}
- <Message message="Cross-checking media ..." />
+ <Skeleton
+ card={false}
+ count={8}
+ pad={false}
+ height={'0.9rem'}
+ width={'100%'}
+ list
+ grid={false}
+ />
+ {:then mediaListUnchecked}
+ {#if mediaListUnchecked}
+ <List {mediaListUnchecked} />
+ {:else}
+ <Message message="Cross-checking media ..." />
- <Skeleton
- card={false}
- count={8}
- pad={false}
- height={'0.9rem'}
- width={'100%'}
- list
- grid={false}
- />
- {/if}
- {:catch}
- <Message message="" loader="ripple" slot withReload fullscreen>Error fetching media.</Message>
- {/await}
+ <Skeleton
+ card={false}
+ count={8}
+ pad={false}
+ height={'0.9rem'}
+ width={'100%'}
+ list
+ grid={false}
+ />
+ {/if}
+ {:catch}
+ <Message message="" loader="ripple" slot withReload fullscreen>Error fetching media.</Message>
+ {/await}
- <p />
+ <p />
- <blockquote style="margin: 0 0 0 1.5rem;">
- Thanks to <Username username="sevengirl" /> and <Username username="esthereae" /> for the idea!
- </blockquote>
- </div>
+ <blockquote style="margin: 0 0 0 1.5rem;">
+ Thanks to <Username username="sevengirl" /> and <Username username="esthereae" /> for the idea!
+ </blockquote>
+ </div>
{/if}
diff --git a/src/lib/Tools/SequelSpy/Prequels.svelte b/src/lib/Tools/SequelSpy/Prequels.svelte
index ab1c4ac5..b22db3af 100644
--- a/src/lib/Tools/SequelSpy/Prequels.svelte
+++ b/src/lib/Tools/SequelSpy/Prequels.svelte
@@ -1,35 +1,35 @@
<script lang="ts">
- import type { MediaPrequel } from '$lib/Data/AniList/prequels';
- import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte';
- import { airingTime } from '$lib/Media/Anime/Airing/time';
- import LinkedTooltip from '$lib/Tooltip/LinkedTooltip.svelte';
- import settings from '$stores/settings';
- import type { Media } from '$lib/Data/AniList/media';
+ import type { MediaPrequel } from '$lib/Data/AniList/prequels';
+ import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte';
+ import { airingTime } from '$lib/Media/Anime/Airing/time';
+ import LinkedTooltip from '$lib/Tooltip/LinkedTooltip.svelte';
+ import settings from '$stores/settings';
+ import type { Media } from '$lib/Data/AniList/media';
- export let currentPrequels: MediaPrequel[];
+ export let currentPrequels: MediaPrequel[];
- const prequelAiringTime = (prequel: MediaPrequel) =>
- airingTime(prequel as unknown as Media, null, false, true);
+ const prequelAiringTime = (prequel: MediaPrequel) =>
+ airingTime(prequel as unknown as Media, null, false, true);
</script>
<ul>
- {#each currentPrequels.sort((a, b) => new Date(a.startDate.year, a.startDate.month - 1, a.startDate.day).getTime() - new Date(b.startDate.year, b.startDate.month - 1, b.startDate.day).getTime()) as prequel}
- <li id={`${prequel.id}`}>
- <LinkedTooltip
- content={`<img src="${
- $settings.displayDataSaver ? prequel.coverImage.medium : prequel.coverImage.extraLarge
- }" style="width: 250px; object-fit: cover; border-radius: 8px;" />`}
- pin={`${prequel.id}`}
- pinPosition="top"
- disable={!$settings.displayHoverCover}
- >
- <a href={`https://anilist.co/anime/${prequel.id}`} target="_blank">
- <MediaTitleDisplay title={prequel.title} />
- </a>
- <span class="opaque">|</span>
- {prequel.seen}<span class="opaque">/{prequel.episodes}</span>
- {@html prequelAiringTime(prequel)}
- </LinkedTooltip>
- </li>
- {/each}
+ {#each currentPrequels.sort((a, b) => new Date(a.startDate.year, a.startDate.month - 1, a.startDate.day).getTime() - new Date(b.startDate.year, b.startDate.month - 1, b.startDate.day).getTime()) as prequel}
+ <li id={`${prequel.id}`}>
+ <LinkedTooltip
+ content={`<img src="${
+ $settings.displayDataSaver ? prequel.coverImage.medium : prequel.coverImage.extraLarge
+ }" style="width: 250px; object-fit: cover; border-radius: 8px;" />`}
+ pin={`${prequel.id}`}
+ pinPosition="top"
+ disable={!$settings.displayHoverCover}
+ >
+ <a href={`https://anilist.co/anime/${prequel.id}`} target="_blank">
+ <MediaTitleDisplay title={prequel.title} />
+ </a>
+ <span class="opaque">|</span>
+ {prequel.seen}<span class="opaque">/{prequel.episodes}</span>
+ {@html prequelAiringTime(prequel)}
+ </LinkedTooltip>
+ </li>
+ {/each}
</ul>
diff --git a/src/lib/Tools/SequelSpy/Tool.svelte b/src/lib/Tools/SequelSpy/Tool.svelte
index 8956e00a..caec4a46 100644
--- a/src/lib/Tools/SequelSpy/Tool.svelte
+++ b/src/lib/Tools/SequelSpy/Tool.svelte
@@ -1,62 +1,62 @@
<script lang="ts">
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import { prequels, type MediaPrequel } from '$lib/Data/AniList/prequels';
- import { onMount } from 'svelte';
- import { clearAllParameters, parseOrDefault } from '../../Utility/parameters';
- import { page } from '$app/stores';
- import { browser } from '$app/environment';
- import { season as getSeason } from '$lib/Media/Anime/season';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import identity from '$stores/identity';
- import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
- import Prequels from './Prequels.svelte';
-
- export let user: AniListAuthorisation;
-
- let currentPrequels: Promise<MediaPrequel[]> = Promise.resolve([]) as Promise<MediaPrequel[]>;
- const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
- let year = parseOrDefault(urlParameters, 'year', new Date().getFullYear());
- let season = parseOrDefault(urlParameters, 'season', getSeason());
-
- $: {
- if (year.toString().length === 4 && $identity.id !== -2 && user)
- currentPrequels = prequels(user, year, season);
- }
- $: {
- if (browser) {
- $page.url.searchParams.set('year', year.toString());
- $page.url.searchParams.set('season', season.toString());
- clearAllParameters(['year', 'season']);
- history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
- }
- }
-
- onMount(() => clearAllParameters(['year', 'season']));
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import { prequels, type MediaPrequel } from '$lib/Data/AniList/prequels';
+ import { onMount } from 'svelte';
+ import { clearAllParameters, parseOrDefault } from '../../Utility/parameters';
+ import { page } from '$app/stores';
+ import { browser } from '$app/environment';
+ import { season as getSeason } from '$lib/Media/Anime/season';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import identity from '$stores/identity';
+ import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
+ import Prequels from './Prequels.svelte';
+
+ export let user: AniListAuthorisation;
+
+ let currentPrequels: Promise<MediaPrequel[]> = Promise.resolve([]) as Promise<MediaPrequel[]>;
+ const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
+ let year = parseOrDefault(urlParameters, 'year', new Date().getFullYear());
+ let season = parseOrDefault(urlParameters, 'season', getSeason());
+
+ $: {
+ if (year.toString().length === 4 && $identity.id !== -2 && user)
+ currentPrequels = prequels(user, year, season);
+ }
+ $: {
+ if (browser) {
+ $page.url.searchParams.set('year', year.toString());
+ $page.url.searchParams.set('season', season.toString());
+ clearAllParameters(['year', 'season']);
+ history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
+ }
+ }
+
+ onMount(() => clearAllParameters(['year', 'season']));
</script>
{#if user === undefined || $identity.id === -2}
- <LogInRestricted />
+ <LogInRestricted />
{:else}
- <div class="card">
- <p>
- <select bind:value={season}>
- <option value="WINTER">Winter</option>
- <option value="SPRING">Spring</option>
- <option value="SUMMER">Summer</option>
- <option value="FALL">Fall</option>
- </select>
- <input type="number" bind:value={year} />
- </p>
-
- {#await currentPrequels}
- <Skeleton card={false} count={5} height="0.9rem" list />
- {:then currentPrequels}
- <Prequels {currentPrequels} />
- {/await}
-
- <p />
-
- The count ratio is the number of episodes you've seen of any direct prequels, and the total
- number of episodes of all direct prequels.
- </div>
+ <div class="card">
+ <p>
+ <select bind:value={season}>
+ <option value="WINTER">Winter</option>
+ <option value="SPRING">Spring</option>
+ <option value="SUMMER">Summer</option>
+ <option value="FALL">Fall</option>
+ </select>
+ <input type="number" bind:value={year} />
+ </p>
+
+ {#await currentPrequels}
+ <Skeleton card={false} count={5} height="0.9rem" list />
+ {:then currentPrequels}
+ <Prequels {currentPrequels} />
+ {/await}
+
+ <p />
+
+ The count ratio is the number of episodes you've seen of any direct prequels, and the total
+ number of episodes of all direct prequels.
+ </div>
{/if}
diff --git a/src/lib/Tools/UmaMusumeBirthdays.svelte b/src/lib/Tools/UmaMusumeBirthdays.svelte
index faf962f8..29b1faa6 100644
--- a/src/lib/Tools/UmaMusumeBirthdays.svelte
+++ b/src/lib/Tools/UmaMusumeBirthdays.svelte
@@ -1,134 +1,134 @@
<script lang="ts">
- import { browser } from '$app/environment';
- import { page } from '$app/stores';
- import Error from '$lib/Error/RateLimited.svelte';
- import { onMount } from 'svelte';
- import { clearAllParameters, parseOrDefault } from '../Utility/parameters';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import Message from '$lib/Loading/Message.svelte';
- import tooltip from '$lib/Tooltip/tooltip';
- import settings from '$stores/settings';
- import locale from '$stores/locale';
+ import { browser } from '$app/environment';
+ import { page } from '$app/stores';
+ import Error from '$lib/Error/RateLimited.svelte';
+ import { onMount } from 'svelte';
+ import { clearAllParameters, parseOrDefault } from '../Utility/parameters';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import Message from '$lib/Loading/Message.svelte';
+ import tooltip from '$lib/Tooltip/tooltip';
+ import settings from '$stores/settings';
+ import locale from '$stores/locale';
- interface Birthday {
- birth_day: number;
- birth_month: number;
- game_id: number;
- id: number;
- name_en: string;
- name_jp: string;
- preferred_url: string;
- sns_icon: string;
- }
+ interface Birthday {
+ birth_day: number;
+ birth_month: number;
+ game_id: number;
+ id: number;
+ name_en: string;
+ name_jp: string;
+ preferred_url: string;
+ sns_icon: string;
+ }
- const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
- let date = new Date();
- let month = parseOrDefault(urlParameters, 'month', date.getMonth() + 1);
- let day = parseOrDefault(urlParameters, 'day', date.getDate());
- let umapyoi: Promise<Birthday[]>;
+ const urlParameters = browser ? new URLSearchParams(window.location.search) : null;
+ let date = new Date();
+ let month = parseOrDefault(urlParameters, 'month', date.getMonth() + 1);
+ let day = parseOrDefault(urlParameters, 'day', date.getDate());
+ let umapyoi: Promise<Birthday[]>;
- $: {
- month = Math.min(month, 12);
- month = Math.max(month, 1);
- day = Math.min(day, new Date(new Date().getFullYear(), month, 0).getDate());
- day = Math.max(day, 1);
+ $: {
+ month = Math.min(month, 12);
+ month = Math.max(month, 1);
+ day = Math.min(day, new Date(new Date().getFullYear(), month, 0).getDate());
+ day = Math.max(day, 1);
- if (browser) {
- $page.url.searchParams.set('month', month.toString());
- $page.url.searchParams.set('day', day.toString());
- clearAllParameters(['month', 'day']);
- history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
- }
- }
+ if (browser) {
+ $page.url.searchParams.set('month', month.toString());
+ $page.url.searchParams.set('day', day.toString());
+ clearAllParameters(['month', 'day']);
+ history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
+ }
+ }
- onMount(() => {
- clearAllParameters(['month', 'day']);
+ onMount(() => {
+ clearAllParameters(['month', 'day']);
- umapyoi = fetch('https://umapyoi.net/api/v1/character/birthday').then((r) => r.json());
- });
+ umapyoi = fetch('https://umapyoi.net/api/v1/character/birthday').then((r) => r.json());
+ });
</script>
{#await umapyoi}
- <Message message="Loading birthdays ..." />
+ <Message message="Loading birthdays ..." />
- <Skeleton grid={true} count={100} width="150px" height="170px" />
+ <Skeleton grid={true} count={100} width="150px" height="170px" />
{:then birthdays}
- {#if birthdays}
- {@const todaysBirthdays = birthdays.filter(
- (birthday) => birthday.birth_month === month && birthday.birth_day === day
- )}
+ {#if birthdays}
+ {@const todaysBirthdays = birthdays.filter(
+ (birthday) => birthday.birth_month === month && birthday.birth_day === day
+ )}
- <p>
- <select bind:value={month}>
- {#each Array.from({ length: 12 }, (_, i) => i + 1) as month}
- <option value={month}>
- {new Date(0, month - 1).toLocaleString('default', { month: 'long' })}
- </option>
- {/each}
- </select>
+ <p>
+ <select bind:value={month}>
+ {#each Array.from({ length: 12 }, (_, i) => i + 1) as month}
+ <option value={month}>
+ {new Date(0, month - 1).toLocaleString('default', { month: 'long' })}
+ </option>
+ {/each}
+ </select>
- <select bind:value={day}>
- {#each Array.from({ length: new Date(new Date().getFullYear(), month, 0).getDate() }, (_, i) => i + 1) as day}
- <option value={day}>{day}</option>
- {/each}
- </select>
- </p>
+ <select bind:value={day}>
+ {#each Array.from({ length: new Date(new Date().getFullYear(), month, 0).getDate() }, (_, i) => i + 1) as day}
+ <option value={day}>{day}</option>
+ {/each}
+ </select>
+ </p>
- {#if todaysBirthdays.length === 0}
- <Message
- message={`No birthdays for ${$locale().dayFormatter(
- new Date(new Date().getFullYear(), month - 1, day)
- )}.`}
- fullscreen={false}
- loader="ripple"
- />
- {:else}
- <div class="characters">
- {#each todaysBirthdays as birthday}
- {@const name = $settings.displayLanguage === 'en' ? birthday.name_en : birthday.name_jp}
- {@const nameOther =
- $settings.displayLanguage === 'ja' ? birthday.name_en : birthday.name_jp}
+ {#if todaysBirthdays.length === 0}
+ <Message
+ message={`No birthdays for ${$locale().dayFormatter(
+ new Date(new Date().getFullYear(), month - 1, day)
+ )}.`}
+ fullscreen={false}
+ loader="ripple"
+ />
+ {:else}
+ <div class="characters">
+ {#each todaysBirthdays as birthday}
+ {@const name = $settings.displayLanguage === 'en' ? birthday.name_en : birthday.name_jp}
+ {@const nameOther =
+ $settings.displayLanguage === 'ja' ? birthday.name_en : birthday.name_jp}
- <div class="card card-small">
- <a
- href={`https://anilist.co/search/characters?search=${encodeURIComponent(
- birthday.name_en
- ).replace(/%20/g, '+')}`}
- target="_blank"
- title={nameOther}
- use:tooltip
- >
- {name}
- <img src={birthday.sns_icon} alt="Character" class="character-image" />
- </a>
- </div>
- {/each}
- </div>
- {/if}
- {:else}
- <Message message="Validating birthdays ..." />
+ <div class="card card-small">
+ <a
+ href={`https://anilist.co/search/characters?search=${encodeURIComponent(
+ birthday.name_en
+ ).replace(/%20/g, '+')}`}
+ target="_blank"
+ title={nameOther}
+ use:tooltip
+ >
+ {name}
+ <img src={birthday.sns_icon} alt="Character" class="character-image" />
+ </a>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ {:else}
+ <Message message="Validating birthdays ..." />
- <Skeleton grid={true} count={100} width="150px" height="170px" />
- {/if}
+ <Skeleton grid={true} count={100} width="150px" height="170px" />
+ {/if}
{:catch}
- <Error type="Character" card />
+ <Error type="Character" card />
{/await}
<style lang="scss">
- .characters {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
- gap: 1rem;
- grid-row-gap: 1rem;
- align-items: start;
+ .characters {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
+ gap: 1rem;
+ grid-row-gap: 1rem;
+ align-items: start;
- img {
- width: 100%;
- height: auto;
- object-fit: cover;
- border-radius: 8px;
- margin-top: 0.5rem;
- box-shadow: 0 4px 30px var(--base01);
- }
- }
+ img {
+ width: 100%;
+ height: auto;
+ object-fit: cover;
+ border-radius: 8px;
+ margin-top: 0.5rem;
+ box-shadow: 0 4px 30px var(--base01);
+ }
+ }
</style>
diff --git a/src/lib/Tools/Wrapped/ActivityHistory.svelte b/src/lib/Tools/Wrapped/ActivityHistory.svelte
index 194f5951..3da401d4 100644
--- a/src/lib/Tools/Wrapped/ActivityHistory.svelte
+++ b/src/lib/Tools/Wrapped/ActivityHistory.svelte
@@ -1,21 +1,21 @@
<script lang="ts">
- import type { ActivityHistoryEntry } from '$lib/Data/AniList/activity';
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import ActivityHistoryGrid from '../ActivityHistory/Grid.svelte';
+ import type { ActivityHistoryEntry } from '$lib/Data/AniList/activity';
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import ActivityHistoryGrid from '../ActivityHistory/Grid.svelte';
- export let user: AniListAuthorisation;
- export let activities: ActivityHistoryEntry[];
- export let year: number;
- export let activityHistoryPosition: 'TOP' | 'BELOW_TOP' | 'ORIGINAL';
+ export let user: AniListAuthorisation;
+ export let activities: ActivityHistoryEntry[];
+ export let year: number;
+ export let activityHistoryPosition: 'TOP' | 'BELOW_TOP' | 'ORIGINAL';
</script>
<div
- class="categories-grid"
- style={`padding-${activityHistoryPosition === 'ORIGINAL' ? 'top' : 'bottom'}: 0;`}
+ class="categories-grid"
+ style={`padding-${activityHistoryPosition === 'ORIGINAL' ? 'top' : 'bottom'}: 0;`}
>
- <div class="category-grid bottom-category pure-category category">
- <div id="activity-history">
- <ActivityHistoryGrid {user} activityData={activities} currentYear={year} />
- </div>
- </div>
+ <div class="category-grid bottom-category pure-category category">
+ <div id="activity-history">
+ <ActivityHistoryGrid {user} activityData={activities} currentYear={year} />
+ </div>
+ </div>
</div>
diff --git a/src/lib/Tools/Wrapped/Media.svelte b/src/lib/Tools/Wrapped/Media.svelte
index f5184bc4..ea8a989b 100644
--- a/src/lib/Tools/Wrapped/Media.svelte
+++ b/src/lib/Tools/Wrapped/Media.svelte
@@ -1,98 +1,98 @@
<script lang="ts">
- import type { Media } from '$lib/Data/AniList/media';
- import type { Wrapped } from '$lib/Data/AniList/wrapped';
- import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte';
- import proxy from '$lib/Utility/proxy';
+ import type { Media } from '$lib/Data/AniList/media';
+ import type { Wrapped } from '$lib/Data/AniList/wrapped';
+ import MediaTitleDisplay from '$lib/List/MediaTitleDisplay.svelte';
+ import proxy from '$lib/Utility/proxy';
- export let animeList: Media[] | undefined;
- export let mangaList: Media[] | undefined;
- export let wrapped: Wrapped;
- export let updateWidth: () => void;
- export let highestRatedMediaPercentage: boolean;
- export let highestRatedCount: number;
- export let animeMostTitle: string;
- export let mangaMostTitle: string;
+ export let animeList: Media[] | undefined;
+ export let mangaList: Media[] | undefined;
+ export let wrapped: Wrapped;
+ export let updateWidth: () => void;
+ export let highestRatedMediaPercentage: boolean;
+ export let highestRatedCount: number;
+ export let animeMostTitle: string;
+ export let mangaMostTitle: string;
</script>
{#if animeList !== undefined || mangaList !== undefined}
- <div class="categories-grid">
- <div class="category-grid pure-category category middle-category">
- <div class="grid-item image-grid">
- <a
- href={animeList && animeList[0] ? `https://anilist.co/anime/${animeList[0].id}` : '#'}
- target="_blank"
- >
- <img
- src={proxy(
- animeList && animeList[0] ? animeList[0].coverImage.extraLarge : wrapped.avatar.large
- )}
- alt="Highest Rated Anime Cover"
- class="cover-image"
- on:load={updateWidth}
- />
- </a>
- <div>
- <b>{animeMostTitle} Anime</b>
- <ol>
- {#if animeList !== undefined && animeList.length !== 0}
- {#each animeList?.slice(0, highestRatedCount) as anime}
- <li>
- <a href={`https://anilist.co/anime/${anime.id}`} target="_blank">
- <MediaTitleDisplay title={anime.title} />
- </a>{highestRatedMediaPercentage &&
- anime.mediaListEntry &&
- anime.mediaListEntry?.score > 0
- ? `: ${anime.mediaListEntry?.score}%`
- : ''}
- </li>
- {/each}
- {:else}
- <li>
- <p class="opaque">(⌣_⌣”)</p>
- </li>
- {/if}
- </ol>
- </div>
- </div>
- </div>
- <div class="category-grid pure-category category middle-category">
- <div class="grid-item image-grid">
- <a
- href={mangaList && mangaList[0] ? `https://anilist.co/manga/${mangaList[0].id}` : '#'}
- target="_blank"
- >
- <img
- src={proxy(
- mangaList && mangaList[0] ? mangaList[0].coverImage.extraLarge : wrapped.avatar.large
- )}
- alt="Highest Rated Manga Cover"
- class="cover-image"
- on:load={updateWidth}
- />
- </a>
- <div>
- <b>{mangaMostTitle} Manga</b>
- <ol>
- {#if mangaList !== undefined && mangaList.length !== 0}
- {#each mangaList?.slice(0, highestRatedCount) as manga}
- <li>
- <a href={`https://anilist.co/manga/${manga.id}`} target="_blank">
- <MediaTitleDisplay title={manga.title} />
- </a>{highestRatedMediaPercentage &&
- manga.mediaListEntry &&
- manga.mediaListEntry?.score > 0
- ? `: ${manga.mediaListEntry?.score}%`
- : ''}
- </li>
- {/each}
- {:else}
- <li>
- <p class="opaque">(⌣_⌣”)</p>
- </li>
- {/if}
- </ol>
- </div>
- </div>
- </div>
- </div>
+ <div class="categories-grid">
+ <div class="category-grid pure-category category middle-category">
+ <div class="grid-item image-grid">
+ <a
+ href={animeList && animeList[0] ? `https://anilist.co/anime/${animeList[0].id}` : '#'}
+ target="_blank"
+ >
+ <img
+ src={proxy(
+ animeList && animeList[0] ? animeList[0].coverImage.extraLarge : wrapped.avatar.large
+ )}
+ alt="Highest Rated Anime Cover"
+ class="cover-image"
+ on:load={updateWidth}
+ />
+ </a>
+ <div>
+ <b>{animeMostTitle} Anime</b>
+ <ol>
+ {#if animeList !== undefined && animeList.length !== 0}
+ {#each animeList?.slice(0, highestRatedCount) as anime}
+ <li>
+ <a href={`https://anilist.co/anime/${anime.id}`} target="_blank">
+ <MediaTitleDisplay title={anime.title} />
+ </a>{highestRatedMediaPercentage &&
+ anime.mediaListEntry &&
+ anime.mediaListEntry?.score > 0
+ ? `: ${anime.mediaListEntry?.score}%`
+ : ''}
+ </li>
+ {/each}
+ {:else}
+ <li>
+ <p class="opaque">(⌣_⌣”)</p>
+ </li>
+ {/if}
+ </ol>
+ </div>
+ </div>
+ </div>
+ <div class="category-grid pure-category category middle-category">
+ <div class="grid-item image-grid">
+ <a
+ href={mangaList && mangaList[0] ? `https://anilist.co/manga/${mangaList[0].id}` : '#'}
+ target="_blank"
+ >
+ <img
+ src={proxy(
+ mangaList && mangaList[0] ? mangaList[0].coverImage.extraLarge : wrapped.avatar.large
+ )}
+ alt="Highest Rated Manga Cover"
+ class="cover-image"
+ on:load={updateWidth}
+ />
+ </a>
+ <div>
+ <b>{mangaMostTitle} Manga</b>
+ <ol>
+ {#if mangaList !== undefined && mangaList.length !== 0}
+ {#each mangaList?.slice(0, highestRatedCount) as manga}
+ <li>
+ <a href={`https://anilist.co/manga/${manga.id}`} target="_blank">
+ <MediaTitleDisplay title={manga.title} />
+ </a>{highestRatedMediaPercentage &&
+ manga.mediaListEntry &&
+ manga.mediaListEntry?.score > 0
+ ? `: ${manga.mediaListEntry?.score}%`
+ : ''}
+ </li>
+ {/each}
+ {:else}
+ <li>
+ <p class="opaque">(⌣_⌣”)</p>
+ </li>
+ {/if}
+ </ol>
+ </div>
+ </div>
+ </div>
+ </div>
{/if}
diff --git a/src/lib/Tools/Wrapped/MediaExtras.svelte b/src/lib/Tools/Wrapped/MediaExtras.svelte
index 9ef8cb65..9e755ea5 100644
--- a/src/lib/Tools/Wrapped/MediaExtras.svelte
+++ b/src/lib/Tools/Wrapped/MediaExtras.svelte
@@ -1,74 +1,74 @@
<script lang="ts">
- import type { TopMedia } from '$lib/Data/AniList/wrapped';
- import proxy from '$lib/Utility/proxy';
+ import type { TopMedia } from '$lib/Data/AniList/wrapped';
+ import proxy from '$lib/Utility/proxy';
- export let topMedia: TopMedia;
- export let updateWidth: () => void;
- export let highestRatedGenreTagPercentage: boolean;
- export let genreTagTitle: string;
+ export let topMedia: TopMedia;
+ export let updateWidth: () => void;
+ export let highestRatedGenreTagPercentage: boolean;
+ export let genreTagTitle: string;
</script>
<div class="categories-grid" style="padding-top: 0;">
- {#if topMedia.topGenreMedia && topMedia.genres.length > 0}
- <div class="category-grid pure-category category">
- <div class="grid-item image-grid">
- <a
- href={`https://anilist.co/${topMedia.topGenreMedia.type.toLowerCase()}/${
- topMedia.topGenreMedia.id
- }`}
- target="_blank"
- >
- <img
- src={proxy(topMedia.topGenreMedia.coverImage.extraLarge)}
- alt="Highest Rated Genre Cover"
- class="cover-image"
- on:load={updateWidth}
- />
- </a>
- <div>
- <b>{genreTagTitle} Genres</b>
- <ol>
- {#each topMedia.genres as genre}
- <li>
- <a href={`https://anilist.co/search/anime?genres=${genre.genre}`} target="_blank">
- {genre.genre}{highestRatedGenreTagPercentage ? `: ${genre.averageScore}%` : ''}
- </a>
- </li>
- {/each}
- </ol>
- </div>
- </div>
- </div>
- {/if}
- {#if topMedia.topTagMedia && topMedia.tags.length > 0}
- <div class="category-grid pure-category category">
- <div class="grid-item image-grid">
- <a
- href={`https://anilist.co/${topMedia.topTagMedia.type.toLowerCase()}/${
- topMedia.topTagMedia.id
- }`}
- target="_blank"
- >
- <img
- src={proxy(topMedia.topTagMedia.coverImage.extraLarge)}
- alt="Highest Rated Tag Cover"
- class="cover-image"
- on:load={updateWidth}
- />
- </a>
- <div>
- <b>{genreTagTitle} Tags</b>
- <ol>
- {#each topMedia.tags as tag}
- <li>
- <a href={`https://anilist.co/search/anime?genres=${tag.tag}`} target="_blank">
- {tag.tag}{highestRatedGenreTagPercentage ? `: ${tag.averageScore}%` : ''}
- </a>
- </li>
- {/each}
- </ol>
- </div>
- </div>
- </div>
- {/if}
+ {#if topMedia.topGenreMedia && topMedia.genres.length > 0}
+ <div class="category-grid pure-category category">
+ <div class="grid-item image-grid">
+ <a
+ href={`https://anilist.co/${topMedia.topGenreMedia.type.toLowerCase()}/${
+ topMedia.topGenreMedia.id
+ }`}
+ target="_blank"
+ >
+ <img
+ src={proxy(topMedia.topGenreMedia.coverImage.extraLarge)}
+ alt="Highest Rated Genre Cover"
+ class="cover-image"
+ on:load={updateWidth}
+ />
+ </a>
+ <div>
+ <b>{genreTagTitle} Genres</b>
+ <ol>
+ {#each topMedia.genres as genre}
+ <li>
+ <a href={`https://anilist.co/search/anime?genres=${genre.genre}`} target="_blank">
+ {genre.genre}{highestRatedGenreTagPercentage ? `: ${genre.averageScore}%` : ''}
+ </a>
+ </li>
+ {/each}
+ </ol>
+ </div>
+ </div>
+ </div>
+ {/if}
+ {#if topMedia.topTagMedia && topMedia.tags.length > 0}
+ <div class="category-grid pure-category category">
+ <div class="grid-item image-grid">
+ <a
+ href={`https://anilist.co/${topMedia.topTagMedia.type.toLowerCase()}/${
+ topMedia.topTagMedia.id
+ }`}
+ target="_blank"
+ >
+ <img
+ src={proxy(topMedia.topTagMedia.coverImage.extraLarge)}
+ alt="Highest Rated Tag Cover"
+ class="cover-image"
+ on:load={updateWidth}
+ />
+ </a>
+ <div>
+ <b>{genreTagTitle} Tags</b>
+ <ol>
+ {#each topMedia.tags as tag}
+ <li>
+ <a href={`https://anilist.co/search/anime?genres=${tag.tag}`} target="_blank">
+ {tag.tag}{highestRatedGenreTagPercentage ? `: ${tag.averageScore}%` : ''}
+ </a>
+ </li>
+ {/each}
+ </ol>
+ </div>
+ </div>
+ </div>
+ {/if}
</div>
diff --git a/src/lib/Tools/Wrapped/Tool.svelte b/src/lib/Tools/Wrapped/Tool.svelte
index 81a60016..1484ab5c 100644
--- a/src/lib/Tools/Wrapped/Tool.svelte
+++ b/src/lib/Tools/Wrapped/Tool.svelte
@@ -1,776 +1,776 @@
<script lang="ts">
- import userIdentity from '$stores/identity';
- import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
- import { onMount } from 'svelte';
- import { tops, wrapped, type TopMedia, SortOptions } from '$lib/Data/AniList/wrapped';
- import {
- fullActivityHistory,
- activityHistory as getActivityHistory
- } from '$lib/Data/AniList/activity';
- import { Type, mediaListCollection, type Media } from '$lib/Data/AniList/media';
- import anime from '$stores/anime';
- import lastPruneTimes from '$stores/lastPruneTimes';
- import manga from '$stores/manga';
- import Error from '$lib/Error/RateLimited.svelte';
- import { domToBlob } from 'modern-screenshot';
- import { browser } from '$app/environment';
- import { page } from '$app/stores';
- import { clearAllParameters } from '../../Utility/parameters';
- import SettingHint from '$lib/Settings/SettingHint.svelte';
- import { database } from '$lib/Database/IDB/activities';
- import Activity from './Top/Activity.svelte';
- import Anime from './Top/Anime.svelte';
- import Manga from './Top/Manga.svelte';
- import ActivityHistory from './ActivityHistory.svelte';
- import MediaExtras from './MediaExtras.svelte';
- import MediaPanel from './Media.svelte';
- import Watermark from './Watermark.svelte';
- import Skeleton from '$lib/Loading/Skeleton.svelte';
- import Message from '$lib/Loading/Message.svelte';
- import tooltip from '$lib/Tooltip/tooltip';
- import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
-
- export let user: AniListAuthorisation;
-
- const currentYear = new Date(Date.now()).getFullYear();
- let selectedYear = new Date(Date.now()).getFullYear();
- let episodes = 0;
- let chapters = 0;
- let minutesWatched = 0;
- let animeList: Media[] | undefined = undefined;
- let mangaList: Media[] | undefined = undefined;
- let calculatedAnimeList: Media[] | undefined = undefined;
- let calculatedMangaList: Media[] | undefined = undefined;
- let originalAnimeList: Media[] | undefined = undefined;
- let originalMangaList: Media[] | undefined = undefined;
- let transparency = false;
- let lightTheme = true;
- let watermark = false;
- let includeMusic = false;
- let includeSpecials = true;
- let includeRepeats = false;
- let width = 1920;
- let lightMode = false;
- let highestRatedCount = 5;
- let genreTagCount = 5;
- let mounted = false;
- let generated = false;
- let disableActivityHistory = true;
- let excludedKeywordsInput = '';
- let excludedKeywords: string[] = [];
- let useFullActivityHistory = false;
- let topGenresTags = true;
- let topMedia: TopMedia;
- let highestRatedMediaPercentage = true;
- let highestRatedGenreTagPercentage = true;
- let genreTagsSort = SortOptions.SCORE;
- let mediaSort = SortOptions.SCORE;
- let includeMovies = true;
- let includeOVAs = true;
- let activityHistoryPosition: 'TOP' | 'BELOW_TOP' | 'ORIGINAL' = 'ORIGINAL';
- let includeOngoingMediaFromPreviousYears = false;
-
- $: {
- if (browser && mounted) {
- $page.url.searchParams.set('transparency', transparency.toString());
- $page.url.searchParams.set('lightTheme', lightTheme.toString());
- $page.url.searchParams.set('watermark', watermark.toString());
- $page.url.searchParams.set('includeMusic', includeMusic.toString());
- $page.url.searchParams.set('includeSpecials', includeSpecials.toString());
- $page.url.searchParams.set('includeRepeats', includeRepeats.toString());
- $page.url.searchParams.set('lightMode', lightMode.toString());
- $page.url.searchParams.set('highestRatedCount', highestRatedCount.toString());
- $page.url.searchParams.set('genreTagCount', genreTagCount.toString());
- $page.url.searchParams.set('disableActivityHistory', disableActivityHistory.toString());
- $page.url.searchParams.set(
- 'highestRatedMediaPercentage',
- highestRatedMediaPercentage.toString()
- );
- $page.url.searchParams.set(
- 'highestRatedGenreTagPercentage',
- highestRatedGenreTagPercentage.toString()
- );
- $page.url.searchParams.set('genreTagsSort', genreTagsSort.toString());
- $page.url.searchParams.set('mediaSort', mediaSort.toString());
- $page.url.searchParams.set('includeMovies', includeMovies.toString());
- $page.url.searchParams.set('includeOVAs', includeOVAs.toString());
-
- history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
- }
- }
- $: {
- includeMusic = includeMusic;
- includeSpecials = includeSpecials;
- includeRepeats = includeRepeats;
- disableActivityHistory = disableActivityHistory;
- highestRatedMediaPercentage = highestRatedMediaPercentage;
- highestRatedGenreTagPercentage = highestRatedGenreTagPercentage;
- topGenresTags = topGenresTags;
- genreTagsSort = genreTagsSort;
- mediaSort = mediaSort;
- includeMovies = includeMovies;
- includeOVAs = includeOVAs;
- selectedYear = selectedYear;
- includeOngoingMediaFromPreviousYears = includeOngoingMediaFromPreviousYears;
-
- update().then(updateWidth).catch(updateWidth);
- }
- $: {
- animeList = animeList;
- mangaList = mangaList;
- highestRatedCount = highestRatedCount;
-
- new Promise((resolve) => setTimeout(resolve, 1)).then(updateWidth);
- }
- $: {
- genreTagCount = genreTagCount;
-
- if (animeList && mangaList)
- topMedia = tops(
- [...(animeList || []), ...(mangaList || [])],
- genreTagCount,
- genreTagsSort,
- excludedKeywords
- );
-
- new Promise((resolve) => setTimeout(resolve, 1)).then(updateWidth);
- }
- $: {
- excludedKeywords = excludedKeywords;
-
- if (excludedKeywords.length > 0 && animeList !== undefined && mangaList !== undefined) {
- animeList = originalAnimeList;
- mangaList = originalMangaList;
- animeList = excludeKeywords(animeList as Media[]);
- mangaList = excludeKeywords(mangaList as Media[]);
- }
-
- updateWidth();
- }
- $: genreTagTitle = (() => {
- switch (genreTagsSort) {
- case SortOptions.SCORE:
- return 'Highest Rated';
- case SortOptions.MINUTES_WATCHED:
- return 'Most Watched';
- case SortOptions.COUNT:
- return 'Most Common';
- }
- })();
- $: animeMostTitle = (() => {
- switch (mediaSort) {
- case SortOptions.SCORE:
- return 'Highest Rated';
- case SortOptions.MINUTES_WATCHED:
- return 'Most Watched';
- case SortOptions.COUNT:
- return 'Most Common';
- }
- })();
- $: mangaMostTitle = (() => {
- switch (mediaSort) {
- case SortOptions.SCORE:
- return 'Highest Rated';
- case SortOptions.MINUTES_WATCHED:
- return 'Most Read';
- case SortOptions.COUNT:
- return 'Most Common';
- }
- })();
-
- const updateWidth = () => {
- if (!browser) return;
-
- const wrappedContainer = document.querySelector('#wrapped') as HTMLElement;
-
- if (!wrappedContainer) return;
-
- wrappedContainer.style.width = `1920px`;
-
- const reset = () => {
- let topWidths = 0;
- let middleWidths = 0;
- let bottomWidths = 0;
-
- wrappedContainer.querySelectorAll('.category').forEach((item) => {
- const category = item as HTMLElement;
- const style = window.getComputedStyle(category);
- const width =
- category.offsetWidth +
- parseFloat(style.marginLeft) +
- parseFloat(style.marginRight) +
- parseFloat(style.paddingLeft) +
- parseFloat(style.paddingRight) +
- parseFloat(style.borderLeftWidth) +
- parseFloat(style.borderRightWidth);
-
- if (category.classList.contains('top-category')) {
- topWidths += width;
- } else if (category.classList.contains('middle-category')) {
- middleWidths += width;
- } else if (category.classList.contains('bottom-category')) {
- bottomWidths += width;
- }
- });
-
- let requiredWidth = topWidths > middleWidths ? topWidths : middleWidths;
-
- if (!disableActivityHistory && bottomWidths > requiredWidth) requiredWidth = bottomWidths;
-
- requiredWidth += wrappedContainer.offsetWidth - wrappedContainer.clientWidth;
-
- wrappedContainer.style.width = `${requiredWidth}px`;
- width = requiredWidth;
- };
-
- reset();
- reset();
- };
-
- onMount(async () => {
- clearAllParameters([
- 'transparency',
- 'lightTheme',
- 'watermark',
- 'includeMusic',
- 'includeSpecials',
- 'includeRepeats',
- 'forceDark',
- 'highestRatedCount',
- 'genreTagCount',
- 'disableActivityHistory',
- 'highestRatedMediaPercentage',
- 'highestRatedGenreTagPercentage',
- 'genreTagsSort',
- 'mediaSort',
- 'includeMovies',
- 'includeOVAs'
- ]);
-
- if (browser) {
- transparency = $page.url.searchParams.get('transparency') === 'true';
- lightTheme = $page.url.searchParams.get('lightTheme') === 'true';
- watermark = $page.url.searchParams.get('watermark') === 'true';
- includeMusic = $page.url.searchParams.get('includeMusic') === 'true';
- includeSpecials = $page.url.searchParams.get('includeSpecials') === 'true';
- includeRepeats = $page.url.searchParams.get('includeRepeats') === 'true';
- lightMode = $page.url.searchParams.get('lightMode') === 'true';
- highestRatedCount = parseInt($page.url.searchParams.get('highestRatedCount') || '5', 10);
- genreTagCount = parseInt($page.url.searchParams.get('genreTagCount') || '5', 10);
- disableActivityHistory = $page.url.searchParams.get('disableActivityHistory') === 'true';
- highestRatedMediaPercentage =
- $page.url.searchParams.get('highestRatedMediaPercentage') === 'true';
- highestRatedGenreTagPercentage =
- $page.url.searchParams.get('highestRatedGenreTagPercentage') === 'true';
- // genreTagsSort = parseInt($page.url.searchParams.get('genreTagsSort') || '0', 10);
- // mediaSort = parseInt($page.url.searchParams.get('mediaSort') || '0', 10);
- includeMovies = $page.url.searchParams.get('includeMovies') === 'true';
- includeOVAs = $page.url.searchParams.get('includeOVAs') === 'true';
- }
-
- await update().then(() => (mounted = true));
- });
-
- const update = async () => {
- if ($userIdentity.id === -1) return;
-
- let rawAnimeList = await mediaListCollection(
- user,
- $userIdentity,
- Type.Anime,
- $anime,
- $lastPruneTimes.anime,
- {
- forcePrune: true,
- includeCompleted: true,
- all: true
- }
- );
- calculatedAnimeList = rawAnimeList
- .filter(
- (item, index, self) =>
- self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
- (includeMusic ? true : item.format !== 'MUSIC') &&
- (includeRepeats
- ? true
- : item.startDate.year === selectedYear || item.endDate.year === selectedYear
- ? true
- : item.mediaListEntry?.repeat === 0) &&
- (item.mediaListEntry?.startedAt.year === selectedYear ||
- item.mediaListEntry?.completedAt.year === selectedYear ||
- ((item.mediaListEntry?.createdAt
- ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
- : false) && item.mediaListEntry
- ? item.mediaListEntry?.progress >= 1
- : false)) &&
- (includeMovies ? true : item.format !== 'MOVIE') &&
- (includeSpecials ? true : item.format !== 'SPECIAL') &&
- (includeOVAs ? true : item.format !== 'OVA')
- )
- .sort((a, b) => {
- switch (mediaSort) {
- case SortOptions.MINUTES_WATCHED:
- if (a.duration === undefined || a.mediaListEntry?.progress === undefined) return 1;
- else if (b.duration === undefined || b.mediaListEntry?.progress === undefined)
- return -1;
- else
- return (
- b.duration * b.mediaListEntry.progress - a.duration * a.mediaListEntry.progress
- );
- case SortOptions.SCORE:
- default:
- if (a.mediaListEntry?.score === undefined) return 1;
- else if (b.mediaListEntry?.score === undefined) return -1;
- else return b.mediaListEntry?.score - a.mediaListEntry?.score;
- }
- });
-
- animeList = rawAnimeList
- .filter(
- (item, index, self) =>
- self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
- (includeMusic ? true : item.format !== 'MUSIC') &&
- (includeRepeats
- ? true
- : item.startDate.year === selectedYear || item.endDate.year === selectedYear
- ? true
- : item.mediaListEntry?.repeat === 0) &&
- (item.mediaListEntry?.startedAt.year === selectedYear ||
- item.mediaListEntry?.completedAt.year === selectedYear ||
- ((item.mediaListEntry?.createdAt
- ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
- : false) && item.mediaListEntry
- ? item.mediaListEntry?.progress >= 1
- : false) ||
- (includeOngoingMediaFromPreviousYears
- ? (item.mediaListEntry?.updatedAt
- ? new Date(item.mediaListEntry?.updatedAt * 1000).getFullYear() === selectedYear
- : false) && item.mediaListEntry
- ? item.mediaListEntry?.status === 'CURRENT'
- : false
- : false)) &&
- (includeMovies ? true : item.format !== 'MOVIE') &&
- (includeSpecials ? true : item.format !== 'SPECIAL') &&
- (includeOVAs ? true : item.format !== 'OVA')
- )
- .sort((a, b) => {
- switch (mediaSort) {
- case SortOptions.MINUTES_WATCHED:
- if (a.duration === undefined || a.mediaListEntry?.progress === undefined) return 1;
- else if (b.duration === undefined || b.mediaListEntry?.progress === undefined)
- return -1;
- else
- return (
- b.duration * b.mediaListEntry.progress - a.duration * a.mediaListEntry.progress
- );
- case SortOptions.SCORE:
- default:
- if (a.mediaListEntry?.score === undefined) return 1;
- else if (b.mediaListEntry?.score === undefined) return -1;
- else return b.mediaListEntry?.score - a.mediaListEntry?.score;
- }
- });
-
- let rawMangaList = await mediaListCollection(
- user,
- $userIdentity,
- Type.Manga,
- $manga,
- $lastPruneTimes.manga,
- {
- forcePrune: true,
- includeCompleted: true,
- all: true
- }
- );
- calculatedMangaList = rawMangaList
- .filter(
- (item, index, self) =>
- self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
- (includeRepeats ? true : item.mediaListEntry?.repeat === 0) &&
- (item.mediaListEntry?.startedAt.year === selectedYear ||
- item.mediaListEntry?.completedAt.year === selectedYear ||
- ((item.mediaListEntry?.createdAt
- ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
- : false) && item.mediaListEntry
- ? item.mediaListEntry?.progress >= 1
- : false))
- )
- .sort((a, b) => {
- if (a.mediaListEntry?.score === undefined) return 1;
- else if (b.mediaListEntry?.score === undefined) return -1;
- else return b.mediaListEntry?.score - a.mediaListEntry?.score;
- });
-
- mangaList = rawMangaList
- .filter(
- (item, index, self) =>
- self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
- (includeRepeats ? true : item.mediaListEntry?.repeat === 0) &&
- (item.mediaListEntry?.startedAt.year === selectedYear ||
- item.mediaListEntry?.completedAt.year === selectedYear ||
- ((item.mediaListEntry?.createdAt
- ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
- : false) && item.mediaListEntry
- ? item.mediaListEntry?.progress >= 1
- : false) ||
- (includeOngoingMediaFromPreviousYears
- ? (item.mediaListEntry?.updatedAt
- ? new Date(item.mediaListEntry?.updatedAt * 1000).getFullYear() === selectedYear
- : false) && item.mediaListEntry
- ? item.mediaListEntry?.status === 'CURRENT'
- : false
- : false))
- )
- .sort((a, b) => {
- if (a.mediaListEntry?.score === undefined) return 1;
- else if (b.mediaListEntry?.score === undefined) return -1;
- else return b.mediaListEntry?.score - a.mediaListEntry?.score;
- });
-
- episodes = 0;
- minutesWatched = 0;
- chapters = 0;
-
- for (const media of calculatedAnimeList) {
- episodes += media.mediaListEntry?.progress || 0;
- minutesWatched += (media.mediaListEntry?.progress || 0) * media.duration || 0;
- }
-
- for (const media of calculatedMangaList) chapters += media.mediaListEntry?.progress || 0;
- };
-
- /* eslint-disable @typescript-eslint/no-explicit-any */
- // const year = (statistic: { startYears: any }) =>
- // statistic.startYears.find((y: { startYear: number }) => y.startYear === 2023);
-
- const screenshot = async () => {
- let element = document.querySelector('#wrapped') as HTMLElement;
-
- if (element !== null) {
- domToBlob(element, {
- backgroundColor: transparency ? 'transparent' : lightTheme ? '#edf1f5' : '#0b1622',
- quality: 1,
- scale: 2,
- fetch: {
- requestInit: {
- mode: 'cors'
- },
- bypassingCache: true
- }
- }).then((blob) => {
- const downloadWrapper = document.createElement('a');
- // const wrappedImageButton = document.getElementById(
- // 'wrapped-image-download'
- // ) as HTMLAnchorElement;
- const image = document.createElement('img');
- const object = (window.URL || window.webkitURL || window || {}).createObjectURL(blob);
-
- // downloadWrapper.download = `due_dot_moe_wrapped_${dark ? 'dark' : 'light'}.png`;
- downloadWrapper.href = object;
- downloadWrapper.target = '_blank';
- image.src = object;
-
- downloadWrapper.appendChild(image);
-
- // if (wrappedImageButton !== null) {
- // wrappedImageButton.href = object;
- // }
-
- const wrappedFinal = document.getElementById('wrapped-final');
-
- if (wrappedFinal !== null) {
- wrappedFinal.innerHTML = '';
-
- wrappedFinal.appendChild(downloadWrapper);
-
- generated = true;
- }
-
- downloadWrapper.click();
- });
- }
- };
-
- // const abbreviate = (string: string, maxLength = 40, enabled = true) => {
- // if (!enabled) {
- // return string;
- // }
-
- // if (string.length <= maxLength) {
- // return string;
- // }
-
- // return string.slice(0, maxLength - 3) + ' …';
- // };
-
- const submitExcludedKeywords = () => {
- if (excludedKeywordsInput.length <= 0 && excludedKeywords.length > 0) {
- animeList = originalAnimeList;
- mangaList = originalMangaList;
- excludedKeywords = [];
- } else if (excludedKeywordsInput.length >= 0 && excludedKeywords.length <= 0) {
- originalAnimeList = animeList;
- originalMangaList = mangaList;
- }
-
- if (excludedKeywordsInput.length > 0)
- excludedKeywords = excludedKeywordsInput
- .split(',')
- .map((k) => k.trim())
- .filter((k) => k.length > 0);
- };
-
- const excludeKeywords = (media: Media[]) => {
- if (excludedKeywords.length <= 0) return media;
-
- return media.filter((m) => {
- for (const keyword of excludedKeywords) {
- if (m.title.english?.toLowerCase().includes(keyword.toLowerCase())) return false;
- if (m.title.romaji?.toLowerCase().includes(keyword.toLowerCase())) return false;
- if (m.title.native?.toLowerCase().includes(keyword.toLowerCase())) return false;
- }
-
- return true;
- });
- };
-
- const pruneFullYear = async () => {
- await database.activities.bulkDelete((await database.activities.toArray()).map((m) => m.page));
- };
-
- // const mergeArraySort = (a: any, b: any, mode: 'tags' | 'genres') => {
- // let merged = [...a, ...b].sort((a, b) => b.meanScore - a.meanScore);
-
- // merged = merged.filter(
- // (item, index, self) =>
- // self.findIndex((itemToCompare) =>
- // mode === 'genres'
- // ? itemToCompare.genre === item.genre
- // : itemToCompare.tag.name === item.tag.name
- // ) === index
- // );
-
- // return merged;
- // };
-
- // const randomCoverFromTop10 = (
- // statistics: { anime: any; manga: any },
- // mode: 'tags' | 'genres'
- // ) => {
- // const top = mergeArraySort(statistics.anime[mode], statistics.manga[mode], mode);
-
- // return mediaCover(top[Math.floor(Math.random() * top.length)].mediaIds[0]);
- // };
+ import userIdentity from '$stores/identity';
+ import type { AniListAuthorisation } from '$lib/Data/AniList/identity';
+ import { onMount } from 'svelte';
+ import { tops, wrapped, type TopMedia, SortOptions } from '$lib/Data/AniList/wrapped';
+ import {
+ fullActivityHistory,
+ activityHistory as getActivityHistory
+ } from '$lib/Data/AniList/activity';
+ import { Type, mediaListCollection, type Media } from '$lib/Data/AniList/media';
+ import anime from '$stores/anime';
+ import lastPruneTimes from '$stores/lastPruneTimes';
+ import manga from '$stores/manga';
+ import Error from '$lib/Error/RateLimited.svelte';
+ import { domToBlob } from 'modern-screenshot';
+ import { browser } from '$app/environment';
+ import { page } from '$app/stores';
+ import { clearAllParameters } from '../../Utility/parameters';
+ import SettingHint from '$lib/Settings/SettingHint.svelte';
+ import { database } from '$lib/Database/IDB/activities';
+ import Activity from './Top/Activity.svelte';
+ import Anime from './Top/Anime.svelte';
+ import Manga from './Top/Manga.svelte';
+ import ActivityHistory from './ActivityHistory.svelte';
+ import MediaExtras from './MediaExtras.svelte';
+ import MediaPanel from './Media.svelte';
+ import Watermark from './Watermark.svelte';
+ import Skeleton from '$lib/Loading/Skeleton.svelte';
+ import Message from '$lib/Loading/Message.svelte';
+ import tooltip from '$lib/Tooltip/tooltip';
+ import LogInRestricted from '$lib/Error/LogInRestricted.svelte';
+
+ export let user: AniListAuthorisation;
+
+ const currentYear = new Date(Date.now()).getFullYear();
+ let selectedYear = new Date(Date.now()).getFullYear();
+ let episodes = 0;
+ let chapters = 0;
+ let minutesWatched = 0;
+ let animeList: Media[] | undefined = undefined;
+ let mangaList: Media[] | undefined = undefined;
+ let calculatedAnimeList: Media[] | undefined = undefined;
+ let calculatedMangaList: Media[] | undefined = undefined;
+ let originalAnimeList: Media[] | undefined = undefined;
+ let originalMangaList: Media[] | undefined = undefined;
+ let transparency = false;
+ let lightTheme = true;
+ let watermark = false;
+ let includeMusic = false;
+ let includeSpecials = true;
+ let includeRepeats = false;
+ let width = 1920;
+ let lightMode = false;
+ let highestRatedCount = 5;
+ let genreTagCount = 5;
+ let mounted = false;
+ let generated = false;
+ let disableActivityHistory = true;
+ let excludedKeywordsInput = '';
+ let excludedKeywords: string[] = [];
+ let useFullActivityHistory = false;
+ let topGenresTags = true;
+ let topMedia: TopMedia;
+ let highestRatedMediaPercentage = true;
+ let highestRatedGenreTagPercentage = true;
+ let genreTagsSort = SortOptions.SCORE;
+ let mediaSort = SortOptions.SCORE;
+ let includeMovies = true;
+ let includeOVAs = true;
+ let activityHistoryPosition: 'TOP' | 'BELOW_TOP' | 'ORIGINAL' = 'ORIGINAL';
+ let includeOngoingMediaFromPreviousYears = false;
+
+ $: {
+ if (browser && mounted) {
+ $page.url.searchParams.set('transparency', transparency.toString());
+ $page.url.searchParams.set('lightTheme', lightTheme.toString());
+ $page.url.searchParams.set('watermark', watermark.toString());
+ $page.url.searchParams.set('includeMusic', includeMusic.toString());
+ $page.url.searchParams.set('includeSpecials', includeSpecials.toString());
+ $page.url.searchParams.set('includeRepeats', includeRepeats.toString());
+ $page.url.searchParams.set('lightMode', lightMode.toString());
+ $page.url.searchParams.set('highestRatedCount', highestRatedCount.toString());
+ $page.url.searchParams.set('genreTagCount', genreTagCount.toString());
+ $page.url.searchParams.set('disableActivityHistory', disableActivityHistory.toString());
+ $page.url.searchParams.set(
+ 'highestRatedMediaPercentage',
+ highestRatedMediaPercentage.toString()
+ );
+ $page.url.searchParams.set(
+ 'highestRatedGenreTagPercentage',
+ highestRatedGenreTagPercentage.toString()
+ );
+ $page.url.searchParams.set('genreTagsSort', genreTagsSort.toString());
+ $page.url.searchParams.set('mediaSort', mediaSort.toString());
+ $page.url.searchParams.set('includeMovies', includeMovies.toString());
+ $page.url.searchParams.set('includeOVAs', includeOVAs.toString());
+
+ history.replaceState(null, '', `?${$page.url.searchParams.toString()}`);
+ }
+ }
+ $: {
+ includeMusic = includeMusic;
+ includeSpecials = includeSpecials;
+ includeRepeats = includeRepeats;
+ disableActivityHistory = disableActivityHistory;
+ highestRatedMediaPercentage = highestRatedMediaPercentage;
+ highestRatedGenreTagPercentage = highestRatedGenreTagPercentage;
+ topGenresTags = topGenresTags;
+ genreTagsSort = genreTagsSort;
+ mediaSort = mediaSort;
+ includeMovies = includeMovies;
+ includeOVAs = includeOVAs;
+ selectedYear = selectedYear;
+ includeOngoingMediaFromPreviousYears = includeOngoingMediaFromPreviousYears;
+
+ update().then(updateWidth).catch(updateWidth);
+ }
+ $: {
+ animeList = animeList;
+ mangaList = mangaList;
+ highestRatedCount = highestRatedCount;
+
+ new Promise((resolve) => setTimeout(resolve, 1)).then(updateWidth);
+ }
+ $: {
+ genreTagCount = genreTagCount;
+
+ if (animeList && mangaList)
+ topMedia = tops(
+ [...(animeList || []), ...(mangaList || [])],
+ genreTagCount,
+ genreTagsSort,
+ excludedKeywords
+ );
+
+ new Promise((resolve) => setTimeout(resolve, 1)).then(updateWidth);
+ }
+ $: {
+ excludedKeywords = excludedKeywords;
+
+ if (excludedKeywords.length > 0 && animeList !== undefined && mangaList !== undefined) {
+ animeList = originalAnimeList;
+ mangaList = originalMangaList;
+ animeList = excludeKeywords(animeList as Media[]);
+ mangaList = excludeKeywords(mangaList as Media[]);
+ }
+
+ updateWidth();
+ }
+ $: genreTagTitle = (() => {
+ switch (genreTagsSort) {
+ case SortOptions.SCORE:
+ return 'Highest Rated';
+ case SortOptions.MINUTES_WATCHED:
+ return 'Most Watched';
+ case SortOptions.COUNT:
+ return 'Most Common';
+ }
+ })();
+ $: animeMostTitle = (() => {
+ switch (mediaSort) {
+ case SortOptions.SCORE:
+ return 'Highest Rated';
+ case SortOptions.MINUTES_WATCHED:
+ return 'Most Watched';
+ case SortOptions.COUNT:
+ return 'Most Common';
+ }
+ })();
+ $: mangaMostTitle = (() => {
+ switch (mediaSort) {
+ case SortOptions.SCORE:
+ return 'Highest Rated';
+ case SortOptions.MINUTES_WATCHED:
+ return 'Most Read';
+ case SortOptions.COUNT:
+ return 'Most Common';
+ }
+ })();
+
+ const updateWidth = () => {
+ if (!browser) return;
+
+ const wrappedContainer = document.querySelector('#wrapped') as HTMLElement;
+
+ if (!wrappedContainer) return;
+
+ wrappedContainer.style.width = `1920px`;
+
+ const reset = () => {
+ let topWidths = 0;
+ let middleWidths = 0;
+ let bottomWidths = 0;
+
+ wrappedContainer.querySelectorAll('.category').forEach((item) => {
+ const category = item as HTMLElement;
+ const style = window.getComputedStyle(category);
+ const width =
+ category.offsetWidth +
+ parseFloat(style.marginLeft) +
+ parseFloat(style.marginRight) +
+ parseFloat(style.paddingLeft) +
+ parseFloat(style.paddingRight) +
+ parseFloat(style.borderLeftWidth) +
+ parseFloat(style.borderRightWidth);
+
+ if (category.classList.contains('top-category')) {
+ topWidths += width;
+ } else if (category.classList.contains('middle-category')) {
+ middleWidths += width;
+ } else if (category.classList.contains('bottom-category')) {
+ bottomWidths += width;
+ }
+ });
+
+ let requiredWidth = topWidths > middleWidths ? topWidths : middleWidths;
+
+ if (!disableActivityHistory && bottomWidths > requiredWidth) requiredWidth = bottomWidths;
+
+ requiredWidth += wrappedContainer.offsetWidth - wrappedContainer.clientWidth;
+
+ wrappedContainer.style.width = `${requiredWidth}px`;
+ width = requiredWidth;
+ };
+
+ reset();
+ reset();
+ };
+
+ onMount(async () => {
+ clearAllParameters([
+ 'transparency',
+ 'lightTheme',
+ 'watermark',
+ 'includeMusic',
+ 'includeSpecials',
+ 'includeRepeats',
+ 'forceDark',
+ 'highestRatedCount',
+ 'genreTagCount',
+ 'disableActivityHistory',
+ 'highestRatedMediaPercentage',
+ 'highestRatedGenreTagPercentage',
+ 'genreTagsSort',
+ 'mediaSort',
+ 'includeMovies',
+ 'includeOVAs'
+ ]);
+
+ if (browser) {
+ transparency = $page.url.searchParams.get('transparency') === 'true';
+ lightTheme = $page.url.searchParams.get('lightTheme') === 'true';
+ watermark = $page.url.searchParams.get('watermark') === 'true';
+ includeMusic = $page.url.searchParams.get('includeMusic') === 'true';
+ includeSpecials = $page.url.searchParams.get('includeSpecials') === 'true';
+ includeRepeats = $page.url.searchParams.get('includeRepeats') === 'true';
+ lightMode = $page.url.searchParams.get('lightMode') === 'true';
+ highestRatedCount = parseInt($page.url.searchParams.get('highestRatedCount') || '5', 10);
+ genreTagCount = parseInt($page.url.searchParams.get('genreTagCount') || '5', 10);
+ disableActivityHistory = $page.url.searchParams.get('disableActivityHistory') === 'true';
+ highestRatedMediaPercentage =
+ $page.url.searchParams.get('highestRatedMediaPercentage') === 'true';
+ highestRatedGenreTagPercentage =
+ $page.url.searchParams.get('highestRatedGenreTagPercentage') === 'true';
+ // genreTagsSort = parseInt($page.url.searchParams.get('genreTagsSort') || '0', 10);
+ // mediaSort = parseInt($page.url.searchParams.get('mediaSort') || '0', 10);
+ includeMovies = $page.url.searchParams.get('includeMovies') === 'true';
+ includeOVAs = $page.url.searchParams.get('includeOVAs') === 'true';
+ }
+
+ await update().then(() => (mounted = true));
+ });
+
+ const update = async () => {
+ if ($userIdentity.id === -1) return;
+
+ let rawAnimeList = await mediaListCollection(
+ user,
+ $userIdentity,
+ Type.Anime,
+ $anime,
+ $lastPruneTimes.anime,
+ {
+ forcePrune: true,
+ includeCompleted: true,
+ all: true
+ }
+ );
+ calculatedAnimeList = rawAnimeList
+ .filter(
+ (item, index, self) =>
+ self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
+ (includeMusic ? true : item.format !== 'MUSIC') &&
+ (includeRepeats
+ ? true
+ : item.startDate.year === selectedYear || item.endDate.year === selectedYear
+ ? true
+ : item.mediaListEntry?.repeat === 0) &&
+ (item.mediaListEntry?.startedAt.year === selectedYear ||
+ item.mediaListEntry?.completedAt.year === selectedYear ||
+ ((item.mediaListEntry?.createdAt
+ ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
+ : false) && item.mediaListEntry
+ ? item.mediaListEntry?.progress >= 1
+ : false)) &&
+ (includeMovies ? true : item.format !== 'MOVIE') &&
+ (includeSpecials ? true : item.format !== 'SPECIAL') &&
+ (includeOVAs ? true : item.format !== 'OVA')
+ )
+ .sort((a, b) => {
+ switch (mediaSort) {
+ case SortOptions.MINUTES_WATCHED:
+ if (a.duration === undefined || a.mediaListEntry?.progress === undefined) return 1;
+ else if (b.duration === undefined || b.mediaListEntry?.progress === undefined)
+ return -1;
+ else
+ return (
+ b.duration * b.mediaListEntry.progress - a.duration * a.mediaListEntry.progress
+ );
+ case SortOptions.SCORE:
+ default:
+ if (a.mediaListEntry?.score === undefined) return 1;
+ else if (b.mediaListEntry?.score === undefined) return -1;
+ else return b.mediaListEntry?.score - a.mediaListEntry?.score;
+ }
+ });
+
+ animeList = rawAnimeList
+ .filter(
+ (item, index, self) =>
+ self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
+ (includeMusic ? true : item.format !== 'MUSIC') &&
+ (includeRepeats
+ ? true
+ : item.startDate.year === selectedYear || item.endDate.year === selectedYear
+ ? true
+ : item.mediaListEntry?.repeat === 0) &&
+ (item.mediaListEntry?.startedAt.year === selectedYear ||
+ item.mediaListEntry?.completedAt.year === selectedYear ||
+ ((item.mediaListEntry?.createdAt
+ ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
+ : false) && item.mediaListEntry
+ ? item.mediaListEntry?.progress >= 1
+ : false) ||
+ (includeOngoingMediaFromPreviousYears
+ ? (item.mediaListEntry?.updatedAt
+ ? new Date(item.mediaListEntry?.updatedAt * 1000).getFullYear() === selectedYear
+ : false) && item.mediaListEntry
+ ? item.mediaListEntry?.status === 'CURRENT'
+ : false
+ : false)) &&
+ (includeMovies ? true : item.format !== 'MOVIE') &&
+ (includeSpecials ? true : item.format !== 'SPECIAL') &&
+ (includeOVAs ? true : item.format !== 'OVA')
+ )
+ .sort((a, b) => {
+ switch (mediaSort) {
+ case SortOptions.MINUTES_WATCHED:
+ if (a.duration === undefined || a.mediaListEntry?.progress === undefined) return 1;
+ else if (b.duration === undefined || b.mediaListEntry?.progress === undefined)
+ return -1;
+ else
+ return (
+ b.duration * b.mediaListEntry.progress - a.duration * a.mediaListEntry.progress
+ );
+ case SortOptions.SCORE:
+ default:
+ if (a.mediaListEntry?.score === undefined) return 1;
+ else if (b.mediaListEntry?.score === undefined) return -1;
+ else return b.mediaListEntry?.score - a.mediaListEntry?.score;
+ }
+ });
+
+ let rawMangaList = await mediaListCollection(
+ user,
+ $userIdentity,
+ Type.Manga,
+ $manga,
+ $lastPruneTimes.manga,
+ {
+ forcePrune: true,
+ includeCompleted: true,
+ all: true
+ }
+ );
+ calculatedMangaList = rawMangaList
+ .filter(
+ (item, index, self) =>
+ self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
+ (includeRepeats ? true : item.mediaListEntry?.repeat === 0) &&
+ (item.mediaListEntry?.startedAt.year === selectedYear ||
+ item.mediaListEntry?.completedAt.year === selectedYear ||
+ ((item.mediaListEntry?.createdAt
+ ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
+ : false) && item.mediaListEntry
+ ? item.mediaListEntry?.progress >= 1
+ : false))
+ )
+ .sort((a, b) => {
+ if (a.mediaListEntry?.score === undefined) return 1;
+ else if (b.mediaListEntry?.score === undefined) return -1;
+ else return b.mediaListEntry?.score - a.mediaListEntry?.score;
+ });
+
+ mangaList = rawMangaList
+ .filter(
+ (item, index, self) =>
+ self.findIndex((itemToCompare) => itemToCompare.id === item.id) === index &&
+ (includeRepeats ? true : item.mediaListEntry?.repeat === 0) &&
+ (item.mediaListEntry?.startedAt.year === selectedYear ||
+ item.mediaListEntry?.completedAt.year === selectedYear ||
+ ((item.mediaListEntry?.createdAt
+ ? new Date(item.mediaListEntry?.createdAt * 1000).getFullYear() === selectedYear
+ : false) && item.mediaListEntry
+ ? item.mediaListEntry?.progress >= 1
+ : false) ||
+ (includeOngoingMediaFromPreviousYears
+ ? (item.mediaListEntry?.updatedAt
+ ? new Date(item.mediaListEntry?.updatedAt * 1000).getFullYear() === selectedYear
+ : false) && item.mediaListEntry
+ ? item.mediaListEntry?.status === 'CURRENT'
+ : false
+ : false))
+ )
+ .sort((a, b) => {
+ if (a.mediaListEntry?.score === undefined) return 1;
+ else if (b.mediaListEntry?.score === undefined) return -1;
+ else return b.mediaListEntry?.score - a.mediaListEntry?.score;
+ });
+
+ episodes = 0;
+ minutesWatched = 0;
+ chapters = 0;
+
+ for (const media of calculatedAnimeList) {
+ episodes += media.mediaListEntry?.progress || 0;
+ minutesWatched += (media.mediaListEntry?.progress || 0) * media.duration || 0;
+ }
+
+ for (const media of calculatedMangaList) chapters += media.mediaListEntry?.progress || 0;
+ };
+
+ /* eslint-disable @typescript-eslint/no-explicit-any */
+ // const year = (statistic: { startYears: any }) =>
+ // statistic.startYears.find((y: { startYear: number }) => y.startYear === 2023);
+
+ const screenshot = async () => {
+ let element = document.querySelector('#wrapped') as HTMLElement;
+
+ if (element !== null) {
+ domToBlob(element, {
+ backgroundColor: transparency ? 'transparent' : lightTheme ? '#edf1f5' : '#0b1622',
+ quality: 1,
+ scale: 2,
+ fetch: {
+ requestInit: {
+ mode: 'cors'
+ },
+ bypassingCache: true
+ }
+ }).then((blob) => {
+ const downloadWrapper = document.createElement('a');
+ // const wrappedImageButton = document.getElementById(
+ // 'wrapped-image-download'
+ // ) as HTMLAnchorElement;
+ const image = document.createElement('img');
+ const object = (window.URL || window.webkitURL || window || {}).createObjectURL(blob);
+
+ // downloadWrapper.download = `due_dot_moe_wrapped_${dark ? 'dark' : 'light'}.png`;
+ downloadWrapper.href = object;
+ downloadWrapper.target = '_blank';
+ image.src = object;
+
+ downloadWrapper.appendChild(image);
+
+ // if (wrappedImageButton !== null) {
+ // wrappedImageButton.href = object;
+ // }
+
+ const wrappedFinal = document.getElementById('wrapped-final');
+
+ if (wrappedFinal !== null) {
+ wrappedFinal.innerHTML = '';
+
+ wrappedFinal.appendChild(downloadWrapper);
+
+ generated = true;
+ }
+
+ downloadWrapper.click();
+ });
+ }
+ };
+
+ // const abbreviate = (string: string, maxLength = 40, enabled = true) => {
+ // if (!enabled) {
+ // return string;
+ // }
+
+ // if (string.length <= maxLength) {
+ // return string;
+ // }
+
+ // return string.slice(0, maxLength - 3) + ' …';
+ // };
+
+ const submitExcludedKeywords = () => {
+ if (excludedKeywordsInput.length <= 0 && excludedKeywords.length > 0) {
+ animeList = originalAnimeList;
+ mangaList = originalMangaList;
+ excludedKeywords = [];
+ } else if (excludedKeywordsInput.length >= 0 && excludedKeywords.length <= 0) {
+ originalAnimeList = animeList;
+ originalMangaList = mangaList;
+ }
+
+ if (excludedKeywordsInput.length > 0)
+ excludedKeywords = excludedKeywordsInput
+ .split(',')
+ .map((k) => k.trim())
+ .filter((k) => k.length > 0);
+ };
+
+ const excludeKeywords = (media: Media[]) => {
+ if (excludedKeywords.length <= 0) return media;
+
+ return media.filter((m) => {
+ for (const keyword of excludedKeywords) {
+ if (m.title.english?.toLowerCase().includes(keyword.toLowerCase())) return false;
+ if (m.title.romaji?.toLowerCase().includes(keyword.toLowerCase())) return false;
+ if (m.title.native?.toLowerCase().includes(keyword.toLowerCase())) return false;
+ }
+
+ return true;
+ });
+ };
+
+ const pruneFullYear = async () => {
+ await database.activities.bulkDelete((await database.activities.toArray()).map((m) => m.page));
+ };
+
+ // const mergeArraySort = (a: any, b: any, mode: 'tags' | 'genres') => {
+ // let merged = [...a, ...b].sort((a, b) => b.meanScore - a.meanScore);
+
+ // merged = merged.filter(
+ // (item, index, self) =>
+ // self.findIndex((itemToCompare) =>
+ // mode === 'genres'
+ // ? itemToCompare.genre === item.genre
+ // : itemToCompare.tag.name === item.tag.name
+ // ) === index
+ // );
+
+ // return merged;
+ // };
+
+ // const randomCoverFromTop10 = (
+ // statistics: { anime: any; manga: any },
+ // mode: 'tags' | 'genres'
+ // ) => {
+ // const top = mergeArraySort(statistics.anime[mode], statistics.manga[mode], mode);
+
+ // return mediaCover(top[Math.floor(Math.random() * top.length)].mediaIds[0]);
+ // };
</script>
{#if $userIdentity.id === -2 || user === undefined}
- <LogInRestricted />
+ <LogInRestricted />
{:else if $userIdentity.id !== -1}
- {#await selectedYear !== currentYear || useFullActivityHistory || new Date().getMonth() <= 6 ? fullActivityHistory(user, $userIdentity, selectedYear) : getActivityHistory($userIdentity)}
- <Message message="Loading activity history ..." />
-
- <Skeleton count={2} />
- {:then activities}
- {#await wrapped(user, $userIdentity, selectedYear)}
- <Message message="Loading user data ..." />
-
- <Skeleton count={2} />
- {:then wrapped}
- <div id="list-container">
- <div class="card">
- <div
- id="wrapped"
- class:light-theme={lightMode}
- style={`width: ${width}px; flex-shrink: 0;`}
- class:transparent={transparency}
- >
- {#if !disableActivityHistory && activityHistoryPosition === 'TOP' && activities.length > 0 && selectedYear === currentYear}
- <ActivityHistory {user} {activities} year={selectedYear} {activityHistoryPosition} />
- {/if}
- <div class="categories-grid" style="padding-bottom: 0;">
- <Activity
- {wrapped}
- year={selectedYear}
- {activities}
- {useFullActivityHistory}
- {updateWidth}
- />
- <Anime animeList={calculatedAnimeList} {minutesWatched} {episodes} />
- <Manga mangaList={calculatedMangaList} {chapters} />
- </div>
- {#if !disableActivityHistory && activityHistoryPosition === 'BELOW_TOP' && activities.length > 0 && selectedYear === currentYear}
- <ActivityHistory {user} {activities} year={selectedYear} {activityHistoryPosition} />
- {/if}
- <MediaPanel
- {animeList}
- {mangaList}
- {highestRatedMediaPercentage}
- {highestRatedCount}
- {updateWidth}
- {wrapped}
- {animeMostTitle}
- {mangaMostTitle}
- />
- {#if topMedia && topGenresTags && ((topMedia.topGenreMedia && topMedia.genres.length > 0) || (topMedia.topTagMedia && topMedia.tags.length > 0))}
- <MediaExtras
- {topMedia}
- {genreTagTitle}
- {highestRatedGenreTagPercentage}
- {updateWidth}
- />
- {/if}
- {#if !disableActivityHistory && activityHistoryPosition === 'ORIGINAL' && activities.length > 0 && selectedYear === currentYear}
- <ActivityHistory {user} {activities} year={selectedYear} {activityHistoryPosition} />
- {/if}
- {#if watermark}
- <Watermark />
- {/if}
- </div>
- </div>
- <div class="list">
- <div class:card={generated}>
- <div id="wrapped-final" />
-
- {#if generated}
- <p />
-
- <blockquote style="margin: 0 0 0 1.5rem;">
- Click on the image to download, or right click and select "Save Image As...".
- </blockquote>
- {/if}
- </div>
-
- {#if generated}
- <p />
- {/if}
-
- <div id="options" class="card">
- <button on:click={screenshot} data-umami-event="Generate Wrapped">
- Generate image
- </button>
-
- <details class="no-shadow" open>
- <summary>Display</summary>
-
- <input type="checkbox" bind:checked={watermark} /> Show watermark<br />
- <input type="checkbox" bind:checked={transparency} /> Enable background transparency<br
- />
- <input type="checkbox" bind:checked={lightMode} />
- Enable light mode<br />
- <input type="checkbox" bind:checked={topGenresTags} />
- Show top genres and tags<br />
- <input
- type="checkbox"
- bind:checked={disableActivityHistory}
- disabled={selectedYear !== currentYear}
- />
- Hide activity history<br />
- <input type="checkbox" bind:checked={highestRatedMediaPercentage} /> Show highest
- rated media percentages<br />
- <input type="checkbox" bind:checked={highestRatedGenreTagPercentage} /> Show highest
- rated genre and tag percentages<br />
- <input type="checkbox" bind:checked={includeOngoingMediaFromPreviousYears} /> Show
- ongoing media from previous years<br />
- <select bind:value={activityHistoryPosition}>
- <option value="TOP">Above Top Row</option>
- <option value="BELOW_TOP">Below Top Row</option>
- <option value="ORIGINAL">Bottom</option>
- </select>
- Activity history position<br />
- <select bind:value={highestRatedCount}>
- {#each [3, 4, 5, 6, 7, 8, 9, 10] as count}
- <option value={count}>{count}</option>
- {/each}
- </select>
- Highest rated media count<br />
- <select bind:value={genreTagCount}>
- {#each [3, 4, 5, 6, 7, 8, 9, 10] as count}
- <option value={count}>{count}</option>
- {/each}
- </select>
- Highest genre and tag count<br />
- <button on:click={updateWidth}>Find best fit</button>
- <button on:click={() => (width -= 25)}>-25px</button>
- <button on:click={() => (width += 25)}>+25px</button>
- Width adjustment<br />
- </details>
-
- <details class="no-shadow" open>
- <summary>Calculation</summary>
-
- <input type="checkbox" bind:checked={useFullActivityHistory} />
- Enable full-year activity<button class="smaller-button" on:click={pruneFullYear}
- >Refresh data</button
- >
- <br />
- <select bind:value={selectedYear}>
- {#each Array.from({ length: currentYear - 2012 }) as _, i}
- <option value={currentYear - i}>
- {currentYear - i}
- </option>
- {/each}
- </select>
- Calculate for year<br />
- <select bind:value={mediaSort}>
- <option value={SortOptions.SCORE}>Score</option>
- <option value={SortOptions.MINUTES_WATCHED}>Minutes Watched/Read</option>
- </select>
- Anime and manga sort<br />
- <select bind:value={genreTagsSort}>
- <option value={SortOptions.SCORE}>Score</option>
- <option value={SortOptions.MINUTES_WATCHED}>Minutes Watched/Read</option>
- <option value={SortOptions.COUNT}>Count</option>
- </select>
- Genre and tag sort<br />
- <input type="checkbox" bind:checked={includeMusic} /> Include music<br />
- <input type="checkbox" bind:checked={includeRepeats} /> Include rewatches & rereads<br
- />
- <input type="checkbox" bind:checked={includeSpecials} /> Include specials<br />
- <input type="checkbox" bind:checked={includeOVAs} /> Include OVAs<br />
- <input type="checkbox" bind:checked={includeMovies} /> Include movies<br />
- <input
- type="text"
- bind:value={excludedKeywordsInput}
- on:keypress={(e) => {
- e.key === 'Enter' && submitExcludedKeywords();
- }}
- />
- Excluded keywords
- <button on:click={submitExcludedKeywords} title="Or click your Enter key" use:tooltip
- >Submit</button
- >
- <br />
- <SettingHint>Comma separated list (e.g., "My Hero, Kaguya")</SettingHint>
- </details>
- </div>
- </div>
- </div>
- {:catch}
- <Error type="User" card list={false} />
- {/await}
- {:catch}
- <Error
- card
- type={`${useFullActivityHistory ? 'Full-year activity' : 'Activity'} history`}
- loginSessionError={!useFullActivityHistory}
- list={false}
- >
- {#if useFullActivityHistory}
- <p>
- With <b>many</b> activities, it may take multiple attempts to obtain all of your activity history
- from AniList. If this occurs, wait one minute and try again to continue populating your local
- activity history database.
- </p>
- {/if}
- </Error>
- {/await}
+ {#await selectedYear !== currentYear || useFullActivityHistory || new Date().getMonth() <= 6 ? fullActivityHistory(user, $userIdentity, selectedYear) : getActivityHistory($userIdentity)}
+ <Message message="Loading activity history ..." />
+
+ <Skeleton count={2} />
+ {:then activities}
+ {#await wrapped(user, $userIdentity, selectedYear)}
+ <Message message="Loading user data ..." />
+
+ <Skeleton count={2} />
+ {:then wrapped}
+ <div id="list-container">
+ <div class="card">
+ <div
+ id="wrapped"
+ class:light-theme={lightMode}
+ style={`width: ${width}px; flex-shrink: 0;`}
+ class:transparent={transparency}
+ >
+ {#if !disableActivityHistory && activityHistoryPosition === 'TOP' && activities.length > 0 && selectedYear === currentYear}
+ <ActivityHistory {user} {activities} year={selectedYear} {activityHistoryPosition} />
+ {/if}
+ <div class="categories-grid" style="padding-bottom: 0;">
+ <Activity
+ {wrapped}
+ year={selectedYear}
+ {activities}
+ {useFullActivityHistory}
+ {updateWidth}
+ />
+ <Anime animeList={calculatedAnimeList} {minutesWatched} {episodes} />
+ <Manga mangaList={calculatedMangaList} {chapters} />
+ </div>
+ {#if !disableActivityHistory && activityHistoryPosition === 'BELOW_TOP' && activities.length > 0 && selectedYear === currentYear}
+ <ActivityHistory {user} {activities} year={selectedYear} {activityHistoryPosition} />
+ {/if}
+ <MediaPanel
+ {animeList}
+ {mangaList}
+ {highestRatedMediaPercentage}
+ {highestRatedCount}
+ {updateWidth}
+ {wrapped}
+ {animeMostTitle}
+ {mangaMostTitle}
+ />
+ {#if topMedia && topGenresTags && ((topMedia.topGenreMedia && topMedia.genres.length > 0) || (topMedia.topTagMedia && topMedia.tags.length > 0))}
+ <MediaExtras
+ {topMedia}
+ {genreTagTitle}
+ {highestRatedGenreTagPercentage}
+ {updateWidth}
+ />
+ {/if}
+ {#if !disableActivityHistory && activityHistoryPosition === 'ORIGINAL' && activities.length > 0 && selectedYear === currentYear}
+ <ActivityHistory {user} {activities} year={selectedYear} {activityHistoryPosition} />
+ {/if}
+ {#if watermark}
+ <Watermark />
+ {/if}
+ </div>
+ </div>
+ <div class="list">
+ <div class:card={generated}>
+ <div id="wrapped-final" />
+
+ {#if generated}
+ <p />
+
+ <blockquote style="margin: 0 0 0 1.5rem;">
+ Click on the image to download, or right click and select "Save Image As...".
+ </blockquote>
+ {/if}
+ </div>
+
+ {#if generated}
+ <p />
+ {/if}
+
+ <div id="options" class="card">
+ <button on:click={screenshot} data-umami-event="Generate Wrapped">
+ Generate image
+ </button>
+
+ <details class="no-shadow" open>
+ <summary>Display</summary>
+
+ <input type="checkbox" bind:checked={watermark} /> Show watermark<br />
+ <input type="checkbox" bind:checked={transparency} /> Enable background transparency<br
+ />
+ <input type="checkbox" bind:checked={lightMode} />
+ Enable light mode<br />
+ <input type="checkbox" bind:checked={topGenresTags} />
+ Show top genres and tags<br />
+ <input
+ type="checkbox"
+ bind:checked={disableActivityHistory}
+ disabled={selectedYear !== currentYear}
+ />
+ Hide activity history<br />
+ <input type="checkbox" bind:checked={highestRatedMediaPercentage} /> Show highest
+ rated media percentages<br />
+ <input type="checkbox" bind:checked={highestRatedGenreTagPercentage} /> Show highest
+ rated genre and tag percentages<br />
+ <input type="checkbox" bind:checked={includeOngoingMediaFromPreviousYears} /> Show
+ ongoing media from previous years<br />
+ <select bind:value={activityHistoryPosition}>
+ <option value="TOP">Above Top Row</option>
+ <option value="BELOW_TOP">Below Top Row</option>
+ <option value="ORIGINAL">Bottom</option>
+ </select>
+ Activity history position<br />
+ <select bind:value={highestRatedCount}>
+ {#each [3, 4, 5, 6, 7, 8, 9, 10] as count}
+ <option value={count}>{count}</option>
+ {/each}
+ </select>
+ Highest rated media count<br />
+ <select bind:value={genreTagCount}>
+ {#each [3, 4, 5, 6, 7, 8, 9, 10] as count}
+ <option value={count}>{count}</option>
+ {/each}
+ </select>
+ Highest genre and tag count<br />
+ <button on:click={updateWidth}>Find best fit</button>
+ <button on:click={() => (width -= 25)}>-25px</button>
+ <button on:click={() => (width += 25)}>+25px</button>
+ Width adjustment<br />
+ </details>
+
+ <details class="no-shadow" open>
+ <summary>Calculation</summary>
+
+ <input type="checkbox" bind:checked={useFullActivityHistory} />
+ Enable full-year activity<button class="smaller-button" on:click={pruneFullYear}
+ >Refresh data</button
+ >
+ <br />
+ <select bind:value={selectedYear}>
+ {#each Array.from({ length: currentYear - 2012 }) as _, i}
+ <option value={currentYear - i}>
+ {currentYear - i}
+ </option>
+ {/each}
+ </select>
+ Calculate for year<br />
+ <select bind:value={mediaSort}>
+ <option value={SortOptions.SCORE}>Score</option>
+ <option value={SortOptions.MINUTES_WATCHED}>Minutes Watched/Read</option>
+ </select>
+ Anime and manga sort<br />
+ <select bind:value={genreTagsSort}>
+ <option value={SortOptions.SCORE}>Score</option>
+ <option value={SortOptions.MINUTES_WATCHED}>Minutes Watched/Read</option>
+ <option value={SortOptions.COUNT}>Count</option>
+ </select>
+ Genre and tag sort<br />
+ <input type="checkbox" bind:checked={includeMusic} /> Include music<br />
+ <input type="checkbox" bind:checked={includeRepeats} /> Include rewatches & rereads<br
+ />
+ <input type="checkbox" bind:checked={includeSpecials} /> Include specials<br />
+ <input type="checkbox" bind:checked={includeOVAs} /> Include OVAs<br />
+ <input type="checkbox" bind:checked={includeMovies} /> Include movies<br />
+ <input
+ type="text"
+ bind:value={excludedKeywordsInput}
+ on:keypress={(e) => {
+ e.key === 'Enter' && submitExcludedKeywords();
+ }}
+ />
+ Excluded keywords
+ <button on:click={submitExcludedKeywords} title="Or click your Enter key" use:tooltip
+ >Submit</button
+ >
+ <br />
+ <SettingHint>Comma separated list (e.g., "My Hero, Kaguya")</SettingHint>
+ </details>
+ </div>
+ </div>
+ </div>
+ {:catch}
+ <Error type="User" card list={false} />
+ {/await}
+ {:catch}
+ <Error
+ card
+ type={`${useFullActivityHistory ? 'Full-year activity' : 'Activity'} history`}
+ loginSessionError={!useFullActivityHistory}
+ list={false}
+ >
+ {#if useFullActivityHistory}
+ <p>
+ With <b>many</b> activities, it may take multiple attempts to obtain all of your activity history
+ from AniList. If this occurs, wait one minute and try again to continue populating your local
+ activity history database.
+ </p>
+ {/if}
+ </Error>
+ {/await}
{:else}
- <Message message="Loading user ..." />
+ <Message message="Loading user ..." />
- <Skeleton count={2} />
+ <Skeleton count={2} />
{/if}
<style>
- @import './wrapped.css';
+ @import './wrapped.css';
</style>
diff --git a/src/lib/Tools/Wrapped/Top/Activity.svelte b/src/lib/Tools/Wrapped/Top/Activity.svelte
index 03f15b5d..a91bedfb 100644
--- a/src/lib/Tools/Wrapped/Top/Activity.svelte
+++ b/src/lib/Tools/Wrapped/Top/Activity.svelte
@@ -1,42 +1,42 @@
<script lang="ts">
- import type { ActivityHistoryEntry } from '$lib/Data/AniList/activity';
- import identity from '$stores/identity';
- import type { Wrapped } from '$lib/Data/AniList/wrapped';
- import proxy from '$lib/Utility/proxy';
+ import type { ActivityHistoryEntry } from '$lib/Data/AniList/activity';
+ import identity from '$stores/identity';
+ import type { Wrapped } from '$lib/Data/AniList/wrapped';
+ import proxy from '$lib/Utility/proxy';
- export let wrapped: Wrapped;
- export let year: number;
- export let activities: ActivityHistoryEntry[];
- export let useFullActivityHistory: boolean;
- export let updateWidth: () => void;
+ export let wrapped: Wrapped;
+ export let year: number;
+ export let activities: ActivityHistoryEntry[];
+ export let useFullActivityHistory: boolean;
+ export let updateWidth: () => void;
- const currentYear = new Date(Date.now()).getFullYear();
+ const currentYear = new Date(Date.now()).getFullYear();
</script>
<div class="grid-item image-grid avatar-grid category top-category">
- <a href={`https://anilist.co/user/${$identity.name}`} target="_blank">
- <img src={proxy(wrapped.avatar.large)} alt="User Avatar" on:load={updateWidth} />
- </a>
- <div>
- <div>
- <a href={`https://anilist.co/user/${$identity.name}`} target="_blank">
- <b>
- {$identity.name}
- </b>
- </a>
- </div>
- <div>
- Status Posts: {wrapped.activities.statusCount}
- </div>
- <div>
- Messages: {wrapped.activities.messageCount}
- </div>
- <div>
- Days Active: {#if year !== currentYear}
- ?/365
- {:else}
- {activities.length}/{useFullActivityHistory ? 365 : 189}
- {/if}
- </div>
- </div>
+ <a href={`https://anilist.co/user/${$identity.name}`} target="_blank">
+ <img src={proxy(wrapped.avatar.large)} alt="User Avatar" on:load={updateWidth} />
+ </a>
+ <div>
+ <div>
+ <a href={`https://anilist.co/user/${$identity.name}`} target="_blank">
+ <b>
+ {$identity.name}
+ </b>
+ </a>
+ </div>
+ <div>
+ Status Posts: {wrapped.activities.statusCount}
+ </div>
+ <div>
+ Messages: {wrapped.activities.messageCount}
+ </div>
+ <div>
+ Days Active: {#if year !== currentYear}
+ ?/365
+ {:else}
+ {activities.length}/{useFullActivityHistory ? 365 : 189}
+ {/if}
+ </div>
+ </div>
</div>
diff --git a/src/lib/Tools/Wrapped/Top/Anime.svelte b/src/lib/Tools/Wrapped/Top/Anime.svelte
index b33a8c08..08df7fd3 100644
--- a/src/lib/Tools/Wrapped/Top/Anime.svelte
+++ b/src/lib/Tools/Wrapped/Top/Anime.svelte
@@ -1,20 +1,20 @@
<script lang="ts">
- import type { Media } from '$lib/Data/AniList/media';
+ import type { Media } from '$lib/Data/AniList/media';
- export let minutesWatched: number;
- export let animeList: Media[] | undefined;
- export let episodes: number;
+ export let minutesWatched: number;
+ export let animeList: Media[] | undefined;
+ export let episodes: number;
</script>
<div class="category-grid pure-category category top-category">
- <div class="grid-item">
- <b>Anime</b>
- </div>
- <div class="grid-item">
- Time Watched: {((minutesWatched || 0) / 60 / 24).toFixed(2)} days
- </div>
- <div class="grid-item">
- Completed: {animeList?.length || 0}
- </div>
- <div class="grid-item">Episodes: {episodes}</div>
+ <div class="grid-item">
+ <b>Anime</b>
+ </div>
+ <div class="grid-item">
+ Time Watched: {((minutesWatched || 0) / 60 / 24).toFixed(2)} days
+ </div>
+ <div class="grid-item">
+ Completed: {animeList?.length || 0}
+ </div>
+ <div class="grid-item">Episodes: {episodes}</div>
</div>
diff --git a/src/lib/Tools/Wrapped/Top/Manga.svelte b/src/lib/Tools/Wrapped/Top/Manga.svelte
index 6c505df2..a36f7724 100644
--- a/src/lib/Tools/Wrapped/Top/Manga.svelte
+++ b/src/lib/Tools/Wrapped/Top/Manga.svelte
@@ -1,22 +1,22 @@
<script lang="ts">
- import type { Media } from '$lib/Data/AniList/media';
- import { estimatedDayReading } from '$lib/Media/Manga/time';
+ import type { Media } from '$lib/Data/AniList/media';
+ import { estimatedDayReading } from '$lib/Media/Manga/time';
- export let mangaList: Media[] | undefined;
- export let chapters: number;
+ export let mangaList: Media[] | undefined;
+ export let chapters: number;
</script>
<div class="category-grid pure-category category top-category">
- <div class="grid-item">
- <b>Manga</b>
- </div>
- <div class="grid-item">
- Time Read: {estimatedDayReading(chapters).toFixed(2)} days
- </div>
- <div class="grid-item">
- Completed: {mangaList?.length || 0}
- </div>
- <div class="grid-item">
- Chapters: {chapters}
- </div>
+ <div class="grid-item">
+ <b>Manga</b>
+ </div>
+ <div class="grid-item">
+ Time Read: {estimatedDayReading(chapters).toFixed(2)} days
+ </div>
+ <div class="grid-item">
+ Completed: {mangaList?.length || 0}
+ </div>
+ <div class="grid-item">
+ Chapters: {chapters}
+ </div>
</div>
diff --git a/src/lib/Tools/Wrapped/Watermark.svelte b/src/lib/Tools/Wrapped/Watermark.svelte
index 2e8dd838..f166d554 100644
--- a/src/lib/Tools/Wrapped/Watermark.svelte
+++ b/src/lib/Tools/Wrapped/Watermark.svelte
@@ -1,5 +1,5 @@
<div class="categories-grid" style="padding-top: 0;">
- <div class="category-grid pure-category" id="watermark">
- <a href="https://due.moe/wrapped" target="_blank">due.moe/wrapped</a>
- </div>
+ <div class="category-grid pure-category" id="watermark">
+ <a href="https://due.moe/wrapped" target="_blank">due.moe/wrapped</a>
+ </div>
</div>
diff --git a/src/lib/Tools/Wrapped/wrapped.css b/src/lib/Tools/Wrapped/wrapped.css
index be42cd0a..c144a6f0 100644
--- a/src/lib/Tools/Wrapped/wrapped.css
+++ b/src/lib/Tools/Wrapped/wrapped.css
@@ -2,99 +2,99 @@
@import url('https://proxy.due.moe/?q=https://fonts.googleapis.com/css?family=Overpass:400,600,700,800');
.categories-grid {
- display: flex;
- flex-wrap: wrap;
- row-gap: 1.5em;
- column-gap: 1.5em;
- padding: 2%;
- justify-content: center;
- font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell,
- Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
- background-color: #0b1622;
+ display: flex;
+ flex-wrap: wrap;
+ row-gap: 1.5em;
+ column-gap: 1.5em;
+ padding: 2%;
+ justify-content: center;
+ font-family: Roboto, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell,
+ Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
+ background-color: #0b1622;
}
.categories-grid b {
- font-family: Overpass, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell,
- Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
- font-weight: 600;
+ font-family: Overpass, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Cantarell,
+ Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
+ font-weight: 600;
}
.category-grid,
.image-grid {
- background-color: #151f2e;
- border-radius: 4px;
- color: rgb(159, 173, 189);
+ background-color: #151f2e;
+ border-radius: 4px;
+ color: rgb(159, 173, 189);
}
.pure-category,
.avatar-grid {
- padding: 1.5%;
+ padding: 1.5%;
}
.category-grid {
- display: grid;
+ display: grid;
}
.image-grid {
- display: flex;
- column-gap: 1em;
- flex-wrap: wrap;
+ display: flex;
+ column-gap: 1em;
+ flex-wrap: wrap;
}
.image-grid img {
- width: 6em;
- height: auto;
- border-radius: 3px;
+ width: 6em;
+ height: auto;
+ border-radius: 3px;
}
.categories-grid a {
- text-decoration: none;
- color: unset;
+ text-decoration: none;
+ color: unset;
}
.transparent .categories-grid {
- background-color: transparent !important;
+ background-color: transparent !important;
}
.light-theme .categories-grid {
- background-color: #edf1f5;
+ background-color: #edf1f5;
}
.light-theme .category-grid {
- background-color: #fafafa;
- color: rgb(92, 114, 138);
+ background-color: #fafafa;
+ color: rgb(92, 114, 138);
}
.light-theme .image-grid {
- background-color: #fafafa;
- color: rgb(92, 114, 138);
+ background-color: #fafafa;
+ color: rgb(92, 114, 138);
}
ol {
- margin: 0 !important;
+ margin: 0 !important;
}
#watermark {
- color: rgb(61, 180, 242);
+ color: rgb(61, 180, 242);
}
#wrapped-final {
- height: auto;
- width: 50%;
+ height: auto;
+ width: 50%;
}
#list-container {
- display: flex;
- gap: 1rem;
- flex-wrap: wrap;
- align-items: start;
+ display: flex;
+ gap: 1rem;
+ flex-wrap: wrap;
+ align-items: start;
}
.list {
- flex-grow: 1;
- flex-basis: 1%;
+ flex-grow: 1;
+ flex-basis: 1%;
}
#wrapped {
- overflow-y: scroll;
+ overflow-y: scroll;
}
diff --git a/src/lib/Tools/tools.ts b/src/lib/Tools/tools.ts
index 2593ea49..b85ff7f6 100644
--- a/src/lib/Tools/tools.ts
+++ b/src/lib/Tools/tools.ts
@@ -2,92 +2,92 @@ import locale from '$stores/locale';
import { get } from 'svelte/store';
export const tools: {
- [key: string]: {
- name: () => string;
- short?: string;
- description?: () => string;
- id: string;
- };
+ [key: string]: {
+ name: () => string;
+ short?: string;
+ description?: () => string;
+ id: string;
+ };
} = {
- default: {
- name: () => 'Tools',
- description: () => 'A collection of tools to help you get the most out of AniList.',
- id: 'default'
- },
- wrapped: {
- name: () => 'AniList Wrapped & Statistics Panel',
- short: 'AniList Wrapped',
- description: () =>
- 'Instantly generate an AniList themed Wrapped for your profile, doubling as a statistics panel for your bio',
- id: 'wrapped'
- },
- birthdays: {
- name: () => {
- return get(locale)().tools.tool.characterBirthdays.long;
- },
- description: () =>
- 'Find and display the birthdays of all characters for today, or any other day of the year',
- id: 'birthdays'
- },
- sequel_spy: {
- name: () => 'Sequel Spy',
- description: () =>
- "Find media with prequels you haven't seen yet for any given simulcast season",
- id: 'sequel_spy'
- },
- uma_musume_birthdays: {
- name: () => {
- return 'Uma Musume: Pretty Derby Character Birthdays';
- },
- description: () =>
- 'Find and display the birthdays of all Uma Musume characters for today, or any other day of the year',
- id: 'uma_musume_birthdays'
- },
- hololive_birthdays: {
- name: () => 'hololive Birthdays',
- description: () =>
- 'Find and display the birthdays of all hololive talents for today, or any other day of the year',
- id: 'hololive_birthdays'
- },
- hayai: {
- name: () => '早い',
- description: () => 'Read light novels at 1.5x speed!',
- id: 'hayai'
- },
- discussions: {
- name: () => 'Episode Discussion Collector',
- description: () => 'Find and display all episode discussions created by a given user',
- id: 'discussions'
- },
- random_follower: {
- name: () => 'Random Follower Finder',
- description: () => "Generate random followers from any user's following list",
- id: 'random_follower'
- },
- dump_profile: {
- name: () => 'Dump Profile',
- description: () => "Dump a user's profile to JSON",
- id: 'dump_profile'
- },
- likes: {
- name: () => 'Likes',
- description: () => 'Get all likes of an activity or forum thread',
- id: 'likes'
- },
- activity_history: {
- name: () => 'Activity History Analyser',
- id: 'activity_history',
- description: () => 'Activity history utilities & image exporter'
- },
- girls: {
- name: () => 'Anime Girls Holding Programming Books',
- id: 'girls',
- description: () => 'Find anime girls holding programming books by language'
- },
- sequel_catcher: {
- name: () => 'Sequel Catcher',
- description: () =>
- 'Check if any completed anime on your lists have sequels you have not yet seen',
- id: 'sequel_catcher'
- }
+ default: {
+ name: () => 'Tools',
+ description: () => 'A collection of tools to help you get the most out of AniList.',
+ id: 'default'
+ },
+ wrapped: {
+ name: () => 'AniList Wrapped & Statistics Panel',
+ short: 'AniList Wrapped',
+ description: () =>
+ 'Instantly generate an AniList themed Wrapped for your profile, doubling as a statistics panel for your bio',
+ id: 'wrapped'
+ },
+ birthdays: {
+ name: () => {
+ return get(locale)().tools.tool.characterBirthdays.long;
+ },
+ description: () =>
+ 'Find and display the birthdays of all characters for today, or any other day of the year',
+ id: 'birthdays'
+ },
+ sequel_spy: {
+ name: () => 'Sequel Spy',
+ description: () =>
+ "Find media with prequels you haven't seen yet for any given simulcast season",
+ id: 'sequel_spy'
+ },
+ uma_musume_birthdays: {
+ name: () => {
+ return 'Uma Musume: Pretty Derby Character Birthdays';
+ },
+ description: () =>
+ 'Find and display the birthdays of all Uma Musume characters for today, or any other day of the year',
+ id: 'uma_musume_birthdays'
+ },
+ hololive_birthdays: {
+ name: () => 'hololive Birthdays',
+ description: () =>
+ 'Find and display the birthdays of all hololive talents for today, or any other day of the year',
+ id: 'hololive_birthdays'
+ },
+ hayai: {
+ name: () => '早い',
+ description: () => 'Read light novels at 1.5x speed!',
+ id: 'hayai'
+ },
+ discussions: {
+ name: () => 'Episode Discussion Collector',
+ description: () => 'Find and display all episode discussions created by a given user',
+ id: 'discussions'
+ },
+ random_follower: {
+ name: () => 'Random Follower Finder',
+ description: () => "Generate random followers from any user's following list",
+ id: 'random_follower'
+ },
+ dump_profile: {
+ name: () => 'Dump Profile',
+ description: () => "Dump a user's profile to JSON",
+ id: 'dump_profile'
+ },
+ likes: {
+ name: () => 'Likes',
+ description: () => 'Get all likes of an activity or forum thread',
+ id: 'likes'
+ },
+ activity_history: {
+ name: () => 'Activity History Analyser',
+ id: 'activity_history',
+ description: () => 'Activity history utilities & image exporter'
+ },
+ girls: {
+ name: () => 'Anime Girls Holding Programming Books',
+ id: 'girls',
+ description: () => 'Find anime girls holding programming books by language'
+ },
+ sequel_catcher: {
+ name: () => 'Sequel Catcher',
+ description: () =>
+ 'Check if any completed anime on your lists have sequels you have not yet seen',
+ id: 'sequel_catcher'
+ }
};