mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-11-09 19:35:56 +00:00
fix: remediate branch review findings
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||
import { inView } from '$lib/actions/inView';
|
||||
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||
|
||||
export let pokemonName: string;
|
||||
export let pokedexNumber: string | number;
|
||||
@@ -12,46 +13,7 @@
|
||||
let imagePath = null as string | null;
|
||||
let isInView = false;
|
||||
|
||||
function isFemaleForm(value?: string) {
|
||||
return /^female\b/i.test((value ?? '').trim());
|
||||
}
|
||||
|
||||
function buildFallbackKey() {
|
||||
const strippedPokedexNumber = pokedexNumber.toString().replace(/^0+/, '') || '0';
|
||||
if (!form) return strippedPokedexNumber;
|
||||
|
||||
let formValue = form.trim();
|
||||
formValue = formValue.replace(/^female[-\s]*/i, '');
|
||||
formValue = formValue
|
||||
.replace(/\s*\(.*?\)/g, '')
|
||||
.replace(/\s*\[.*?\]/g, '')
|
||||
.trim();
|
||||
if (!formValue || formValue.toLowerCase() === 'male') return strippedPokedexNumber;
|
||||
|
||||
formValue = formValue
|
||||
.toLowerCase()
|
||||
.replace(/%/g, '')
|
||||
.replace(/\balolan\b/g, 'alola')
|
||||
.replace(/\bgalarian\b/g, 'galar')
|
||||
.replace(/\bhisuian\b/g, 'hisui')
|
||||
.replace(/\bpaldean\b/g, 'paldea')
|
||||
.replace(/\bform(e)?$/, '')
|
||||
.replace(/\bability$/, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.replace(/2/g, 'two')
|
||||
.replace(/3/g, 'three')
|
||||
.replace(/4/g, 'four');
|
||||
|
||||
return formValue ? `${strippedPokedexNumber}-${formValue}` : strippedPokedexNumber;
|
||||
}
|
||||
|
||||
$: {
|
||||
const rootFolderBase =
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
? '/sprites-small/home'
|
||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
||||
const resolvedSpriteKey = spriteKey?.trim() || buildFallbackKey();
|
||||
if (!spriteKey?.trim()) {
|
||||
console.warn('Missing sprite key for pokemon entry', {
|
||||
pokemonName,
|
||||
@@ -60,14 +22,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
let rootFolder = rootFolderBase;
|
||||
if (shiny) {
|
||||
rootFolder += '/shiny';
|
||||
}
|
||||
if (isFemaleForm(form)) {
|
||||
rootFolder += '/female';
|
||||
}
|
||||
imagePath = `${rootFolder}/${resolvedSpriteKey}.webp`;
|
||||
imagePath = resolveSpriteUrl(
|
||||
{ pokedexNumber: Number(pokedexNumber), form, spriteKey },
|
||||
!!shiny,
|
||||
PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true'
|
||||
);
|
||||
}
|
||||
|
||||
$: if (loadingStrategy !== 'inView') {
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { clearOfflineData } from '$lib/stores/offlineSync';
|
||||
const dispatch = createEventDispatcher();
|
||||
let errorMessage = '';
|
||||
let isSigningOut = false;
|
||||
|
||||
function emitSignedOutEvent() {
|
||||
dispatch('signedOut', {});
|
||||
@@ -12,15 +15,32 @@
|
||||
export let supabase: SupabaseClient;
|
||||
|
||||
async function signOut() {
|
||||
// `.then(() => {...})` resolved to undefined, so destructuring `error` off it threw a
|
||||
// TypeError on every sign-out - after the event had already been emitted.
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) console.error('Sign out failed', error);
|
||||
emitSignedOutEvent();
|
||||
// Signing out used to leave the user sitting on the protected page they were on, still
|
||||
// showing its content. Send them to the public home page and re-run the server loads.
|
||||
await goto('/', { invalidateAll: true });
|
||||
errorMessage = '';
|
||||
isSigningOut = true;
|
||||
try {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
if (error) {
|
||||
errorMessage = `Sign out failed: ${error.message || 'Please try again.'}`;
|
||||
dispatch('signOutFailed', { message: errorMessage });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await clearOfflineData();
|
||||
} catch (cacheError) {
|
||||
console.error('Signed out, but failed to clear offline data', cacheError);
|
||||
}
|
||||
emitSignedOutEvent();
|
||||
await goto('/', { invalidateAll: true });
|
||||
} catch (error) {
|
||||
console.error('Sign out failed', error);
|
||||
errorMessage = 'Sign out failed. Please try again.';
|
||||
dispatch('signOutFailed', { message: errorMessage });
|
||||
} finally {
|
||||
isSigningOut = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button on:click={signOut}>Sign Out</button>
|
||||
<button on:click={signOut} disabled={isSigningOut}>
|
||||
{isSigningOut ? 'Signing Out…' : 'Sign Out'}
|
||||
</button>
|
||||
|
||||
+35490
-29191
File diff suppressed because it is too large
Load Diff
+29197
-29101
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import type { CombinedData } from './CombinedData';
|
||||
import type { Pokedex } from './Pokedex';
|
||||
|
||||
export const OFFLINE_SNAPSHOT_VERSION = 1;
|
||||
|
||||
export type OfflinePokedexSnapshot = {
|
||||
pokedex: Pokedex;
|
||||
entries: CombinedData[];
|
||||
};
|
||||
|
||||
export type OfflineSnapshot = {
|
||||
version: typeof OFFLINE_SNAPSHOT_VERSION;
|
||||
generatedAt: string;
|
||||
userId: string;
|
||||
pokedexes: OfflinePokedexSnapshot[];
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import { PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER } from '$env/static/public';
|
||||
import type { OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||
import { resolveSpriteUrl } from '$lib/utils/spriteUrl';
|
||||
|
||||
export type OfflineSyncStatus = {
|
||||
state: 'idle' | 'syncing' | 'ready' | 'partial' | 'error';
|
||||
generatedAt: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export const offlineSyncStatus = writable<OfflineSyncStatus>({
|
||||
state: 'idle',
|
||||
generatedAt: null,
|
||||
message: null
|
||||
});
|
||||
|
||||
const SYNC_EVENT = 'livingdex:offline-sync';
|
||||
const OFFLINE_CACHE_PREFIX = 'livingdex-offline-';
|
||||
const OFFLINE_META_CACHE = `${OFFLINE_CACHE_PREFIX}meta-v1`;
|
||||
const OFFLINE_META_URL = '/__offline/current';
|
||||
|
||||
async function workerMessage(
|
||||
message: unknown,
|
||||
timeoutMs = 120_000,
|
||||
waitForReady = true
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!('serviceWorker' in navigator)) throw new Error('Service workers are unavailable');
|
||||
const registration = waitForReady
|
||||
? await navigator.serviceWorker.ready
|
||||
: await navigator.serviceWorker.getRegistration();
|
||||
if (!registration?.active) throw new Error('Offline worker is not active');
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel();
|
||||
const timeout = window.setTimeout(
|
||||
() => reject(new Error('Offline worker timed out')),
|
||||
timeoutMs
|
||||
);
|
||||
channel.port1.onmessage = (event) => {
|
||||
window.clearTimeout(timeout);
|
||||
const result = event.data as Record<string, unknown>;
|
||||
if (result?.ok) resolve(result);
|
||||
else reject(new Error(String(result?.error ?? 'Offline worker failed')));
|
||||
};
|
||||
registration.active?.postMessage(message, [channel.port2]);
|
||||
});
|
||||
}
|
||||
|
||||
export function requestOfflineSync(): void {
|
||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event(SYNC_EVENT));
|
||||
}
|
||||
|
||||
async function deleteOfflineCaches(): Promise<void> {
|
||||
if (typeof window === 'undefined' || !('caches' in window)) return;
|
||||
const names = await caches.keys();
|
||||
await Promise.all(
|
||||
names.filter((name) => name.startsWith(OFFLINE_CACHE_PREFIX)).map((name) => caches.delete(name))
|
||||
);
|
||||
}
|
||||
|
||||
export async function claimOfflineData(userId: string): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
if ('caches' in window) {
|
||||
const names = await caches.keys();
|
||||
if (names.includes(OFFLINE_META_CACHE)) {
|
||||
const response = await (await caches.open(OFFLINE_META_CACHE)).match(OFFLINE_META_URL);
|
||||
const meta = await response?.json().catch(() => null);
|
||||
if (typeof meta?.userId !== 'string' || meta.userId !== userId) await deleteOfflineCaches();
|
||||
}
|
||||
}
|
||||
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||
await workerMessage({ type: 'CLAIM_OFFLINE_USER', userId }, 10_000, false);
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearOfflineData(): Promise<void> {
|
||||
if (typeof window === 'undefined') return;
|
||||
await deleteOfflineCaches();
|
||||
if ('serviceWorker' in navigator && (await navigator.serviceWorker.getRegistration())?.active) {
|
||||
await workerMessage({ type: 'CLEAR_OFFLINE_DATA' }, 10_000, false);
|
||||
}
|
||||
offlineSyncStatus.set({ state: 'idle', generatedAt: null, message: null });
|
||||
}
|
||||
|
||||
export function startOfflineSync(getUserId: () => string | null): () => void {
|
||||
let timer: number | null = null;
|
||||
let stopped = false;
|
||||
let running = false;
|
||||
let rerun = false;
|
||||
let lastGeneratedAt: string | null = null;
|
||||
|
||||
const synchronize = async () => {
|
||||
if (stopped || !navigator.onLine) return;
|
||||
if (running) {
|
||||
rerun = true;
|
||||
return;
|
||||
}
|
||||
const userId = getUserId();
|
||||
if (!userId || !('serviceWorker' in navigator)) return;
|
||||
running = true;
|
||||
offlineSyncStatus.set({ state: 'syncing', generatedAt: null, message: null });
|
||||
try {
|
||||
await claimOfflineData(userId);
|
||||
const response = await fetch('/api/offline-snapshot', {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Snapshot request failed (${response.status})`);
|
||||
const snapshot = (await response.json()) as OfflineSnapshot;
|
||||
if (snapshot.userId !== userId) throw new Error('Snapshot owner did not match the session');
|
||||
const useLocal = PUBLIC_USE_LOCAL_POKEMON_SPRITE_FOLDER === 'true';
|
||||
const artworkUrls = Array.from(
|
||||
new Set(
|
||||
snapshot.pokedexes.flatMap(({ pokedex, entries }) =>
|
||||
entries.map(({ pokedexEntry }) => {
|
||||
const url = resolveSpriteUrl(pokedexEntry, pokedex.isShinyDex, useLocal);
|
||||
(
|
||||
pokedexEntry as typeof pokedexEntry & { offlineSpriteUrl: string }
|
||||
).offlineSpriteUrl = url;
|
||||
return url;
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
const result = await workerMessage({
|
||||
type: 'SYNC_OFFLINE_SNAPSHOT',
|
||||
snapshot,
|
||||
artworkUrls
|
||||
});
|
||||
const failed = Number(result.failedArtwork ?? 0);
|
||||
offlineSyncStatus.set({
|
||||
state: failed > 0 ? 'partial' : 'ready',
|
||||
generatedAt: snapshot.generatedAt,
|
||||
message: failed > 0 ? `${failed} artwork files could not be cached` : null
|
||||
});
|
||||
lastGeneratedAt = snapshot.generatedAt;
|
||||
} catch (error) {
|
||||
offlineSyncStatus.set({
|
||||
state: 'error',
|
||||
generatedAt: lastGeneratedAt,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
if (rerun && !stopped) {
|
||||
rerun = false;
|
||||
schedule();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
timer = null;
|
||||
void synchronize();
|
||||
}, 1_000);
|
||||
};
|
||||
|
||||
window.addEventListener(SYNC_EVENT, schedule);
|
||||
window.addEventListener('online', schedule);
|
||||
schedule();
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
window.removeEventListener(SYNC_EVENT, schedule);
|
||||
window.removeEventListener('online', schedule);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export function resolveSpriteUrl(
|
||||
entry: { pokedexNumber: number; form?: string; spriteKey?: string },
|
||||
shiny: boolean,
|
||||
useLocalSprites: boolean
|
||||
): string {
|
||||
const form = entry.form?.trim() ?? '';
|
||||
const strippedNumber = String(entry.pokedexNumber).replace(/^0+/, '') || '0';
|
||||
let key = entry.spriteKey?.trim();
|
||||
|
||||
if (!key) {
|
||||
let formKey = form
|
||||
.replace(/^female[-\s]*/i, '')
|
||||
.replace(/\s*\(.*?\)/g, '')
|
||||
.replace(/\s*\[.*?\]/g, '')
|
||||
.trim();
|
||||
if (!formKey || formKey.toLowerCase() === 'male') {
|
||||
key = strippedNumber;
|
||||
} else {
|
||||
formKey = formKey
|
||||
.toLowerCase()
|
||||
.replace(/%/g, '')
|
||||
.replace(/\balolan\b/g, 'alola')
|
||||
.replace(/\bgalarian\b/g, 'galar')
|
||||
.replace(/\bhisuian\b/g, 'hisui')
|
||||
.replace(/\bpaldean\b/g, 'paldea')
|
||||
.replace(/\bform(e)?$/, '')
|
||||
.replace(/\bability$/, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/(^-|-$)/g, '')
|
||||
.replace(/2/g, 'two')
|
||||
.replace(/3/g, 'three')
|
||||
.replace(/4/g, 'four');
|
||||
key = formKey ? `${strippedNumber}-${formKey}` : strippedNumber;
|
||||
}
|
||||
}
|
||||
|
||||
let root = useLocalSprites
|
||||
? '/sprites-small/home'
|
||||
: 'https://raw.githubusercontent.com/jcreek/LivingDexTracker/master/static/sprites-small/home';
|
||||
if (shiny) root += '/shiny';
|
||||
if (/^female\b/i.test(form)) root += '/female';
|
||||
return `${root}/${key}.webp`;
|
||||
}
|
||||
+5
-35
@@ -2,26 +2,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference lib="esnext" />
|
||||
import {
|
||||
cleanupOutdatedCaches,
|
||||
// createHandlerBoundToURL,
|
||||
precacheAndRoute,
|
||||
precache
|
||||
} from 'workbox-precaching';
|
||||
import { registerRoute } from 'workbox-routing';
|
||||
import { CacheFirst } from 'workbox-strategies';
|
||||
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
||||
import { ExpirationPlugin } from 'workbox-expiration';
|
||||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope;
|
||||
|
||||
// Kept as a static script so the same snapshot, artwork and navigation behavior can be imported
|
||||
// by Workbox's generateSW output too.
|
||||
self.importScripts('/offline-worker.js');
|
||||
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
|
||||
});
|
||||
|
||||
// Add root route to precache manifest
|
||||
precache([{ url: '/', revision: null }]);
|
||||
|
||||
// self.__WB_MANIFEST is default injection point
|
||||
// Handle the case where __WB_MANIFEST might be undefined in development
|
||||
const manifest = self.__WB_MANIFEST || [];
|
||||
@@ -29,26 +21,4 @@ if (Array.isArray(manifest)) {
|
||||
precacheAndRoute(manifest);
|
||||
}
|
||||
|
||||
// clean old assets
|
||||
cleanupOutdatedCaches();
|
||||
|
||||
registerRoute(
|
||||
({ request }) => request.destination === 'image',
|
||||
new CacheFirst({
|
||||
cacheName: 'image-cache',
|
||||
plugins: [
|
||||
new CacheableResponsePlugin({ statuses: [0, 200] }),
|
||||
new ExpirationPlugin({
|
||||
maxEntries: 3000,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30,
|
||||
purgeOnQuotaError: true
|
||||
})
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
// let allowlist: undefined | RegExp[];
|
||||
// if (import.meta.env.DEV) allowlist = [/^\/$/];
|
||||
|
||||
// // to allow work offline
|
||||
// registerRoute(new NavigationRoute(createHandlerBoundToURL('/'), { allowlist }));
|
||||
|
||||
+92
-22
@@ -6,6 +6,13 @@
|
||||
import SignIn from '$lib/components/SignIn.svelte';
|
||||
import SignOut from '$lib/components/SignOut.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import {
|
||||
claimOfflineData,
|
||||
clearOfflineData,
|
||||
offlineSyncStatus,
|
||||
requestOfflineSync,
|
||||
startOfflineSync
|
||||
} from '$lib/stores/offlineSync';
|
||||
|
||||
import { pwaInfo } from 'virtual:pwa-info';
|
||||
import { pwaAssetsHead } from 'virtual:pwa-assets/head';
|
||||
@@ -24,9 +31,35 @@
|
||||
onDestroy(unsubscribe);
|
||||
|
||||
let authSubscription: { unsubscribe: () => void } | null = null;
|
||||
let stopOfflineSync: (() => void) | null = null;
|
||||
let isOnline = true;
|
||||
let signOutError = '';
|
||||
|
||||
onMount(() => {
|
||||
void getUser();
|
||||
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;
|
||||
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) 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 {
|
||||
@@ -34,35 +67,26 @@
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (session) {
|
||||
localUser = session.user;
|
||||
void claimOfflineData(session.user.id)
|
||||
.then(requestOfflineSync)
|
||||
.catch((error) => console.error('Unable to claim offline data', error));
|
||||
} else {
|
||||
localUser = null;
|
||||
if (event === 'SIGNED_OUT') void clearOfflineData();
|
||||
}
|
||||
user.set(localUser);
|
||||
});
|
||||
authSubscription = subscription;
|
||||
|
||||
if (pwaInfo) {
|
||||
void (async () => {
|
||||
const { registerSW } = await import('virtual:pwa-register');
|
||||
registerSW({
|
||||
immediate: true,
|
||||
onRegistered(r) {
|
||||
// uncomment following code if you want check for updates
|
||||
// r && setInterval(() => {
|
||||
// console.log('Checking for sw update')
|
||||
// r.update()
|
||||
// }, 20000 /* 20s for testing purposes */)
|
||||
console.log(`SW Registered: ${r}`);
|
||||
},
|
||||
onRegisterError(error) {
|
||||
console.log('SW registration error', error);
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
return () => {
|
||||
authSubscription?.unsubscribe();
|
||||
stopOfflineSync?.();
|
||||
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');
|
||||
};
|
||||
});
|
||||
|
||||
@@ -162,7 +186,16 @@
|
||||
<li>
|
||||
<a href="/backup-settings"> Backup Settings </a>
|
||||
</li>
|
||||
<li><SignOut {supabase} on:signedOut={getUser} /></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}
|
||||
@@ -174,6 +207,33 @@
|
||||
</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>
|
||||
{:else if localUser && $offlineSyncStatus.state === 'error'}
|
||||
<div class="alert alert-warning rounded-none" role="status">
|
||||
<span>Offline copy could not be refreshed: {$offlineSyncStatus.message}</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
</div>
|
||||
{:else if localUser && $offlineSyncStatus.state === 'partial'}
|
||||
<div class="alert alert-warning rounded-none" role="status">
|
||||
<span>Offline data is saved, but {$offlineSyncStatus.message}.</span>
|
||||
<button class="btn btn-sm" on:click={requestOfflineSync}>Retry</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isOnline && localUser && $offlineSyncStatus.state === 'syncing'}
|
||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">Updating offline copy…</p>
|
||||
{:else if isOnline && localUser && $offlineSyncStatus.generatedAt}
|
||||
<p class="bg-base-200 px-4 py-1 text-center text-xs" role="status">
|
||||
Offline copy updated {new Date($offlineSyncStatus.generatedAt).toLocaleString()}.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<main class="flex-grow">
|
||||
<slot />
|
||||
@@ -223,3 +283,13 @@
|
||||
<ReloadPrompt />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.offline-readonly button),
|
||||
:global(.offline-readonly input),
|
||||
:global(.offline-readonly textarea),
|
||||
:global(.offline-readonly select) {
|
||||
pointer-events: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
</style>
|
||||
|
||||
+16
-2
@@ -1,9 +1,15 @@
|
||||
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
|
||||
import type { LayoutLoad } from './$types';
|
||||
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
|
||||
import { createBrowserClient, isBrowser, parse, serialize } from '@supabase/ssr';
|
||||
import type { CookieSerializeOptions } from 'cookie';
|
||||
|
||||
export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
depends('supabase:auth');
|
||||
const recoveryIntent =
|
||||
isBrowser() &&
|
||||
window.location.pathname === '/reset-password' &&
|
||||
(new URLSearchParams(window.location.hash.slice(1)).get('type') === 'recovery' ||
|
||||
new URL(window.location.href).searchParams.has('code'));
|
||||
|
||||
const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
global: {
|
||||
@@ -17,6 +23,14 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
|
||||
const cookie = parse(document.cookie);
|
||||
return cookie[key];
|
||||
},
|
||||
set(key: string, value: string, options: CookieSerializeOptions) {
|
||||
if (isBrowser()) document.cookie = serialize(key, value, { ...options, path: '/' });
|
||||
},
|
||||
remove(key: string, options: CookieSerializeOptions) {
|
||||
if (isBrowser()) {
|
||||
document.cookie = serialize(key, '', { ...options, path: '/', maxAge: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -30,5 +44,5 @@ export const load: LayoutLoad = async ({ fetch, data, depends }) => {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
return { supabase, session };
|
||||
return { supabase, session, recoveryIntent };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { json, type RequestEvent } from '@sveltejs/kit';
|
||||
import CombinedDataRepository from '$lib/repositories/CombinedDataRepository';
|
||||
import PokedexRepository from '$lib/repositories/PokedexRepository';
|
||||
import { resolveDexScopes } from '$lib/services/PokedexDexScopeService';
|
||||
import { OFFLINE_SNAPSHOT_VERSION, type OfflineSnapshot } from '$lib/models/OfflineSnapshot';
|
||||
import { requireAuth } from '$lib/utils/auth';
|
||||
|
||||
export const GET = async (event: RequestEvent) => {
|
||||
try {
|
||||
const userId = await requireAuth(event);
|
||||
const pokedexes = await new PokedexRepository(event.locals.supabase, userId).findAll();
|
||||
const snapshots = await Promise.all(
|
||||
pokedexes.map(async (pokedex) => {
|
||||
const dexScopes = await resolveDexScopes(event.locals.supabase, pokedex);
|
||||
const entries = await new CombinedDataRepository(
|
||||
event.locals.supabase,
|
||||
userId,
|
||||
pokedex._id
|
||||
).findAllCombinedData(userId, pokedex.isFormDex, '', pokedex.gameScope ?? '', dexScopes);
|
||||
return { pokedex: { ...pokedex, dexScopes }, entries };
|
||||
})
|
||||
);
|
||||
|
||||
const snapshot: OfflineSnapshot = {
|
||||
version: OFFLINE_SNAPSHOT_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
userId,
|
||||
pokedexes: snapshots
|
||||
};
|
||||
|
||||
return json(snapshot, {
|
||||
headers: {
|
||||
'Cache-Control': 'private, no-store',
|
||||
Vary: 'Cookie'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to build offline snapshot:', error);
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return json({ error: 'Unable to build offline snapshot' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import PokedexCard from '$lib/components/pokedex/PokedexCard.svelte';
|
||||
import PokedexForm from '$lib/components/pokedex/PokedexForm.svelte';
|
||||
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||
|
||||
export let data;
|
||||
let { pokedexes } = data;
|
||||
@@ -93,6 +94,7 @@
|
||||
|
||||
closeModal();
|
||||
await loadPokedexes();
|
||||
requestOfflineSync();
|
||||
return;
|
||||
} else {
|
||||
// Create new pokédex
|
||||
@@ -114,6 +116,7 @@
|
||||
// Refresh local state BEFORE deciding whether this is the user's first pokédex.
|
||||
closeModal();
|
||||
await loadPokedexes();
|
||||
requestOfflineSync();
|
||||
|
||||
// If the user's total pokédex count is now 1, this newly created one is their first.
|
||||
if (pokedexes.length === 1) {
|
||||
@@ -149,6 +152,7 @@
|
||||
}
|
||||
|
||||
await loadPokedexes();
|
||||
requestOfflineSync();
|
||||
} catch (error) {
|
||||
console.error('Error deleting pokédex:', error);
|
||||
alert('An error occurred');
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import PokedexEntryCatchRecord from '$lib/components/pokedex/PokedexEntryCatchRecord.svelte';
|
||||
import type { Pokedex } from '$lib/models/Pokedex';
|
||||
import type { PageData } from './$types';
|
||||
import { requestOfflineSync } from '$lib/stores/offlineSync';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
@@ -59,6 +60,7 @@
|
||||
lastFlushAttemptAt: null,
|
||||
lastSuccessfulFlushAt: null
|
||||
};
|
||||
let lastOfflineSyncFlush: number | null = null;
|
||||
let exportAfterFlush = false;
|
||||
let exportInFlight = false;
|
||||
let exportTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -179,6 +181,15 @@
|
||||
|
||||
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
|
||||
catchWriteStatus = s;
|
||||
if (
|
||||
s.lastSuccessfulFlushAt &&
|
||||
s.lastSuccessfulFlushAt !== lastOfflineSyncFlush &&
|
||||
s.pending === 0 &&
|
||||
s.inFlight === 0
|
||||
) {
|
||||
lastOfflineSyncFlush = s.lastSuccessfulFlushAt;
|
||||
requestOfflineSync();
|
||||
}
|
||||
if (s.pending > 0 || s.inFlight > 0) {
|
||||
if (exportTimer) {
|
||||
clearTimeout(exportTimer);
|
||||
|
||||
@@ -1,42 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { user } from '$lib/stores/user.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { User } from '@supabase/auth-js';
|
||||
|
||||
export let data;
|
||||
let { supabase } = data;
|
||||
$: ({ supabase } = data);
|
||||
|
||||
let localUser: User | null = null;
|
||||
const unsubscribe = user.subscribe((value) => {
|
||||
localUser = value;
|
||||
});
|
||||
onDestroy(unsubscribe);
|
||||
|
||||
let showAnimation = false;
|
||||
let hasCheckedAuth = false;
|
||||
let authReady = false;
|
||||
let authChecking = true;
|
||||
let authError = '';
|
||||
const recoveryMarkerKey = 'livingdex:password-recovery';
|
||||
|
||||
onMount(() => {
|
||||
// Start animation
|
||||
setTimeout(() => {
|
||||
const animationTimer = setTimeout(() => {
|
||||
showAnimation = true;
|
||||
}, 100);
|
||||
|
||||
// Check authentication after a short delay to allow Supabase to process the token
|
||||
setTimeout(() => {
|
||||
hasCheckedAuth = true;
|
||||
if (!localUser) {
|
||||
goto('/signin');
|
||||
let settled = false;
|
||||
const settle = (hasSession: boolean) => {
|
||||
if (settled && !hasSession) return;
|
||||
settled = hasSession;
|
||||
authReady = hasSession;
|
||||
authChecking = false;
|
||||
authError = hasSession
|
||||
? ''
|
||||
: 'This password-reset link is invalid or expired. Request a new link and try again.';
|
||||
};
|
||||
const {
|
||||
data: { subscription }
|
||||
} = supabase.auth.onAuthStateChange((event, session) => {
|
||||
if (event === 'PASSWORD_RECOVERY' && session) {
|
||||
sessionStorage.setItem(
|
||||
recoveryMarkerKey,
|
||||
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||
);
|
||||
settle(true);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
void supabase.auth.getSession().then(({ data: { session }, error }) => {
|
||||
if (error) {
|
||||
authChecking = false;
|
||||
authError = error.message;
|
||||
return;
|
||||
}
|
||||
let marker: { userId?: string; expiresAt?: number } | null = null;
|
||||
try {
|
||||
marker = JSON.parse(sessionStorage.getItem(recoveryMarkerKey) ?? 'null');
|
||||
} catch {
|
||||
sessionStorage.removeItem(recoveryMarkerKey);
|
||||
}
|
||||
const suppliedRecoveryIntent = data.recoveryIntent === true;
|
||||
const isRecoverySession =
|
||||
!!session &&
|
||||
(suppliedRecoveryIntent ||
|
||||
(marker?.userId === session.user.id && Number(marker.expiresAt) > Date.now()));
|
||||
if (session && suppliedRecoveryIntent) {
|
||||
sessionStorage.setItem(
|
||||
recoveryMarkerKey,
|
||||
JSON.stringify({ userId: session.user.id, expiresAt: Date.now() + 30 * 60_000 })
|
||||
);
|
||||
}
|
||||
if (!isRecoverySession) sessionStorage.removeItem(recoveryMarkerKey);
|
||||
settle(isRecoverySession);
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(animationTimer);
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
});
|
||||
|
||||
// Reactive: redirect if user becomes null after initial check
|
||||
$: if (hasCheckedAuth && !localUser) {
|
||||
goto('/signin');
|
||||
}
|
||||
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let isLoading = false;
|
||||
@@ -44,6 +76,7 @@
|
||||
let successMessage = '';
|
||||
|
||||
async function updatePassword() {
|
||||
if (!authReady) return;
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
successMessage = '';
|
||||
@@ -74,11 +107,11 @@
|
||||
}
|
||||
|
||||
successMessage = 'Password updated successfully! Redirecting to sign in...';
|
||||
|
||||
// Redirect to sign in after a short delay
|
||||
setTimeout(() => {
|
||||
goto('/signin');
|
||||
}, 2000);
|
||||
sessionStorage.removeItem(recoveryMarkerKey);
|
||||
setTimeout(async () => {
|
||||
await supabase.auth.signOut();
|
||||
await goto('/signin');
|
||||
}, 1_000);
|
||||
} catch (err) {
|
||||
console.error('Update password error:', err);
|
||||
errorMessage = 'An unexpected error occurred. Please try again.';
|
||||
@@ -132,6 +165,11 @@
|
||||
<!-- Reset Password Card -->
|
||||
<div class="card bg-base-200 shadow-xl {showAnimation ? 'animate-slide-up' : ''}">
|
||||
<div class="card-body p-6 md:p-8">
|
||||
{#if authChecking}
|
||||
<div class="alert"><span>Validating your password-reset link…</span></div>
|
||||
{:else if authError}
|
||||
<div class="alert alert-error" role="alert"><span>{authError}</span></div>
|
||||
{/if}
|
||||
<!-- Error Message -->
|
||||
{#if errorMessage}
|
||||
<div class="alert alert-error text-sm">
|
||||
@@ -185,7 +223,7 @@
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={password}
|
||||
on:keypress={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !authReady}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -215,7 +253,7 @@
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={confirmPassword}
|
||||
on:keypress={handleKeyPress}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || !authReady}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -237,7 +275,7 @@
|
||||
<button
|
||||
class="btn btn-primary w-full"
|
||||
on:click={updatePassword}
|
||||
disabled={isLoading || !password || !confirmPassword}
|
||||
disabled={isLoading || !authReady || !password || !confirmPassword}
|
||||
>
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
|
||||
Reference in New Issue
Block a user