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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
63
frontend-nuxt/components/map/MapContextMenu.vue
Normal file
63
frontend-nuxt/components/map/MapContextMenu.vue
Normal 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>
|
||||
167
frontend-nuxt/components/map/MapControls.vue
Normal file
167
frontend-nuxt/components/map/MapControls.vue
Normal 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>
|
||||
61
frontend-nuxt/components/map/MapCoordSetModal.vue
Normal file
61
frontend-nuxt/components/map/MapCoordSetModal.vue
Normal 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>
|
||||
17
frontend-nuxt/components/map/MapCoordsDisplay.vue
Normal file
17
frontend-nuxt/components/map/MapCoordsDisplay.vue
Normal 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>
|
||||
23
frontend-nuxt/composables/useAdminApi.ts
Normal file
23
frontend-nuxt/composables/useAdminApi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
15
frontend-nuxt/composables/useAuth.ts
Normal file
15
frontend-nuxt/composables/useAuth.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
157
frontend-nuxt/composables/useMapLogic.ts
Normal file
157
frontend-nuxt/composables/useMapLogic.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user