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.
This commit is contained in:
2026-02-25 16:32:55 +03:00
parent 104fde7640
commit 5ffa10f8b7
48 changed files with 2699 additions and 465 deletions

View File

@@ -19,7 +19,8 @@
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
onApiError(() => {
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>

View File

@@ -1,21 +1,6 @@
<template>
<div class="absolute inset-0">
<ClientOnly>
<div class="absolute inset-0">
<slot />
</div>
<template #fallback>
<div class="h-screen flex flex-col items-center justify-center gap-4 bg-base-200">
<span class="loading loading-spinner loading-lg text-primary" />
<p class="text-base-content/80 font-medium">Loading map</p>
<div class="flex gap-2">
<div class="w-24 h-3 rounded bg-base-300 animate-pulse" />
<div class="w-32 h-3 rounded bg-base-300 animate-pulse" />
<div class="w-20 h-3 rounded bg-base-300 animate-pulse" />
</div>
</div>
</template>
</ClientOnly>
<slot />
</div>
</template>

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative h-full w-full" @click="contextMenu.tile.show = false; contextMenu.marker.show = false">
<div class="relative h-full w-full" @click="mapLogic.closeContextMenus()">
<div
v-if="mapsLoaded && maps.length === 0"
class="absolute inset-0 z-[500] flex flex-col items-center justify-center gap-4 bg-base-200/90 p-6"
@@ -14,160 +14,48 @@
</div>
</div>
<div ref="mapRef" class="map h-full w-full" />
<!-- Grid coords & zoom (bottom-right) -->
<div
v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
aria-label="Current grid position and zoom"
title="mapId · x, y · zoom"
>
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
</div>
<!-- Control panel -->
<div
class="absolute left-3 top-[10%] z-[502] flex transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12' : 'w-64'"
>
<div
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden transition-all duration-300 flex flex-col"
:class="panelCollapsed ? 'w-12 items-center py-2' : 'w-56'"
>
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0">
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom in"
aria-label="Zoom in"
@click="zoomIn"
>
<icons-icon-zoom-in />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom out"
aria-label="Zoom out"
@click="zoomOutControl"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="zoomOut"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="null">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
<select v-model="selectedMarkerId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="null">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
<select v-model="selectedPlayerId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="null">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</section>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" />
<icons-icon-panel-left v-else />
</button>
</div>
</div>
<!-- Context menu (tile) -->
<div
v-show="contextMenu.tile.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.tile.data && (wipeTile(contextMenu.tile.data), (contextMenu.tile.show = false))">
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
</button>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.tile.data && (openCoordSet(contextMenu.tile.data), (contextMenu.tile.show = false))">
Rewrite tile coords
</button>
</div>
<!-- Context menu (marker) -->
<div
v-show="contextMenu.marker.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
>
<button type="button" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" @click="contextMenu.marker.data?.id != null && (hideMarkerById(contextMenu.marker.data.id), (contextMenu.marker.show = false))">
Hide marker {{ contextMenu.marker.data?.name }}
</button>
</div>
<!-- Coord-set modal: close via Cancel, backdrop click, or Escape -->
<dialog ref="coordSetModal" class="modal" @cancel="closeCoordSetModal">
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="coordSet.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="coordSet.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit="submitCoordSet">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn" @click="closeCoordSetModal">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="closeCoordSetModal" />
</dialog>
<MapMapCoordsDisplay
:mapid="mapLogic.state.mapid"
:display-coords="mapLogic.state.displayCoords"
/>
<MapControls
:show-grid-coordinates="mapLogic.state.showGridCoordinates"
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
:hide-markers="mapLogic.state.hideMarkers"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
:overlay-map-id="mapLogic.state.overlayMapId.value"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
:selected-marker-id="mapLogic.state.selectedMarkerId.value"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
:selected-player-id="mapLogic.state.selectedPlayerId.value"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
:maps="maps"
:quest-givers="questGivers"
:players="players"
@zoom-in="mapLogic.zoomIn(map)"
@zoom-out="mapLogic.zoomOutControl(map)"
@reset-view="mapLogic.resetView(map)"
/>
<MapMapContextMenu
:context-menu="mapLogic.contextMenu"
@wipe-tile="onWipeTile"
@rewrite-coords="onRewriteCoords"
@hide-marker="onHideMarker"
/>
<MapMapCoordSetModal
:coord-set-from="mapLogic.coordSetFrom"
:coord-set="mapLogic.coordSet"
:open="mapLogic.coordSetModalOpen"
@close="mapLogic.closeCoordSetModal()"
@submit="onSubmitCoordSet"
/>
</div>
</template>
<script setup lang="ts">
import MapControls from '~/components/map/MapControls.vue'
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer'
import { Marker } from '~/lib/Marker'
@@ -187,31 +75,14 @@ const props = withDefaults(
)
const mapRef = ref<HTMLElement | null>(null)
const route = useRoute()
const api = useMapApi()
const mapLogic = useMapLogic()
const showGridCoordinates = ref(false)
const hideMarkers = ref(false)
const panelCollapsed = ref(false)
const trackingCharacterId = ref(-1)
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
const mapsLoaded = ref(false)
const selectedMapId = ref<number | null>(null)
const overlayMapId = ref<number>(-1)
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
const players = ref<{ id: number; name: string }[]>([])
const selectedMarkerId = ref<number | null>(null)
const selectedPlayerId = ref<number | null>(null)
const auths = ref<string[]>([])
const coordSetFrom = ref({ x: 0, y: 0 })
const coordSet = ref({ x: 0, y: 0 })
const coordSetModal = ref<HTMLDialogElement | null>(null)
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
const contextMenu = reactive({
tile: { show: false, x: 0, y: 0, data: null as { coords: { x: number; y: number } } | null },
marker: { show: false, x: 0, y: 0, data: null as { id: number; name: string } | null },
})
let map: L.Map | null = null
let layer: InstanceType<typeof SmartTileLayer> | null = null
@@ -220,7 +91,6 @@ let coordLayer: InstanceType<typeof GridCoordLayer> | null = null
let markerLayer: L.LayerGroup | null = null
let source: EventSource | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let mapid = 0
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
let characters: UniqueList<InstanceType<typeof Character>> | null = null
let markersHidden = false
@@ -231,10 +101,11 @@ function toLatLng(x: number, y: number) {
}
function changeMap(id: number) {
if (id === mapid) return
mapid = id
if (id === mapLogic.state.mapid.value) return
mapLogic.state.mapid.value = id
mapLogic.state.selectedMapId.value = id
if (layer) {
layer.map = mapid
layer.map = id
layer.redraw()
}
if (overlayLayer) {
@@ -242,71 +113,50 @@ function changeMap(id: number) {
overlayLayer.redraw()
}
if (markers && !markersHidden) {
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid }))
markers.getElements().filter((it: any) => it.map === mapid).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid }))
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid: id }))
markers.getElements().filter((it: any) => it.map === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
}
if (characters) {
characters.getElements().forEach((it: any) => {
it.remove({ map: map! })
it.add({ map: map!, mapid })
it.add({ map: map!, mapid: id })
})
}
}
function zoomIn() {
map?.zoomIn()
function onWipeTile(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y })
}
function zoomOutControl() {
map?.zoomOut()
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (!coords) return
mapLogic.closeContextMenus()
mapLogic.openCoordSet(coords)
}
function zoomOut() {
trackingCharacterId.value = -1
map?.setView([0, 0], HnHMinZoom, { animate: false })
}
function wipeTile(data: { coords: { x: number; y: number } }) {
if (!data?.coords) return
api.adminWipeTile({ map: mapid, x: data.coords.x, y: data.coords.y })
}
function closeCoordSetModal() {
coordSetModal.value?.close()
}
function openCoordSet(data: { coords: { x: number; y: number } }) {
if (!data?.coords) return
coordSetFrom.value = { ...data.coords }
coordSet.value = { x: data.coords.x, y: data.coords.y }
coordSetModal.value?.showModal()
}
function submitCoordSet() {
api.adminSetCoords({
map: mapid,
fx: coordSetFrom.value.x,
fy: coordSetFrom.value.y,
tx: coordSet.value.x,
ty: coordSet.value.y,
})
coordSetModal.value?.close()
}
function hideMarkerById(id: number) {
function onHideMarker(id: number | undefined) {
if (id == null) return
mapLogic.closeContextMenus()
api.adminHideMarker({ id })
const m = markers?.byId(id)
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid })
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value })
}
function closeContextMenus() {
contextMenu.tile.show = false
contextMenu.marker.show = false
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
api.adminSetCoords({
map: mapLogic.state.mapid.value,
fx: from.x,
fy: from.y,
tx: to.x,
ty: to.y,
})
mapLogic.closeCoordSetModal()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeContextMenus()
if (e.key === 'Escape') mapLogic.closeContextMenus()
}
onMounted(async () => {
@@ -353,9 +203,8 @@ onMounted(async () => {
const initialMapId =
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
mapid = initialMapId
mapLogic.state.mapid.value = initialMapId
// Tiles are served at /map/grids/ (backend path, not SPA baseURL)
const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
@@ -412,14 +261,10 @@ onMounted(async () => {
if (auths.value.includes('admin')) {
const point = map!.project(mev.latlng, 6)
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
contextMenu.tile.show = true
contextMenu.tile.x = mev.originalEvent.clientX
contextMenu.tile.y = mev.originalEvent.clientY
contextMenu.tile.data = { coords }
mapLogic.openTileContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, coords)
}
})
// SSE is at /map/updates (backend path, not SPA baseURL). Same origin so it connects to correct host/port.
const updatesPath = `${backendBase}/updates`
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
source = new EventSource(updatesUrl)
@@ -437,28 +282,26 @@ onMounted(async () => {
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
}
} catch {
// Ignore parse errors (e.g. empty SSE message or non-JSON)
// Ignore parse errors
}
}
source.onerror = () => {
// Connection lost or 401; avoid uncaught errors
}
source.onerror = () => {}
source.addEventListener('merge', (e: MessageEvent) => {
try {
const merge = JSON.parse(e?.data ?? '{}')
if (mapid === merge.From) {
const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6)
const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
z: map!.getZoom(),
if (mapLogic.state.mapid.value === merge.From) {
const mapTo = merge.To
const point = map!.project(map!.getCenter(), 6)
const coordinate = {
x: Math.floor(point.x / TileSize) + merge.Shift.x,
y: Math.floor(point.y / TileSize) + merge.Shift.y,
z: map!.getZoom(),
}
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
changeMap(mapTo)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
map!.setView(latLng, map!.getZoom())
}
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
changeMap(mapTo)
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
map!.setView(latLng, map!.getZoom())
}
} catch {
// Ignore merge parse errors
}
@@ -470,22 +313,21 @@ onMounted(async () => {
updateCharacters(charactersData as any[])
if (props.characterId !== undefined && props.characterId >= 0) {
trackingCharacterId.value = props.characterId
mapLogic.state.trackingCharacterId.value = props.characterId
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
if (mapid !== props.mapId) changeMap(props.mapId)
selectedMapId.value = props.mapId
if (mapLogic.state.mapid.value !== props.mapId) changeMap(props.mapId)
mapLogic.state.selectedMapId.value = props.mapId
map.setView(latLng, props.zoom, { animate: false })
} else if (mapsList.length > 0) {
const first = mapsList[0]
if (first) {
changeMap(first.ID)
selectedMapId.value = first.ID
mapLogic.state.selectedMapId.value = first.ID
map.setView([0, 0], HnHDefaultZoom, { animate: false })
}
}
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
nextTick(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
@@ -503,18 +345,15 @@ onMounted(async () => {
function updateMarkers(markersData: any[]) {
if (!markers || !map || !markerLayer) return
const list = Array.isArray(markersData) ? markersData : []
const ctx = { map, markerLayer, mapid, overlayLayer, auths: auths.value }
const ctx = { map, markerLayer, mapid: mapLogic.state.mapid.value, overlayLayer, auths: auths.value }
markers.update(
list.map((it) => new Marker(it)),
(marker: InstanceType<typeof Marker>) => {
if (marker.map === mapid || marker.map === overlayLayer?.map) marker.add(ctx)
if (marker.map === mapLogic.state.mapid.value || marker.map === overlayLayer?.map) marker.add(ctx)
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
if (auths.value.includes('admin')) {
contextMenu.marker.show = true
contextMenu.marker.x = mev.originalEvent.clientX
contextMenu.marker.y = mev.originalEvent.clientY
contextMenu.marker.data = { id: marker.id, name: marker.name }
mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
}
})
},
@@ -527,17 +366,17 @@ onMounted(async () => {
function updateCharacters(charactersData: any[]) {
if (!characters || !map) return
const list = Array.isArray(charactersData) ? charactersData : []
const ctx = { map, mapid }
const ctx = { map, mapid: mapLogic.state.mapid.value }
characters.update(
list.map((it) => new Character(it)),
(character: InstanceType<typeof Character>) => {
character.add(ctx)
character.setClickCallback(() => (trackingCharacterId.value = character.id))
character.setClickCallback(() => (mapLogic.state.trackingCharacterId.value = character.id))
},
(character: InstanceType<typeof Character>) => character.remove(ctx),
(character: InstanceType<typeof Character>, updated: any) => {
if (trackingCharacterId.value === updated.id) {
if (mapid !== updated.map) changeMap(updated.map)
if (mapLogic.state.trackingCharacterId.value === updated.id) {
if (mapLogic.state.mapid.value !== updated.map) changeMap(updated.map)
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
map!.setView(latlng, HnHMaxZoom)
}
@@ -547,7 +386,7 @@ onMounted(async () => {
players.value = characters.getElements()
}
watch(showGridCoordinates, (v) => {
watch(mapLogic.state.showGridCoordinates, (v) => {
if (coordLayer) {
;(coordLayer.options as { visible?: boolean }).visible = v
coordLayer.setOpacity(v ? 1 : 0)
@@ -561,19 +400,19 @@ onMounted(async () => {
}
})
watch(hideMarkers, (v) => {
watch(mapLogic.state.hideMarkers, (v) => {
markersHidden = v
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
if (v) {
markers.getElements().forEach((it: any) => it.remove(ctx))
} else {
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapid || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
}
})
watch(trackingCharacterId, (value) => {
watch(mapLogic.state.trackingCharacterId, (value) => {
if (value === -1) return
const character = characters?.byId(value)
if (character) {
@@ -583,59 +422,49 @@ onMounted(async () => {
autoMode = true
} else {
map!.setView([0, 0], HnHMinZoom)
trackingCharacterId.value = -1
mapLogic.state.trackingCharacterId.value = -1
}
})
watch(selectedMapId, (value) => {
watch(mapLogic.state.selectedMapId, (value) => {
if (value == null) return
changeMap(value)
const zoom = map!.getZoom()
map!.setView([0, 0], zoom)
})
watch(overlayMapId, (value) => {
watch(mapLogic.state.overlayMapId, (value) => {
if (overlayLayer) overlayLayer.map = value ?? -1
overlayLayer?.redraw()
if (!markers) return
const ctx = { map: map!, markerLayer: markerLayer!, mapid, overlayLayer }
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
markers.getElements().forEach((it: any) => it.remove(ctx))
markers.getElements().filter((it: any) => it.map === mapid || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
})
watch(selectedMarkerId, (value) => {
watch(mapLogic.state.selectedMarkerId, (value) => {
if (value == null) return
const marker = markers?.byId(value)
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
})
watch(selectedPlayerId, (value) => {
if (value != null) trackingCharacterId.value = value
watch(mapLogic.state.selectedPlayerId, (value) => {
if (value != null) mapLogic.state.trackingCharacterId.value = value
})
function updateDisplayCoords() {
if (!map) return
const point = map.project(map.getCenter(), 6)
displayCoords.value = {
x: Math.floor(point.x / TileSize),
y: Math.floor(point.y / TileSize),
z: map.getZoom(),
}
}
map.on('moveend', updateDisplayCoords)
updateDisplayCoords()
map.on('moveend', () => mapLogic.updateDisplayCoords(map))
mapLogic.updateDisplayCoords(map)
map.on('zoomend', () => {
if (map) map.invalidateSize()
})
map.on('drag', () => {
trackingCharacterId.value = -1
mapLogic.state.trackingCharacterId.value = -1
})
map.on('zoom', () => {
if (autoMode) {
autoMode = false
} else {
trackingCharacterId.value = -1
mapLogic.state.trackingCharacterId.value = -1
}
})
})

View File

@@ -0,0 +1,63 @@
<template>
<!-- Context menu (tile) -->
<div
v-show="contextMenu.tile.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onWipeTile(contextMenu.tile.data?.coords)"
>
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
</button>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onRewriteCoords(contextMenu.tile.data?.coords)"
>
Rewrite tile coords
</button>
</div>
<!-- Context menu (marker) -->
<div
v-show="contextMenu.marker.show"
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
>
<button
type="button"
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
@click="onHideMarker(contextMenu.marker.data?.id)"
>
Hide marker {{ contextMenu.marker.data?.name }}
</button>
</div>
</template>
<script setup lang="ts">
import type { ContextMenuState } from '~/composables/useMapLogic'
defineProps<{
contextMenu: ContextMenuState
}>()
const emit = defineEmits<{
wipeTile: [coords: { x: number; y: number } | undefined]
rewriteCoords: [coords: { x: number; y: number } | undefined]
hideMarker: [id: number | undefined]
}>()
function onWipeTile(coords: { x: number; y: number } | undefined) {
if (coords) emit('wipeTile', coords)
}
function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (coords) emit('rewriteCoords', coords)
}
function onHideMarker(id: number | undefined) {
if (id != null) emit('hideMarker', id)
}
</script>

View File

@@ -0,0 +1,167 @@
<template>
<div
class="absolute left-3 top-[10%] z-[502] flex min-w-[3rem] min-h-[3rem] transition-all duration-300 ease-out"
:class="panelCollapsed ? 'w-12' : 'w-64'"
>
<div
class="rounded-xl bg-base-100/80 backdrop-blur-xl border border-base-300/50 shadow-xl overflow-hidden transition-all duration-300 flex flex-col"
:class="panelCollapsed ? 'w-12 items-center py-2' : 'w-56'"
>
<div v-show="!panelCollapsed" class="flex flex-col p-4 gap-4 flex-1 min-w-0">
<!-- Zoom -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Zoom</h3>
<div class="flex items-center gap-2">
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom in"
aria-label="Zoom in"
@click="$emit('zoomIn')"
>
<icons-icon-zoom-in />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Zoom out"
aria-label="Zoom out"
@click="$emit('zoomOut')"
>
<icons-icon-zoom-out />
</button>
<button
type="button"
class="btn btn-outline btn-sm btn-square transition-all duration-200 hover:scale-105"
title="Reset view — center map and minimum zoom"
aria-label="Reset view"
@click="$emit('resetView')"
>
<icons-icon-home />
</button>
</div>
</section>
<!-- Display -->
<section class="flex flex-col gap-2">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Show grid coordinates</span>
</label>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
<span class="label-text">Hide markers</span>
</label>
</section>
<!-- Navigation -->
<section class="flex flex-col gap-3">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
<select v-model="selectedMapIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select map</option>
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option :value="-1">None</option>
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
<select v-model="selectedMarkerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
</select>
</div>
<div class="form-control">
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
<select v-model="selectedPlayerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
<option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</section>
</div>
<button
type="button"
class="btn btn-ghost btn-sm btn-square shrink-0 m-1 transition-all duration-200 hover:scale-105"
:title="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
:aria-label="panelCollapsed ? 'Expand panel' : 'Collapse panel'"
@click.stop="panelCollapsed = !panelCollapsed"
>
<icons-icon-chevron-right v-if="panelCollapsed" />
<icons-icon-panel-left v-else />
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { MapInfo } from '~/types/api'
interface QuestGiver {
id: number
name: string
}
interface Player {
id: number
name: string
}
const props = withDefaults(
defineProps<{
maps: MapInfo[]
questGivers: QuestGiver[]
players: Player[]
}>(),
{ maps: () => [], questGivers: () => [], players: () => [] }
)
const emit = defineEmits<{
zoomIn: []
zoomOut: []
resetView: []
}>()
const showGridCoordinates = defineModel<boolean>('showGridCoordinates', { default: false })
const hideMarkers = defineModel<boolean>('hideMarkers', { default: false })
const panelCollapsed = ref(false)
const selectedMapId = defineModel<number | null>('selectedMapId', { default: null })
const overlayMapId = defineModel<number>('overlayMapId', { default: -1 })
const selectedMarkerId = defineModel<number | null>('selectedMarkerId', { default: null })
const selectedPlayerId = defineModel<number | null>('selectedPlayerId', { default: null })
// String bindings for selects with placeholder ('' ↔ null)
const selectedMapIdSelect = computed({
get() {
const v = selectedMapId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedMapId.value = v === '' ? null : Number(v)
},
})
const selectedMarkerIdSelect = computed({
get() {
const v = selectedMarkerId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedMarkerId.value = v === '' ? null : Number(v)
},
})
const selectedPlayerIdSelect = computed({
get() {
const v = selectedPlayerId.value
return v == null ? '' : String(v)
},
set(v: string) {
selectedPlayerId.value = v === '' ? null : Number(v)
},
})
</script>

View File

@@ -0,0 +1,61 @@
<template>
<dialog ref="modalRef" class="modal" @cancel="$emit('close')">
<div class="modal-box transition-all duration-200" @click.stop>
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2">
<input v-model.number="localTo.x" type="number" class="input input-bordered flex-1" placeholder="X" />
<input v-model.number="localTo.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
</div>
<div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit">
<button type="submit" class="btn btn-primary">Submit</button>
<button type="button" class="btn" @click="$emit('close')">Cancel</button>
</form>
</div>
</div>
<div class="modal-backdrop cursor-pointer" aria-label="Close" @click="$emit('close')" />
</dialog>
</template>
<script setup lang="ts">
const props = defineProps<{
coordSetFrom: { x: number; y: number }
coordSet: { x: number; y: number }
open: boolean
}>()
const emit = defineEmits<{
close: []
submit: [from: { x: number; y: number }; to: { x: number; y: number }]
}>()
const modalRef = ref<HTMLDialogElement | null>(null)
const localTo = ref({ x: props.coordSet.x, y: props.coordSet.y })
watch(
() => props.open,
(open) => {
if (open) {
localTo.value = { x: props.coordSet.x, y: props.coordSet.y }
nextTick(() => modalRef.value?.showModal())
} else {
modalRef.value?.close()
}
},
{ immediate: true }
)
watch(
() => props.coordSet,
(c) => {
localTo.value = { x: c.x, y: c.y }
},
{ deep: true }
)
function onSubmit() {
emit('submit', props.coordSetFrom, localTo.value)
emit('close')
}
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div
v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow"
aria-label="Current grid position and zoom"
title="mapId · x, y · zoom"
>
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
</div>
</template>
<script setup lang="ts">
defineProps<{
mapid: number
displayCoords: { x: number; y: number; z: number } | null
}>()
</script>

View File

@@ -0,0 +1,23 @@
/** Admin API composable. Uses useMapApi internally. */
export function useAdminApi() {
const api = useMapApi()
return {
adminUsers: api.adminUsers,
adminUserByName: api.adminUserByName,
adminUserPost: api.adminUserPost,
adminUserDelete: api.adminUserDelete,
adminSettings: api.adminSettings,
adminSettingsPost: api.adminSettingsPost,
adminMaps: api.adminMaps,
adminMapPost: api.adminMapPost,
adminMapToggleHidden: api.adminMapToggleHidden,
adminWipe: api.adminWipe,
adminRebuildZooms: api.adminRebuildZooms,
adminExportUrl: api.adminExportUrl,
adminMerge: api.adminMerge,
adminWipeTile: api.adminWipeTile,
adminSetCoords: api.adminSetCoords,
adminHideMarker: api.adminHideMarker,
}
}

View File

@@ -0,0 +1,15 @@
import type { MeResponse } from '~/types/api'
/** Auth composable: login, logout, me, OAuth, setup. Uses useMapApi internally. */
export function useAuth() {
const api = useMapApi()
return {
login: api.login,
logout: api.logout,
me: api.me,
oauthLoginUrl: api.oauthLoginUrl,
oauthProviders: api.oauthProviders,
setupRequired: api.setupRequired,
}
}

View File

@@ -1,16 +1,27 @@
import type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse } from '~/types/api'
import type {
Character,
ConfigResponse,
MapInfo,
MapInfoAdmin,
Marker,
MeResponse,
SettingsResponse,
} from '~/types/api'
export type { ConfigResponse, MapInfo, MapInfoAdmin, MeResponse, SettingsResponse }
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: (() => void)[] = []
const onApiErrorCallbacks = new Map<symbol, () => void>()
export function useMapApi() {
const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string
function onApiError(cb: () => void) {
onApiErrorCallbacks.push(cb)
/** 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> {
@@ -34,11 +45,11 @@ export function useMapApi() {
}
async function getCharacters() {
return request<unknown[]>('v1/characters')
return request<Character[]>('v1/characters')
}
async function getMarkers() {
return request<unknown[]>('v1/markers')
return request<Marker[]>('v1/markers')
}
async function getMaps() {

View File

@@ -0,0 +1,157 @@
import type L from 'leaflet'
import { HnHMinZoom, HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export interface MapLogicState {
showGridCoordinates: Ref<boolean>
hideMarkers: Ref<boolean>
panelCollapsed: Ref<boolean>
trackingCharacterId: Ref<number>
selectedMapId: Ref<number | null>
overlayMapId: Ref<number>
selectedMarkerId: Ref<number | null>
selectedPlayerId: Ref<number | null>
displayCoords: Ref<{ x: number; y: number; z: number } | null>
mapid: Ref<number>
}
export interface ContextMenuTileData {
coords: { x: number; y: number }
}
export interface ContextMenuMarkerData {
id: number
name: string
}
export interface ContextMenuState {
tile: {
show: boolean
x: number
y: number
data: ContextMenuTileData | null
}
marker: {
show: boolean
x: number
y: number
data: ContextMenuMarkerData | null
}
}
export interface CoordSetState {
from: { x: number; y: number }
to: { x: number; y: number }
}
/**
* Composable for map logic: zoom, display options, overlays, navigation, context menus.
* Map instance is passed to functions that need it (set by MapView after init).
*/
export function useMapLogic() {
const showGridCoordinates = ref(false)
const hideMarkers = ref(false)
const panelCollapsed = ref(false)
const trackingCharacterId = ref(-1)
const selectedMapId = ref<number | null>(null)
const overlayMapId = ref(-1)
const selectedMarkerId = ref<number | null>(null)
const selectedPlayerId = ref<number | null>(null)
const displayCoords = ref<{ x: number; y: number; z: number } | null>(null)
const mapid = ref(0)
const contextMenu = reactive<ContextMenuState>({
tile: { show: false, x: 0, y: 0, data: null },
marker: { show: false, x: 0, y: 0, data: null },
})
const coordSetFrom = ref({ x: 0, y: 0 })
const coordSet = ref({ x: 0, y: 0 })
const coordSetModalOpen = ref(false)
function zoomIn(map: L.Map | null) {
map?.zoomIn()
}
function zoomOutControl(map: L.Map | null) {
map?.zoomOut()
}
function resetView(map: L.Map | null) {
trackingCharacterId.value = -1
map?.setView([0, 0], HnHMinZoom, { animate: false })
}
function updateDisplayCoords(map: L.Map | null) {
if (!map) return
const point = map.project(map.getCenter(), 6)
displayCoords.value = {
x: Math.floor(point.x / TileSize),
y: Math.floor(point.y / TileSize),
z: map.getZoom(),
}
}
function toLatLng(map: L.Map | null, x: number, y: number): L.LatLng | null {
return map ? map.unproject([x, y], HnHMaxZoom) : null
}
function closeContextMenus() {
contextMenu.tile.show = false
contextMenu.marker.show = false
}
function openTileContextMenu(clientX: number, clientY: number, coords: { x: number; y: number }) {
contextMenu.tile.show = true
contextMenu.tile.x = clientX
contextMenu.tile.y = clientY
contextMenu.tile.data = { coords }
}
function openMarkerContextMenu(clientX: number, clientY: number, id: number, name: string) {
contextMenu.marker.show = true
contextMenu.marker.x = clientX
contextMenu.marker.y = clientY
contextMenu.marker.data = { id, name }
}
function openCoordSet(coords: { x: number; y: number }) {
coordSetFrom.value = { ...coords }
coordSet.value = { x: coords.x, y: coords.y }
coordSetModalOpen.value = true
}
function closeCoordSetModal() {
coordSetModalOpen.value = false
}
const state: MapLogicState = {
showGridCoordinates,
hideMarkers,
panelCollapsed,
trackingCharacterId,
selectedMapId,
overlayMapId,
selectedMarkerId,
selectedPlayerId,
displayCoords,
mapid,
}
return {
state,
contextMenu,
coordSetFrom,
coordSet,
zoomIn,
zoomOutControl,
resetView,
updateDisplayCoords,
toLatLng,
closeContextMenus,
openTileContextMenu,
openMarkerContextMenu,
openCoordSet,
closeCoordSetModal,
coordSetModalOpen,
}
}

View File

@@ -28,3 +28,20 @@ export interface MapInfo {
Name: string
size?: number
}
export interface Character {
name: string
id: number
map: number
position: { x: number; y: number }
type: string
}
export interface Marker {
name: string
id: number
map: number
position: { x: number; y: number }
image: string
hidden: boolean
}