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)
50 lines
1.4 KiB
TypeScript
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()
|
|
}
|