mirror of
https://github.com/jcreek/LivingDexTracker.git
synced 2026-09-18 19:42:04 +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`;
|
||||
}
|
||||
Reference in New Issue
Block a user