Add initial project structure with backend and frontend setup
- Created backend structure with Go, including main application logic and API endpoints. - Added Docker support for both development and production environments. - Introduced frontend using Nuxt 3 with Tailwind CSS for styling. - Included configuration files for Docker and environment variables. - Established basic documentation for contributing, development, and deployment processes. - Set up .gitignore and .dockerignore files to manage ignored files in the repository.
This commit is contained in:
15
frontend-nuxt/components/MapPageWrapper.vue
Normal file
15
frontend-nuxt/components/MapPageWrapper.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="absolute inset-0">
|
||||
<ClientOnly>
|
||||
<div class="absolute inset-0">
|
||||
<slot />
|
||||
</div>
|
||||
<template #fallback>
|
||||
<div class="h-screen flex items-center justify-center bg-base-200">Loading map…</div>
|
||||
</template>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
651
frontend-nuxt/components/MapView.vue
Normal file
651
frontend-nuxt/components/MapView.vue
Normal file
@@ -0,0 +1,651 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full" @click="contextMenu.tile.show = false; contextMenu.marker.show = false">
|
||||
<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"
|
||||
>
|
||||
<p class="text-center text-lg">Map list is empty.</p>
|
||||
<p class="text-center text-sm opacity-80">
|
||||
Make sure you are logged in and at least one map exists in Admin (uncheck «Hidden» if needed).
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/login" class="btn btn-sm">Login</NuxtLink>
|
||||
<NuxtLink to="/admin" class="btn btn-sm btn-primary">Admin</NuxtLink>
|
||||
</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 bg-base-100/90 px-2 py-1 font-mono text-xs shadow"
|
||||
aria-label="Current grid position and zoom"
|
||||
>
|
||||
{{ mapid }} · {{ displayCoords.x }}, {{ displayCoords.y }} · z{{ displayCoords.z }}
|
||||
</div>
|
||||
<!-- Control panel -->
|
||||
<div class="absolute left-3 top-[10%] z-[502] card card-compact bg-base-100 shadow-xl w-56">
|
||||
<div class="card-body p-3 gap-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral btn-sm btn-square"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
@click="zoomIn"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral btn-sm btn-square"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
@click="zoomOutControl"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
</div>
|
||||
<label class="label cursor-pointer justify-start gap-2 py-0">
|
||||
<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">
|
||||
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
||||
<span class="label-text">Hide markers</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-neutral btn-sm"
|
||||
title="Center map and minimum zoom"
|
||||
@click="zoomOut"
|
||||
>
|
||||
Reset view
|
||||
</button>
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<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">
|
||||
<option :value="null">Select player</option>
|
||||
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Context menu (tile) -->
|
||||
<div
|
||||
v-show="contextMenu.tile.show"
|
||||
class="fixed z-[1000] bg-base-100 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
|
||||
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
|
||||
>
|
||||
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="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" @click="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 shadow-lg rounded-lg border border-base-300 py-1 min-w-[180px]"
|
||||
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
|
||||
>
|
||||
<button type="button" class="btn btn-ghost btn-sm w-full justify-start" @click="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" @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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import { Marker } from '~/lib/Marker'
|
||||
import { Character } from '~/lib/Character'
|
||||
import { UniqueList } from '~/lib/UniqueList'
|
||||
import type L from 'leaflet'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
characterId?: number
|
||||
mapId?: number
|
||||
gridX?: number
|
||||
gridY?: number
|
||||
zoom?: number
|
||||
}>(),
|
||||
{ characterId: -1, mapId: undefined, gridX: 0, gridY: 0, zoom: 1 }
|
||||
)
|
||||
|
||||
const mapRef = ref<HTMLElement | null>(null)
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const api = useMapApi()
|
||||
|
||||
const showGridCoordinates = ref(false)
|
||||
const hideMarkers = 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
|
||||
let overlayLayer: InstanceType<typeof SmartTileLayer> | null = null
|
||||
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: InstanceType<typeof UniqueList> | null = null
|
||||
let characters: InstanceType<typeof UniqueList> | null = null
|
||||
let markersHidden = false
|
||||
let autoMode = false
|
||||
|
||||
function toLatLng(x: number, y: number) {
|
||||
return map!.unproject([x, y], HnHMaxZoom)
|
||||
}
|
||||
|
||||
function changeMap(id: number) {
|
||||
if (id === mapid) return
|
||||
mapid = id
|
||||
if (layer) {
|
||||
layer.map = mapid
|
||||
layer.redraw()
|
||||
}
|
||||
if (overlayLayer) {
|
||||
overlayLayer.map = -1
|
||||
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 }))
|
||||
}
|
||||
if (characters) {
|
||||
characters.getElements().forEach((it: any) => {
|
||||
it.remove({ map: map! })
|
||||
it.add({ map: map!, mapid })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
map?.zoomIn()
|
||||
}
|
||||
|
||||
function zoomOutControl() {
|
||||
map?.zoomOut()
|
||||
}
|
||||
|
||||
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) {
|
||||
if (id == null) return
|
||||
api.adminHideMarker({ id })
|
||||
const m = markers?.byId(id)
|
||||
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid })
|
||||
}
|
||||
|
||||
function closeContextMenus() {
|
||||
contextMenu.tile.show = false
|
||||
contextMenu.marker.show = false
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') closeContextMenus()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (import.meta.client) {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
if (!import.meta.client || !mapRef.value) return
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
const [charactersData, mapsData] = await Promise.all([
|
||||
api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []),
|
||||
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
|
||||
])
|
||||
|
||||
const mapsList: { ID: number; Name: string; size?: number }[] = []
|
||||
const raw = mapsData as Record<string, { ID?: number; Name?: string; id?: number; name?: string; size?: number }>
|
||||
for (const id in raw) {
|
||||
const m = raw[id]
|
||||
if (!m || typeof m !== 'object') continue
|
||||
const idVal = m.ID ?? m.id
|
||||
const nameVal = m.Name ?? m.name
|
||||
if (idVal == null || nameVal == null) continue
|
||||
mapsList.push({ ID: Number(idVal), Name: String(nameVal), size: m.size })
|
||||
}
|
||||
mapsList.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
|
||||
maps.value = mapsList
|
||||
mapsLoaded.value = true
|
||||
|
||||
const config = await api.getConfig().catch(() => ({}))
|
||||
if (config?.title) document.title = config.title
|
||||
if (config?.auths) auths.value = config.auths
|
||||
|
||||
map = L.map(mapRef.value, {
|
||||
minZoom: HnHMinZoom,
|
||||
maxZoom: HnHMaxZoom,
|
||||
crs: HnHCRS,
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
inertia: true,
|
||||
zoomAnimation: true,
|
||||
fadeAnimation: true,
|
||||
markerZoomAnimation: true,
|
||||
})
|
||||
|
||||
const initialMapId =
|
||||
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? mapsList[0].ID : 0
|
||||
mapid = initialMapId
|
||||
|
||||
const tileBase = (useRuntimeConfig().app.baseURL as string) ?? '/'
|
||||
const tileUrl = tileBase.endsWith('/') ? `${tileBase}grids/{map}/{z}/{x}_{y}.png?{cache}` : `${tileBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
|
||||
layer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 2,
|
||||
}) as any
|
||||
layer.map = initialMapId
|
||||
layer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
||||
layer.addTo(map)
|
||||
|
||||
overlayLayer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
opacity: 0.5,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 2,
|
||||
}) as any
|
||||
overlayLayer.map = -1
|
||||
overlayLayer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
overlayLayer.addTo(map)
|
||||
|
||||
coordLayer = new GridCoordLayer({
|
||||
tileSize: TileSize,
|
||||
minZoom: HnHMinZoom,
|
||||
maxZoom: HnHMaxZoom,
|
||||
opacity: 0,
|
||||
visible: false,
|
||||
})
|
||||
coordLayer.addTo(map)
|
||||
coordLayer.setZIndex(500)
|
||||
|
||||
markerLayer = L.layerGroup()
|
||||
markerLayer.addTo(map)
|
||||
markerLayer.setZIndex(600)
|
||||
|
||||
const baseURL = useRuntimeConfig().app.baseURL ?? '/'
|
||||
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
|
||||
L.Icon.Default.imagePath = markerIconPath
|
||||
|
||||
map.on('contextmenu', (mev: L.LeafletMouseEvent) => {
|
||||
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 }
|
||||
}
|
||||
})
|
||||
|
||||
// Use same origin as page so SSE connects to correct host/port (e.g. 3080 not 3088)
|
||||
const base = (useRuntimeConfig().app.baseURL as string) ?? '/'
|
||||
const updatesPath = base.endsWith('/') ? `${base}updates` : `${base}/updates`
|
||||
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
||||
source = new EventSource(updatesUrl)
|
||||
source.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const raw = event?.data
|
||||
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
|
||||
const updates = JSON.parse(raw)
|
||||
if (!Array.isArray(updates)) return
|
||||
for (const u of updates) {
|
||||
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
||||
layer.cache[key] = u.T
|
||||
if (overlayLayer) overlayLayer.cache[key] = u.T
|
||||
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
|
||||
if (overlayLayer && overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
||||
}
|
||||
// After initial batch (or any batch), redraw so tiles re-request with filled cache
|
||||
if (updates.length > 0 && layer) {
|
||||
layer.redraw()
|
||||
if (overlayLayer) overlayLayer.redraw()
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors (e.g. empty SSE message or non-JSON)
|
||||
}
|
||||
}
|
||||
source.onerror = () => {
|
||||
// Connection lost or 401; avoid uncaught errors
|
||||
}
|
||||
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(),
|
||||
}
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
markers = new UniqueList()
|
||||
characters = new UniqueList()
|
||||
|
||||
updateCharacters(charactersData as any[])
|
||||
|
||||
if (props.characterId !== undefined && props.characterId >= 0) {
|
||||
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
|
||||
map.setView(latLng, props.zoom)
|
||||
} else if (mapsList.length > 0) {
|
||||
changeMap(mapsList[0].ID)
|
||||
selectedMapId.value = mapsList[0].ID
|
||||
map.setView([0, 0], HnHDefaultZoom)
|
||||
}
|
||||
|
||||
// Recompute map size after layout (fixes grid/container height chain in Nuxt)
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (map) map.invalidateSize()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
api.getCharacters().then((body) => updateCharacters(Array.isArray(body) ? body : [])).catch(() => clearInterval(intervalId!))
|
||||
}, 2000)
|
||||
|
||||
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
|
||||
|
||||
function updateMarkers(markersData: any[]) {
|
||||
if (!markers || !map || !markerLayer) return
|
||||
const list = Array.isArray(markersData) ? markersData : []
|
||||
const ctx = { map, markerLayer, mapid, 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)
|
||||
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 }
|
||||
}
|
||||
})
|
||||
},
|
||||
(marker: InstanceType<typeof Marker>) => marker.remove(ctx),
|
||||
(marker: InstanceType<typeof Marker>, updated: any) => marker.update(ctx, updated)
|
||||
)
|
||||
questGivers.value = markers.getElements().filter((it: any) => it.type === 'quest')
|
||||
}
|
||||
|
||||
function updateCharacters(charactersData: any[]) {
|
||||
if (!characters || !map) return
|
||||
const list = Array.isArray(charactersData) ? charactersData : []
|
||||
const ctx = { map, mapid }
|
||||
characters.update(
|
||||
list.map((it) => new Character(it)),
|
||||
(character: InstanceType<typeof Character>) => {
|
||||
character.add(ctx)
|
||||
character.setClickCallback(() => (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)
|
||||
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
map!.setView(latlng, HnHMaxZoom)
|
||||
}
|
||||
character.update(ctx, updated)
|
||||
}
|
||||
)
|
||||
players.value = characters.getElements()
|
||||
}
|
||||
|
||||
watch(showGridCoordinates, (v) => {
|
||||
if (coordLayer) {
|
||||
coordLayer.options.visible = v
|
||||
coordLayer.setOpacity(v ? 1 : 0)
|
||||
if (v && map) {
|
||||
coordLayer.bringToFront?.()
|
||||
coordLayer.redraw?.()
|
||||
map.invalidateSize()
|
||||
} else {
|
||||
coordLayer.redraw?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(hideMarkers, (v) => {
|
||||
markersHidden = v
|
||||
if (!markers) return
|
||||
const ctx = { map: map!, markerLayer: markerLayer!, mapid, 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))
|
||||
}
|
||||
})
|
||||
|
||||
watch(trackingCharacterId, (value) => {
|
||||
if (value === -1) return
|
||||
const character = characters?.byId(value)
|
||||
if (character) {
|
||||
changeMap(character.map)
|
||||
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
map!.setView(latlng, HnHMaxZoom)
|
||||
router.push(`/character/${value}`)
|
||||
autoMode = true
|
||||
} else {
|
||||
map!.setView([0, 0], HnHMinZoom)
|
||||
trackingCharacterId.value = -1
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedMapId, (value) => {
|
||||
if (value == null) return
|
||||
changeMap(value)
|
||||
const zoom = map!.getZoom()
|
||||
map!.setView([0, 0], zoom)
|
||||
})
|
||||
|
||||
watch(overlayMapId, (value) => {
|
||||
if (overlayLayer) overlayLayer.map = value ?? -1
|
||||
overlayLayer?.redraw()
|
||||
if (!markers) return
|
||||
const ctx = { map: map!, markerLayer: markerLayer!, mapid, 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))
|
||||
})
|
||||
|
||||
watch(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
|
||||
})
|
||||
|
||||
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('zoomend', () => {
|
||||
if (map) map.invalidateSize()
|
||||
})
|
||||
map.on('drag', () => {
|
||||
trackingCharacterId.value = -1
|
||||
})
|
||||
map.on('zoom', () => {
|
||||
if (autoMode) {
|
||||
autoMode = false
|
||||
} else {
|
||||
trackingCharacterId.value = -1
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (import.meta.client) {
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
}
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
if (source) source.close()
|
||||
if (map) map.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map {
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
:deep(.leaflet-container .leaflet-tile-pane img.leaflet-tile) {
|
||||
mix-blend-mode: normal;
|
||||
visibility: visible !important;
|
||||
}
|
||||
:deep(.map-tile) {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
min-width: 100px;
|
||||
min-height: 100px;
|
||||
position: relative;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.3);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 0 2px #000, 0 1px 2px #000;
|
||||
}
|
||||
:deep(.map-tile-text) {
|
||||
position: absolute;
|
||||
left: 2px;
|
||||
top: 2px;
|
||||
}
|
||||
</style>
|
||||
48
frontend-nuxt/components/PasswordInput.vue
Normal file
48
frontend-nuxt/components/PasswordInput.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
</label>
|
||||
<div class="relative flex">
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
:type="showPass ? 'text' : 'password'"
|
||||
class="input input-bordered flex-1 pr-10"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:autocomplete="autocomplete"
|
||||
:readonly="readonly"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-0 h-8 w-8"
|
||||
:aria-label="showPass ? 'Hide password' : 'Show password'"
|
||||
@click="showPass = !showPass"
|
||||
>
|
||||
{{ showPass ? '🙈' : '👁' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
autocomplete?: string
|
||||
readonly?: boolean
|
||||
inputId?: string
|
||||
}>(),
|
||||
{ required: false, autocomplete: 'off', inputId: undefined }
|
||||
)
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const showPass = ref(false)
|
||||
const inputId = computed(() => props.inputId ?? `password-${Math.random().toString(36).slice(2, 9)}`)
|
||||
</script>
|
||||
Reference in New Issue
Block a user