peregrine/web/src/composables/useApi.ts
pyr0ball cc18927437 feat(web): Vue 3 SPA scaffold with avocet lessons applied
Sets up web/ Vue 3 SPA skeleton for issue #8, synthesizing all 15 gotchas
from avocet's Vue port testbed. Key fixes baked in before any component work:

- App.vue root uses .app-root class (not id="app") — gotcha #1
- overflow-x: clip on html (not hidden) — gotcha #3
- UnoCSS presetAttributify with prefixedOnly: true — gotcha #4
- peregrine.css alias map for theme variable names — gotcha #5
- useHaptics guards navigator.vibrate — gotcha #9
- Pinia setup store pattern documented — gotcha #10
- test-setup.ts stubs matchMedia, vibrate, ResizeObserver — gotcha #12
- min-height: 100dvh throughout — gotcha #13

Includes:
- All 7 Peregrine views as stubs (ready to port from Streamlit)
- AppNav with all routes
- useApi (fetch + SSE), useMotion, useHaptics, useEasterEgg composables
- Konami hacker mode easter egg + confetti + cursor trail
- docs/vue-spa-migration.md: full migration guide + implementation order
- Build verified clean (0 errors)
- .gitleaks.toml: allowlist web/package-lock.json (sha512 integrity hashes)
2026-03-17 21:24:00 -07:00

50 lines
1.4 KiB
TypeScript

export type ApiError =
| { kind: 'network'; message: string }
| { kind: 'http'; status: number; detail: string }
export async function useApiFetch<T>(
url: string,
opts?: RequestInit,
): Promise<{ data: T | null; error: ApiError | null }> {
try {
const res = await fetch(url, opts)
if (!res.ok) {
const detail = await res.text().catch(() => '')
return { data: null, error: { kind: 'http', status: res.status, detail } }
}
const data = await res.json() as T
return { data, error: null }
} catch (e) {
return { data: null, error: { kind: 'network', message: String(e) } }
}
}
/**
* Open an SSE connection. Returns a cleanup function.
* onEvent receives each parsed JSON payload.
* onComplete is called when the server sends a {"type":"complete"} event.
* onError is called on connection error.
*/
export function useApiSSE(
url: string,
onEvent: (data: Record<string, unknown>) => void,
onComplete?: () => void,
onError?: (e: Event) => void,
): () => void {
const es = new EventSource(url)
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Record<string, unknown>
onEvent(data)
if (data.type === 'complete') {
es.close()
onComplete?.()
}
} catch { /* ignore malformed events */ }
}
es.onerror = (e) => {
onError?.(e)
es.close()
}
return () => es.close()
}