Files
hnh-map/frontend-nuxt/composables/useMapApi.ts
Nikolay Tatarinov 179357bc93 Refactor Dockerignore and enhance Leaflet styles for improved map functionality
- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded.
- Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups.
- Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
2026-03-04 18:16:41 +03:00

296 lines
8.6 KiB
TypeScript

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<symbol, () => void>()
// In-flight dedup: one me() request at a time; concurrent callers share the same promise.
let mePromise: Promise<MeResponse> | null = null
// In-flight dedup for GET endpoints: same path + method shares one request across all callers.
const inFlightByKey = new Map<string, Promise<unknown>>()
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<T>(path: string, opts?: RequestInit): Promise<T> {
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<T>
}
return undefined as T
}
function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> {
const key = path + (opts?.method ?? 'GET')
const existing = inFlightByKey.get(key)
if (existing) return existing as Promise<T>
const p = request<T>(path, opts).finally(() => {
inFlightByKey.delete(key)
})
inFlightByKey.set(key, p)
return p
}
async function getConfig() {
return requestDeduped<ConfigResponse>('config')
}
async function getCharacters() {
return requestDeduped<Character[]>('v1/characters')
}
async function getMarkers() {
return requestDeduped<Marker[]>('v1/markers')
}
async function getMaps() {
return requestDeduped<Record<string, MapInfo>>('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<MeResponse>
}
/** 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<string[]> {
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<MeResponse>('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<string[]>('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<SettingsResponse>('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<MapInfoAdmin[]>('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<MapInfoAdmin>(`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,
}
}