feat(#89): Add backup functionality for google drive and dropbox

This commit is contained in:
Josh Creek
2026-01-24 13:32:10 +00:00
parent 3f5c14a8bc
commit ab82f75dc6
22 changed files with 1582 additions and 7 deletions
+3
View File
@@ -159,6 +159,9 @@
<li>
<a href="/my-pokedexes"> My Pokédexes </a>
</li>
<li>
<a href="/backup-settings"> Backup Settings </a>
</li>
<li><SignOut {supabase} on:signedOut={getUser} /></li>
{:else}
<li><SignIn {supabase} on:signedIn={getUser} /></li>
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { requireAuth } from '$lib/utils/auth';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const integrations = await repo.listAll();
const safe = integrations.map((integration) => ({
id: integration._id,
provider: integration.provider,
enabled: integration.enabled,
fileName: integration.fileName,
folderId: integration.folderId,
path: integration.path,
metadata: integration.metadata,
lastExportedAt: integration.lastExportedAt,
lastError: integration.lastError
}));
return json(safe);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,51 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { requireAuth } from '$lib/utils/auth';
const ALLOWED_PROVIDERS = new Set(['google_drive', 'dropbox']);
export const PUT = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { provider } = event.params;
if (!provider || !ALLOWED_PROVIDERS.has(provider)) {
return json({ error: 'Invalid provider' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const body = (await event.request.json()) as {
folderId?: string | null;
path?: string | null;
};
const patch: Record<string, unknown> = {};
if (body.folderId !== undefined) patch.folderId = body.folderId;
if (body.path !== undefined) patch.path = body.path;
const { data, error } = await event.locals.supabase
.from('pokedex_export_integrations')
.update(patch)
.eq('userId', userId)
.is('pokedexId', null)
.eq('provider', provider)
.select('id, provider, enabled, fileName, folderId, path, metadata, lastExportedAt, lastError')
.single();
if (error || !data) {
return json({ error: 'Integration not found' }, { status: 404 });
}
return json(data);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,115 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { requireAuth } from '$lib/utils/auth';
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const oauthState = readOAuthStateCookie(event, 'dropbox');
if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) {
clearOAuthStateCookie(event, 'dropbox');
return json({ error: 'Invalid OAuth state' }, { status: 400 });
}
clearOAuthStateCookie(event, 'dropbox');
const fallbackReturnTo = oauthState.pokedexId
? `/pokedex/${oauthState.pokedexId}`
: '/backup-settings';
const baseReturnTo =
oauthState.returnTo && oauthState.returnTo.startsWith('/')
? oauthState.returnTo
: fallbackReturnTo;
const returnTo = baseReturnTo.includes('?')
? `${baseReturnTo}&export=dropbox`
: `${baseReturnTo}?export=dropbox`;
if (errorParam) {
throw redirect(302, `${returnTo}-denied`);
}
if (!code) {
return json({ error: 'Missing OAuth code' }, { status: 400 });
}
const env = getEnv();
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
const clientSecret = env.DROPBOX_OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return json({ error: 'Missing Dropbox OAuth credentials' }, { status: 500 });
}
const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`;
const tokenParams = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
});
const tokenResponse = await fetch('https://api.dropbox.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString()
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
return json({ error: `Dropbox token exchange failed: ${text}` }, { status: 500 });
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
};
const expiresAt = tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
: null;
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null;
const path = oauthState.path?.trim() ? oauthState.path : null;
try {
await repo.upsert({
provider: 'dropbox',
enabled: true,
fileName,
path,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
});
} catch (saveError) {
console.error('Dropbox integration save failed:', saveError);
throw redirect(302, `${returnTo}-error`);
}
throw redirect(302, `${returnTo}-connected`);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,59 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const pokedexId = url.searchParams.get('pokedexId');
const fileName = url.searchParams.get('fileName');
const path = url.searchParams.get('path');
const returnToRaw = url.searchParams.get('returnTo');
const returnTo =
returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings';
if (pokedexId) {
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
throw redirect(302, '/my-pokedexes');
}
}
const env = getEnv();
const clientId = env.DROPBOX_OAUTH_CLIENT_ID;
if (!clientId) {
throw redirect(302, `/pokedex/${pokedexId}?export=dropbox-missing-client`);
}
const state = createOAuthState();
setOAuthStateCookie(event, 'dropbox', {
state,
userId,
pokedexId,
provider: 'dropbox',
fileName,
path,
returnTo
});
const redirectUri = `${event.url.origin}/api/integrations/dropbox/callback`;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
state,
token_access_type: 'offline',
scope: 'files.content.write'
});
throw redirect(302, `https://www.dropbox.com/oauth2/authorize?${params.toString()}`);
};
@@ -0,0 +1,111 @@
import { json, redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexExportIntegrationRepository from '$lib/repositories/PokedexExportIntegrationRepository';
import { requireAuth } from '$lib/utils/auth';
import { clearOAuthStateCookie, readOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const errorParam = url.searchParams.get('error');
const oauthState = readOAuthStateCookie(event, 'google_drive');
if (!oauthState || !state || oauthState.state !== state || oauthState.userId !== userId) {
clearOAuthStateCookie(event, 'google_drive');
return json({ error: 'Invalid OAuth state' }, { status: 400 });
}
clearOAuthStateCookie(event, 'google_drive');
const fallbackReturnTo = oauthState.pokedexId
? `/pokedex/${oauthState.pokedexId}`
: '/backup-settings';
const baseReturnTo =
oauthState.returnTo && oauthState.returnTo.startsWith('/')
? oauthState.returnTo
: fallbackReturnTo;
const returnTo = baseReturnTo.includes('?')
? `${baseReturnTo}&export=google`
: `${baseReturnTo}?export=google`;
if (errorParam) {
throw redirect(302, `${returnTo}-denied`);
}
if (!code) {
return json({ error: 'Missing OAuth code' }, { status: 400 });
}
const env = getEnv();
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET;
if (!clientId || !clientSecret) {
return json({ error: 'Missing Google OAuth credentials' }, { status: 500 });
}
const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`;
const tokenParams = new URLSearchParams({
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code'
});
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString()
});
if (!tokenResponse.ok) {
const text = await tokenResponse.text();
return json({ error: `Google token exchange failed: ${text}` }, { status: 500 });
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
};
const expiresAt = tokenData.expires_in
? new Date(Date.now() + tokenData.expires_in * 1000).toISOString()
: null;
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const repo = new PokedexExportIntegrationRepository(event.locals.supabase, userId, null);
const fileName = oauthState.fileName?.trim() ? oauthState.fileName : null;
const folderId = oauthState.folderId?.trim() ? oauthState.folderId : null;
await repo.upsert({
provider: 'google_drive',
enabled: true,
fileName,
folderId,
accessToken: tokenData.access_token,
refreshToken: tokenData.refresh_token ?? null,
accessTokenExpiresAt: expiresAt,
metadata: tokenData.scope ? { scope: tokenData.scope } : null
});
throw redirect(302, `${returnTo}-connected`);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,64 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { requireAuth } from '$lib/utils/auth';
import { createOAuthState, setOAuthStateCookie } from '$lib/utils/oauthState';
import { getEnv } from '$lib/utils/env';
export const GET = async (event: RequestEvent) => {
const userId = await requireAuth(event);
const url = new URL(event.request.url);
const pokedexId = url.searchParams.get('pokedexId');
const fileName = url.searchParams.get('fileName');
const folderId = url.searchParams.get('folderId');
const returnToRaw = url.searchParams.get('returnTo');
const returnTo =
returnToRaw && returnToRaw.startsWith('/') ? returnToRaw : '/backup-settings';
if (pokedexId) {
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
throw redirect(302, '/my-pokedexes');
}
}
const env = getEnv();
const clientId = env.GOOGLE_OAUTH_CLIENT_ID;
if (!clientId) {
throw redirect(302, `/pokedex/${pokedexId}?export=google-missing-client`);
}
const state = createOAuthState();
setOAuthStateCookie(event, 'google_drive', {
state,
userId,
pokedexId,
provider: 'google_drive',
fileName,
folderId,
returnTo
});
const redirectUri = `${event.url.origin}/api/integrations/google-drive/callback`;
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: [
'https://www.googleapis.com/auth/drive.file',
'https://www.googleapis.com/auth/drive.metadata.readonly'
].join(' '),
access_type: 'offline',
prompt: 'consent',
include_granted_scopes: 'true',
state
});
throw redirect(302, `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`);
};
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import { type CatchRecord } from '$lib/models/CatchRecord';
import CatchRecordRepository from '$lib/repositories/CatchRecordRepository';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
import { requireAuth } from '$lib/utils/auth';
import type { RequestEvent } from '@sveltejs/kit';
@@ -73,6 +74,11 @@ export const PUT = async (event: RequestEvent) => {
const repo = new CatchRecordRepository(event.locals.supabase, userId, pokedexId);
const upsertedRecord = await repo.upsert(data);
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error('Failed to export pokedex after catch record update:', exportError);
}
return json(upsertedRecord);
} catch (err) {
console.error(err);
@@ -137,6 +143,11 @@ export const POST = async (event: RequestEvent) => {
// Prefer a single bulk upsert when the DB supports it; fall back to per-record upserts.
try {
const upserted = await repo.bulkUpsert(scoped);
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error('Failed to export pokedex after bulk catch record update:', exportError);
}
return json(upserted);
} catch (bulkErr) {
console.warn('Bulk upsert failed; falling back to per-record upserts:', bulkErr);
@@ -145,6 +156,14 @@ export const POST = async (event: RequestEvent) => {
const upsertedRecord = await repo.upsert(record);
insertedRecords.push(upsertedRecord);
}
try {
await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
} catch (exportError) {
console.error(
'Failed to export pokedex after per-record catch updates:',
exportError
);
}
return json(insertedRecords);
}
} catch (err) {
@@ -0,0 +1,37 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import PokedexRepository from '$lib/repositories/PokedexRepository';
import { exportPokedexIfConfigured } from '$lib/services/PokedexExportService';
import { requireAuth } from '$lib/utils/auth';
export const POST = async (event: RequestEvent) => {
try {
const userId = await requireAuth(event);
const { id: pokedexId } = event.params;
if (!pokedexId) {
return json({ error: 'Pokedex ID is required' }, { status: 400 });
}
const { session } = await event.locals.safeGetSession();
if (session) {
await event.locals.supabase.auth.setSession(session);
}
const pokedexRepo = new PokedexRepository(event.locals.supabase, userId);
const pokedex = await pokedexRepo.findById(pokedexId);
if (!pokedex) {
return json({ error: 'Pokedex not found' }, { status: 404 });
}
const result = await exportPokedexIfConfigured(event.locals.supabase, userId, pokedexId);
return json(result);
} catch (err) {
console.error(err);
if (err && typeof err === 'object' && 'status' in err) {
throw err;
}
return json({ error: 'Internal Server Error' }, { status: 500 });
}
};
@@ -0,0 +1,13 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
const { safeGetSession } = locals;
const { session, user } = await safeGetSession();
if (!session || !user) {
throw redirect(303, '/signin');
}
return {};
};
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import { onMount } from 'svelte';
import { browser } from '$app/environment';
type ExportIntegrationSummary = {
id: string;
provider: 'google_drive' | 'dropbox';
enabled: boolean;
fileName: string | null;
folderId: string | null;
path: string | null;
metadata?: Record<string, unknown> | null;
lastExportedAt: string | null;
lastError: string | null;
};
let exportIntegrations: ExportIntegrationSummary[] = [];
let exportLoading = false;
let exportError: string | null = null;
let googleIntegration: ExportIntegrationSummary | undefined;
let dropboxIntegration: ExportIntegrationSummary | undefined;
function formatTimestamp(value: string | null) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
async function loadExportIntegrations() {
exportLoading = true;
exportError = null;
try {
const response = await fetch('/api/export-integrations', {
credentials: 'include'
});
if (!response.ok) {
throw new Error(await response.text());
}
exportIntegrations = (await response.json()) as ExportIntegrationSummary[];
googleIntegration = exportIntegrations.find((i) => i.provider === 'google_drive');
dropboxIntegration = exportIntegrations.find((i) => i.provider === 'dropbox');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
exportError = message || 'Failed to load export settings';
} finally {
exportLoading = false;
}
}
function getGoogleFolderUrl(folderId: string): string {
return `https://drive.google.com/drive/folders/${folderId}`;
}
function getDropboxFolderUrl(path?: string | null): string {
const fallback = '/Apps/Living Dex Tracker';
const trimmed = (path ?? '').trim() || fallback;
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return `https://www.dropbox.com/home${normalized}`;
}
function connectGoogleDrive() {
const params = new URLSearchParams({
returnTo: '/backup-settings'
});
window.location.href = `/api/integrations/google-drive/connect?${params.toString()}`;
}
function connectDropbox() {
const params = new URLSearchParams({
returnTo: '/backup-settings'
});
window.location.href = `/api/integrations/dropbox/connect?${params.toString()}`;
}
onMount(() => {
if (!browser) return;
void loadExportIntegrations();
});
</script>
<svelte:head>
<title>Backup Settings - Living Dex Tracker</title>
</svelte:head>
<div class="container mx-auto p-4 max-w-screen-lg">
<div class="flex items-center justify-between mb-6">
<h1 class="text-3xl font-bold">Backup Settings</h1>
<a href="/my-pokedexes" class="btn btn-outline btn-sm">Back to My Pokédexes</a>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<p class="text-sm text-base-content/70">
Backups automatically export every time you update a pokédex. Each export is named after the
pokédex, so you only need to connect once.
</p>
{#if exportLoading}
<p class="text-sm text-base-content/70 mt-4">Loading export settings…</p>
{:else if exportError}
<p class="text-sm text-error mt-4">{exportError}</p>
{/if}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Google Drive</h2>
<span class={`badge ${googleIntegration ? 'badge-success' : 'badge-ghost'}`}>
{googleIntegration ? 'Connected' : 'Not Connected'}
</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectGoogleDrive}>
{googleIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
{#if googleIntegration?.folderId}
<div class="text-xs">
<a
href={getGoogleFolderUrl(googleIntegration.folderId)}
target="_blank"
class="link link-primary"
>
Open Drive folder
</a>
</div>
{/if}
{#if googleIntegration?.lastExportedAt}
<p class="text-xs text-base-content/60">
Last export: {formatTimestamp(googleIntegration.lastExportedAt)}
</p>
{/if}
{#if googleIntegration?.lastError}
<p class="text-xs text-error">{googleIntegration.lastError}</p>
{/if}
</div>
</div>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Dropbox</h2>
<span class={`badge ${dropboxIntegration ? 'badge-success' : 'badge-ghost'}`}>
{dropboxIntegration ? 'Connected' : 'Not Connected'}
</span>
</div>
<div class="mt-3 space-y-2">
<div class="flex items-center justify-end gap-2">
<button class="btn btn-sm btn-outline" on:click={connectDropbox}>
{dropboxIntegration ? 'Reconnect' : 'Connect'}
</button>
</div>
{#if dropboxIntegration}
<div class="text-xs">
<a
href={getDropboxFolderUrl(dropboxIntegration.path)}
target="_blank"
class="link link-primary"
>
Open Dropbox folder
</a>
</div>
{/if}
{#if dropboxIntegration?.lastExportedAt}
<p class="text-xs text-base-content/60">
Last export: {formatTimestamp(dropboxIntegration.lastExportedAt)}
</p>
{/if}
{#if dropboxIntegration?.lastError}
<p class="text-xs text-error">{dropboxIntegration.lastError}</p>
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
+4 -1
View File
@@ -167,7 +167,10 @@
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold">My Pokédexes</h1>
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
<div class="flex items-center gap-2">
<a class="btn btn-outline" href="/backup-settings">Backup Settings</a>
<button class="btn btn-primary" on:click={openCreateModal}> Create New Pokédex </button>
</div>
</div>
{#if pokedexes.length === 0}
+79
View File
@@ -17,6 +17,7 @@
import type { Pokedex } from '$lib/models/Pokedex';
import type { PageData } from './$types';
export let data: PageData;
// Get pokédex from server load (guarded for transient undefined during navigation/HMR)
@@ -24,6 +25,13 @@
let pokedexId = '';
$: pokedex = data?.pokedex;
$: pokedexId = pokedex?._id ?? '';
$: if (browser && pokedexId) {
try {
localStorage.setItem('activePokedexId', pokedexId);
} catch {
// Ignore storage errors (private mode, etc.)
}
}
let combinedData = null as CombinedData[] | null;
let currentPage = 1 as number;
@@ -49,6 +57,57 @@
lastFlushAttemptAt: null,
lastSuccessfulFlushAt: null
};
let exportAfterFlush = false;
let exportInFlight = false;
let exportTimer: ReturnType<typeof setTimeout> | null = null;
let exportGeneration = 0;
let exportInFlightGeneration = 0;
let toggleFlushTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleExportIfIdle() {
if (!browser) return;
if (!pokedexId) return;
const isIdle = catchWriteStatus.pending === 0 && catchWriteStatus.inFlight === 0;
if (!isIdle) return;
if (!exportAfterFlush || exportInFlight) return;
if (exportTimer) return;
exportTimer = setTimeout(async () => {
exportTimer = null;
if (!exportAfterFlush || exportInFlight) return;
exportInFlight = true;
exportInFlightGeneration = exportGeneration;
try {
await fetch(`/api/pokedexes/${pokedexId}/export`, {
method: 'POST',
credentials: 'include'
});
if (exportGeneration === exportInFlightGeneration) {
exportAfterFlush = false;
}
} catch (error) {
console.error('Failed to auto-export pokedex:', error);
} finally {
exportInFlight = false;
scheduleExportIfIdle();
}
}, 400);
}
function scheduleToggleFlush() {
if (!catchWriteQueue) return;
if (toggleFlushTimer) {
clearTimeout(toggleFlushTimer);
}
toggleFlushTimer = setTimeout(async () => {
toggleFlushTimer = null;
try {
await catchWriteQueue?.flushNow();
} catch (error) {
console.error('Failed to flush catch record updates:', error);
}
}, 250);
}
// Derive from pokedex config
$: showOrigins = !!pokedex?.isOriginDex;
@@ -94,9 +153,20 @@
catchWriteQueueUnsubscribe = catchWriteQueue.getStatus.subscribe((s) => {
catchWriteStatus = s;
if (s.pending > 0 || s.inFlight > 0) {
if (exportTimer) {
clearTimeout(exportTimer);
exportTimer = null;
}
return;
}
scheduleExportIfIdle();
});
}
function applyOptimisticCatchRecordUpdate(next: CatchRecord) {
if (!combinedData) return;
const idx = combinedData.findIndex((cd) => cd.pokedexEntry._id === next.pokemonId);
@@ -184,6 +254,11 @@
debounceMs,
flushSoon: true
});
exportGeneration++;
exportAfterFlush = true;
if (source === 'toggle') {
scheduleToggleFlush();
}
if (source === 'notes-blur') {
// Force a flush attempt when the user leaves the textarea.
await catchWriteQueue?.flushNow();
@@ -239,6 +314,8 @@
if (record.haveToEvolve) record.caught = false;
applyOptimisticCatchRecordUpdate(record);
catchWriteQueue?.enqueue(record, { flushSoon: true });
exportGeneration++;
exportAfterFlush = true;
}
// Kick a flush attempt (batching will occur inside the queue).
await catchWriteQueue?.flushNow();
@@ -362,6 +439,7 @@
window.clearInterval(reconcileInterval);
};
});
</script>
<svelte:head>
@@ -496,6 +574,7 @@
{#if pokedex.description}
<p class="text-sm text-base-content/70 mt-3">{pokedex.description}</p>
{/if}
</div>
<!-- Right side: Actions -->