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