e.button === 0 && mapLogic.closeContextMenus()">
+
-
-
-
@@ -56,6 +70,9 @@
diff --git a/frontend-nuxt/components/ToastContainer.vue b/frontend-nuxt/components/ToastContainer.vue
new file mode 100644
index 0000000..4b0f989
--- /dev/null
+++ b/frontend-nuxt/components/ToastContainer.vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+ {{ t.text }}
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend-nuxt/components/map/MapControls.vue b/frontend-nuxt/components/map/MapControls.vue
index e767c4d..7b93535 100644
--- a/frontend-nuxt/components/map/MapControls.vue
+++ b/frontend-nuxt/components/map/MapControls.vue
@@ -4,96 +4,135 @@
:class="panelCollapsed ? 'w-12' : 'w-64'"
>
-
-
-
- Zoom
-
-
-
-
-
-
-
-
-
-
- Navigation
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Zoom
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Navigation
+
+
+
+
+
+
+
+
@@ -165,3 +204,15 @@ const selectedPlayerIdSelect = computed({
},
})
+
+
diff --git a/frontend-nuxt/components/map/MapCoordsDisplay.vue b/frontend-nuxt/components/map/MapCoordsDisplay.vue
index b6f3cf0..6bb1eaa 100644
--- a/frontend-nuxt/components/map/MapCoordsDisplay.vue
+++ b/frontend-nuxt/components/map/MapCoordsDisplay.vue
@@ -1,17 +1,50 @@
- {{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
+
+ {{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
+
+ Copied!
+
+
diff --git a/frontend-nuxt/composables/useMapLayers.ts b/frontend-nuxt/composables/useMapLayers.ts
index 2739487..03d5078 100644
--- a/frontend-nuxt/composables/useMapLayers.ts
+++ b/frontend-nuxt/composables/useMapLayers.ts
@@ -26,6 +26,10 @@ export interface MapLayersOptions {
getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
+ /** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */
+ resolveIconUrl?: (path: string) => string
+ /** Fallback icon URL when a marker image fails to load. */
+ fallbackIconUrl?: string
}
export interface MapLayersManager {
@@ -55,6 +59,8 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
getTrackingCharacterId,
setTrackingCharacterId,
onMarkerContextMenu,
+ resolveIconUrl,
+ fallbackIconUrl,
} = options
const markers = createUniqueList
()
@@ -93,9 +99,13 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
function updateMarkers(markersData: ApiMarker[]) {
const list = Array.isArray(markersData) ? markersData : []
const ctx = markerCtx()
+ const iconOptions =
+ resolveIconUrl != null
+ ? { resolveIconUrl, fallbackIconUrl }
+ : undefined
uniqueListUpdate(
markers,
- list.map((it) => createMarker(it as MarkerData)),
+ list.map((it) => createMarker(it as MarkerData, iconOptions)),
(marker: MapMarker) => {
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
marker.setClickCallback(() => {
diff --git a/frontend-nuxt/composables/useToast.ts b/frontend-nuxt/composables/useToast.ts
new file mode 100644
index 0000000..5da66bb
--- /dev/null
+++ b/frontend-nuxt/composables/useToast.ts
@@ -0,0 +1,36 @@
+export type ToastType = 'success' | 'error' | 'info'
+
+export interface Toast {
+ id: number
+ type: ToastType
+ text: string
+}
+
+const TOAST_STATE_KEY = 'hnh-map-toasts'
+const DEFAULT_DURATION_MS = 4000
+
+let nextId = 0
+
+export function useToast() {
+ const toasts = useState(TOAST_STATE_KEY, () => [])
+
+ function dismiss(id: number) {
+ toasts.value = toasts.value.filter((t) => t.id !== id)
+ }
+
+ function show(type: ToastType, text: string, durationMs = DEFAULT_DURATION_MS) {
+ const id = ++nextId
+ toasts.value = [...toasts.value, { id, type, text }]
+ if (durationMs > 0 && import.meta.client) {
+ setTimeout(() => dismiss(id), durationMs)
+ }
+ }
+
+ return {
+ toasts: readonly(toasts),
+ success: (text: string, durationMs?: number) => show('success', text, durationMs),
+ error: (text: string, durationMs?: number) => show('error', text, durationMs),
+ info: (text: string, durationMs?: number) => show('info', text, durationMs),
+ dismiss,
+ }
+}
diff --git a/frontend-nuxt/layouts/default.vue b/frontend-nuxt/layouts/default.vue
index 51cf622..ef60735 100644
--- a/frontend-nuxt/layouts/default.vue
+++ b/frontend-nuxt/layouts/default.vue
@@ -1,54 +1,103 @@
-
- {{ title }}
+
-
-
+
+
+
+
+
+
@@ -71,6 +120,11 @@ const title = ref('HnH Map')
const dark = ref(false)
const live = ref(false)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
+const userDropdownRef = ref(null)
+
+function closeDropdown() {
+ userDropdownRef.value?.removeAttribute('open')
+}
const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path))
@@ -95,8 +149,7 @@ async function loadConfig() {
onMounted(() => {
dark.value = getInitialDark()
- const html = document.documentElement
- html.setAttribute('data-theme', dark.value ? 'dark' : 'light')
+ applyTheme()
})
watch(
@@ -107,15 +160,17 @@ watch(
{ immediate: true }
)
-function toggleTheme() {
+function onThemeToggle() {
+ dark.value = !dark.value
+ applyTheme()
+}
+
+function applyTheme() {
+ if (!import.meta.client) return
const html = document.documentElement
- if (dark.value) {
- html.setAttribute('data-theme', 'dark')
- localStorage.setItem(THEME_KEY, 'dark')
- } else {
- html.setAttribute('data-theme', 'light')
- localStorage.setItem(THEME_KEY, 'light')
- }
+ const theme = dark.value ? 'dark' : 'light'
+ html.setAttribute('data-theme', theme)
+ localStorage.setItem(THEME_KEY, theme)
}
async function doLogout() {
diff --git a/frontend-nuxt/lib/LeafletCustomTypes.ts b/frontend-nuxt/lib/LeafletCustomTypes.ts
index 9c1fc46..6c78fa7 100644
--- a/frontend-nuxt/lib/LeafletCustomTypes.ts
+++ b/frontend-nuxt/lib/LeafletCustomTypes.ts
@@ -70,12 +70,29 @@ export const GridCoordLayer = L.GridLayer.extend({
},
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
+export interface ImageIconOptions extends L.IconOptions {
+ /** When the main icon image fails to load, use this URL (e.g. data URL or default marker). */
+ fallbackIconUrl?: string
+}
+
export const ImageIcon = L.Icon.extend({
options: {
iconSize: [32, 32],
iconAnchor: [16, 16],
+ } as ImageIconOptions,
+
+ createIcon(oldIcon?: HTMLElement): HTMLElement {
+ const img = L.Icon.prototype.createIcon.call(this, oldIcon) as HTMLImageElement
+ const fallback = (this.options as ImageIconOptions).fallbackIconUrl
+ if (fallback && img && img.tagName === 'IMG') {
+ img.onerror = () => {
+ img.onerror = null
+ img.src = fallback
+ }
+ }
+ return img
},
-}) as unknown as new (options?: L.IconOptions) => L.Icon
+}) as unknown as new (options?: ImageIconOptions) => L.Icon
const latNormalization = (90.0 * TileSize) / 2500000.0
const lngNormalization = (180.0 * TileSize) / 2500000.0
diff --git a/frontend-nuxt/lib/Marker.ts b/frontend-nuxt/lib/Marker.ts
index a567f2b..c01e8b7 100644
--- a/frontend-nuxt/lib/Marker.ts
+++ b/frontend-nuxt/lib/Marker.ts
@@ -41,7 +41,14 @@ function detectType(name: string): string {
return name.substring('gfx/terobjs/mm/'.length)
}
-export function createMarker(data: MarkerData): MapMarker {
+export interface MarkerIconOptions {
+ /** Resolves relative icon path to absolute URL (e.g. with app base path). */
+ resolveIconUrl: (path: string) => string
+ /** Optional fallback URL when the icon image fails to load. */
+ fallbackIconUrl?: string
+}
+
+export function createMarker(data: MarkerData, iconOptions?: MarkerIconOptions): MapMarker {
let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
@@ -70,17 +77,24 @@ export function createMarker(data: MarkerData): MapMarker {
add(mapview: MapViewRef): void {
if (!marker.hidden) {
+ const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
+ const fallback = iconOptions?.fallbackIconUrl
let icon: L.Icon
if (marker.image === 'gfx/terobjs/mm/custom') {
icon = new ImageIcon({
- iconUrl: 'gfx/terobjs/mm/custom.png',
+ iconUrl: resolve('gfx/terobjs/mm/custom.png'),
iconSize: [21, 23],
iconAnchor: [11, 21],
popupAnchor: [1, 3],
tooltipAnchor: [1, 3],
+ fallbackIconUrl: fallback,
})
} else {
- icon = new ImageIcon({ iconUrl: `${marker.image}.png`, iconSize: [32, 32] })
+ icon = new ImageIcon({
+ iconUrl: resolve(`${marker.image}.png`),
+ iconSize: [32, 32],
+ fallbackIconUrl: fallback,
+ })
}
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
diff --git a/frontend-nuxt/pages/admin/index.vue b/frontend-nuxt/pages/admin/index.vue
index d4ad85f..d2db7f3 100644
--- a/frontend-nuxt/pages/admin/index.vue
+++ b/frontend-nuxt/pages/admin/index.vue
@@ -2,13 +2,47 @@
Admin
-
- {{ message.text }}
+
+
+
+
+
+
+
+
+
+
+
Users
+
{{ users.length }}
+
+
+
Maps
+
{{ maps.length }}
+
@@ -17,9 +51,18 @@
Users
+
+
+
@@ -35,8 +78,8 @@
Edit
-
- No users yet.
+
+ {{ userSearch ? 'No users match.' : 'No users yet.' }}
Add user
@@ -51,13 +94,38 @@
Maps
+
+
+
- | ID | Name | Hidden | Priority | |
+
+ |
+
+ |
+
+
+ |
+ Hidden |
+ Priority |
+ |
+
-
+
| {{ map.ID }} |
{{ map.Name }} |
{{ map.Hidden ? 'Yes' : 'No' }} |
@@ -68,8 +136,8 @@
-
- | No maps. |
+
+ | {{ mapSearch ? 'No maps match.' : 'No maps.' }} |
@@ -136,7 +204,7 @@
-
+
+
+
+
+
+ {{ rebuilding ? 'Rebuilding zooms…' : merging ? 'Merging…' : 'Wiping data…' }}
+
+
This may take a moment.
+
+
+
+
+
+
@@ -189,6 +288,7 @@
definePageMeta({ middleware: 'admin' })
const api = useMapApi()
+const toast = useToast()
const users = ref
([])
const maps = ref>([])
const settings = ref({ prefix: '', defaultHide: false, title: '' })
@@ -199,20 +299,53 @@ const merging = ref(false)
const mergeFile = ref(null)
const mergeFileRef = ref(null)
const wipeModalRef = ref(null)
+const rebuildModalRef = ref(null)
+const loading = ref(true)
+const userSearch = ref('')
+const mapSearch = ref('')
+const mapsSortBy = ref<'ID' | 'Name'>('ID')
+const mapsSortDir = ref<'asc' | 'desc'>('asc')
-const message = ref<{ type: 'success' | 'error'; text: string }>({ type: 'success', text: '' })
-let messageTimeout: ReturnType | null = null
-function setMessage(type: 'success' | 'error', text: string) {
- message.value = { type, text }
- if (messageTimeout) clearTimeout(messageTimeout)
- messageTimeout = setTimeout(() => {
- message.value = { type: 'success', text: '' }
- messageTimeout = null
- }, 4000)
+const filteredUsers = computed(() => {
+ const q = userSearch.value.trim().toLowerCase()
+ if (!q) return users.value
+ return users.value.filter((u) => u.toLowerCase().includes(q))
+})
+
+const filteredMaps = computed(() => {
+ const q = mapSearch.value.trim().toLowerCase()
+ if (!q) return maps.value
+ return maps.value.filter(
+ (m) =>
+ m.Name.toLowerCase().includes(q) ||
+ String(m.ID).includes(q)
+ )
+})
+
+const sortedFilteredMaps = computed(() => {
+ const list = [...filteredMaps.value]
+ const by = mapsSortBy.value
+ const dir = mapsSortDir.value
+ list.sort((a, b) => {
+ let cmp = 0
+ if (by === 'ID') cmp = a.ID - b.ID
+ else cmp = a.Name.localeCompare(b.Name)
+ return dir === 'asc' ? cmp : -cmp
+ })
+ return list
+})
+
+function setMapsSort(by: 'ID' | 'Name') {
+ if (mapsSortBy.value === by) mapsSortDir.value = mapsSortDir.value === 'asc' ? 'desc' : 'asc'
+ else {
+ mapsSortBy.value = by
+ mapsSortDir.value = 'asc'
+ }
}
onMounted(async () => {
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
+ loading.value = false
})
async function loadUsers() {
@@ -244,21 +377,26 @@ async function saveSettings() {
savingSettings.value = true
try {
await api.adminSettingsPost(settings.value)
- setMessage('success', 'Settings saved.')
+ toast.success('Settings saved.')
} catch (e) {
- setMessage('error', (e as Error)?.message ?? 'Failed to save settings.')
+ toast.error((e as Error)?.message ?? 'Failed to save settings.')
} finally {
savingSettings.value = false
}
}
-async function rebuildZooms() {
+function confirmRebuildZooms() {
+ rebuildModalRef.value?.showModal()
+}
+
+async function doRebuildZooms() {
+ rebuildModalRef.value?.close()
rebuilding.value = true
try {
await api.adminRebuildZooms()
- setMessage('success', 'Zooms rebuilt.')
+ toast.success('Zooms rebuilt.')
} catch (e) {
- setMessage('error', (e as Error)?.message ?? 'Failed to rebuild zooms.')
+ toast.error((e as Error)?.message ?? 'Failed to rebuild zooms.')
} finally {
rebuilding.value = false
}
@@ -274,9 +412,9 @@ async function doWipe() {
await api.adminWipe()
wipeModalRef.value?.close()
await loadMaps()
- setMessage('success', 'All data wiped.')
+ toast.success('All data wiped.')
} catch (e) {
- setMessage('error', (e as Error)?.message ?? 'Failed to wipe.')
+ toast.error((e as Error)?.message ?? 'Failed to wipe.')
} finally {
wiping.value = false
}
@@ -297,11 +435,12 @@ async function doMerge() {
mergeFile.value = null
if (mergeFileRef.value) mergeFileRef.value.value = ''
await loadMaps()
- setMessage('success', 'Merge completed.')
+ toast.success('Merge completed.')
} catch (e) {
- setMessage('error', (e as Error)?.message ?? 'Merge failed.')
+ toast.error((e as Error)?.message ?? 'Merge failed.')
} finally {
merging.value = false
}
}
+
diff --git a/frontend-nuxt/pages/admin/maps/[id].vue b/frontend-nuxt/pages/admin/maps/[id].vue
index df17b2f..702d7a8 100644
--- a/frontend-nuxt/pages/admin/maps/[id].vue
+++ b/frontend-nuxt/pages/admin/maps/[id].vue
@@ -48,8 +48,10 @@ const mapsLoaded = ref(false)
const form = ref({ name: '', hidden: false, priority: false })
const loading = ref(false)
const error = ref('')
+const adminMapName = useState('admin-breadcrumb-map-name', () => null)
onMounted(async () => {
+ adminMapName.value = null
try {
const maps = await api.adminMaps()
mapsLoaded.value = true
@@ -57,6 +59,7 @@ onMounted(async () => {
if (found) {
map.value = found
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
+ adminMapName.value = found.Name
}
} catch {
mapsLoaded.value = true
@@ -64,6 +67,10 @@ onMounted(async () => {
}
})
+onUnmounted(() => {
+ adminMapName.value = null
+})
+
async function submit() {
if (!map.value) return
error.value = ''
diff --git a/frontend-nuxt/pages/profile.vue b/frontend-nuxt/pages/profile.vue
index e955502..eade2c6 100644
--- a/frontend-nuxt/pages/profile.vue
+++ b/frontend-nuxt/pages/profile.vue
@@ -2,31 +2,84 @@
Profile
+
-
-
- Upload tokens
-
-
Tokens for upload API. Generate and copy as needed.
-
- -
- {{ uploadTokenDisplay(t) }}
-
+
+
+
+
+
+
+ {{ (me.username || '?')[0].toUpperCase() }}
+
+
+
+
{{ me.username }}
+
+
+ {{ auth }}
+
+ No roles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Upload tokens
+
+ Tokens for upload API. Generate and copy as needed.
+
- No tokens yet.
- {{ tokenError }}
-
- {{ loadingTokens ? '…' : 'Generate token' }}
-
+ {{ uploadTokenDisplay(t) }}
+ Token {{ idx + 1 }}
+
+ Copied!
+ Copy
+
+
+
+ No tokens yet.
+ {{ tokenError }}
+
+ {{ loadingTokens ? '…' : 'Generate token' }}
+
+
@@ -52,6 +105,9 @@