From 1a5ecf9ecfeded3c96c108fcbdf755dd508921be Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Sun, 31 Aug 2025 11:03:27 -0700 Subject: [PATCH] feat(autosave): add autosave controller and status component --- src/lib/admin/autoSave.ts | 107 ++++++++++++++++++ .../components/admin/AutoSaveStatus.svelte | 75 ++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/lib/admin/autoSave.ts create mode 100644 src/lib/components/admin/AutoSaveStatus.svelte diff --git a/src/lib/admin/autoSave.ts b/src/lib/admin/autoSave.ts new file mode 100644 index 0000000..431f592 --- /dev/null +++ b/src/lib/admin/autoSave.ts @@ -0,0 +1,107 @@ +export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline' + +interface CreateAutoSaveControllerOptions { + debounceMs?: number + getPayload: () => TPayload | null | undefined + save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise + onSaved?: (res: TResponse) => void +} + +export function createAutoSaveController( + opts: CreateAutoSaveControllerOptions +) { + const debounceMs = opts.debounceMs ?? 2000 + let timer: ReturnType | null = null + let controller: AbortController | null = null + let lastSentHash: string | null = null + + let _status: AutoSaveStatus = 'idle' + let _lastError: string | null = null + const statusSubs = new Set<(v: AutoSaveStatus) => void>() + const errorSubs = new Set<(v: string | null) => void>() + + function setStatus(next: AutoSaveStatus) { + _status = next + statusSubs.forEach((fn) => fn(_status)) + } + + function schedule() { + if (timer) clearTimeout(timer) + timer = setTimeout(() => void run(), debounceMs) + } + + async function run() { + if (timer) { + clearTimeout(timer) + timer = null + } + + const payload = opts.getPayload() + if (!payload) return + + const hash = safeHash(payload) + if (lastSentHash && hash === lastSentHash) return + + if (controller) controller.abort() + controller = new AbortController() + + setStatus('saving') + _lastError = null + try { + const res = await opts.save(payload, { signal: controller.signal }) + lastSentHash = hash + setStatus('saved') + if (opts.onSaved) opts.onSaved(res) + } catch (e: any) { + if (e?.name === 'AbortError') { + // Newer save superseded this one + return + } + if (typeof navigator !== 'undefined' && navigator.onLine === false) { + setStatus('offline') + } else { + setStatus('error') + } + _lastError = e?.message || 'Auto-save failed' + errorSubs.forEach((fn) => fn(_lastError)) + } + } + + function flush() { + return run() + } + + function destroy() { + if (timer) clearTimeout(timer) + if (controller) controller.abort() + } + + return { + status: { + subscribe(run: (v: AutoSaveStatus) => void) { + run(_status) + statusSubs.add(run) + return () => statusSubs.delete(run) + } + }, + lastError: { + subscribe(run: (v: string | null) => void) { + run(_lastError) + errorSubs.add(run) + return () => errorSubs.delete(run) + } + }, + schedule, + flush, + destroy + } +} + +function safeHash(obj: unknown): string { + try { + return JSON.stringify(obj) + } catch { + // Fallback for circular structures; not expected for form payloads + return String(obj) + } +} diff --git a/src/lib/components/admin/AutoSaveStatus.svelte b/src/lib/components/admin/AutoSaveStatus.svelte new file mode 100644 index 0000000..adf48eb --- /dev/null +++ b/src/lib/components/admin/AutoSaveStatus.svelte @@ -0,0 +1,75 @@ + + +{#if label} +
+ {#if status === 'saving'} + + {/if} + {label} +
+{/if} + +