Refactor frontend components for improved functionality and accessibility

- Consolidated global error handling in app.vue to redirect users to the login page on API authentication failure.
- Enhanced MapView component by reintroducing event listeners for selected map and marker updates, improving interactivity.
- Updated PasswordInput and various modal components to ensure proper input handling and accessibility compliance.
- Refactored MapControls and MapControlsContent to streamline prop management and enhance user experience.
- Improved error handling in local storage operations within useMapBookmarks and useRecentLocations composables.
- Standardized input elements across forms for consistency in user interaction.
This commit is contained in:
2026-03-04 14:06:27 +03:00
parent 761fbaed55
commit fd624c2357
30 changed files with 109 additions and 97 deletions

View File

@@ -4,6 +4,16 @@
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>
<style> <style>
.page-enter-active, .page-enter-active,
.page-leave-active { .page-leave-active {
@@ -14,13 +24,3 @@
opacity: 0; opacity: 0;
} }
</style> </style>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>

View File

@@ -88,15 +88,10 @@
</div> </div>
<MapControls <MapControls
:hide-markers="mapLogic.state.hideMarkers.value" :hide-markers="mapLogic.state.hideMarkers.value"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value" :selected-map-id="mapLogic.state.selectedMapId.value"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
:overlay-map-id="mapLogic.state.overlayMapId.value" :overlay-map-id="mapLogic.state.overlayMapId.value"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
:selected-marker-id="mapLogic.state.selectedMarkerId.value" :selected-marker-id="mapLogic.state.selectedMarkerId.value"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
:selected-player-id="mapLogic.state.selectedPlayerId.value" :selected-player-id="mapLogic.state.selectedPlayerId.value"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
@@ -105,6 +100,11 @@
:current-map-id="mapLogic.state.mapid.value" :current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value" :current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
@zoom-in="mapLogic.zoomIn(leafletMap)" @zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)" @zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)" @reset-view="mapLogic.resetView(leafletMap)"

View File

@@ -15,7 +15,7 @@
:readonly="readonly" :readonly="readonly"
:aria-describedby="ariaDescribedby" :aria-describedby="ariaDescribedby"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/> >
<button <button
type="button" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation" class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation"
@@ -41,7 +41,14 @@ const props = withDefaults(
inputId?: string inputId?: string
ariaDescribedby?: string ariaDescribedby?: string
}>(), }>(),
{ required: false, autocomplete: 'off', inputId: undefined, ariaDescribedby: undefined } {
required: false,
autocomplete: 'off',
inputId: undefined,
ariaDescribedby: undefined,
label: undefined,
placeholder: undefined,
}
) )
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -6,5 +6,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ inheritAttrs: false }) defineOptions({ name: 'AppSkeleton', inheritAttrs: false })
</script> </script>

View File

@@ -30,7 +30,7 @@ const props = withDefaults(
email?: string email?: string
size?: number size?: number
}>(), }>(),
{ size: 32 } { size: 32, email: undefined }
) )
const gravatarError = ref(false) const gravatarError = ref(false)

View File

@@ -18,7 +18,7 @@
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="Bookmark name" placeholder="Bookmark name"
@keydown.enter.prevent="onSubmit" @keydown.enter.prevent="onSubmit"
/> >
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">

View File

@@ -39,13 +39,9 @@
<MapControlsContent <MapControlsContent
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId" :overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect" :selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect" :selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
@@ -54,6 +50,10 @@
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@update:overlay-map-id="(v) => (overlayMapId = v)"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@@ -133,13 +133,9 @@
<MapControlsContent <MapControlsContent
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId" :overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect" :selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect" :selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
@@ -149,6 +145,10 @@
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
:touch-friendly="true" :touch-friendly="true"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@update:overlay-map-id="(v) => (overlayMapId = v)"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@@ -191,9 +191,9 @@ interface Player {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps?: MapInfo[]
questGivers: QuestGiver[] questGivers?: QuestGiver[]
players: Player[] players?: Player[]
markers?: ApiMarker[] markers?: ApiMarker[]
currentZoom?: number currentZoom?: number
currentMapId?: number | null currentMapId?: number | null

View File

@@ -71,7 +71,7 @@
Display Display
</h3> </h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" /> <input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" >
<span>Hide markers</span> <span>Hide markers</span>
</label> </label>
</section> </section>

View File

@@ -11,8 +11,8 @@
<h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3> <h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p> <p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2"> <div class="flex gap-2">
<input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" /> <input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" >
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" /> <input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" >
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">

View File

@@ -22,7 +22,7 @@
@keydown.enter="onEnter" @keydown.enter="onEnter"
@keydown.down.prevent="moveHighlight(1)" @keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)" @keydown.up.prevent="moveHighlight(-1)"
/> >
<button <button
v-if="query" v-if="query"
type="button" type="button"

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAppPaths } from '../useAppPaths'
const useRuntimeConfigMock = vi.fn() const useRuntimeConfigMock = vi.fn()
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock) vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
import { useAppPaths } from '../useAppPaths'
describe('useAppPaths with default base /', () => { describe('useAppPaths with default base /', () => {
beforeEach(() => { beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } }) useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })

View File

@@ -1,12 +1,12 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useMapApi } from '../useMapApi'
vi.stubGlobal('useRuntimeConfig', () => ({ vi.stubGlobal('useRuntimeConfig', () => ({
app: { baseURL: '/' }, app: { baseURL: '/' },
public: { apiBase: '/map/api' }, public: { apiBase: '/map/api' },
})) }))
import { useMapApi } from '../useMapApi'
function mockFetch(status: number, body: unknown, contentType = 'application/json') { function mockFetch(status: number, body: unknown, contentType = 'application/json') {
return vi.fn().mockResolvedValue({ return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useMapBookmarks } from '../useMapBookmarks'
const stateByKey: Record<string, ReturnType<typeof ref>> = {} const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => { const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) { if (!stateByKey[key]) {
@@ -18,15 +20,13 @@ const localStorageMock = {
storage[key] = value storage[key] = value
}), }),
clear: vi.fn(() => { clear: vi.fn(() => {
for (const k of Object.keys(storage)) delete storage[k] delete storage['hnh-map-bookmarks']
}), }),
} }
vi.stubGlobal('localStorage', localStorageMock) vi.stubGlobal('localStorage', localStorageMock)
vi.stubGlobal('import.meta.server', false) vi.stubGlobal('import.meta.server', false)
vi.stubGlobal('import.meta.client', true) vi.stubGlobal('import.meta.client', true)
import { useMapBookmarks } from '../useMapBookmarks'
describe('useMapBookmarks', () => { describe('useMapBookmarks', () => {
beforeEach(() => { beforeEach(() => {
storage['hnh-map-bookmarks'] = '[]' storage['hnh-map-bookmarks'] = '[]'

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import type { Map } from 'leaflet'
import { useMapLogic } from '../useMapLogic'
vi.stubGlobal('ref', ref) vi.stubGlobal('ref', ref)
vi.stubGlobal('reactive', reactive) vi.stubGlobal('reactive', reactive)
import { useMapLogic } from '../useMapLogic'
describe('useMapLogic', () => { describe('useMapLogic', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -27,7 +28,7 @@ describe('useMapLogic', () => {
it('zoomIn calls map.zoomIn', () => { it('zoomIn calls map.zoomIn', () => {
const { zoomIn } = useMapLogic() const { zoomIn } = useMapLogic()
const mockMap = { zoomIn: vi.fn() } const mockMap = { zoomIn: vi.fn() }
zoomIn(mockMap as unknown as import('leaflet').Map) zoomIn(mockMap as unknown as Map)
expect(mockMap.zoomIn).toHaveBeenCalled() expect(mockMap.zoomIn).toHaveBeenCalled()
}) })
@@ -39,7 +40,7 @@ describe('useMapLogic', () => {
it('zoomOutControl calls map.zoomOut', () => { it('zoomOutControl calls map.zoomOut', () => {
const { zoomOutControl } = useMapLogic() const { zoomOutControl } = useMapLogic()
const mockMap = { zoomOut: vi.fn() } const mockMap = { zoomOut: vi.fn() }
zoomOutControl(mockMap as unknown as import('leaflet').Map) zoomOutControl(mockMap as unknown as Map)
expect(mockMap.zoomOut).toHaveBeenCalled() expect(mockMap.zoomOut).toHaveBeenCalled()
}) })
@@ -47,7 +48,7 @@ describe('useMapLogic', () => {
const { state, resetView } = useMapLogic() const { state, resetView } = useMapLogic()
state.trackingCharacterId.value = 42 state.trackingCharacterId.value = 42
const mockMap = { setView: vi.fn() } const mockMap = { setView: vi.fn() }
resetView(mockMap as unknown as import('leaflet').Map) resetView(mockMap as unknown as Map)
expect(state.trackingCharacterId.value).toBe(-1) expect(state.trackingCharacterId.value).toBe(-1)
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false }) expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
}) })
@@ -59,7 +60,7 @@ describe('useMapLogic', () => {
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })), getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
getZoom: vi.fn(() => 3), getZoom: vi.fn(() => 3),
} }
updateDisplayCoords(mockMap as unknown as import('leaflet').Map) updateDisplayCoords(mockMap as unknown as Map)
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 }) expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
}) })
@@ -72,7 +73,7 @@ describe('useMapLogic', () => {
it('toLatLng calls map.unproject', () => { it('toLatLng calls map.unproject', () => {
const { toLatLng } = useMapLogic() const { toLatLng } = useMapLogic()
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) } const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200) const result = toLatLng(mockMap as unknown as Map, 100, 200)
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6) expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
expect(result).toEqual({ lat: 1, lng: 2 }) expect(result).toEqual({ lat: 1, lng: 2 })
}) })

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from '../useToast'
const stateByKey: Record<string, ReturnType<typeof ref>> = {} const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => { const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) { if (!stateByKey[key]) {
@@ -10,8 +12,6 @@ const useStateMock = vi.fn((key: string, init: () => unknown) => {
}) })
vi.stubGlobal('useState', useStateMock) vi.stubGlobal('useState', useStateMock)
import { useToast } from '../useToast'
describe('useToast', () => { describe('useToast', () => {
beforeEach(() => { beforeEach(() => {
stateByKey['hnh-map-toasts'] = ref([]) stateByKey['hnh-map-toasts'] = ref([])

View File

@@ -29,7 +29,7 @@ function saveBookmarks(bookmarks: MapBookmark[]) {
if (import.meta.server) return if (import.meta.server) return
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS))) localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS)))
} catch (_) {} } catch { /* ignore */ }
} }
export function useMapBookmarks() { export function useMapBookmarks() {

View File

@@ -12,6 +12,7 @@ import {
import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api' import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
type LeafletModule = L
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
function escapeHtml(s: string): string { function escapeHtml(s: string): string {
@@ -25,7 +26,7 @@ function escapeHtml(s: string): string {
export interface MapLayersOptions { export interface MapLayersOptions {
/** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */ /** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */
L: typeof import('leaflet') L: LeafletModule
map: L.Map map: L.Map
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
@@ -33,7 +34,7 @@ export interface MapLayersOptions {
getCurrentMapId: () => number getCurrentMapId: () => number
setCurrentMapId: (id: number) => void setCurrentMapId: (id: number) => void
setSelectedMapId: (id: number) => void setSelectedMapId: (id: number) => void
getAuths: () => string[] getAuths?: () => string[]
getTrackingCharacterId: () => number getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
@@ -69,7 +70,7 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
getCurrentMapId, getCurrentMapId,
setCurrentMapId, setCurrentMapId,
setSelectedMapId, setSelectedMapId,
getAuths, getAuths: _getAuths,
getTrackingCharacterId, getTrackingCharacterId,
setTrackingCharacterId, setTrackingCharacterId,
onMarkerContextMenu, onMarkerContextMenu,

View File

@@ -26,7 +26,7 @@ function saveRecent(list: RecentLocation[]) {
if (import.meta.server) return if (import.meta.server) return
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT))) localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT)))
} catch (_) {} } catch { /* ignore */ }
} }
export function useRecentLocations() { export function useRecentLocations() {

View File

@@ -12,7 +12,7 @@
type="checkbox" type="checkbox"
class="drawer-toggle" class="drawer-toggle"
@change="onDrawerChange" @change="onDrawerChange"
/> >
<div class="drawer-content flex flex-col h-screen overflow-hidden"> <div class="drawer-content flex flex-col h-screen overflow-hidden">
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0"> <header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200"> <NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
@@ -87,7 +87,7 @@
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
:checked="dark" :checked="dark"
@change="onThemeToggle" @change="onThemeToggle"
/> >
</label> </label>
</li> </li>
<li> <li>
@@ -177,7 +177,7 @@
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
:checked="dark" :checked="dark"
@change="onThemeToggle" @change="onThemeToggle"
/> >
</label> </label>
</li> </li>
<li> <li>
@@ -296,7 +296,7 @@ async function loadConfig(loadToken: number) {
const config = await useMapApi().getConfig() const config = await useMapApi().getConfig()
if (loadToken !== loadId) return if (loadToken !== loadId) return
if (config?.title) title.value = config.title if (config?.title) title.value = config.title
} catch (_) {} } catch { /* ignore */ }
} }
onMounted(() => { onMounted(() => {

View File

@@ -2,7 +2,7 @@ import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
export type LeafletApi = typeof import('leaflet') export type LeafletApi = L
function buildCharacterIconUrl(colors: CharacterColors): string { function buildCharacterIconUrl(colors: CharacterColors): string {
const svg = const svg =

View File

@@ -48,7 +48,7 @@ export interface MarkerIconOptions {
fallbackIconUrl?: string fallbackIconUrl?: string
} }
export type LeafletApi = typeof import('leaflet') export type LeafletApi = L
export function createMarker( export function createMarker(
data: MarkerData, data: MarkerData,

View File

@@ -39,7 +39,10 @@ export function uniqueListUpdate<T extends Identifiable>(
if (addCallback) { if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it)) elementsToAdd.forEach((it) => addCallback(it))
} }
elementsToRemove.forEach((it) => delete list.elements[String(it.id)]) const toRemove = new Set(elementsToRemove.map((it) => String(it.id)))
list.elements = Object.fromEntries(
Object.entries(list.elements).filter(([id]) => !toRemove.has(id))
) as Record<string, T>
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it)) elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
} }

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import type L from 'leaflet'
import type { Map, LayerGroup } from 'leaflet'
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
vi.mock('leaflet', () => { vi.mock('leaflet', () => {
const markerMock = { const markerMock = {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
@@ -21,9 +25,6 @@ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
})) }))
import type L from 'leaflet'
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
function getL(): L { function getL(): L {
return require('leaflet').default return require('leaflet').default
} }
@@ -44,12 +45,12 @@ function makeMapViewRef(mapid = 1): CharacterMapViewRef {
map: { map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
removeLayer: vi.fn(), removeLayer: vi.fn(),
} as unknown as import('leaflet').Map, } as unknown as Map,
mapid, mapid,
markerLayer: { markerLayer: {
removeLayer: vi.fn(), removeLayer: vi.fn(),
addLayer: vi.fn(), addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup, } as unknown as LayerGroup,
} }
} }

View File

@@ -1,5 +1,9 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import L from 'leaflet'
import type { Map, LayerGroup } from 'leaflet'
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
vi.mock('leaflet', () => { vi.mock('leaflet', () => {
const markerMock = { const markerMock = {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
@@ -14,24 +18,19 @@ vi.mock('leaflet', () => {
return { return {
default: { default: {
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon: class {}, Icon: vi.fn(),
}, },
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon: class {}, Icon: vi.fn(),
} }
}) })
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
TileSize: 100, TileSize: 100,
ImageIcon: class { ImageIcon: vi.fn(),
constructor(_opts: Record<string, unknown>) {}
},
})) }))
import L from 'leaflet'
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData { function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
return { return {
id: 1, id: 1,
@@ -48,12 +47,12 @@ function makeMapViewRef(): MapViewRef {
return { return {
map: { map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
} as unknown as import('leaflet').Map, } as unknown as Map,
mapid: 1, mapid: 1,
markerLayer: { markerLayer: {
removeLayer: vi.fn(), removeLayer: vi.fn(),
addLayer: vi.fn(), addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup, } as unknown as LayerGroup,
} }
} }

View File

@@ -58,7 +58,7 @@
placeholder="Search users…" placeholder="Search users…"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation" class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search users" aria-label="Search users"
/> >
</div> </div>
<div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto"> <div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
<div <div
@@ -101,7 +101,7 @@
placeholder="Search maps…" placeholder="Search maps…"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation" class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search maps" aria-label="Search maps"
/> >
</div> </div>
<div class="overflow-x-auto max-h-[60vh] overflow-y-auto"> <div class="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table class="table table-sm table-zebra min-w-[32rem]"> <table class="table table-sm table-zebra min-w-[32rem]">
@@ -121,7 +121,7 @@
</th> </th>
<th scope="col">Hidden</th> <th scope="col">Hidden</th>
<th scope="col">Priority</th> <th scope="col">Priority</th>
<th scope="col" class="text-right"></th> <th scope="col" class="text-right"/>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -159,7 +159,7 @@
v-model="settings.prefix" v-model="settings.prefix"
type="text" type="text"
class="input input-sm w-full min-h-11 touch-manipulation" class="input input-sm w-full min-h-11 touch-manipulation"
/> >
</fieldset> </fieldset>
<fieldset class="fieldset w-full max-w-xs"> <fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label> <label class="label" for="admin-settings-title">Title</label>
@@ -168,7 +168,7 @@
v-model="settings.title" v-model="settings.title"
type="text" type="text"
class="input input-sm w-full min-h-11 touch-manipulation" class="input input-sm w-full min-h-11 touch-manipulation"
/> >
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide"> <label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide">
@@ -177,7 +177,7 @@
v-model="settings.defaultHide" v-model="settings.defaultHide"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
/> >
Default hide new maps Default hide new maps
</label> </label>
</fieldset> </fieldset>
@@ -211,7 +211,7 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" /> <input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" >
<button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()"> <button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
Choose merge file Choose merge file
</button> </button>

View File

@@ -2,20 +2,20 @@
<div class="container mx-auto p-4 max-w-2xl min-w-0"> <div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1> <h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4"> <form v-if="map" class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="name">Name</label> <label class="label" for="name">Name</label>
<input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required /> <input id="name" v-model="form.name" type="text" class="input min-h-11 touch-manipulation" required >
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.hidden" type="checkbox" class="checkbox" /> <input v-model="form.hidden" type="checkbox" class="checkbox" >
<span>Hidden</span> <span>Hidden</span>
</label> </label>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label cursor-pointer gap-2"> <label class="label cursor-pointer gap-2">
<input v-model="form.priority" type="checkbox" class="checkbox" /> <input v-model="form.priority" type="checkbox" class="checkbox" >
<span>Priority</span> <span>Priority</span>
</label> </label>
</fieldset> </fieldset>

View File

@@ -2,7 +2,7 @@
<div class="container mx-auto p-4 max-w-2xl min-w-0"> <div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1> <h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">Username</label> <label class="label" for="user">Username</label>
<input <input
@@ -12,7 +12,7 @@
class="input min-h-11 touch-manipulation" class="input min-h-11 touch-manipulation"
required required
:readonly="!isNew" :readonly="!isNew"
/> >
</fieldset> </fieldset>
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p> <p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
<PasswordInput <PasswordInput
@@ -25,7 +25,7 @@
<label class="label">Auths</label> <label class="label">Auths</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`"> <label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" /> <input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" >
<span>{{ a }}</span> <span>{{ a }}</span>
</label> </label>
</div> </div>

View File

@@ -14,18 +14,18 @@
</a> </a>
<div class="divider text-sm">or</div> <div class="divider text-sm">or</div>
</div> </div>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">User</label> <label class="label" for="user">User</label>
<input <input
ref="userInputRef"
id="user" id="user"
ref="userInputRef"
v-model="user" v-model="user"
type="text" type="text"
class="input min-h-11 touch-manipulation" class="input min-h-11 touch-manipulation"
required required
autocomplete="username" autocomplete="username"
/> >
</fieldset> </fieldset>
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"

View File

@@ -115,7 +115,7 @@
<icons-icon-settings /> <icons-icon-settings />
Change password Change password
</h2> </h2>
<form @submit.prevent="changePass" class="flex flex-col gap-2"> <form class="flex flex-col gap-2" @submit.prevent="changePass">
<PasswordInput <PasswordInput
v-model="newPass" v-model="newPass"
placeholder="New password" placeholder="New password"

View File

@@ -6,7 +6,7 @@
This is the first run. Create the administrator account using the bootstrap password This is the first run. Create the administrator account using the bootstrap password
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>). from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
</p> </p>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"
label="Bootstrap password" label="Bootstrap password"