diff options
35 files changed, 531 insertions, 233 deletions
diff --git a/apps/cdn/index.ts b/apps/cdn/index.ts index e0d63db5..63b6e798 100644 --- a/apps/cdn/index.ts +++ b/apps/cdn/index.ts @@ -99,14 +99,16 @@ const handleRequest = async (originalRequest) => { // ); // } + const responseHeaders = new Headers(response.headers); + responseHeaders.set( + "Cache-Control", + "public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60", + ); + responseHeaders.set("Access-Control-Allow-Origin", "https://due.moe"); + return new Response(response.body, { status: response.status, statusText: response.statusText, - headers: { - "Cache-Control": - "public, immutable, s-maxage=31536000, max-age=31536000, stale-while-revalidate=60", - "Access-Control-Allow-Origin": "https://due.moe", - ...response.headers, - }, + headers: responseHeaders, }); }; diff --git a/apps/cdn/wrangler.toml b/apps/cdn/wrangler.toml index adb3e071..fcb5cdff 100644 --- a/apps/cdn/wrangler.toml +++ b/apps/cdn/wrangler.toml @@ -1,4 +1,4 @@ name = "cdn" main = "./index.ts" -compatibility_date = "2023-10-30" +compatibility_date = "2025-01-01" diff --git a/apps/proxy/src/index.js b/apps/proxy/src/index.js index 592899c0..e6ed8690 100644 --- a/apps/proxy/src/index.js +++ b/apps/proxy/src/index.js @@ -18,6 +18,7 @@ const isPrivateHostname = (hostname) => hostname === "localhost" || hostname === "127.0.0.1" || hostname.endsWith(".local") || + hostname.endsWith(".localhost") || /^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname); @@ -241,7 +242,7 @@ const recommendedVolumeText = (volumeChapterBoundaries, progress) => { return recommended; }; -const handleMangaChapterCounts = async (request, env, ctx) => { +const handleMangaChapterCounts = async (request, env, executionContext) => { if (!hasSupabaseConfig(env)) return jsonResponse( request, @@ -299,7 +300,7 @@ const handleMangaChapterCounts = async (request, env, ctx) => { ); if (queueableRows.length) - ctx.waitUntil( + executionContext.waitUntil( Promise.all(queueBootstrap(env, queueableRows)).catch((error) => { if (!isMangadexIdConstraintConflict(error)) throw error; }), @@ -374,14 +375,14 @@ const handleMangaSync = async (request, env) => { }; export default { - async fetch(request, env, ctx) { + async fetch(request, env, executionContext) { try { const url = new URL(request.url); if (request.method === "OPTIONS") return handleOptions(request); if (url.pathname === "/manga/chapter-counts" && request.method === "POST") - return handleMangaChapterCounts(request, env, ctx); + return handleMangaChapterCounts(request, env, executionContext); if ( url.pathname === "/manga/native-chapter-counts" && @@ -408,9 +409,9 @@ export default { } }, - async scheduled(_controller, env, ctx) { + async scheduled(_controller, env, executionContext) { if (!hasSupabaseConfig(env)) return; - ctx.waitUntil(syncMangadexIndex(env)); + executionContext.waitUntil(syncMangadexIndex(env)); }, }; diff --git a/apps/proxy/wrangler.toml b/apps/proxy/wrangler.toml index 1115beeb..b86f92d6 100644 --- a/apps/proxy/wrangler.toml +++ b/apps/proxy/wrangler.toml @@ -1,6 +1,6 @@ name = "due-proxy" main = "src/index.js" -compatibility_date = "2023-12-18" +compatibility_date = "2025-01-01" [triggers] crons = ["* * * * *"] diff --git a/package.json b/package.json index 058298c1..cf138b81 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite dev", + "dev": "PUBLIC_APP_ORIGIN=https://due.localhost portless due vite dev", "graphql:generate": "pnpm exec sveltekit-graphql generate", "build": "pnpm run graphql:generate && vite build", "preview": "vite preview", @@ -37,6 +37,7 @@ "graphql": "^16.13.0", "houdini": "^2.0.0-next.11", "houdini-svelte": "^2.1.20", + "portless": "^0.10.3", "sass": "^1.69.7", "svelte": "^5.0.0", "svelte-check": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dbd0cf3..e8e3e20a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -114,6 +114,9 @@ importers: houdini-svelte: specifier: ^2.1.20 version: 2.1.20(@sveltejs/[email protected](@sveltejs/[email protected]([email protected])([email protected](@types/[email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])))([email protected])([email protected](@types/[email protected])([email protected])) + portless: + specifier: ^0.10.3 + version: 0.10.3 sass: specifier: ^1.69.7 version: 1.97.3 @@ -3698,6 +3701,12 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + resolution: {integrity: sha512-lXbVUsNiwWMKJGMi49HhTMW0lS7u0EzawonDZQ+yglSnHioiQK18Y4Fe0Ilk+TuXej71E/nTOHIFIBjUGvTm9w==} + engines: {node: '>=20'} + os: [darwin, linux, win32] + hasBin: true + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -8193,6 +8202,8 @@ snapshots: + [email protected]: {} + diff --git a/src/lib/CommandPalette/actions.ts b/src/lib/CommandPalette/actions.ts index 01259130..9915fe66 100644 --- a/src/lib/CommandPalette/actions.ts +++ b/src/lib/CommandPalette/actions.ts @@ -1,3 +1,5 @@ +import { invalidateListCaches } from "$lib/Media/invalidate"; + export interface CommandPaletteAction { name: string; url: string; @@ -165,4 +167,20 @@ export const defaultActions: CommandPaletteAction[] = [ url: "/user?badges=1", tags: ["user", "me", "settings"], }, + { + name: "Refresh Anime & Manga List Caches", + url: "", + preventDefault: true, + tags: [ + "cache", + "clear", + "refresh", + "invalidate", + "debug", + "anime", + "manga", + "list", + ], + onClick: invalidateListCaches, + }, ]; diff --git a/src/lib/Data/AniList/media.ts b/src/lib/Data/AniList/media.ts index 099d4182..b9e0ef3f 100644 --- a/src/lib/Data/AniList/media.ts +++ b/src/lib/Data/AniList/media.ts @@ -429,23 +429,24 @@ export const mediaListCollection = async ( export const publicMediaListCollection = async ( userId: number, type: Type, -): Promise<Media[]> => - flattenLists( - ( - await ( - await fetch("https://graphql.anilist.co", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - query: collectionQueryTemplate(type, userId, {}), - }), - }) - ).json() - )["data"]["MediaListCollection"]["lists"], - ); +): Promise<Media[]> => { + const response = await ( + await fetch("https://graphql.anilist.co", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + query: collectionQueryTemplate(type, userId, {}), + }), + }) + ).json(); + + if (!response?.data?.MediaListCollection?.lists) return []; + + return flattenLists(response.data.MediaListCollection.lists); +}; const countMedian = (guesses: number[]) => { guesses.sort((a: number, b: number) => a - b); diff --git a/src/lib/Data/AniList/schedule.ts b/src/lib/Data/AniList/schedule.ts index eb70a03b..474f0e92 100644 --- a/src/lib/Data/AniList/schedule.ts +++ b/src/lib/Data/AniList/schedule.ts @@ -57,28 +57,32 @@ const schedulePage = async ( type Season = "WINTER" | "SPRING" | "SUMMER" | "FALL"; -export const scheduleMediaListCollection = async ( +const collectAllSchedulePages = async ( year: number, season: Season, - includeLastSeason = false, + into: SchedulePage["data"]["Page"]["media"], ) => { - const scheduledMedia = []; let page = 1; - let currentPage = await schedulePage(page, year, season); - for (const candidate of currentPage.data.Page.media) - scheduledMedia.push(candidate); + while (true) { + const currentPage = await schedulePage(page, year, season); - while (currentPage["data"]["Page"]["pageInfo"]["hasNextPage"]) { - for (const candidate of currentPage.data.Page.media) - scheduledMedia.push(candidate); + for (const candidate of currentPage.data.Page.media) into.push(candidate); + + if (!currentPage.data.Page.pageInfo.hasNextPage) break; page += 1; - currentPage = await schedulePage(page, year, season); } +}; + +export const scheduleMediaListCollection = async ( + year: number, + season: Season, + includeLastSeason = false, +) => { + const scheduledMedia: SchedulePage["data"]["Page"]["media"] = []; - for (const candidate of currentPage.data.Page.media) - scheduledMedia.push(candidate); + await collectAllSchedulePages(year, season, scheduledMedia); if (includeLastSeason) { const lastSeason = { @@ -86,34 +90,11 @@ export const scheduleMediaListCollection = async ( SPRING: "WINTER", SUMMER: "SPRING", FALL: "SUMMER", - }[season]; + }[season] as Season; const lastSeasonYear = season === "WINTER" ? year - 1 : year; - let page = 1; - let currentPage = await schedulePage( - page, - lastSeasonYear, - lastSeason as Season, - ); - - for (const candidate of currentPage.data.Page.media) - scheduledMedia.push(candidate); - - while (currentPage["data"]["Page"]["pageInfo"]["hasNextPage"]) { - for (const candidate of currentPage.data.Page.media) - scheduledMedia.push(candidate); - - page += 1; - currentPage = await schedulePage( - page, - lastSeasonYear, - lastSeason as Season, - ); - } - - for (const candidate of currentPage.data.Page.media) - scheduledMedia.push(candidate); + await collectAllSchedulePages(lastSeasonYear, lastSeason, scheduledMedia); } return scheduledMedia as Partial<Media[]>; diff --git a/src/lib/Events/AniListBadges/EasterEvent2025/EasterEgg.svelte b/src/lib/Events/AniListBadges/EasterEvent2025/EasterEgg.svelte index c48003d7..d717f5d0 100644 --- a/src/lib/Events/AniListBadges/EasterEvent2025/EasterEgg.svelte +++ b/src/lib/Events/AniListBadges/EasterEvent2025/EasterEgg.svelte @@ -59,9 +59,6 @@ onMount(() => { return () => { if (intervalId) clearInterval(intervalId); - - window.removeEventListener("resize", updatePosition); - window.removeEventListener("scroll", updatePosition); }; }); diff --git a/src/lib/List/Anime/CleanAnimeList.svelte b/src/lib/List/Anime/CleanAnimeList.svelte index 808a8e5b..76701a93 100644 --- a/src/lib/List/Anime/CleanAnimeList.svelte +++ b/src/lib/List/Anime/CleanAnimeList.svelte @@ -90,6 +90,20 @@ $: lists = Array.from( ), ); +$: hasDistinguishingList = lists.some((list) => + media.some((m) => !m.mediaListEntry?.customLists?.[list]), +); + +$: if ( + lists.length > 0 && + selectedList !== "All" && + !lists.includes(selectedList) +) { + selectedList = "All"; + + if (!disableFilter) $stateBin[filterKey] = "All"; +} + $: filteredMedia = selectedList === "All" || !$settings.displayMediaListFilter ? media @@ -193,7 +207,7 @@ onDestroy(() => clearAiringRefreshTimeout()); const increment = (anime: Media, progress: number) => { if (!dummy && pendingUpdate !== anime.id) { - $revalidateAnime = true; + $revalidateAnime = $revalidateAnime + 1; lastUpdatedMedia = anime.id; pendingUpdate = anime.id; @@ -224,9 +238,9 @@ const increment = (anime: Media, progress: number) => { : filteredMedia.length} {title} hideTime={dummy} - hideCount={dummy} + hideCount={dummy || media.length === 0} > - {#if $settings.displayMediaRoulette && !upcoming && !notYetReleased && filteredMedia.length > 0} + {#if $settings.displayMediaRoulette && !upcoming && !notYetReleased && filteredMedia.length > 1} <button class="small-button" onclick={() => (showRoulette = true)} @@ -241,7 +255,7 @@ const increment = (anime: Media, progress: number) => { No anime to display. <button onclick={() => (animeLists = cleanCache(user, $identity))}> Force refresh </button> -{:else if $settings.displayMediaListFilter && !disableFilter} +{:else if $settings.displayMediaListFilter && !disableFilter && hasDistinguishingList} <select value={selectedList} onchange={updateSelectedList}> <option value="All">All</option> diff --git a/src/lib/List/Anime/DueAnimeList.svelte b/src/lib/List/Anime/DueAnimeList.svelte index 2c707ffb..d2c47ebe 100644 --- a/src/lib/List/Anime/DueAnimeList.svelte +++ b/src/lib/List/Anime/DueAnimeList.svelte @@ -16,6 +16,7 @@ import { import { addNotification } from "$lib/Notification/store"; import locale from "$stores/locale"; import identity from "$stores/identity"; +import revalidateAnime from "$stores/revalidateAnime"; export let user: AniListAuthorisation; let animeLists: Promise<Media[]>; @@ -68,6 +69,27 @@ onMount(async () => { $: if (keyCacher && keyCacheMinutes !== $settings.cacheMinutes) restartKeyCacher($settings.cacheMinutes); +let lastAnimeRevalidation = 0; + +$: if ($revalidateAnime > lastAnimeRevalidation) { + lastAnimeRevalidation = $revalidateAnime; + + startTime = performance.now(); + endTime = -1; + + animeLists = mediaListCollection( + user, + $identity, + Type.Anime, + $anime, + $lastPruneTimes.anime, + { + forcePrune: true, + addNotification, + }, + ); +} + onDestroy(() => { if (keyCacher) clearInterval(keyCacher); }); diff --git a/src/lib/List/Anime/UpcomingAnimeList.svelte b/src/lib/List/Anime/UpcomingAnimeList.svelte index d9b91122..ed47f22f 100644 --- a/src/lib/List/Anime/UpcomingAnimeList.svelte +++ b/src/lib/List/Anime/UpcomingAnimeList.svelte @@ -96,22 +96,26 @@ const cleanMedia = ( return upcomingAnime; }; -$: { - if ($revalidateAnime) { - $revalidateAnime = false; - $lastPruneTimes.anime = -1; - animeLists = mediaListCollection( - user, - $identity, - Type.Anime, - $anime, - $lastPruneTimes.anime, - { - addNotification, - notificationType: "Upcoming Episodes", - }, - ); - } +let lastAnimeRevalidation = 0; + +$: if ($revalidateAnime > lastAnimeRevalidation) { + lastAnimeRevalidation = $revalidateAnime; + + startTime = performance.now(); + endTime = -1; + + animeLists = mediaListCollection( + user, + $identity, + Type.Anime, + $anime, + $lastPruneTimes.anime, + { + forcePrune: true, + addNotification, + notificationType: "Upcoming Episodes", + }, + ); } </script> diff --git a/src/lib/List/Manga/CleanMangaList.svelte b/src/lib/List/Manga/CleanMangaList.svelte index 497e2ae6..a52a9d7e 100644 --- a/src/lib/List/Manga/CleanMangaList.svelte +++ b/src/lib/List/Manga/CleanMangaList.svelte @@ -83,6 +83,20 @@ $: lists = Array.from( ), ); +$: hasDistinguishingList = lists.some((list) => + media.some((m) => !m.mediaListEntry?.customLists?.[list]), +); + +$: if ( + lists.length > 0 && + selectedList !== "All" && + !lists.includes(selectedList) +) { + selectedList = "All"; + + if (!disableFilter) $stateBin[filterKey] = "All"; +} + $: filteredMedia = selectedList === "All" || !$settings.displayMediaListFilter ? media @@ -126,7 +140,7 @@ const increment = (manga: Media) => { ? $locale().lists.due.mangaAndLightNovels : $locale().lists.completed.mangaAndLightNovels} hideTime={dummy} - hideCount={dummy} + hideCount={dummy || media.length === 0} > {#if !dummy} <button @@ -135,7 +149,7 @@ const increment = (manga: Media) => { onclick={cleanCache} data-umami-event="Force Refresh Manga">Refresh</button > - {#if $settings.displayMediaRoulette && filteredMedia.length > 0} + {#if $settings.displayMediaRoulette && filteredMedia.length > 1} <button class="small-button" onclick={() => (showRoulette = true)} @@ -189,7 +203,7 @@ const increment = (manga: Media) => { > You can re-enable it later in the <a href={root('/settings')}>Settings</a>. </span> -{:else if $settings.displayMediaListFilter && !disableFilter} +{:else if $settings.displayMediaListFilter && !disableFilter && hasDistinguishingList} <select value={selectedList} onchange={updateSelectedList}> <option value="All">All</option> diff --git a/src/lib/List/Manga/MangaListTemplate.svelte b/src/lib/List/Manga/MangaListTemplate.svelte index 52649098..df894910 100644 --- a/src/lib/List/Manga/MangaListTemplate.svelte +++ b/src/lib/List/Manga/MangaListTemplate.svelte @@ -19,6 +19,7 @@ import identity from "$stores/identity"; import lastPruneTimes from "$stores/lastPruneTimes"; import locale from "$stores/locale"; import manga from "$stores/manga"; +import revalidateManga from "$stores/revalidateManga"; import settings from "$stores/settings"; import ListTitle from "../ListTitle.svelte"; import CleanMangaList from "./CleanMangaList.svelte"; @@ -141,6 +142,27 @@ $: if ( ) restartKeyCacher(Math.max($settings.cacheMangaMinutes, 5)); +let lastMangaRevalidation = 0; + +$: if (!dummy && $revalidateManga > lastMangaRevalidation) { + lastMangaRevalidation = $revalidateManga; + + startTime = performance.now(); + endTime = -1; + + mangaLists = mediaListCollection( + user, + $identity, + Type.Manga, + $manga, + $lastPruneTimes.manga, + { + forcePrune: true, + addNotification, + }, + ); +} + onDestroy(() => { if (keyCacher) clearInterval(keyCacher); }); diff --git a/src/lib/Locale/english.ts b/src/lib/Locale/english.ts index 97d00a4b..9918d98f 100644 --- a/src/lib/Locale/english.ts +++ b/src/lib/Locale/english.ts @@ -108,6 +108,8 @@ const English: Locale = { }, tooltips: { beta: "Beta", + dataSaver: + "Use smaller images and lighter data paths where possible to reduce bandwidth usage.", }, }, debug: { @@ -269,7 +271,7 @@ const English: Locale = { }, }, debug: { - clearCaches: "Clear anime and manga list caches", + clearCaches: "Invalidate anime and manga list caches", showListTimings: "Show media list timings", resetAllSettings: { title: "Reset ALL settings", diff --git a/src/lib/Locale/japanese.ts b/src/lib/Locale/japanese.ts index b220a986..79e15e96 100644 --- a/src/lib/Locale/japanese.ts +++ b/src/lib/Locale/japanese.ts @@ -107,6 +107,8 @@ const Japanese: Locale = { }, tooltips: { beta: "ベータ", + dataSaver: + "可能な場合は小さい画像や軽量なデータ経路を使い、通信量を抑えます。", }, }, debug: { @@ -269,7 +271,7 @@ const Japanese: Locale = { }, }, debug: { - clearCaches: "ブラウザのAniListアニメと漫画リストのキャッシュを消去する", + clearCaches: "ブラウザのAniListアニメと漫画リストのキャッシュを無効化する", showListTimings: "メディアリストの処理時間を表示する", resetAllSettings: { title: "すべての設定をリセット", diff --git a/src/lib/Locale/layout.ts b/src/lib/Locale/layout.ts index 08ddc22a..f5149b71 100644 --- a/src/lib/Locale/layout.ts +++ b/src/lib/Locale/layout.ts @@ -110,6 +110,7 @@ export interface Locale { }; tooltips: { beta: LocaleValue; + dataSaver: LocaleValue; }; }; debug: { diff --git a/src/lib/Media/invalidate.ts b/src/lib/Media/invalidate.ts new file mode 100644 index 00000000..3a9ff067 --- /dev/null +++ b/src/lib/Media/invalidate.ts @@ -0,0 +1,18 @@ +import { browser } from "$app/environment"; +import { addNotification } from "$lib/Notification/store"; +import { options } from "$lib/Notification/options"; +import revalidateAnime from "$stores/revalidateAnime"; +import revalidateManga from "$stores/revalidateManga"; + +export const invalidateListCaches = () => { + if (!browser) return; + + revalidateAnime.update((n) => n + 1); + revalidateManga.update((n) => n + 1); + + addNotification( + options({ + heading: "Anime and manga list caches successfully invalidated", + }), + ); +}; diff --git a/src/lib/Notification/Notification.svelte b/src/lib/Notification/Notification.svelte index 76775ff3..a3271d58 100644 --- a/src/lib/Notification/Notification.svelte +++ b/src/lib/Notification/Notification.svelte @@ -9,7 +9,11 @@ export let onRemove: () => void = () => { }; export let removed = false; -onMount(() => setTimeout(remove, notification.duration)); +onMount(() => { + const removeTimer = setTimeout(remove, notification.duration); + + return () => clearTimeout(removeTimer); +}); const remove = () => { removed = true; diff --git a/src/lib/Settings/Categories/Debug.svelte b/src/lib/Settings/Categories/Debug.svelte index c6b16086..ad388fee 100644 --- a/src/lib/Settings/Categories/Debug.svelte +++ b/src/lib/Settings/Categories/Debug.svelte @@ -7,19 +7,7 @@ import { options } from "$lib/Notification/options"; import locale from "$stores/locale"; import SettingCheckboxToggle from "../SettingCheckboxToggle.svelte"; import localforage from "localforage"; -import { browser } from "$app/environment"; - -const clearCaches = async () => { - if (!browser) return; - - await localforage.removeItem("anime"); - await localforage.removeItem("manga"); - addNotification( - options({ - heading: "Anime and manga list caches successfully cleared", - }), - ); -}; +import { invalidateListCaches } from "$lib/Media/invalidate"; </script> <SettingCheckboxToggle setting="debugDummyLists" text={$locale().debug.dummyLists} /> @@ -29,7 +17,7 @@ const clearCaches = async () => { /> <br /> -<button onclick={clearCaches}>{$locale().debug.clearCaches}</button> +<button onclick={invalidateListCaches}>{$locale().debug.clearCaches}</button> <Spacer /> diff --git a/src/lib/Settings/Categories/Display.svelte b/src/lib/Settings/Categories/Display.svelte index 313e6cf5..8b1f0a43 100644 --- a/src/lib/Settings/Categories/Display.svelte +++ b/src/lib/Settings/Categories/Display.svelte @@ -181,6 +181,7 @@ const onHelperChange = () => { <SettingCheckboxToggle setting="displayDataSaver" text={$locale().settings.display.categories.dataSaver} + tooltipText={$locale().settings.display.tooltips.dataSaver} /> <select bind:value={$settings.displayLanguage} class="no-shadow"> <option value="en"> diff --git a/src/lib/Tools/FollowFix.svelte b/src/lib/Tools/FollowFix.svelte index c072cf5e..6c599569 100644 --- a/src/lib/Tools/FollowFix.svelte +++ b/src/lib/Tools/FollowFix.svelte @@ -26,9 +26,11 @@ let submit = ""; } }} /> - <a href={'#'} onclick={() => (submit = input)}> - Toggle follow for {input.length === 0 ? '...' : input} - </a> + {#if input.length > 0} + <a href={'#'} onclick={() => (submit = input)}> + Toggle follow for {input} + </a> + {/if} </p> {#if submit.length > 0} diff --git a/src/lib/Tools/Wrapped/Tool.svelte b/src/lib/Tools/Wrapped/Tool.svelte index 4932c2a9..b04bc5f6 100644 --- a/src/lib/Tools/Wrapped/Tool.svelte +++ b/src/lib/Tools/Wrapped/Tool.svelte @@ -42,8 +42,8 @@ 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(); +const currentYear = new Date().getFullYear(); +let selectedYear = currentYear; let episodes = 0; let chapters = 0; let minutesWatched = 0; diff --git a/src/lib/Tooltip/LinkedTooltip.svelte b/src/lib/Tooltip/LinkedTooltip.svelte index 82147536..bbf0ffe5 100644 --- a/src/lib/Tooltip/LinkedTooltip.svelte +++ b/src/lib/Tooltip/LinkedTooltip.svelte @@ -1,6 +1,7 @@ <script lang="ts"> import tooltipPosition from "$stores/tooltipPosition"; import { fade } from "svelte/transition"; +import { onDestroy } from "svelte"; export let id: string | undefined = undefined; export let pin: string | undefined = undefined; @@ -20,10 +21,24 @@ let hideTimeout: number | null = null; let debounceTimer: number | null = null; let opacity = 0; +onDestroy(() => { + if (hideTimeout !== null) clearTimeout(hideTimeout); + + if (debounceTimer !== null) clearTimeout(debounceTimer); + + if (tooltipDiv && tooltipDiv.parentNode === document.body) { + document.body.removeChild(tooltipDiv); + tooltipDiv = null; + } +}); + const createTooltip = () => { if (!tooltipDiv) { tooltipDiv = document.createElement("div"); tooltipDiv.style.position = "absolute"; + tooltipDiv.style.top = "-9999px"; + tooltipDiv.style.left = "-9999px"; + tooltipDiv.style.visibility = "hidden"; tooltipDiv.style.zIndex = "1000"; opacity = 0; tooltipDiv.style.pointerEvents = "none"; diff --git a/src/lib/Tooltip/tooltip.ts b/src/lib/Tooltip/tooltip.ts index 5772c33f..3235cb25 100644 --- a/src/lib/Tooltip/tooltip.ts +++ b/src/lib/Tooltip/tooltip.ts @@ -14,12 +14,13 @@ const tooltip = (element: HTMLElement) => { tooltipDiv = document.createElement("div"); tooltipDiv.style.position = "absolute"; + tooltipDiv.style.top = "-9999px"; + tooltipDiv.style.left = "-9999px"; tooltipDiv.style.zIndex = "1000"; tooltipDiv.style.opacity = "0"; - tooltipDiv.style.transition = `opacity ${tooltipTransitionTime}ms ease-in-out, top 0.3s ease, left 0.3s ease`; + tooltipDiv.style.transition = `opacity ${tooltipTransitionTime}ms ease-in-out`; tooltipDiv.style.pointerEvents = "none"; tooltipDiv.style.whiteSpace = "nowrap"; - tooltipDiv.style.zIndex = "1000"; tooltipDiv.classList.add("card"); tooltipDiv.classList.add("card-small"); @@ -81,11 +82,12 @@ const tooltip = (element: HTMLElement) => { tooltipDiv.innerHTML = content.replace(/\n/g, "<br>"); updateTooltipPosition(x, y); - setTimeout(() => { + requestAnimationFrame(() => { if (tooltipDiv) { + tooltipDiv.style.transition = `opacity ${tooltipTransitionTime}ms ease-in-out, top 0.3s ease, left 0.3s ease`; tooltipDiv.style.opacity = "1"; } - }, 10); + }); } }; diff --git a/src/lib/User/BadgeWall/BadgePreview.svelte b/src/lib/User/BadgeWall/BadgePreview.svelte index fd323564..e651a2aa 100644 --- a/src/lib/User/BadgeWall/BadgePreview.svelte +++ b/src/lib/User/BadgeWall/BadgePreview.svelte @@ -118,7 +118,7 @@ const onClick = (event: MouseEvent) => { <a href={'#'} onclick={onClick} class="badge-container-image"> <ParallaxImage {source} - alternativeText="selectedBadge.description" + alternativeText={selectedBadge.description ?? ''} limit={100} duration={1500} /> diff --git a/src/lib/Utility/appOrigin.ts b/src/lib/Utility/appOrigin.ts index cdc53995..1e2c6708 100644 --- a/src/lib/Utility/appOrigin.ts +++ b/src/lib/Utility/appOrigin.ts @@ -11,6 +11,7 @@ const isPrivateHostname = (hostname: string) => hostname === "localhost" || hostname === "127.0.0.1" || hostname.endsWith(".local") || + hostname.endsWith(".localhost") || /^10\./.test(hostname) || /^192\.168\./.test(hostname) || /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 268e9713..ad3971d8 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,5 +1,6 @@ <script lang="ts"> import type { Component } from "svelte"; +import { browser } from "$app/environment"; import Spacer from "$lib/Layout/Spacer.svelte"; import { onDestroy, onMount } from "svelte"; import userIdentity from "$stores/identity.js"; @@ -41,6 +42,86 @@ let IndexColumnComponent: IndexColumnSvelteComponent | null = $state(null); let MangaListTemplateComponent: MangaListTemplateSvelteComponent | null = $state(null); let authenticatedHomeSurfaceImport: Promise<void> | null = null; +let balancedListFlowElement: HTMLDivElement | undefined = $state(); +let balancedListResizeObserver: ResizeObserver | undefined; +let balancedListMeasurementFrame = 0; +const balancedListPanelElements = new Map<string, HTMLElement>(); +let balancedListPanelAssignments = $state<Record<string, "left" | "right">>({}); + +const resetBalancedListLayout = () => { + balancedListPanelAssignments = {}; +}; + +const updateBalancedListLayout = () => { + if (!balancedListFlowElement) return; + + const balancedListFlowStyles = getComputedStyle(balancedListFlowElement); + const balancedListRowGap = parseFloat( + balancedListFlowStyles.getPropertyValue("row-gap"), + ); + const balancedListColumnCount = balancedListFlowStyles + .getPropertyValue("grid-template-columns") + .split(" ") + .filter(Boolean).length; + + if (balancedListColumnCount <= 1 || !Number.isFinite(balancedListRowGap)) { + balancedListPanelAssignments = Object.fromEntries( + Array.from(balancedListPanelElements.keys()).map((key) => [key, "left"]), + ); + + return; + } + + const balancedListKeys = ["upcoming", "due", "manga"].filter((key) => + balancedListPanelElements.has(key), + ); + const balancedListHeights = { left: 0, right: 0 }; + const nextBalancedListAssignments: Record<string, "left" | "right"> = {}; + + balancedListKeys.forEach((key) => { + const balancedListPanel = balancedListPanelElements.get(key); + if (!balancedListPanel) return; + + const balancedListColumn = + balancedListHeights.left <= balancedListHeights.right ? "left" : "right"; + const balancedListPanelHeight = + balancedListPanel.getBoundingClientRect().height; + + nextBalancedListAssignments[key] = balancedListColumn; + balancedListHeights[balancedListColumn] += + balancedListPanelHeight + + (balancedListHeights[balancedListColumn] > 0 ? balancedListRowGap : 0); + }); + + balancedListPanelAssignments = nextBalancedListAssignments; +}; + +const queueBalancedListLayout = () => { + if (!browser) return; + + cancelAnimationFrame(balancedListMeasurementFrame); + + balancedListMeasurementFrame = requestAnimationFrame(() => { + updateBalancedListLayout(); + }); +}; + +const observeBalancedListPanel = (panel: HTMLElement, key: string) => { + balancedListPanelElements.set(key, panel); + balancedListResizeObserver?.observe(panel); + queueBalancedListLayout(); + + return { + destroy: () => { + balancedListPanelElements.delete(key); + balancedListResizeObserver?.unobserve(panel); + queueBalancedListLayout(); + }, + }; +}; + +const isBalancedListPanelInColumn = (key: string, column: "left" | "right") => + (balancedListPanelAssignments[key] ?? "left") === column; const loadAuthenticatedHomeSurface = () => { if (data.user === undefined) return null; @@ -68,10 +149,30 @@ onMount(async () => { await hydrateStateBin(); $stateBin.upcomingAnimeListOpen ??= true; $stateBin.dueMangaListOpen ??= true; + + balancedListResizeObserver = new ResizeObserver(() => { + queueBalancedListLayout(); + }); + + balancedListPanelElements.forEach((panel) => { + balancedListResizeObserver?.observe(panel); + }); + + window.addEventListener("resize", queueBalancedListLayout); void loadAuthenticatedHomeSurface(); + + queueBalancedListLayout(); }); -onDestroy(() => removeHeightObserver?.()); +onDestroy(() => { + removeHeightObserver?.(); + balancedListResizeObserver?.disconnect(); + if (browser) { + cancelAnimationFrame(balancedListMeasurementFrame); + window.removeEventListener("resize", queueBalancedListLayout); + } + resetBalancedListLayout(); +}); </script> <HeadTitle /> @@ -87,85 +188,140 @@ onDestroy(() => removeHeightObserver?.()); <Landing /> {:else} + {@const balancedListColumnCount = + [!$settings.disableUpcomingAnime, !$settings.disableAnime, !$settings.disableManga] + .map(Number) + .reduce((a, b) => a + b) > 1 + ? 2 + : 1} <div - class="grid-container" + bind:this={balancedListFlowElement} + class="balanced-list-flow" style={` - grid-template-columns: ${ - [!$settings.disableUpcomingAnime, !$settings.disableAnime, !$settings.disableManga] - .map(Number) - .reduce((a, b) => a + b) > 1 - ? '1fr 1fr' - : '1fr' - } + --balanced-list-columns: ${balancedListColumnCount} `} > - <div class="left-column"> - {#if !$settings.disableUpcomingAnime} - <details bind:open={$stateBin.upcomingAnimeListOpen} class="list list-upcoming"> - {#if $userIdentity.id !== -2} - {#if UpcomingAnimeListComponent} - <UpcomingAnimeListComponent user={data.user} /> + <div class="balanced-list-column"> + {#if !$settings.disableUpcomingAnime && isBalancedListPanelInColumn("upcoming", "left")} + <div class="balanced-list-panel" use:observeBalancedListPanel={"upcoming"}> + <details bind:open={$stateBin.upcomingAnimeListOpen} class="list list-upcoming"> + {#if $userIdentity.id !== -2} + {#if UpcomingAnimeListComponent} + <UpcomingAnimeListComponent user={data.user} /> + {:else} + <ListTitle title={$locale().lists.upcoming.episodes} /> + + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} {:else} <ListTitle title={$locale().lists.upcoming.episodes} /> <Skeleton card={false} count={5} height="0.9rem" list /> {/if} + </details> + </div> + {/if} + + {#if !$settings.disableAnime && isBalancedListPanelInColumn("due", "left")} + <div class="balanced-list-panel" use:observeBalancedListPanel={"due"}> + {#if IndexColumnComponent} + <IndexColumnComponent user={data.user} userIdentity={$userIdentity} /> {:else} - <ListTitle title={$locale().lists.upcoming.episodes} /> + <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due"> + <ListTitle title={$locale().lists.due.episodes} /> - <Skeleton card={false} count={5} height="0.9rem" list /> + <Skeleton card={false} count={5} height="0.9rem" list /> + </details> {/if} - </details> + </div> {/if} - {#if !$settings.disableAnime && !$settings.disableManga} - {#if IndexColumnComponent} - <IndexColumnComponent user={data.user} userIdentity={$userIdentity} /> - {:else} - <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due"> - <ListTitle title={$locale().lists.due.episodes} /> + {#if !$settings.disableManga && isBalancedListPanelInColumn("manga", "left")} + <div class="balanced-list-panel" use:observeBalancedListPanel={"manga"}> + <details bind:open={$stateBin.dueMangaListOpen} class="list list-manga"> + {#if $userIdentity.id !== -2} + {#if MangaListTemplateComponent} + <MangaListTemplateComponent + user={data.user} + displayUnresolved={$settings.displayUnresolved} + due={true} + /> + {:else} + <ListTitle title={$locale().lists.due.mangaAndLightNovels} /> - <Skeleton card={false} count={5} height="0.9rem" list /> + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} + {:else} + <ListTitle title={$locale().lists.due.mangaAndLightNovels} /> + + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} </details> - {/if} + </div> {/if} </div> - <div class="right-column"> - {#if !$settings.disableAnime && $settings.disableManga} - {#if IndexColumnComponent} - <IndexColumnComponent user={data.user} userIdentity={$userIdentity} /> - {:else} - <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due"> - <ListTitle title={$locale().lists.due.episodes} /> + {#if balancedListColumnCount > 1} + <div class="balanced-list-column"> + {#if !$settings.disableUpcomingAnime && isBalancedListPanelInColumn("upcoming", "right")} + <div class="balanced-list-panel" use:observeBalancedListPanel={"upcoming"}> + <details bind:open={$stateBin.upcomingAnimeListOpen} class="list list-upcoming"> + {#if $userIdentity.id !== -2} + {#if UpcomingAnimeListComponent} + <UpcomingAnimeListComponent user={data.user} /> + {:else} + <ListTitle title={$locale().lists.upcoming.episodes} /> - <Skeleton card={false} count={5} height="0.9rem" list /> - </details> + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} + {:else} + <ListTitle title={$locale().lists.upcoming.episodes} /> + + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} + </details> + </div> {/if} - {/if} - {#if !$settings.disableManga} - <details bind:open={$stateBin.dueMangaListOpen} class="list list-manga"> - {#if $userIdentity.id !== -2} - {#if MangaListTemplateComponent} - <MangaListTemplateComponent - user={data.user} - displayUnresolved={$settings.displayUnresolved} - due={true} - /> + {#if !$settings.disableAnime && isBalancedListPanelInColumn("due", "right")} + <div class="balanced-list-panel" use:observeBalancedListPanel={"due"}> + {#if IndexColumnComponent} + <IndexColumnComponent user={data.user} userIdentity={$userIdentity} /> {:else} - <ListTitle title={$locale().lists.due.mangaAndLightNovels} /> + <details bind:open={$stateBin.dueAnimeListOpen} class="list list-due"> + <ListTitle title={$locale().lists.due.episodes} /> - <Skeleton card={false} count={5} height="0.9rem" list /> + <Skeleton card={false} count={5} height="0.9rem" list /> + </details> {/if} - {:else} - <ListTitle title={$locale().lists.due.mangaAndLightNovels} /> + </div> + {/if} - <Skeleton card={false} count={5} height="0.9rem" list /> - {/if} - </details> - {/if} - </div> + {#if !$settings.disableManga && isBalancedListPanelInColumn("manga", "right")} + <div class="balanced-list-panel" use:observeBalancedListPanel={"manga"}> + <details bind:open={$stateBin.dueMangaListOpen} class="list list-manga"> + {#if $userIdentity.id !== -2} + {#if MangaListTemplateComponent} + <MangaListTemplateComponent + user={data.user} + displayUnresolved={$settings.displayUnresolved} + due={true} + /> + {:else} + <ListTitle title={$locale().lists.due.mangaAndLightNovels} /> + + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} + {:else} + <ListTitle title={$locale().lists.due.mangaAndLightNovels} /> + + <Skeleton card={false} count={5} height="0.9rem" list /> + {/if} + </details> + </div> + {/if} + </div> + {/if} {#if $settings.disableUpcomingAnime && $settings.disableAnime && $settings.disableManga} <video src="https://video.twimg.com/tweet_video/Do_eDPnX0AAKV9f.mp4" autoplay loop> @@ -176,30 +332,32 @@ onDestroy(() => removeHeightObserver?.()); {/if} <style> - .grid-container { + .balanced-list-flow { display: grid; + grid-template-columns: repeat(var(--balanced-list-columns), minmax(0, 1fr)); gap: 1rem; + align-items: start; } - .left-column { + .balanced-list-column { display: grid; gap: 1rem; align-content: start; + min-width: 0; } - .right-column { - align-self: start; + .balanced-list-panel { + min-width: 0; } .list { overflow-y: auto; - break-inside: avoid; - page-break-inside: avoid; + margin: 0; } @media (max-width: 800px) { - .grid-container { - grid-template-columns: 1fr !important; + .balanced-list-flow { + grid-template-columns: 1fr; } } </style> diff --git a/src/routes/api/badges/+server.ts b/src/routes/api/badges/+server.ts index 476fb264..46b98cbc 100644 --- a/src/routes/api/badges/+server.ts +++ b/src/routes/api/badges/+server.ts @@ -15,10 +15,10 @@ import { incrementClickCount, } from "$lib/Database/SB/User/badges"; import { Schema } from "effect"; -import { appOriginHeaders } from "$lib/Utility/appOrigin"; +import { appOrigin, appOriginHeaders } from "$lib/Utility/appOrigin"; import privilegedUser from "$lib/Utility/privilegedUser"; -const unauthorised = new Response("Unauthorised", { status: 401 }); +const unauthorised = () => new Response("Unauthorised", { status: 401 }); const importedBadgeSchema = Schema.Record(Schema.String, Schema.Unknown); const badges = async (id: number) => @@ -33,15 +33,15 @@ export const GET = async ({ url }) => { export const DELETE = async ({ url, cookies }) => { const userCookie = cookies.get("user"); - if (!userCookie) return unauthorised; + if (!userCookie) return unauthorised(); const user = decodeAuthCookieOrNull(userCookie); - if (!user) return unauthorised; + if (!user) return unauthorised(); const identity = await safeUserIdentity(user); - if (!identity) return unauthorised; + if (!identity) return unauthorised(); if ((url.searchParams.get("prune") || 0) === "true") { await removeAllUserBadges(identity.id); @@ -54,6 +54,8 @@ export const DELETE = async ({ url, cookies }) => { export const PUT = async ({ cookies, url, request }) => { if (url.searchParams.get("incrementClickCount") || undefined) { + if (request.headers.get("origin") !== appOrigin()) return unauthorised(); + await incrementClickCount( Number(url.searchParams.get("incrementClickCount")), ); @@ -63,19 +65,22 @@ export const PUT = async ({ cookies, url, request }) => { const userCookie = cookies.get("user"); - if (!userCookie) return unauthorised; + if (!userCookie) return unauthorised(); const user = decodeAuthCookieOrNull(userCookie); - if (!user) return unauthorised; + if (!user) return unauthorised(); const identity = await safeUserIdentity(user); - if (!identity) return unauthorised; + if (!identity) return unauthorised(); const authorised = privilegedUser(identity.id); if (url.searchParams.get("shadowHide")) - setShadowHidden(Number(url.searchParams.get("shadowHide")), authorised); + await setShadowHidden( + Number(url.searchParams.get("shadowHide")), + authorised, + ); if (url.searchParams.get("import") || undefined) { const importedBadges = await decodeRequestJsonOrThrow( @@ -135,7 +140,7 @@ export const PUT = async ({ cookies, url, request }) => { } if (url.searchParams.get("shadowHideBadge") || undefined) { - if (!authorised) return unauthorised; + if (!authorised) return unauthorised(); await setShadowHiddenBadge( Number(url.searchParams.get("shadowHideBadge")), diff --git a/src/routes/api/subsplease/+server.ts b/src/routes/api/subsplease/+server.ts index 6ef2d832..1f678d8c 100644 --- a/src/routes/api/subsplease/+server.ts +++ b/src/routes/api/subsplease/+server.ts @@ -1,12 +1,12 @@ import { appOriginHeaders } from "$lib/Utility/appOrigin"; -export const GET = async ({ url }) => - Response.json( +export const GET = async ({ url }) => { + const timezone = url.searchParams.get("tz") || "America/Los_Angeles"; + + return Response.json( await ( await fetch( - `https://subsplease.org/api/?f=schedule&tz=${ - url.searchParams.get("tz") || "America/Los_Angeles" - }`, + `https://subsplease.org/api/?f=schedule&tz=${encodeURIComponent(timezone)}`, ) ).json(), { @@ -15,3 +15,4 @@ export const GET = async ({ url }) => }), }, ); +}; diff --git a/src/stores/revalidateAnime.ts b/src/stores/revalidateAnime.ts index acbbf367..db3ccb2d 100644 --- a/src/stores/revalidateAnime.ts +++ b/src/stores/revalidateAnime.ts @@ -1,5 +1,5 @@ import { writable } from "svelte/store"; -const revalidateAnime = writable<boolean>(false); +const revalidateAnime = writable<number>(0); export default revalidateAnime; diff --git a/src/stores/revalidateManga.ts b/src/stores/revalidateManga.ts new file mode 100644 index 00000000..b72ea1af --- /dev/null +++ b/src/stores/revalidateManga.ts @@ -0,0 +1,5 @@ +import { writable } from "svelte/store"; + +const revalidateManga = writable<number>(0); + +export default revalidateManga; diff --git a/src/stores/settings.ts b/src/stores/settings.ts index cd0f306d..24d9eef0 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -195,42 +195,47 @@ settings.subscribe((value) => { if (!browser) return; if (value.settingsSync && get(settingsSyncPulled) === true) { - fetch(root(`/api/configuration?id=${get(identity).id}`)).then( - (response) => { - if (response.ok) - response.json().then((data) => { - const isEqualsJson = ( - firstObject: Settings, - secondObject: Settings, - ) => { - type AnyObject = { [key: string]: unknown }; - - return ( - Object.keys(firstObject).length === - Object.keys(secondObject).length && - Object.keys(firstObject).every( - (key) => - (firstObject as unknown as AnyObject)[key] === - (secondObject as unknown as AnyObject)[key], - ) - ); - }; - - if (data?.configuration && !isEqualsJson(data.configuration, value)) - fetch(root(`/api/configuration`), { - method: "PUT", - body: JSON.stringify(value), - }).then((response) => { + fetch(root(`/api/configuration?id=${get(identity).id}`)) + .then((response) => { + if (!response.ok) return; + + return response.json().then((data) => { + const isEqualsJson = ( + firstObject: Settings, + secondObject: Settings, + ) => { + type AnyObject = { [key: string]: unknown }; + + return ( + Object.keys(firstObject).length === + Object.keys(secondObject).length && + Object.keys(firstObject).every( + (key) => + (firstObject as unknown as AnyObject)[key] === + (secondObject as unknown as AnyObject)[key], + ) + ); + }; + + if (data?.configuration && !isEqualsJson(data.configuration, value)) + fetch(root(`/api/configuration`), { + method: "PUT", + body: JSON.stringify(value), + }) + .then((response) => { if (response.ok) console.log("Pushed local configuration"); settingsSyncTimes.update((times) => ({ ...times, lastPush: new Date(), })); - }); - }); - }, - ); + }) + .catch((error) => + console.error("Settings sync push failed", error), + ); + }); + }) + .catch((error) => console.error("Settings sync pull failed", error)); } }); diff --git a/src/trigger/notifications.ts b/src/trigger/notifications.ts index 30a50a12..e36f913f 100644 --- a/src/trigger/notifications.ts +++ b/src/trigger/notifications.ts @@ -25,9 +25,9 @@ const subscriptionEndpoint = (subscription: unknown) => export const notificationsTask = schedules.task({ id: "notifications", - run: async (_payload, { ctx }) => { - const environment = ctx.environment.slug; - const triggerProjectReference = ctx.project.ref; + run: async (_payload, { ctx: taskContext }) => { + const environment = taskContext.environment.slug; + const triggerProjectReference = taskContext.project.ref; const supabase = createClient( ( await envvars.retrieve( |