mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-17 02:52:04 +00:00
03b74089a0
Opening an entry while offline needed a request that could not be made. Read it from the snapshot claimed by the signed-in account instead, re-checking ownership after the read so a sign-in mid-read cannot surface another account's data, and let controls marked data-offline-action stay usable in read-only mode.
325 lines
11 KiB
Svelte
325 lines
11 KiB
Svelte
<script lang="ts">
|
|
// The only app stylesheet: Vite bundles, minifies and content-hashes it so it is cached for good.
|
|
// static/output.css is built separately for the credential-free offline.html page only.
|
|
import '../app.css';
|
|
import { afterCriticalPageWork } from '$lib/utils/criticalPageWork';
|
|
import { onDestroy, onMount } from 'svelte';
|
|
import { user } from '$lib/stores/user.js';
|
|
import { type User } from '@supabase/auth-js';
|
|
import SignIn from '$lib/components/SignIn.svelte';
|
|
import SignOut from '$lib/components/SignOut.svelte';
|
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
|
import { page } from '$app/stores';
|
|
import { claimOfflineData, requestOfflineSync, startOfflineSync } from '$lib/stores/offlineSync';
|
|
import {
|
|
PROVIDER_LABELS,
|
|
backupsNeedingReconnect,
|
|
clearBackupStatus,
|
|
refreshBackupStatus
|
|
} from '$lib/stores/backupStatus';
|
|
|
|
import { pwaInfo } from 'virtual:pwa-info';
|
|
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
|
|
|
$: webManifestLink = pwaInfo ? pwaInfo.webManifest.linkTag : '';
|
|
const pwaDescription = (pwaAssetsHead as { description?: { content: string } }).description;
|
|
|
|
export let data;
|
|
let { supabase } = data;
|
|
$: ({ supabase } = data);
|
|
|
|
let localUser: User | null = data.user ?? null;
|
|
let userStoreReady = false;
|
|
const unsubscribe = user.subscribe((value) => {
|
|
if (userStoreReady) localUser = value;
|
|
});
|
|
onDestroy(unsubscribe);
|
|
|
|
let authSubscription: { unsubscribe: () => void } | null = null;
|
|
let cancelBackupStartup: (() => void) | null = null;
|
|
let stopOfflineSync: (() => void) | null = null;
|
|
let isOnline = true;
|
|
let signOutError = '';
|
|
|
|
onMount(() => {
|
|
userStoreReady = true;
|
|
isOnline = navigator.onLine;
|
|
const updateOnlineState = () => {
|
|
isOnline = navigator.onLine;
|
|
document.documentElement.classList.toggle('offline-readonly', !isOnline);
|
|
};
|
|
const blockOfflineMutation = (event: Event) => {
|
|
if (navigator.onLine) return;
|
|
const target = event.target instanceof Element ? event.target : null;
|
|
if (!target?.closest('button, input, textarea, select, form')) return;
|
|
if (target.closest('[data-offline-action]')) return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
};
|
|
window.addEventListener('online', updateOnlineState);
|
|
window.addEventListener('offline', updateOnlineState);
|
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
|
window.addEventListener(name, blockOfflineMutation, true);
|
|
}
|
|
updateOnlineState();
|
|
void getUser()
|
|
.then(async () => {
|
|
if (localUser) {
|
|
cancelBackupStartup = afterCriticalPageWork(() => {
|
|
if (localUser) void refreshBackupStatus();
|
|
});
|
|
await claimOfflineData(localUser.id);
|
|
}
|
|
stopOfflineSync = startOfflineSync(() => localUser?.id ?? null);
|
|
})
|
|
.catch((error) => console.error('Unable to claim offline data', error));
|
|
|
|
// Listen for auth state changes to keep the user store in sync
|
|
const {
|
|
data: { subscription }
|
|
} = supabase.auth.onAuthStateChange((event, session) => {
|
|
const previousUserId = localUser?.id ?? null;
|
|
if (session) {
|
|
localUser = session.user;
|
|
// SIGNED_IN also fires when a tab regains focus, so only a change of account fetches a new
|
|
// offline copy. Page loads and token refreshes reuse the saved one while it is fresh.
|
|
if (event === 'SIGNED_IN' && session.user.id !== previousUserId) {
|
|
cancelBackupStartup?.();
|
|
clearBackupStatus();
|
|
void claimOfflineData(session.user.id)
|
|
.then(requestOfflineSync)
|
|
.catch((error) => console.error('Unable to claim offline data', error));
|
|
void refreshBackupStatus();
|
|
}
|
|
} else {
|
|
// Offline data is only cleared by the Sign Out button (or another account claiming it).
|
|
// An expired or rejected session must not throw away artwork that would then have to be
|
|
// downloaded again after signing back in.
|
|
localUser = null;
|
|
clearBackupStatus();
|
|
}
|
|
user.set(localUser);
|
|
});
|
|
authSubscription = subscription;
|
|
|
|
return () => {
|
|
authSubscription?.unsubscribe();
|
|
stopOfflineSync?.();
|
|
cancelBackupStartup?.();
|
|
window.removeEventListener('online', updateOnlineState);
|
|
window.removeEventListener('offline', updateOnlineState);
|
|
for (const name of ['click', 'submit', 'input', 'change', 'keydown']) {
|
|
window.removeEventListener(name, blockOfflineMutation, true);
|
|
}
|
|
document.documentElement.classList.remove('offline-readonly');
|
|
};
|
|
});
|
|
|
|
$: reconnectLabels = $backupsNeedingReconnect.map((provider) => PROVIDER_LABELS[provider]);
|
|
|
|
async function getUser() {
|
|
const {
|
|
data: { session }
|
|
} = await supabase.auth.getSession();
|
|
|
|
if (session) {
|
|
localUser = session.user;
|
|
} else {
|
|
localUser = null;
|
|
}
|
|
|
|
// Update the store so that user is available to all components
|
|
user.set(localUser);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
{#if pwaAssetsHead.themeColor}
|
|
<meta name="theme-color" content={pwaAssetsHead.themeColor.content} />
|
|
{/if}
|
|
{#if pwaDescription}
|
|
<meta name="description" content={pwaDescription.content} />
|
|
{/if}
|
|
{#each pwaAssetsHead.links as link}
|
|
<link {...link} />
|
|
{/each}
|
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
{@html webManifestLink}
|
|
|
|
<!-- <meta
|
|
name="description"
|
|
content="A free and open source web app to track completion of a living Pokédex, which works offline."
|
|
/> -->
|
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
|
<!-- <link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" /> -->
|
|
</svelte:head>
|
|
|
|
<div class="flex flex-col min-h-screen">
|
|
<header>
|
|
<div class="navbar bg-primary text-primary-content">
|
|
<div class="navbar-start">
|
|
<!-- <div class="dropdown">
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle">
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
class="h-5 w-5"
|
|
fill="none"
|
|
viewBox="0 0 24 24"
|
|
stroke="currentColor"
|
|
><path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M4 6h16M4 12h16M4 18h7"
|
|
/></svg
|
|
>
|
|
</div>
|
|
<ul
|
|
class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-primary text-primary-content rounded-box w-52"
|
|
>
|
|
<li>
|
|
<a href="https://github.com/jcreek/LivingDexTracker" target="_blank">Contribute</a>
|
|
</li>
|
|
</ul>
|
|
</div> -->
|
|
</div>
|
|
<div class="navbar-center">
|
|
<a class="btn btn-ghost text-xl" href="/">Living Dex Tracker</a>
|
|
</div>
|
|
<div class="navbar-end">
|
|
<ThemeToggle />
|
|
<div class="dropdown dropdown-end">
|
|
{#if localUser}
|
|
<div tabindex="0" role="button" class="btn btn-ghost btn-circle avatar">
|
|
<div class="w-10 rounded-full">
|
|
<img alt="Account menu" src="/avatar.webp" width="40" height="40" />
|
|
</div>
|
|
</div>
|
|
<ul
|
|
tabindex="-1"
|
|
class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-primary text-primary-content rounded-box min-w-[13rem] w-auto"
|
|
>
|
|
{#if localUser}
|
|
<li>
|
|
<a href="/my-pokedexes"> My Pokédexes </a>
|
|
</li>
|
|
<li>
|
|
<a href="/backup-settings"> Backup Settings </a>
|
|
</li>
|
|
<li>
|
|
<a href="/offline-guide"> Using Offline </a>
|
|
</li>
|
|
<li>
|
|
<SignOut
|
|
{supabase}
|
|
on:signedOut={() => {
|
|
signOutError = '';
|
|
void getUser();
|
|
}}
|
|
on:signOutFailed={(event) => (signOutError = event.detail.message)}
|
|
/>
|
|
</li>
|
|
{:else}
|
|
<li><SignIn {supabase} on:signedIn={getUser} /></li>
|
|
{/if}
|
|
</ul>
|
|
{:else}
|
|
<a href="/signin">Sign In</a>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
{#if signOutError}
|
|
<div class="alert alert-error rounded-none" role="alert">
|
|
<span>{signOutError}</span>
|
|
</div>
|
|
{/if}
|
|
{#if !isOnline}
|
|
<div class="alert rounded-none" role="status">
|
|
<span>Offline read-only mode: saved data remains available, but changes are disabled.</span>
|
|
</div>
|
|
{/if}
|
|
{#if localUser && reconnectLabels.length > 0 && $page.url.pathname !== '/backup-settings'}
|
|
<div
|
|
class="alert alert-warning rounded-none"
|
|
role="alert"
|
|
data-testid="backup-reconnect-banner"
|
|
>
|
|
<span>
|
|
Your {reconnectLabels.join(' and ')} backup has stopped because access expired or was revoked.
|
|
</span>
|
|
<a class="btn btn-sm" href="/backup-settings">Reconnect</a>
|
|
</div>
|
|
{/if}
|
|
|
|
<main class="flex-grow">
|
|
<slot />
|
|
</main>
|
|
|
|
<footer class="footer items-center p-4 bg-neutral text-neutral-content bottom-0">
|
|
<aside class="items-center grid-flow-col">
|
|
<a
|
|
href="https://github.com/jcreek/LivingDexTracker"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="Living Dex Tracker on GitHub"
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
width="36"
|
|
height="36"
|
|
fill-rule="evenodd"
|
|
clip-rule="evenodd"
|
|
class="fill-current"
|
|
viewBox="0 0 120 120"
|
|
>
|
|
<path
|
|
fill-rule="evenodd"
|
|
clip-rule="evenodd"
|
|
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
<p>
|
|
Open source and free to use, forever. We are in no way associated with Nintendo, Game Freak
|
|
or The Pokémon Company.
|
|
</p>
|
|
</aside>
|
|
<nav class="grid-flow-col gap-4 md:place-self-center md:justify-self-end">
|
|
<a
|
|
href="https://discord.gg/SQcJkaXDye"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
aria-label="Living Dex Tracker Discord community"
|
|
><svg
|
|
aria-hidden="true"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 127.14 96.36"
|
|
class="fill-current"
|
|
>
|
|
<path
|
|
d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"
|
|
/>
|
|
</svg>
|
|
</a>
|
|
</nav>
|
|
</footer>
|
|
|
|
{#await import('$lib/components/pwa/ReloadPrompt.svelte') then { default: ReloadPrompt }}
|
|
<ReloadPrompt />
|
|
{/await}
|
|
</div>
|
|
|
|
<style>
|
|
:global(.offline-readonly button:not([data-offline-action])),
|
|
:global(.offline-readonly input:not([data-offline-action])),
|
|
:global(.offline-readonly textarea:not([data-offline-action])),
|
|
:global(.offline-readonly select:not([data-offline-action])) {
|
|
pointer-events: none;
|
|
opacity: 0.65;
|
|
}
|
|
</style>
|