Files
hnh-map/frontend-nuxt/composables/useMapApi.ts
Nikolay Tatarinov 5ffa10f8b7 Update project structure and enhance frontend functionality
- Added a new AGENTS.md file to document the project structure and conventions.
- Updated .gitignore to include node_modules and refined cursor rules.
- Introduced new backend and frontend components for improved map interactions, including context menus and controls.
- Enhanced API composables for better admin and authentication functionalities.
- Refactored existing components for cleaner code and improved user experience.
- Updated README.md to clarify production asset serving and user setup instructions.
2026-02-25 16:32:55 +03:00

242 lines
7.1 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>()
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
}
async function getConfig() {
return request<ConfigResponse>('config')
}
async function getCharacters() {
return request<Character[]>('v1/characters')
}
async function getMarkers() {
return request<Marker[]>('v1/markers')
}
async function getMaps() {
return request<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() {
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
}
async function me() {
return request<MeResponse>('me')
}
/** 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() {
await request('admin/rebuildZooms', { method: 'POST' })
}
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 }) {
return request(`admin/wipeTile?${new URLSearchParams(params as any)}`)
}
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
return request(`admin/setCoords?${new URLSearchParams(params as any)}`)
}
async function adminHideMarker(params: { id: number }) {
return request(`admin/hideMarker?${new URLSearchParams(params as any)}`)
}
return {
apiBase,
onApiError,
getConfig,
getCharacters,
getMarkers,
getMaps,
login,
logout,
me,
oauthLoginUrl,
oauthProviders,
setupRequired,
meTokens,
mePassword,
adminUsers,
adminUserByName,
adminUserPost,
adminUserDelete,
adminSettings,
adminSettingsPost,
adminMaps,
adminMapPost,
adminMapToggleHidden,
adminWipe,
adminRebuildZooms,
adminExportUrl,
adminMerge,
adminWipeTile,
adminSetCoords,
adminHideMarker,
}
}