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.
This commit is contained in:
@@ -1,295 +1,295 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user