feat(autosave): add autosave controller and status component

This commit is contained in:
Justin Edmund 2025-08-31 11:03:27 -07:00
parent 9bc942211a
commit 1a5ecf9ecf
2 changed files with 182 additions and 0 deletions

107
src/lib/admin/autoSave.ts Normal file
View file

@ -0,0 +1,107 @@
export type AutoSaveStatus = 'idle' | 'saving' | 'saved' | 'error' | 'offline'
interface CreateAutoSaveControllerOptions<TPayload, TResponse = unknown> {
debounceMs?: number
getPayload: () => TPayload | null | undefined
save: (payload: TPayload, ctx: { signal: AbortSignal }) => Promise<TResponse>
onSaved?: (res: TResponse) => void
}
export function createAutoSaveController<TPayload, TResponse = unknown>(
opts: CreateAutoSaveControllerOptions<TPayload, TResponse>
) {
const debounceMs = opts.debounceMs ?? 2000
let timer: ReturnType<typeof setTimeout> | 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)
}
}

View file

@ -0,0 +1,75 @@
<script lang="ts">
import type { AutoSaveStatus } from '$lib/admin/autoSave'
interface Props {
statusStore: { subscribe: (run: (v: AutoSaveStatus) => void) => () => void }
errorStore?: { subscribe: (run: (v: string | null) => void) => () => void }
compact?: boolean
}
let { statusStore, errorStore, compact = true }: Props = $props()
let status = $state<AutoSaveStatus>('idle')
let errorText = $state<string | null>(null)
$effect(() => {
const unsub = statusStore.subscribe((v) => (status = v))
let unsubErr: (() => void) | null = null
if (errorStore) unsubErr = errorStore.subscribe((v) => (errorText = v))
return () => {
unsub()
if (unsubErr) unsubErr()
}
})
const label = $derived(() => {
switch (status) {
case 'saving':
return 'Saving…'
case 'saved':
return 'All changes saved'
case 'offline':
return 'Offline'
case 'error':
return errorText ? `Error — ${errorText}` : 'Save failed'
case 'idle':
default:
return ''
}
})
</script>
{#if label}
<div class="autosave-status" class:compact>
{#if status === 'saving'}
<span class="spinner" aria-hidden="true"></span>
{/if}
<span class="text">{label}</span>
</div>
{/if}
<style lang="scss">
.autosave-status {
display: inline-flex;
align-items: center;
gap: 6px;
color: $gray-40;
font-size: 0.875rem;
&.compact {
font-size: 0.75rem;
}
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid $gray-80;
border-top-color: $gray-40;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>