feat(autosave): add autosave controller and status component
This commit is contained in:
parent
9bc942211a
commit
1a5ecf9ecf
2 changed files with 182 additions and 0 deletions
107
src/lib/admin/autoSave.ts
Normal file
107
src/lib/admin/autoSave.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/lib/components/admin/AutoSaveStatus.svelte
Normal file
75
src/lib/components/admin/AutoSaveStatus.svelte
Normal 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>
|
||||||
Loading…
Reference in a new issue