fix: remediate branch review findings

This commit is contained in:
Josh Creek
2026-09-14 11:47:58 +01:00
parent 4af33709a3
commit 86f1c21e4d
38 changed files with 65833 additions and 58545 deletions
+6 -47
View File
@@ -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') {
+29 -9
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+29197 -29101
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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[];
};
+169
View File
@@ -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);
};
}
+43
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
};
+4
View File
@@ -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');
+11
View File
@@ -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);
+70 -32
View File
@@ -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>