import type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse, } from '~/types/api' export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse } // Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue) const onApiErrorCallbacks = new Map void>() // In-flight dedup: one me() request at a time; concurrent callers share the same promise. let mePromise: Promise | null = null // In-flight dedup for GET endpoints: same path + method shares one request across all callers. const inFlightByKey = new Map>() export function useMapApi() { const config = useRuntimeConfig() const apiBase = config.public.apiBase as string /** Subscribe to API auth errors (401). Returns unsubscribe function. */ function onApiError(cb: () => void): () => void { const id = Symbol() onApiErrorCallbacks.set(id, cb) return () => onApiErrorCallbacks.delete(id) } async function request(path: string, opts?: RequestInit): Promise { const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}` const res = await fetch(url, { credentials: 'include', ...opts }) // Only redirect to login on 401 (session invalid); 403 = forbidden (no permission) if (res.status === 401) { onApiErrorCallbacks.forEach((cb) => cb()) throw new Error('Unauthorized') } if (res.status === 403) throw new Error('Forbidden') if (!res.ok) throw new Error(`API ${res.status}`) if (res.headers.get('content-type')?.includes('application/json')) { return res.json() as Promise } return undefined as T } function requestDeduped(path: string, opts?: RequestInit): Promise { const key = path + (opts?.method ?? 'GET') const existing = inFlightByKey.get(key) if (existing) return existing as Promise const p = request(path, opts).finally(() => { inFlightByKey.delete(key) }) inFlightByKey.set(key, p) return p } async function getConfig() { return requestDeduped('config') } async function getCharacters() { return requestDeduped('v1/characters') } async function getMarkers() { return requestDeduped('v1/markers') } async function getMaps() { return requestDeduped>('maps') } // Auth async function login(user: string, pass: string) { const res = await fetch(`${apiBase}/login`, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }), }) if (res.status === 401) { const data = (await res.json().catch(() => ({}))) as { error?: string } throw new Error(data.error || 'Unauthorized') } if (!res.ok) throw new Error(`API ${res.status}`) return res.json() as Promise } /** OAuth login URL for redirect (full page navigation). */ function oauthLoginUrl(provider: string, redirect?: string): string { const url = new URL(`${apiBase}/oauth/${provider}/login`) if (redirect) url.searchParams.set('redirect', redirect) return url.toString() } /** List of configured OAuth providers. */ async function oauthProviders(): Promise { try { const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' }) if (!res.ok) return [] const data = await res.json() return Array.isArray(data) ? data : [] } catch { return [] } } async function logout() { mePromise = null inFlightByKey.clear() await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' }) } async function me() { if (mePromise) return mePromise mePromise = request('me').finally(() => { mePromise = null }) return mePromise } async function meUpdate(body: { email?: string }) { await request('me', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } /** Public: whether first-time setup (no users) is required. */ async function setupRequired(): Promise<{ setupRequired: boolean }> { const res = await fetch(`${apiBase}/setup`, { credentials: 'include' }) if (!res.ok) throw new Error(`API ${res.status}`) return res.json() as Promise<{ setupRequired: boolean }> } // Profile async function meTokens() { const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' }) return data?.tokens ?? [] } async function mePassword(pass: string) { await request('me/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pass }), }) } // Admin async function adminUsers() { return request('admin/users') } async function adminUserByName(name: string) { return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`) } async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) { await request('admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } async function adminUserDelete(name: string) { await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' }) } async function adminSettings() { return request('admin/settings') } async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) { await request('admin/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } async function adminMaps() { return request('admin/maps') } async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) { await request(`admin/maps/${id}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) } async function adminMapToggleHidden(id: number) { return request(`admin/maps/${id}/toggle-hidden`, { method: 'POST' }) } async function adminWipe() { await request('admin/wipe', { method: 'POST' }) } async function adminRebuildZooms() { const res = await fetch(`${apiBase}/admin/rebuildZooms`, { method: 'POST', credentials: 'include', }) if (res.status === 401 || res.status === 403) { onApiErrorCallbacks.forEach((cb) => cb()) throw new Error('Unauthorized') } if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`) } async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> { return request<{ running: boolean }>('admin/rebuildZooms/status') } function adminExportUrl() { return `${apiBase}/admin/export` } async function adminMerge(formData: FormData) { const res = await fetch(`${apiBase}/admin/merge`, { method: 'POST', credentials: 'include', body: formData, }) if (res.status === 401 || res.status === 403) { onApiErrorCallbacks.forEach((cb) => cb()) throw new Error('Unauthorized') } if (!res.ok) throw new Error(`API ${res.status}`) } async function adminWipeTile(params: { map: number; x: number; y: number }) { const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) }) return request(`admin/wipeTile?${qs}`) } async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) { const qs = new URLSearchParams({ map: String(params.map), fx: String(params.fx), fy: String(params.fy), tx: String(params.tx), ty: String(params.ty), }) return request(`admin/setCoords?${qs}`) } async function adminHideMarker(params: { id: number }) { const qs = new URLSearchParams({ id: String(params.id) }) return request(`admin/hideMarker?${qs}`) } return { apiBase, onApiError, getConfig, getCharacters, getMarkers, getMaps, login, logout, me, meUpdate, oauthLoginUrl, oauthProviders, setupRequired, meTokens, mePassword, adminUsers, adminUserByName, adminUserPost, adminUserDelete, adminSettings, adminSettingsPost, adminMaps, adminMapPost, adminMapToggleHidden, adminWipe, adminRebuildZooms, adminRebuildZoomsStatus, adminExportUrl, adminMerge, adminWipeTile, adminSetCoords, adminHideMarker, } }