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:
2026-02-24 22:27:05 +03:00
commit 605a31567e
97 changed files with 18350 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
You have extensive expertise in Vue 3, Nuxt 3, TypeScript, Node.js, Vite, Vue Router, Pinia, VueUse, Nuxt UI, and Tailwind CSS. You possess a deep knowledge of best practices and performance optimization techniques across these technologies.
Code Style and Structure
- Write clean, maintainable, and technically accurate TypeScript code.
- Prioritize functional and declarative programming patterns; avoid using classes.
- Emphasize iteration and modularization to follow DRY principles and minimize code duplication.
- Prefer Composition API <script setup> style.
- Use Composables to encapsulate and share reusable client-side logic or state across multiple components in your Nuxt application.
Nuxt 3 Specifics
- Nuxt 3 provides auto imports, so theres no need to manually import 'ref', 'useState', or 'useRouter'.
- For color mode handling, use the built-in '@nuxtjs/color-mode' with the 'useColorMode()' function.
- Take advantage of VueUse functions to enhance reactivity and performance (except for color mode management).
- Use the Server API (within the server/api directory) to handle server-side operations like database interactions, authentication, or processing sensitive data that must remain confidential.
- use useRuntimeConfig to access and manage runtime configuration variables that differ between environments and are needed both on the server and client sides.
- For SEO use useHead and useSeoMeta.
- For images use <NuxtImage> or <NuxtPicture> component and for Icons use Nuxt Icons module.
- use app.config.ts for app theme configuration.
Fetching Data
1. Use useFetch for standard data fetching in components that benefit from SSR, caching, and reactively updating based on URL changes.
2. Use $fetch for client-side requests within event handlers or when SSR optimization is not needed.
3. Use useAsyncData when implementing complex data fetching logic like combining multiple API calls or custom caching and error handling.
4. Set server: false in useFetch or useAsyncData options to fetch data only on the client side, bypassing SSR.
5. Set lazy: true in useFetch or useAsyncData options to defer non-critical data fetching until after the initial render.
Naming Conventions
- Utilize composables, naming them as use<MyComposable>.
- Use **PascalCase** for component file names (e.g., components/MyComponent.vue).
- Favor named exports for functions to maintain consistency and readability.
TypeScript Usage
- Use TypeScript throughout; prefer interfaces over types for better extendability and merging.
- Avoid enums, opting for maps for improved type safety and flexibility.
- Use functional components with TypeScript interfaces.
UI and Styling
- Use Nuxt UI and Tailwind CSS for components and styling.
- Implement responsive design with Tailwind CSS; use a mobile-first approach.

1
frontend-nuxt/.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

16
frontend-nuxt/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
# Use Node 20+ for build (required by Tailwind/PostCSS toolchain)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run generate
# Output: .output/public is the static site root (for Go http.Dir("frontend"))
FROM alpine:3.19
RUN apk add --no-cache bash
COPY --from=builder /app/.output/public /frontend
# Optional: when integrating with main Dockerfile, copy /frontend into the image

66
frontend-nuxt/README.md Normal file
View File

@@ -0,0 +1,66 @@
# HnH Map Nuxt 3 frontend
Nuxt 3 + Tailwind + DaisyUI frontend for the HnH map. Served by the Go backend under `/map/`.
In dev mode the app is available at the path with baseURL **`/map/`** (e.g. `http://localhost:3000/map/`). The Go backend must be reachable (directly or via the dev proxy in `nuxt.config.ts`).
## Project structure
- **pages/** — route pages (e.g. map view, profile, login)
- **components/** — Vue components
- **composables/** — shared composition functions
- **layouts/** — layout components
- **server/** — Nitro server (if used)
- **plugins/** — Nuxt plugins
- **public/gfx/** — static assets (sprites, terrain, etc.)
## Requirements
- **Node.js 20+** (required for build; `engines` in package.json). Use `nvm use` if you have `.nvmrc`, or build via Docker (see below).
- npm
## Setup
```bash
npm install
```
## Development
```bash
npm run dev
```
Then open the app at the path shown (e.g. `http://localhost:3000/map/`). Ensure the Go backend is running and proxying or serving this app if needed.
## Build
```bash
npm run build
```
Static export (for Go `http.Dir`):
```bash
npm run generate
```
Output is in `.output/public`. To serve from the existing `frontend` directory, copy contents to `../frontend` after generate, or set `nitro.output.dir` in `nuxt.config.ts` and build from the repo root.
## Build with Docker (Node 20)
Build requires Node 20+. If your host has an older version (e.g. Node 18), build the frontend in Docker:
```bash
docker build -t frontend-nuxt .
docker create --name fn frontend-nuxt
docker cp fn:/frontend ./output-public
docker rm fn
# Copy output-public/* into repo frontend/ and run Go server
```
## Cutover from Vue 2 frontend
1. Build this app (`npm run generate`).
2. Copy `.output/public/*` into the repos `frontend` directory (or point Go at the Nuxt output directory).
3. Restart the Go server. The same `/map/` routes and API remain.

14
frontend-nuxt/app.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
</script>

View File

@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#__nuxt {
height: 100%;
}

View File

@@ -0,0 +1,10 @@
/* Map container background from theme (DaisyUI base-200) */
.leaflet-container {
background: hsl(var(--b2));
}
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
(e.g. due to cache, Nuxt hydration, or load event order). */
.leaflet-tile {
visibility: visible !important;
}

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,52 @@
/**
* Single source of truth for app base path. Use instead of hardcoded `/map/`.
* All path checks and redirects should use this composable.
*/
export function useAppPaths() {
const config = useRuntimeConfig()
const baseURL = (config.app?.baseURL as string) ?? '/'
const base = baseURL.endsWith('/') ? baseURL.slice(0, -1) : baseURL
/** Path without base prefix, for comparison with route names like 'login', 'setup'. */
function pathWithoutBase(fullPath: string): string {
if (!base || base === '/') return fullPath
const normalized = fullPath.replace(new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/?`), '') || '/'
return normalized.startsWith('/') ? normalized : `/${normalized}`
}
/** Full URL for redirects (e.g. window.location.href). */
function fullUrl(relativePath: string): string {
if (import.meta.server) return base + (relativePath.startsWith('/') ? relativePath : `/${relativePath}`)
const path = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath
const withBase = base ? `${base}/${path}` : `/${path}`
return `${window.location.origin}${withBase}`
}
/** Path with base for internal navigation (e.g. navigateTo). Nuxt router uses paths without origin. */
function resolvePath(relativePath: string): string {
const path = relativePath.startsWith('/') ? relativePath : `/${relativePath}`
return base ? `${base}${path}` : path
}
/** Whether the path is login page (path without base is /login or ends with /login). */
function isLoginPath(fullPath: string): boolean {
const p = pathWithoutBase(fullPath)
return p === '/login' || p.endsWith('/login')
}
/** Whether the path is setup page. */
function isSetupPath(fullPath: string): boolean {
const p = pathWithoutBase(fullPath)
return p === '/setup' || p.endsWith('/setup')
}
return {
baseURL,
base,
pathWithoutBase,
fullUrl,
resolvePath,
isLoginPath,
isSetupPath,
}
}

View File

@@ -0,0 +1,222 @@
export interface MeResponse {
username: string
auths: string[]
tokens?: string[]
prefix?: string
}
export interface MapInfoAdmin {
ID: number
Name: string
Hidden: boolean
Priority: boolean
}
export interface SettingsResponse {
prefix: string
defaultHide: boolean
title: string
}
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
const onApiErrorCallbacks: (() => void)[] = []
export function useMapApi() {
const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string
function onApiError(cb: () => void) {
onApiErrorCallbacks.push(cb)
}
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}`
const res = await fetch(url, { credentials: 'include', ...opts })
// Only redirect to login on 401 (session invalid); 403 = forbidden (no permission)
if (res.status === 401) {
onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized')
}
if (res.status === 403) throw new Error('Forbidden')
if (!res.ok) throw new Error(`API ${res.status}`)
if (res.headers.get('content-type')?.includes('application/json')) {
return res.json() as Promise<T>
}
return undefined as T
}
async function getConfig() {
return request<{ title?: string; auths?: string[] }>('config')
}
async function getCharacters() {
return request<unknown[]>('v1/characters')
}
async function getMarkers() {
return request<unknown[]>('v1/markers')
}
async function getMaps() {
return request<Record<string, { ID: number; Name: string; size?: number }>>('maps')
}
// Auth
async function login(user: string, pass: string) {
const res = await fetch(`${apiBase}/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, pass }),
})
if (res.status === 401) throw new Error('Unauthorized')
if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<MeResponse>
}
async function logout() {
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
}
async function me() {
return request<MeResponse>('me')
}
/** Public: whether first-time setup (no users) is required. */
async function setupRequired(): Promise<{ setupRequired: boolean }> {
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<{ setupRequired: boolean }>
}
// Profile
async function meTokens() {
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
return data!.tokens
}
async function mePassword(pass: string) {
await request('me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pass }),
})
}
// Admin
async function adminUsers() {
return request<string[]>('admin/users')
}
async function adminUserByName(name: string) {
return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`)
}
async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) {
await request('admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
async function adminUserDelete(name: string) {
await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' })
}
async function adminSettings() {
return request<SettingsResponse>('admin/settings')
}
async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) {
await request('admin/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
async function adminMaps() {
return request<MapInfoAdmin[]>('admin/maps')
}
async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) {
await request(`admin/maps/${id}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
async function adminMapToggleHidden(id: number) {
return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' })
}
async function adminWipe() {
await request('admin/wipe', { method: 'POST' })
}
async function adminRebuildZooms() {
await request('admin/rebuildZooms', { method: 'POST' })
}
function adminExportUrl() {
return `${apiBase}/admin/export`
}
async function adminMerge(formData: FormData) {
const res = await fetch(`${apiBase}/admin/merge`, {
method: 'POST',
credentials: 'include',
body: formData,
})
if (res.status === 401 || res.status === 403) {
onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized')
}
if (!res.ok) throw new Error(`API ${res.status}`)
}
async function adminWipeTile(params: { map: number; x: number; y: number }) {
return request(`admin/wipeTile?${new URLSearchParams(params as any)}`)
}
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
return request(`admin/setCoords?${new URLSearchParams(params as any)}`)
}
async function adminHideMarker(params: { id: number }) {
return request(`admin/hideMarker?${new URLSearchParams(params as any)}`)
}
return {
apiBase,
onApiError,
getConfig,
getCharacters,
getMarkers,
getMaps,
login,
logout,
me,
setupRequired,
meTokens,
mePassword,
adminUsers,
adminUserByName,
adminUserPost,
adminUserDelete,
adminSettings,
adminSettingsPost,
adminMaps,
adminMapPost,
adminMapToggleHidden,
adminWipe,
adminRebuildZooms,
adminExportUrl,
adminMerge,
adminWipeTile,
adminSetCoords,
adminHideMarker,
}
}

View File

@@ -0,0 +1,103 @@
<template>
<div class="h-screen flex flex-col bg-base-100 overflow-hidden">
<header class="navbar bg-base-200/80 backdrop-blur px-4 gap-2 shrink-0">
<NuxtLink to="/" class="text-lg font-semibold hover:opacity-80">{{ title }}</NuxtLink>
<div class="flex-1" />
<NuxtLink v-if="!isLogin" to="/" class="btn btn-ghost btn-sm">Map</NuxtLink>
<NuxtLink v-if="!isLogin" to="/profile" class="btn btn-ghost btn-sm">Profile</NuxtLink>
<NuxtLink v-if="!isLogin && isAdmin" to="/admin" class="btn btn-ghost btn-sm">Admin</NuxtLink>
<button
v-if="!isLogin && me"
type="button"
class="btn btn-ghost btn-sm btn-outline"
@click="doLogout"
>
Logout
</button>
<label class="swap swap-rotate btn btn-ghost btn-sm">
<input type="checkbox" v-model="dark" @change="toggleTheme" />
<span class="swap-off"></span>
<span class="swap-on">🌙</span>
</label>
<span v-if="live" class="badge badge-success badge-sm">Live</span>
</header>
<main class="flex-1 min-h-0 relative">
<slot />
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const THEME_KEY = 'hnh-map-theme'
function getInitialDark(): boolean {
if (import.meta.client) {
const stored = localStorage.getItem(THEME_KEY)
if (stored === 'dark') return true
if (stored === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches
}
return false
}
const title = ref('HnH Map')
const dark = ref(false)
const live = ref(false)
const me = ref<{ username?: string; auths?: string[] } | null>(null)
const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path))
const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
async function loadMe() {
if (isLogin.value) return
try {
me.value = await useMapApi().me()
} catch {
me.value = null
}
}
async function loadConfig() {
if (isLogin.value) return
try {
const config = await useMapApi().getConfig()
if (config?.title) title.value = config.title
} catch (_) {}
}
onMounted(() => {
dark.value = getInitialDark()
const html = document.documentElement
html.setAttribute('data-theme', dark.value ? 'dark' : 'light')
})
watch(
() => route.path,
(path) => {
if (!isLoginPath(path)) loadMe().then(loadConfig)
},
{ immediate: true }
)
function toggleTheme() {
const html = document.documentElement
if (dark.value) {
html.setAttribute('data-theme', 'dark')
localStorage.setItem(THEME_KEY, 'dark')
} else {
html.setAttribute('data-theme', 'light')
localStorage.setItem(THEME_KEY, 'light')
}
}
async function doLogout() {
await useMapApi().logout()
await router.push('/login')
me.value = null
}
defineExpose({ setLive: (v: boolean) => { live.value = v } })
</script>

View File

@@ -0,0 +1,61 @@
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
import * as L from 'leaflet'
export class Character {
constructor(characterData) {
this.name = characterData.name
this.position = characterData.position
this.type = characterData.type
this.id = characterData.id
this.map = characterData.map
this.marker = null
this.text = this.name
this.value = this.id
this.onClick = null
}
getId() {
return `${this.name}`
}
remove(mapview) {
if (this.marker) {
const layer = mapview.markerLayer ?? mapview.map
layer.removeLayer(this.marker)
this.marker = null
}
}
add(mapview) {
if (this.map === mapview.mapid) {
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker = L.marker(position, { title: this.name })
this.marker.on('click', this.callCallback.bind(this))
const targetLayer = mapview.markerLayer ?? mapview.map
this.marker.addTo(targetLayer)
}
}
update(mapview, updated) {
if (this.map !== updated.map) {
this.remove(mapview)
}
this.map = updated.map
this.position = updated.position
if (!this.marker && this.map === mapview.mapid) {
this.add(mapview)
}
if (this.marker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
setClickCallback(callback) {
this.onClick = callback
}
callCallback(e) {
if (this.onClick != null) this.onClick(e)
}
}

View File

@@ -0,0 +1,88 @@
import L, { Bounds, LatLng, Point } from 'leaflet'
export const TileSize = 100
export const HnHMaxZoom = 6
export const HnHMinZoom = 1
export const HnHDefaultZoom = 6
/** When scaleFactor exceeds this, render one label per tile instead of a full grid (avoids 100k+ DOM nodes at zoom 1). */
const GRID_COORD_SCALE_FACTOR_THRESHOLD = 8
export const GridCoordLayer = L.GridLayer.extend({
options: {
visible: true,
},
createTile(coords) {
if (!this.options.visible) {
const element = document.createElement('div')
element.style.width = TileSize + 'px'
element.style.height = TileSize + 'px'
element.classList.add('map-tile')
return element
}
const element = document.createElement('div')
element.style.width = TileSize + 'px'
element.style.height = TileSize + 'px'
element.style.position = 'relative'
element.classList.add('map-tile')
const scaleFactor = Math.pow(2, HnHMaxZoom - coords.z)
const topLeft = { x: coords.x * scaleFactor, y: coords.y * scaleFactor }
const bottomRight = { x: topLeft.x + scaleFactor - 1, y: topLeft.y + scaleFactor - 1 }
if (scaleFactor > GRID_COORD_SCALE_FACTOR_THRESHOLD) {
// Low zoom: one label per tile to avoid hundreds of thousands of DOM nodes (Reset view freeze fix)
const textElement = document.createElement('div')
textElement.classList.add('map-tile-text')
textElement.textContent = `(${topLeft.x}, ${topLeft.y})`
textElement.style.position = 'absolute'
textElement.style.left = '2px'
textElement.style.top = '2px'
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
element.appendChild(textElement)
return element
}
for (let gx = topLeft.x; gx <= bottomRight.x; gx++) {
for (let gy = topLeft.y; gy <= bottomRight.y; gy++) {
const leftPx = ((gx - topLeft.x) / scaleFactor) * TileSize
const topPx = ((gy - topLeft.y) / scaleFactor) * TileSize
const textElement = document.createElement('div')
textElement.classList.add('map-tile-text')
textElement.textContent = `(${gx}, ${gy})`
textElement.style.position = 'absolute'
textElement.style.left = leftPx + 2 + 'px'
textElement.style.top = topPx + 2 + 'px'
if (scaleFactor > 1) {
textElement.style.fontSize = Math.max(8, 12 - Math.log2(scaleFactor) * 2) + 'px'
}
element.appendChild(textElement)
}
}
return element
},
})
export const ImageIcon = L.Icon.extend({
options: {
iconSize: [32, 32],
iconAnchor: [16, 16],
},
})
const latNormalization = (90.0 * TileSize) / 2500000.0
const lngNormalization = (180.0 * TileSize) / 2500000.0
const HnHProjection = {
project(latlng) {
return new Point(latlng.lat / latNormalization, latlng.lng / lngNormalization)
},
unproject(point) {
return new LatLng(point.x * latNormalization, point.y * lngNormalization)
},
bounds: (() => new Bounds([-latNormalization, -lngNormalization], [latNormalization, lngNormalization]))(),
}
export const HnHCRS = L.extend({}, L.CRS.Simple, {
projection: HnHProjection,
})

View File

@@ -0,0 +1,89 @@
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes'
import * as L from 'leaflet'
function detectType(name) {
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
if (name === 'custom') return 'custom'
return name.substring('gfx/terobjs/mm/'.length)
}
export class Marker {
constructor(markerData) {
this.id = markerData.id
this.position = markerData.position
this.name = markerData.name
this.image = markerData.image
this.type = detectType(this.image)
this.marker = null
this.text = this.name
this.value = this.id
this.hidden = markerData.hidden
this.map = markerData.map
this.onClick = null
this.onContext = null
}
remove(mapview) {
if (this.marker) {
this.marker.remove()
this.marker = null
}
}
add(mapview) {
if (!this.hidden) {
let icon
if (this.image === 'gfx/terobjs/mm/custom') {
icon = new ImageIcon({
iconUrl: 'gfx/terobjs/mm/custom.png',
iconSize: [21, 23],
iconAnchor: [11, 21],
popupAnchor: [1, 3],
tooltipAnchor: [1, 3],
})
} else {
icon = new ImageIcon({ iconUrl: `${this.image}.png`, iconSize: [32, 32] })
}
const position = mapview.map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker = L.marker(position, { icon, title: this.name })
this.marker.addTo(mapview.markerLayer)
this.marker.on('click', this.callClickCallback.bind(this))
this.marker.on('contextmenu', this.callContextCallback.bind(this))
}
}
update(mapview, updated) {
this.position = updated.position
this.name = updated.name
this.hidden = updated.hidden
this.map = updated.map
if (this.marker) {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
jumpTo(map) {
if (this.marker) {
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
this.marker.setLatLng(position)
}
}
setClickCallback(callback) {
this.onClick = callback
}
callClickCallback(e) {
if (this.onClick != null) this.onClick(e)
}
setContextMenu(callback) {
this.onContext = callback
}
callContextCallback(e) {
if (this.onContext != null) this.onContext(e)
}
}

View File

@@ -0,0 +1,73 @@
import L, { Util, Browser } from 'leaflet'
export const SmartTileLayer = L.TileLayer.extend({
cache: {},
invalidTile: '',
map: 0,
getTileUrl(coords) {
if (!this._map) return this.invalidTile
let zoom
try {
zoom = this._getZoomForUrl()
} catch {
return this.invalidTile
}
return this.getTrueTileUrl(coords, zoom)
},
getTrueTileUrl(coords, zoom) {
const data = {
r: Browser.retina ? '@2x' : '',
s: this._getSubdomain(coords),
x: coords.x,
y: coords.y,
map: this.map,
z: zoom,
}
if (this._map && !this._map.options.crs.infinite) {
const invertedY = this._globalTileRange.max.y - coords.y
if (this.options.tms) {
data.y = invertedY
}
data['-y'] = invertedY
}
const cacheKey = `${data.map}:${data.x}:${data.y}:${data.z}`
data.cache = this.cache[cacheKey]
// Don't request tiles for invalid/unknown map (avoids 404 spam in console)
if (data.map === undefined || data.map === null || data.map < 1) {
return this.invalidTile
}
// Only use placeholder when server explicitly marks tile as invalid (-1)
if (data.cache === -1) {
return this.invalidTile
}
// Allow tile request when map is valid even if SSE snapshot hasn't arrived yet
// (avoids empty map when proxy/SSE delays or drops first message)
if (data.cache === undefined || data.cache === null) {
data.cache = 0
}
return Util.template(this._url, Util.extend(data, this.options))
},
refresh(x, y, z) {
let zoom = z
const maxZoom = this.options.maxZoom
const zoomReverse = this.options.zoomReverse
const zoomOffset = this.options.zoomOffset
if (zoomReverse) {
zoom = maxZoom - zoom
}
zoom += zoomOffset
const key = `${x}:${y}:${zoom}`
const tile = this._tiles[key]
if (tile) {
tile.el.src = this.getTrueTileUrl({ x, y }, z)
}
},
})

View File

@@ -0,0 +1,39 @@
/**
* Elements should have unique field "id"
*/
export class UniqueList {
constructor() {
this.elements = {}
}
update(dataList, addCallback, removeCallback, updateCallback) {
const elementsToAdd = dataList.filter((it) => this.elements[it.id] === undefined)
const elementsToRemove = Object.keys(this.elements)
.filter((it) => dataList.find((up) => String(up.id) === it) === undefined)
.map((id) => this.elements[id])
if (removeCallback) {
elementsToRemove.forEach((it) => removeCallback(it))
}
if (updateCallback) {
dataList.forEach((newElement) => {
const oldElement = this.elements[newElement.id]
if (oldElement) {
updateCallback(oldElement, newElement)
}
})
}
if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it))
}
elementsToRemove.forEach((it) => delete this.elements[it.id])
elementsToAdd.forEach((it) => (this.elements[it.id] = it))
}
getElements() {
return Object.values(this.elements)
}
byId(id) {
return this.elements[id]
}
}

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
const api = useMapApi()
try {
const me = await api.me()
if (!me.auths?.includes('admin')) {
return navigateTo('/profile')
}
} catch {
return navigateTo('/login')
}
})

View File

@@ -0,0 +1,14 @@
export default defineNuxtRouteMiddleware(async (to) => {
const { isLoginPath, isSetupPath } = useAppPaths()
if (isLoginPath(to.path)) return
if (isSetupPath(to.path)) return
const api = useMapApi()
try {
await api.me()
} catch {
const { setupRequired } = await api.setupRequired()
if (setupRequired) return navigateTo('/setup')
return navigateTo('/login')
}
})

View File

@@ -0,0 +1,48 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
import { viteUriGuard } from './plugins/vite-uri-guard'
export default defineNuxtConfig({
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
app: {
baseURL: '/map/',
head: {
title: 'HnH Map',
meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }],
},
},
ssr: false,
runtimeConfig: {
public: {
apiBase: '/map/api',
},
},
modules: ['@nuxtjs/tailwindcss'],
tailwindcss: {
cssPath: '~/assets/css/app.css',
},
css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'],
vite: {
plugins: [viteUriGuard()],
optimizeDeps: {
include: ['leaflet'],
},
},
// Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml)
nitro: {
devProxy: {
'/map/api': { target: 'http://backend:3080', changeOrigin: true },
'/map/updates': { target: 'http://backend:3080', changeOrigin: true },
'/map/grids': { target: 'http://backend:3080', changeOrigin: true },
},
},
// For cutover: set nitro.preset to 'static' and optionally copy .output/public to ../frontend
// nitro: { output: { dir: '../frontend' } },
})

10438
frontend-nuxt/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "hnh-map-frontend",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"leaflet": "^1.9.4",
"nuxt": "^3.14.1593",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "^6.12.2",
"daisyui": "^3.9.4",
"tailwindcss": "^3.4.17",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,278 @@
<template>
<div class="container mx-auto p-4 max-w-2xl">
<h1 class="text-2xl font-bold mb-6">Admin</h1>
<div
v-if="message.text"
class="mb-4 rounded-lg px-4 py-2"
:class="message.type === 'error' ? 'bg-error/20 text-error' : 'bg-success/20 text-success'"
role="alert"
>
{{ message.text }}
</div>
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Users</h2>
<ul class="space-y-2">
<li
v-for="u in users"
:key="u"
class="flex justify-between items-center gap-3 py-1 border-b border-base-300 last:border-0"
>
<span>{{ u }}</span>
<NuxtLink :to="`/admin/users/${u}`" class="btn btn-ghost btn-xs">Edit</NuxtLink>
</li>
<li v-if="!users.length" class="py-1 text-base-content/60">
No users yet.
</li>
</ul>
<NuxtLink to="/admin/users/new" class="btn btn-primary btn-sm mt-2">Add user</NuxtLink>
</div>
</div>
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Maps</h2>
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th></th></tr>
</thead>
<tbody>
<tr v-for="map in maps" :key="map.ID">
<td>{{ map.ID }}</td>
<td>{{ map.Name }}</td>
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
<td>{{ map.Priority ? 'Yes' : 'No' }}</td>
<td>
<NuxtLink :to="`/admin/maps/${map.ID}`" class="btn btn-ghost btn-xs">Edit</NuxtLink>
</td>
</tr>
<tr v-if="!maps.length">
<td colspan="5" class="text-base-content/60">No maps.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Settings</h2>
<div class="flex flex-col gap-4">
<div class="form-control w-full max-w-xs">
<label class="label" for="admin-settings-prefix">Prefix</label>
<input
id="admin-settings-prefix"
v-model="settings.prefix"
type="text"
class="input input-bordered input-sm w-full"
/>
</div>
<div class="form-control w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label>
<input
id="admin-settings-title"
v-model="settings.title"
type="text"
class="input input-bordered input-sm w-full"
/>
</div>
<div class="form-control">
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
<input
id="admin-settings-default-hide"
v-model="settings.defaultHide"
type="checkbox"
class="checkbox checkbox-sm"
/>
Default hide new maps
</label>
</div>
</div>
<div class="flex justify-end mt-2">
<button class="btn btn-sm" :disabled="savingSettings" @click="saveSettings">
{{ savingSettings ? '' : 'Save settings' }}
</button>
</div>
</div>
</div>
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Actions</h2>
<div class="flex flex-col gap-3">
<div>
<a :href="api.adminExportUrl()" target="_blank" rel="noopener" class="btn btn-sm">
Export zip
</a>
</div>
<div>
<button class="btn btn-sm" :disabled="rebuilding" @click="rebuildZooms">
{{ rebuilding ? '' : 'Rebuild zooms' }}
</button>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" />
<button type="button" class="btn btn-sm" @click="mergeFileRef?.click()">
Choose merge file
</button>
<span class="text-sm text-base-content/70">
{{ mergeFile ? mergeFile.name : 'No file chosen' }}
</span>
</div>
<form @submit.prevent="doMerge">
<button type="submit" class="btn btn-sm btn-primary" :disabled="!mergeFile || merging">
{{ merging ? '…' : 'Merge' }}
</button>
</form>
</div>
<div class="border-t border-base-300 pt-4 mt-1">
<p class="text-sm font-medium text-error/90 mb-2">Danger zone</p>
<button class="btn btn-sm btn-error" :disabled="wiping" @click="confirmWipe">
{{ wiping ? '' : 'Wipe all data' }}
</button>
</div>
</div>
</div>
</div>
<dialog ref="wipeModalRef" class="modal" aria-labelledby="wipe-modal-title">
<div class="modal-box">
<h2 id="wipe-modal-title" class="font-bold text-lg mb-2">Confirm wipe</h2>
<p>Wipe all grids, markers, tiles and maps? This cannot be undone.</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-error" :disabled="wiping" @click="doWipe">Wipe</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const api = useMapApi()
const users = ref<string[]>([])
const maps = ref<Array<{ ID: number; Name: string; Hidden: boolean; Priority: boolean }>>([])
const settings = ref({ prefix: '', defaultHide: false, title: '' })
const savingSettings = ref(false)
const rebuilding = ref(false)
const wiping = ref(false)
const merging = ref(false)
const mergeFile = ref<File | null>(null)
const mergeFileRef = ref<HTMLInputElement | null>(null)
const wipeModalRef = ref<HTMLDialogElement | null>(null)
const message = ref<{ type: 'success' | 'error'; text: string }>({ type: 'success', text: '' })
let messageTimeout: ReturnType<typeof setTimeout> | null = null
function setMessage(type: 'success' | 'error', text: string) {
message.value = { type, text }
if (messageTimeout) clearTimeout(messageTimeout)
messageTimeout = setTimeout(() => {
message.value = { type: 'success', text: '' }
messageTimeout = null
}, 4000)
}
onMounted(async () => {
await Promise.all([loadUsers(), loadMaps(), loadSettings()])
})
async function loadUsers() {
try {
users.value = await api.adminUsers()
} catch {
users.value = []
}
}
async function loadMaps() {
try {
maps.value = await api.adminMaps()
} catch {
maps.value = []
}
}
async function loadSettings() {
try {
const s = await api.adminSettings()
settings.value = { prefix: s.prefix ?? '', defaultHide: s.defaultHide ?? false, title: s.title ?? '' }
} catch {
//
}
}
async function saveSettings() {
savingSettings.value = true
try {
await api.adminSettingsPost(settings.value)
setMessage('success', 'Settings saved.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Failed to save settings.')
} finally {
savingSettings.value = false
}
}
async function rebuildZooms() {
rebuilding.value = true
try {
await api.adminRebuildZooms()
setMessage('success', 'Zooms rebuilt.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Failed to rebuild zooms.')
} finally {
rebuilding.value = false
}
}
function confirmWipe() {
wipeModalRef.value?.showModal()
}
async function doWipe() {
wiping.value = true
try {
await api.adminWipe()
wipeModalRef.value?.close()
await loadMaps()
setMessage('success', 'All data wiped.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Failed to wipe.')
} finally {
wiping.value = false
}
}
function onMergeFile(e: Event) {
const input = e.target as HTMLInputElement
mergeFile.value = input.files?.[0] ?? null
}
async function doMerge() {
if (!mergeFile.value) return
merging.value = true
try {
const fd = new FormData()
fd.append('merge', mergeFile.value)
await api.adminMerge(fd)
mergeFile.value = null
if (mergeFileRef.value) mergeFileRef.value.value = ''
await loadMaps()
setMessage('success', 'Merge completed.')
} catch (e) {
setMessage('error', (e as Error)?.message ?? 'Merge failed.')
} finally {
merging.value = false
}
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<div class="container mx-auto p-4 max-w-lg">
<h1 class="text-2xl font-bold mb-6">Edit map {{ id }}</h1>
<form v-if="map" @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control">
<label class="label" for="name">Name</label>
<input id="name" v-model="form.name" type="text" class="input input-bordered" required />
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2">
<input v-model="form.hidden" type="checkbox" class="checkbox" />
<span class="label-text">Hidden</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer gap-2">
<input v-model="form.priority" type="checkbox" class="checkbox" />
<span class="label-text">Priority</span>
</label>
</div>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading">{{ loading ? '…' : 'Save' }}</button>
<NuxtLink to="/admin" class="btn btn-ghost">Back</NuxtLink>
</div>
</form>
<template v-else-if="mapsLoaded">
<p class="text-base-content/70">Map not found.</p>
<NuxtLink to="/admin" class="btn btn-ghost mt-2">Back to Admin</NuxtLink>
</template>
<p v-else class="text-base-content/70">Loading</p>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const router = useRouter()
const api = useMapApi()
const id = computed(() => parseInt(route.params.id as string, 10))
const map = ref<{ ID: number; Name: string; Hidden: boolean; Priority: boolean } | null>(null)
const mapsLoaded = ref(false)
const form = ref({ name: '', hidden: false, priority: false })
const loading = ref(false)
const error = ref('')
onMounted(async () => {
try {
const maps = await api.adminMaps()
mapsLoaded.value = true
const found = maps.find((m) => m.ID === id.value)
if (found) {
map.value = found
form.value = { name: found.Name, hidden: found.Hidden, priority: found.Priority }
}
} catch {
mapsLoaded.value = true
error.value = 'Failed to load map'
}
})
async function submit() {
if (!map.value) return
error.value = ''
loading.value = true
try {
await api.adminMapPost(map.value.ID, form.value)
await router.push('/admin')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,123 @@
<template>
<div class="container mx-auto p-4 max-w-lg">
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control">
<label class="label" for="user">Username</label>
<input
id="user"
v-model="form.user"
type="text"
class="input input-bordered"
required
:readonly="!isNew"
/>
</div>
<PasswordInput
v-model="form.pass"
label="Password (leave blank to keep)"
autocomplete="new-password"
/>
<div class="form-control">
<label class="label">Auths</label>
<div class="flex flex-wrap gap-2">
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`">
<input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" />
<span class="label-text">{{ a }}</span>
</label>
</div>
</div>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<div class="flex gap-2">
<button type="submit" class="btn btn-primary" :disabled="loading">{{ loading ? '…' : 'Save' }}</button>
<NuxtLink to="/admin" class="btn btn-ghost">Back</NuxtLink>
<button
v-if="!isNew"
type="button"
class="btn btn-error btn-outline ml-auto"
:disabled="loading"
@click="confirmDelete"
>
Delete
</button>
</div>
</form>
<dialog ref="deleteModalRef" class="modal">
<div class="modal-box">
<p>Delete user {{ form.user }}?</p>
<div class="modal-action">
<form method="dialog">
<button class="btn">Cancel</button>
</form>
<button class="btn btn-error" :disabled="deleting" @click="doDelete">Delete</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button aria-label="Close">Close</button></form>
</dialog>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: 'admin' })
const route = useRoute()
const router = useRouter()
const api = useMapApi()
const username = computed(() => (route.params.username as string) ?? '')
const isNew = computed(() => username.value === 'new')
const form = ref({ user: '', pass: '', auths: [] as string[] })
const authOptions = ['admin', 'map', 'markers', 'upload']
const loading = ref(false)
const deleting = ref(false)
const error = ref('')
const deleteModalRef = ref<HTMLDialogElement | null>(null)
onMounted(async () => {
if (!isNew.value) {
form.value.user = username.value
try {
const u = await api.adminUserByName(username.value)
form.value.auths = u.auths ?? []
} catch {
error.value = 'Failed to load user'
}
}
})
async function submit() {
error.value = ''
loading.value = true
try {
await api.adminUserPost({
user: form.value.user,
pass: form.value.pass || undefined,
auths: form.value.auths,
})
await router.push('/admin')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed'
} finally {
loading.value = false
}
}
function confirmDelete() {
deleteModalRef.value?.showModal()
}
async function doDelete() {
deleting.value = true
try {
await api.adminUserDelete(form.value.user)
deleteModalRef.value?.close()
await router.push('/admin')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Delete failed'
} finally {
deleting.value = false
}
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="h-full min-h-0">
<MapPageWrapper>
<MapView :character-id="characterId" />
</MapPageWrapper>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const characterId = computed(() => Number(route.params.characterId) || -1)
</script>

View File

@@ -0,0 +1,18 @@
<template>
<MapPageWrapper>
<MapView
:map-id="mapId"
:grid-x="gridX"
:grid-y="gridY"
:zoom="zoom"
/>
</MapPageWrapper>
</template>
<script setup lang="ts">
const route = useRoute()
const mapId = computed(() => Number(route.params.map) || 0)
const gridX = computed(() => Number(route.params.gridX) || 0)
const gridY = computed(() => Number(route.params.gridY) || 0)
const zoom = computed(() => Number(route.params.zoom) || 1)
</script>

View File

@@ -0,0 +1,8 @@
<template>
<MapPageWrapper>
<MapView />
</MapPageWrapper>
</template>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4 overflow-hidden">
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title justify-center">Log in</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<div class="form-control">
<label class="label" for="user">User</label>
<input
id="user"
v-model="user"
type="text"
class="input input-bordered"
required
autocomplete="username"
/>
</div>
<PasswordInput
v-model="pass"
label="Password"
required
autocomplete="current-password"
/>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? '…' : 'Log in' }}
</button>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// No auth required; auth.global skips this path
const user = ref('')
const pass = ref('')
const error = ref('')
const loading = ref(false)
const router = useRouter()
const api = useMapApi()
async function submit() {
error.value = ''
loading.value = true
try {
await api.login(user.value, pass.value)
await router.push('/profile')
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Login failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div class="container mx-auto p-4 max-w-2xl">
<h1 class="text-2xl font-bold mb-6">Profile</h1>
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Upload tokens</h2>
<p class="text-sm opacity-80">Tokens for upload API. Generate and copy as needed.</p>
<ul v-if="tokens?.length" class="list-disc list-inside mt-2 space-y-1">
<li v-for="t in tokens" :key="t" class="font-mono text-sm flex items-center gap-2">
<span class="break-all">{{ uploadTokenDisplay(t) }}</span>
<button
type="button"
class="btn btn-ghost btn-xs shrink-0"
aria-label="Copy token"
@click="copyToken(t)"
>
Copy
</button>
</li>
</ul>
<p v-else class="text-sm mt-2">No tokens yet.</p>
<p v-if="tokenError" class="text-error text-sm mt-2">{{ tokenError }}</p>
<button class="btn btn-primary btn-sm mt-2" :disabled="loadingTokens" @click="generateToken">
{{ loadingTokens ? '' : 'Generate token' }}
</button>
</div>
</div>
<div class="card bg-base-200 shadow-xl mb-6">
<div class="card-body">
<h2 class="card-title">Change password</h2>
<form @submit.prevent="changePass" class="flex flex-col gap-2">
<PasswordInput
v-model="newPass"
placeholder="New password"
autocomplete="new-password"
/>
<p v-if="passMsg" class="text-sm" :class="passOk ? 'text-success' : 'text-error'">{{ passMsg }}</p>
<button type="submit" class="btn btn-sm" :disabled="loadingPass">Save password</button>
</form>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const api = useMapApi()
const tokens = ref<string[]>([])
const uploadPrefix = ref('')
const newPass = ref('')
const loadingTokens = ref(false)
const loadingPass = ref(false)
const passMsg = ref('')
const passOk = ref(false)
const tokenError = ref('')
function uploadTokenDisplay(token: string): string {
const base = (uploadPrefix.value ?? '').replace(/\/+$/, '')
return base ? `${base}/client/${token}` : `client/${token}`
}
function copyToken(token: string) {
navigator.clipboard.writeText(uploadTokenDisplay(token)).catch(() => {})
}
onMounted(async () => {
try {
const me = await api.me()
tokens.value = me.tokens ?? []
uploadPrefix.value = me.prefix ?? ''
} catch {
tokens.value = []
uploadPrefix.value = ''
}
})
async function generateToken() {
tokenError.value = ''
loadingTokens.value = true
try {
await api.meTokens()
const me = await api.me()
tokens.value = me.tokens ?? []
uploadPrefix.value = me.prefix ?? ''
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : ''
tokenError.value = msg === 'Forbidden'
? 'You need "upload" permission to generate tokens. Ask an admin to add it to your account.'
: (msg || 'Failed to generate token')
} finally {
loadingTokens.value = false
}
}
async function changePass() {
passMsg.value = ''
loadingPass.value = true
try {
await api.mePassword(newPass.value)
passMsg.value = 'Password updated.'
passOk.value = true
newPass.value = ''
} catch (e: unknown) {
passMsg.value = e instanceof Error ? e.message : 'Failed'
passOk.value = false
} finally {
loadingPass.value = false
}
}
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-base-200 p-4">
<div class="card w-full max-w-sm bg-base-100 shadow-xl">
<div class="card-body">
<h1 class="card-title justify-center">First-time setup</h1>
<p class="text-sm text-base-content/80">
This is the first run. Create the administrator account using the bootstrap password
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
</p>
<form @submit.prevent="submit" class="flex flex-col gap-4">
<PasswordInput
v-model="pass"
label="Bootstrap password"
required
autocomplete="current-password"
/>
<p v-if="error" class="text-error text-sm">{{ error }}</p>
<button type="submit" class="btn btn-primary" :disabled="loading">
{{ loading ? '…' : 'Create and log in' }}
</button>
</form>
</div>
</div>
<NuxtLink to="/" class="link link-hover underline underline-offset-2 mt-4 text-primary">Map</NuxtLink>
</div>
</template>
<script setup lang="ts">
// No auth required; auth.global skips this path
const pass = ref('')
const error = ref('')
const loading = ref(false)
const router = useRouter()
const api = useMapApi()
onMounted(async () => {
try {
const { setupRequired: required } = await api.setupRequired()
if (!required) await navigateTo('/login')
} catch {
// If API fails, stay on page
}
})
async function submit() {
error.value = ''
loading.value = true
try {
await api.login('admin', pass.value)
await router.push('/profile')
} catch (e: unknown) {
error.value = (e as Error)?.message === 'Unauthorized'
? 'Invalid bootstrap password.'
: (e as Error)?.message || 'Setup failed'
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,31 @@
import type { Plugin } from 'vite'
/**
* Dev-only: reject requests with malformed URIs before Vite's static/transform
* middleware runs decodeURI(), which would throw and crash the server.
* See: https://github.com/vitejs/vite/issues/6482
*/
export function viteUriGuard(): Plugin {
return {
name: 'vite-uri-guard',
apply: 'serve',
configureServer(server) {
const guard = (req: any, res: any, next: () => void) => {
const raw = req.url ?? req.originalUrl ?? ''
try {
decodeURI(raw)
const path = raw.includes('?') ? raw.slice(0, raw.indexOf('?')) : raw
if (path) decodeURI(path)
} catch {
res.statusCode = 400
res.setHeader('Content-Type', 'text/plain')
res.end('Bad Request: malformed URI')
return
}
next()
}
// Prepend so we run before Vite's static/transform middleware (which calls decodeURI)
server.middlewares.stack.unshift({ route: '', handle: guard })
},
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"
integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="
crossorigin=""/>
<title></title>
</head>
<body>
<noscript>
<strong>We're sorry but map-frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -0,0 +1,12 @@
/**
* Reject requests with malformed URI before they reach Vite.
* Vite's static middleware calls decodeURI() and throws "URI malformed" on invalid sequences (e.g. %2: or %91).
*/
export default defineEventHandler((event) => {
const path = event.path ?? event.node.req.url ?? ''
try {
decodeURI(path)
} catch {
throw createError({ statusCode: 400, statusMessage: 'Bad Request' })
}
})

View File

@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./components/**/*.{js,vue,ts}',
'./layouts/**/*.vue',
'./pages/**/*.vue',
'./plugins/**/*.{js,ts}',
'./app.vue',
'./lib/**/*.js',
],
plugins: [require('daisyui')],
daisyui: {
themes: ['light', 'dark'],
},
}

View File

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}