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.
41
frontend-nuxt/.cursorrules
Normal 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
@@ -0,0 +1 @@
|
||||
20
|
||||
16
frontend-nuxt/Dockerfile
Normal 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
@@ -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 repo’s `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
@@ -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>
|
||||
9
frontend-nuxt/assets/css/app.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#__nuxt {
|
||||
height: 100%;
|
||||
}
|
||||
10
frontend-nuxt/assets/css/leaflet-overrides.css
Normal 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;
|
||||
}
|
||||
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
@@ -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
@@ -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>
|
||||
52
frontend-nuxt/composables/useAppPaths.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
222
frontend-nuxt/composables/useMapApi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
103
frontend-nuxt/layouts/default.vue
Normal 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>
|
||||
61
frontend-nuxt/lib/Character.js
Normal 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)
|
||||
}
|
||||
}
|
||||
88
frontend-nuxt/lib/LeafletCustomTypes.js
Normal 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,
|
||||
})
|
||||
89
frontend-nuxt/lib/Marker.js
Normal 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)
|
||||
}
|
||||
}
|
||||
73
frontend-nuxt/lib/SmartTileLayer.js
Normal 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)
|
||||
}
|
||||
},
|
||||
})
|
||||
39
frontend-nuxt/lib/UniqueList.js
Normal 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]
|
||||
}
|
||||
}
|
||||
11
frontend-nuxt/middleware/admin.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
14
frontend-nuxt/middleware/auth.global.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
48
frontend-nuxt/nuxt.config.ts
Normal 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
26
frontend-nuxt/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
278
frontend-nuxt/pages/admin/index.vue
Normal 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>
|
||||
77
frontend-nuxt/pages/admin/maps/[id].vue
Normal 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>
|
||||
123
frontend-nuxt/pages/admin/users/[username].vue
Normal 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>
|
||||
12
frontend-nuxt/pages/character/[characterId].vue
Normal 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>
|
||||
18
frontend-nuxt/pages/grid/[map]/[gridX]/[gridY]/[zoom].vue
Normal 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>
|
||||
8
frontend-nuxt/pages/index.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<MapPageWrapper>
|
||||
<MapView />
|
||||
</MapPageWrapper>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
56
frontend-nuxt/pages/login.vue
Normal 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>
|
||||
111
frontend-nuxt/pages/profile.vue
Normal 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>
|
||||
60
frontend-nuxt/pages/setup.vue
Normal 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>
|
||||
31
frontend-nuxt/plugins/vite-uri-guard.ts
Normal 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 })
|
||||
},
|
||||
}
|
||||
}
|
||||
BIN
frontend-nuxt/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend-nuxt/public/gfx/invobjs/small/bumling.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend-nuxt/public/gfx/invobjs/small/bush.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/abyssalchasm.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/algaeblob.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/caveorgan.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/claypit.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/coralreef.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/crystalpatch.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/custom.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/fairystone.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/geyser.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/guanopile.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/headwaters.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/icespire.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/irminsul.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/jotunmussel.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/lilypadlotus.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/monolith.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/saltbasin.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/tarpit.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/watervortex.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/windthrow.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend-nuxt/public/gfx/terobjs/mm/woodheart.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
20
frontend-nuxt/public/index.html
Normal 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>
|
||||
661
frontend-nuxt/public/leaflet.css
Normal 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;
|
||||
}
|
||||
}
|
||||
BIN
frontend-nuxt/public/map/marker-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend-nuxt/public/map/marker-shadow.png
Normal file
|
After Width: | Height: | Size: 618 B |
12
frontend-nuxt/server/middleware/0.uri-guard.ts
Normal 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' })
|
||||
}
|
||||
})
|
||||
15
frontend-nuxt/tailwind.config.js
Normal 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'],
|
||||
},
|
||||
}
|
||||
3
frontend-nuxt/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
}
|
||||