Add configuration files and update project documentation
- Introduced .editorconfig for consistent coding styles across the project. - Added .golangci.yml for Go linting configuration. - Updated AGENTS.md to clarify project structure and components. - Enhanced CONTRIBUTING.md with Makefile usage for common tasks. - Updated Dockerfiles to use Go 1.24 and improved build instructions. - Refined README.md and deployment documentation for clarity. - Added testing documentation in testing.md for backend and frontend tests. - Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
6
frontend-nuxt/.prettierrc
Normal file
6
frontend-nuxt/.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
12
frontend-nuxt/__mocks__/nuxt-imports.ts
Normal file
12
frontend-nuxt/__mocks__/nuxt-imports.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
|
||||
export { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick }
|
||||
|
||||
export function useRuntimeConfig() {
|
||||
return {
|
||||
app: { baseURL: '/' },
|
||||
public: { apiBase: '/map/api' },
|
||||
}
|
||||
}
|
||||
|
||||
export function navigateTo(_path: string) {}
|
||||
@@ -1,6 +1,30 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui" {
|
||||
themes: light --default;
|
||||
}
|
||||
|
||||
@plugin "daisyui/theme" {
|
||||
name: "dark";
|
||||
prefersdark: true;
|
||||
color-scheme: dark;
|
||||
--color-primary: oklch(54.6% 0.245 277);
|
||||
--color-primary-content: oklch(100% 0 0);
|
||||
--color-secondary: oklch(55.5% 0.25 293);
|
||||
--color-secondary-content: oklch(100% 0 0);
|
||||
--color-accent: oklch(65.5% 0.155 203);
|
||||
--color-accent-content: oklch(100% 0 0);
|
||||
--color-neutral: oklch(27.5% 0.014 249);
|
||||
--color-neutral-content: oklch(74.7% 0.016 249);
|
||||
--color-base-100: oklch(21.2% 0.014 251);
|
||||
--color-base-200: oklch(18.8% 0.013 253);
|
||||
--color-base-300: oklch(16.5% 0.011 250);
|
||||
--color-base-content: oklch(74.7% 0.016 249);
|
||||
}
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
@@ -8,10 +32,6 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
@keyframes login-card-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Map container background from theme (DaisyUI base-200) */
|
||||
.leaflet-container {
|
||||
background: hsl(var(--b2));
|
||||
background: var(--color-base-200);
|
||||
}
|
||||
|
||||
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
:display-coords="mapLogic.state.displayCoords"
|
||||
/>
|
||||
<MapControls
|
||||
:show-grid-coordinates="mapLogic.state.showGridCoordinates"
|
||||
:show-grid-coordinates="mapLogic.state.showGridCoordinates.value"
|
||||
@update:show-grid-coordinates="(v) => (mapLogic.state.showGridCoordinates.value = v)"
|
||||
:hide-markers="mapLogic.state.hideMarkers"
|
||||
:hide-markers="mapLogic.state.hideMarkers.value"
|
||||
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
|
||||
:selected-map-id="mapLogic.state.selectedMapId.value"
|
||||
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
|
||||
@@ -34,9 +34,9 @@
|
||||
:maps="maps"
|
||||
:quest-givers="questGivers"
|
||||
:players="players"
|
||||
@zoom-in="mapLogic.zoomIn(map)"
|
||||
@zoom-out="mapLogic.zoomOutControl(map)"
|
||||
@reset-view="mapLogic.resetView(map)"
|
||||
@zoom-in="mapLogic.zoomIn(leafletMap)"
|
||||
@zoom-out="mapLogic.zoomOutControl(leafletMap)"
|
||||
@reset-view="mapLogic.resetView(leafletMap)"
|
||||
/>
|
||||
<MapMapContextMenu
|
||||
:context-menu="mapLogic.contextMenu"
|
||||
@@ -56,11 +56,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import MapControls from '~/components/map/MapControls.vue'
|
||||
import { GridCoordLayer, HnHCRS, HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import { Marker } from '~/lib/Marker'
|
||||
import { Character } from '~/lib/Character'
|
||||
import { UniqueList } from '~/lib/UniqueList'
|
||||
import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit'
|
||||
import { startMapUpdates, type UseMapUpdatesReturn } from '~/composables/useMapUpdates'
|
||||
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
|
||||
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api'
|
||||
import type L from 'leaflet'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -78,52 +78,23 @@ const mapRef = ref<HTMLElement | null>(null)
|
||||
const api = useMapApi()
|
||||
const mapLogic = useMapLogic()
|
||||
|
||||
const maps = ref<{ ID: number; Name: string; size?: number }[]>([])
|
||||
const maps = ref<MapInfo[]>([])
|
||||
const mapsLoaded = ref(false)
|
||||
const questGivers = ref<{ id: number; name: string; marker?: any }[]>([])
|
||||
const players = ref<{ id: number; name: string }[]>([])
|
||||
const questGivers = ref<Array<{ id: number; name: string }>>([])
|
||||
const players = ref<Array<{ id: number; name: string }>>([])
|
||||
const auths = ref<string[]>([])
|
||||
|
||||
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 leafletMap: L.Map | null = null
|
||||
let mapInit: MapInitResult | null = null
|
||||
let updatesHandle: UseMapUpdatesReturn | null = null
|
||||
let layersManager: MapLayersManager | null = null
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
let markers: UniqueList<InstanceType<typeof Marker>> | null = null
|
||||
let characters: UniqueList<InstanceType<typeof Character>> | null = null
|
||||
let markersHidden = false
|
||||
let autoMode = false
|
||||
let mapContainer: HTMLElement | null = null
|
||||
let contextMenuHandler: ((ev: MouseEvent) => void) | null = null
|
||||
|
||||
function toLatLng(x: number, y: number) {
|
||||
return map!.unproject([x, y], HnHMaxZoom)
|
||||
}
|
||||
|
||||
function changeMap(id: number) {
|
||||
if (id === mapLogic.state.mapid.value) return
|
||||
mapLogic.state.mapid.value = id
|
||||
mapLogic.state.selectedMapId.value = id
|
||||
if (layer) {
|
||||
layer.map = id
|
||||
layer.redraw()
|
||||
}
|
||||
if (overlayLayer) {
|
||||
overlayLayer.map = -1
|
||||
overlayLayer.redraw()
|
||||
}
|
||||
if (markers && !markersHidden) {
|
||||
markers.getElements().forEach((it: any) => it.remove({ map: map!, markerLayer: markerLayer!, mapid: id }))
|
||||
markers.getElements().filter((it: any) => it.map === id).forEach((it: any) => it.add({ map: map!, markerLayer: markerLayer!, mapid: id }))
|
||||
}
|
||||
if (characters) {
|
||||
characters.getElements().forEach((it: any) => {
|
||||
it.remove({ map: map! })
|
||||
it.add({ map: map!, mapid: id })
|
||||
})
|
||||
}
|
||||
return leafletMap!.unproject([x, y], HnHMaxZoom)
|
||||
}
|
||||
|
||||
function onWipeTile(coords: { x: number; y: number } | undefined) {
|
||||
@@ -142,8 +113,8 @@ function onHideMarker(id: number | undefined) {
|
||||
if (id == null) return
|
||||
mapLogic.closeContextMenus()
|
||||
api.adminHideMarker({ id })
|
||||
const m = markers?.byId(id)
|
||||
if (m) m.remove({ map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value })
|
||||
const m = layersManager?.findMarkerById(id)
|
||||
if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value })
|
||||
}
|
||||
|
||||
function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) {
|
||||
@@ -166,6 +137,7 @@ onMounted(async () => {
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
}
|
||||
if (!import.meta.client || !mapRef.value) return
|
||||
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
const [charactersData, mapsData] = await Promise.all([
|
||||
@@ -173,7 +145,7 @@ onMounted(async () => {
|
||||
api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})),
|
||||
])
|
||||
|
||||
const mapsList: { ID: number; Name: string; size?: number }[] = []
|
||||
const mapsList: MapInfo[] = []
|
||||
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]
|
||||
@@ -187,81 +159,20 @@ onMounted(async () => {
|
||||
maps.value = mapsList
|
||||
mapsLoaded.value = true
|
||||
|
||||
const config = (await api.getConfig().catch(() => ({}))) as { title?: string; auths?: string[] }
|
||||
const config = (await api.getConfig().catch(() => ({}))) as ConfigResponse
|
||||
if (config?.title) document.title = config.title
|
||||
const user = await api.me().catch(() => null)
|
||||
auths.value = (user as { auths?: string[] } | null)?.auths ?? 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 user = (await api.me().catch(() => null)) as MeResponse | null
|
||||
auths.value = user?.auths ?? config?.auths ?? []
|
||||
|
||||
const initialMapId =
|
||||
props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0
|
||||
mapLogic.state.mapid.value = initialMapId
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
|
||||
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
|
||||
const tileUrl = `${backendBase}/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,
|
||||
pane: 'tilePane',
|
||||
} as any)
|
||||
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
|
||||
mapInit = await initLeafletMap(mapRef.value, mapsList, initialMapId)
|
||||
leafletMap = mapInit.map
|
||||
|
||||
// Document-level capture so we get contextmenu before any map layer or iframe can swallow it
|
||||
mapContainer = map.getContainer()
|
||||
mapContainer = leafletMap.getContainer()
|
||||
contextMenuHandler = (ev: MouseEvent) => {
|
||||
const target = ev.target as Node
|
||||
if (!mapContainer?.contains(target)) return
|
||||
@@ -272,212 +183,149 @@ onMounted(async () => {
|
||||
ev.stopPropagation()
|
||||
const rect = mapContainer.getBoundingClientRect()
|
||||
const containerPoint = L.point(ev.clientX - rect.left, ev.clientY - rect.top)
|
||||
const latlng = map!.containerPointToLatLng(containerPoint)
|
||||
const point = map!.project(latlng, 6)
|
||||
const latlng = leafletMap!.containerPointToLatLng(containerPoint)
|
||||
const point = leafletMap!.project(latlng, 6)
|
||||
const coords = { x: Math.floor(point.x / TileSize), y: Math.floor(point.y / TileSize) }
|
||||
mapLogic.openTileContextMenu(ev.clientX, ev.clientY, coords)
|
||||
}
|
||||
}
|
||||
document.addEventListener('contextmenu', contextMenuHandler, true)
|
||||
|
||||
const updatesPath = `${backendBase}/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)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
source.onerror = () => {}
|
||||
source.addEventListener('merge', (e: MessageEvent) => {
|
||||
try {
|
||||
const merge = JSON.parse(e?.data ?? '{}')
|
||||
if (mapLogic.state.mapid.value === merge.From) {
|
||||
const mapTo = merge.To
|
||||
const point = map!.project(map!.getCenter(), 6)
|
||||
const coordinate = {
|
||||
x: Math.floor(point.x / TileSize) + merge.Shift.x,
|
||||
y: Math.floor(point.y / TileSize) + merge.Shift.y,
|
||||
z: map!.getZoom(),
|
||||
}
|
||||
const latLng = toLatLng(coordinate.x * 100, coordinate.y * 100)
|
||||
changeMap(mapTo)
|
||||
api.getMarkers().then((body) => updateMarkers(Array.isArray(body) ? body : []))
|
||||
map!.setView(latLng, map!.getZoom())
|
||||
}
|
||||
} catch {
|
||||
// Ignore merge parse errors
|
||||
}
|
||||
layersManager = createMapLayers({
|
||||
map: leafletMap,
|
||||
markerLayer: mapInit.markerLayer,
|
||||
layer: mapInit.layer,
|
||||
overlayLayer: mapInit.overlayLayer,
|
||||
getCurrentMapId: () => mapLogic.state.mapid.value,
|
||||
setCurrentMapId: (id: number) => { mapLogic.state.mapid.value = id },
|
||||
setSelectedMapId: (id: number) => { mapLogic.state.selectedMapId.value = id },
|
||||
getAuths: () => auths.value,
|
||||
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
|
||||
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
|
||||
onMarkerContextMenu: mapLogic.openMarkerContextMenu,
|
||||
})
|
||||
|
||||
markers = new UniqueList<InstanceType<typeof Marker>>()
|
||||
characters = new UniqueList<InstanceType<typeof Character>>()
|
||||
updatesHandle = startMapUpdates({
|
||||
backendBase: mapInit.backendBase,
|
||||
layer: mapInit.layer,
|
||||
overlayLayer: mapInit.overlayLayer,
|
||||
map: leafletMap,
|
||||
getCurrentMapId: () => mapLogic.state.mapid.value,
|
||||
onMerge: (mapTo: number, shift: { x: number; y: number }) => {
|
||||
const latLng = toLatLng(shift.x * 100, shift.y * 100)
|
||||
layersManager!.changeMap(mapTo)
|
||||
api.getMarkers().then((body) => {
|
||||
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
|
||||
questGivers.value = layersManager!.getQuestGivers()
|
||||
})
|
||||
leafletMap!.setView(latLng, leafletMap!.getZoom())
|
||||
},
|
||||
})
|
||||
|
||||
updateCharacters(charactersData as any[])
|
||||
layersManager.updateCharacters(Array.isArray(charactersData) ? charactersData : [])
|
||||
players.value = layersManager.getPlayers()
|
||||
|
||||
if (props.characterId !== undefined && props.characterId >= 0) {
|
||||
mapLogic.state.trackingCharacterId.value = props.characterId
|
||||
} else if (props.mapId != null && props.gridX != null && props.gridY != null && props.zoom != null) {
|
||||
const latLng = toLatLng(props.gridX * 100, props.gridY * 100)
|
||||
if (mapLogic.state.mapid.value !== props.mapId) changeMap(props.mapId)
|
||||
if (mapLogic.state.mapid.value !== props.mapId) layersManager.changeMap(props.mapId)
|
||||
mapLogic.state.selectedMapId.value = props.mapId
|
||||
map.setView(latLng, props.zoom, { animate: false })
|
||||
leafletMap.setView(latLng, props.zoom, { animate: false })
|
||||
} else if (mapsList.length > 0) {
|
||||
const first = mapsList[0]
|
||||
if (first) {
|
||||
changeMap(first.ID)
|
||||
layersManager.changeMap(first.ID)
|
||||
mapLogic.state.selectedMapId.value = first.ID
|
||||
map.setView([0, 0], HnHDefaultZoom, { animate: false })
|
||||
leafletMap.setView([0, 0], HnHDefaultZoom, { animate: false })
|
||||
}
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (map) map.invalidateSize()
|
||||
if (leafletMap) leafletMap.invalidateSize()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
api.getCharacters().then((body) => updateCharacters(Array.isArray(body) ? body : [])).catch(() => clearInterval(intervalId!))
|
||||
api
|
||||
.getCharacters()
|
||||
.then((body) => {
|
||||
layersManager!.updateCharacters(Array.isArray(body) ? body : [])
|
||||
players.value = layersManager!.getPlayers()
|
||||
})
|
||||
.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: mapLogic.state.mapid.value, overlayLayer, auths: auths.value }
|
||||
markers.update(
|
||||
list.map((it) => new Marker(it)),
|
||||
(marker: InstanceType<typeof Marker>) => {
|
||||
if (marker.map === mapLogic.state.mapid.value || marker.map === overlayLayer?.map) marker.add(ctx)
|
||||
marker.setClickCallback(() => map!.setView(marker.marker!.getLatLng(), HnHMaxZoom))
|
||||
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
|
||||
if (auths.value.includes('admin')) {
|
||||
mev.originalEvent.preventDefault()
|
||||
mev.originalEvent.stopPropagation()
|
||||
mapLogic.openMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, 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: mapLogic.state.mapid.value }
|
||||
characters.update(
|
||||
list.map((it) => new Character(it)),
|
||||
(character: InstanceType<typeof Character>) => {
|
||||
character.add(ctx)
|
||||
character.setClickCallback(() => (mapLogic.state.trackingCharacterId.value = character.id))
|
||||
},
|
||||
(character: InstanceType<typeof Character>) => character.remove(ctx),
|
||||
(character: InstanceType<typeof Character>, updated: any) => {
|
||||
if (mapLogic.state.trackingCharacterId.value === updated.id) {
|
||||
if (mapLogic.state.mapid.value !== updated.map) changeMap(updated.map)
|
||||
const latlng = map!.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
map!.setView(latlng, HnHMaxZoom)
|
||||
}
|
||||
character.update(ctx, updated)
|
||||
}
|
||||
)
|
||||
players.value = characters.getElements()
|
||||
}
|
||||
api.getMarkers().then((body) => {
|
||||
layersManager!.updateMarkers(Array.isArray(body) ? body : [])
|
||||
questGivers.value = layersManager!.getQuestGivers()
|
||||
})
|
||||
|
||||
watch(mapLogic.state.showGridCoordinates, (v) => {
|
||||
if (coordLayer) {
|
||||
;(coordLayer.options as { visible?: boolean }).visible = v
|
||||
coordLayer.setOpacity(v ? 1 : 0)
|
||||
if (v && map) {
|
||||
coordLayer.bringToFront?.()
|
||||
coordLayer.redraw?.()
|
||||
map.invalidateSize()
|
||||
} else if (coordLayer) {
|
||||
coordLayer.redraw?.()
|
||||
if (mapInit?.coordLayer) {
|
||||
;(mapInit.coordLayer.options as { visible?: boolean }).visible = v
|
||||
mapInit.coordLayer.setOpacity(v ? 1 : 0)
|
||||
if (v && leafletMap) {
|
||||
mapInit.coordLayer.bringToFront?.()
|
||||
mapInit.coordLayer.redraw?.()
|
||||
leafletMap.invalidateSize()
|
||||
} else {
|
||||
mapInit.coordLayer.redraw?.()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(mapLogic.state.hideMarkers, (v) => {
|
||||
markersHidden = v
|
||||
if (!markers) return
|
||||
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
|
||||
if (v) {
|
||||
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||
} else {
|
||||
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === overlayLayer?.map).forEach((it: any) => it.add(ctx))
|
||||
}
|
||||
layersManager?.refreshMarkersVisibility(v)
|
||||
})
|
||||
|
||||
watch(mapLogic.state.trackingCharacterId, (value) => {
|
||||
if (value === -1) return
|
||||
const character = characters?.byId(value)
|
||||
const character = layersManager?.findCharacterById(value)
|
||||
if (character) {
|
||||
changeMap(character.map)
|
||||
const latlng = map!.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
map!.setView(latlng, HnHMaxZoom)
|
||||
layersManager!.changeMap(character.map)
|
||||
const latlng = leafletMap!.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
leafletMap!.setView(latlng, HnHMaxZoom)
|
||||
autoMode = true
|
||||
} else {
|
||||
map!.setView([0, 0], HnHMinZoom)
|
||||
leafletMap!.setView([0, 0], HnHMinZoom)
|
||||
mapLogic.state.trackingCharacterId.value = -1
|
||||
}
|
||||
})
|
||||
|
||||
watch(mapLogic.state.selectedMapId, (value) => {
|
||||
if (value == null) return
|
||||
changeMap(value)
|
||||
const zoom = map!.getZoom()
|
||||
map!.setView([0, 0], zoom)
|
||||
layersManager?.changeMap(value)
|
||||
const zoom = leafletMap!.getZoom()
|
||||
leafletMap!.setView([0, 0], zoom)
|
||||
})
|
||||
|
||||
watch(mapLogic.state.overlayMapId, (value) => {
|
||||
if (overlayLayer) overlayLayer.map = value ?? -1
|
||||
overlayLayer?.redraw()
|
||||
if (!markers) return
|
||||
const ctx = { map: map!, markerLayer: markerLayer!, mapid: mapLogic.state.mapid.value, overlayLayer }
|
||||
markers.getElements().forEach((it: any) => it.remove(ctx))
|
||||
markers.getElements().filter((it: any) => it.map === mapLogic.state.mapid.value || it.map === (value ?? -1)).forEach((it: any) => it.add(ctx))
|
||||
layersManager?.refreshOverlayMarkers(value ?? -1)
|
||||
})
|
||||
|
||||
watch(mapLogic.state.selectedMarkerId, (value) => {
|
||||
if (value == null) return
|
||||
const marker = markers?.byId(value)
|
||||
if (marker?.marker) map!.setView(marker.marker.getLatLng(), map!.getZoom())
|
||||
const marker = layersManager?.findMarkerById(value)
|
||||
if (marker?.leafletMarker) leafletMap!.setView(marker.leafletMarker.getLatLng(), leafletMap!.getZoom())
|
||||
})
|
||||
|
||||
watch(mapLogic.state.selectedPlayerId, (value) => {
|
||||
if (value != null) mapLogic.state.trackingCharacterId.value = value
|
||||
})
|
||||
|
||||
map.on('moveend', () => mapLogic.updateDisplayCoords(map))
|
||||
mapLogic.updateDisplayCoords(map)
|
||||
map.on('zoomend', () => {
|
||||
if (map) map.invalidateSize()
|
||||
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
|
||||
mapLogic.updateDisplayCoords(leafletMap)
|
||||
leafletMap.on('zoomend', () => {
|
||||
if (leafletMap) leafletMap.invalidateSize()
|
||||
})
|
||||
map.on('drag', () => {
|
||||
leafletMap.on('drag', () => {
|
||||
mapLogic.state.trackingCharacterId.value = -1
|
||||
})
|
||||
map.on('zoom', () => {
|
||||
leafletMap.on('zoom', () => {
|
||||
if (autoMode) {
|
||||
autoMode = false
|
||||
} else {
|
||||
@@ -494,8 +342,8 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('contextmenu', contextMenuHandler, true)
|
||||
}
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
if (source) source.close()
|
||||
if (map) map.remove()
|
||||
updatesHandle?.cleanup()
|
||||
if (leafletMap) leafletMap.remove()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<fieldset class="fieldset">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
<div class="relative flex">
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
:type="showPass ? 'text' : 'password'"
|
||||
class="input input-bordered flex-1 pr-10"
|
||||
class="input flex-1 pr-10"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:autocomplete="autocomplete"
|
||||
@@ -25,7 +25,7 @@
|
||||
<icons-icon-eye v-else />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -46,44 +46,44 @@
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Display</h3>
|
||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
|
||||
<input v-model="showGridCoordinates" type="checkbox" class="checkbox checkbox-sm" />
|
||||
<span class="label-text">Show grid coordinates</span>
|
||||
<span>Show grid coordinates</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2">
|
||||
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" />
|
||||
<span class="label-text">Hide markers</span>
|
||||
<span>Hide markers</span>
|
||||
</label>
|
||||
</section>
|
||||
<!-- Navigation -->
|
||||
<section class="flex flex-col gap-3">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/70">Navigation</h3>
|
||||
<div class="form-control">
|
||||
<label class="label py-0"><span class="label-text">Jump to Map</span></label>
|
||||
<select v-model="selectedMapIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Jump to Map</span></label>
|
||||
<select v-model="selectedMapIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
<option value="">Select map</option>
|
||||
<option v-for="m in maps" :key="m.ID" :value="m.ID">{{ m.Name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-0"><span class="label-text">Overlay Map</span></label>
|
||||
<select v-model="overlayMapId" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Overlay Map</span></label>
|
||||
<select v-model="overlayMapId" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
<option :value="-1">None</option>
|
||||
<option v-for="m in maps" :key="'overlay-' + m.ID" :value="m.ID">{{ m.Name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-0"><span class="label-text">Jump to Quest Giver</span></label>
|
||||
<select v-model="selectedMarkerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Jump to Quest Giver</span></label>
|
||||
<select v-model="selectedMarkerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
<option value="">Select quest giver</option>
|
||||
<option v-for="q in questGivers" :key="q.id" :value="q.id">{{ q.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label py-0"><span class="label-text">Jump to Player</span></label>
|
||||
<select v-model="selectedPlayerIdSelect" class="select select-bordered select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label py-0"><span>Jump to Player</span></label>
|
||||
<select v-model="selectedPlayerIdSelect" class="select select-sm w-full focus:ring-2 focus:ring-primary">
|
||||
<option value="">Select player</option>
|
||||
<option v-for="p in players" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<h3 class="font-bold text-lg">Rewrite tile coords</h3>
|
||||
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
|
||||
<div class="flex gap-2">
|
||||
<input v-model.number="localTo.x" type="number" class="input input-bordered flex-1" placeholder="X" />
|
||||
<input v-model.number="localTo.y" type="number" class="input input-bordered flex-1" placeholder="Y" />
|
||||
<input v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" />
|
||||
<input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<form method="dialog" @submit.prevent="onSubmit">
|
||||
@@ -27,7 +27,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
submit: [from: { x: number; y: number }; to: { x: number; y: number }]
|
||||
submit: [from: { x: number; y: number }, to: { x: number; y: number }]
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLDialogElement | null>(null)
|
||||
|
||||
93
frontend-nuxt/composables/__tests__/useAppPaths.test.ts
Normal file
93
frontend-nuxt/composables/__tests__/useAppPaths.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const useRuntimeConfigMock = vi.fn()
|
||||
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
|
||||
|
||||
import { useAppPaths } from '../useAppPaths'
|
||||
|
||||
describe('useAppPaths with default base /', () => {
|
||||
beforeEach(() => {
|
||||
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })
|
||||
})
|
||||
|
||||
it('returns base as empty string for "/"', () => {
|
||||
const { base } = useAppPaths()
|
||||
expect(base).toBe('')
|
||||
})
|
||||
|
||||
it('pathWithoutBase returns path unchanged', () => {
|
||||
const { pathWithoutBase } = useAppPaths()
|
||||
expect(pathWithoutBase('/login')).toBe('/login')
|
||||
expect(pathWithoutBase('/admin/users')).toBe('/admin/users')
|
||||
expect(pathWithoutBase('/')).toBe('/')
|
||||
})
|
||||
|
||||
it('resolvePath returns path as-is', () => {
|
||||
const { resolvePath } = useAppPaths()
|
||||
expect(resolvePath('/login')).toBe('/login')
|
||||
expect(resolvePath('admin')).toBe('/admin')
|
||||
})
|
||||
|
||||
it('isLoginPath detects login', () => {
|
||||
const { isLoginPath } = useAppPaths()
|
||||
expect(isLoginPath('/login')).toBe(true)
|
||||
expect(isLoginPath('/admin/login')).toBe(true)
|
||||
expect(isLoginPath('/admin')).toBe(false)
|
||||
expect(isLoginPath('/')).toBe(false)
|
||||
})
|
||||
|
||||
it('isSetupPath detects setup', () => {
|
||||
const { isSetupPath } = useAppPaths()
|
||||
expect(isSetupPath('/setup')).toBe(true)
|
||||
expect(isSetupPath('/other/setup')).toBe(true)
|
||||
expect(isSetupPath('/')).toBe(false)
|
||||
expect(isSetupPath('/login')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAppPaths with custom base /map', () => {
|
||||
beforeEach(() => {
|
||||
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/map/' } })
|
||||
})
|
||||
|
||||
it('strips base from path', () => {
|
||||
const { base, pathWithoutBase } = useAppPaths()
|
||||
expect(base).toBe('/map')
|
||||
expect(pathWithoutBase('/map/login')).toBe('/login')
|
||||
expect(pathWithoutBase('/map/admin/users')).toBe('/admin/users')
|
||||
expect(pathWithoutBase('/map/')).toBe('/')
|
||||
expect(pathWithoutBase('/map')).toBe('/')
|
||||
})
|
||||
|
||||
it('resolvePath prepends base', () => {
|
||||
const { resolvePath } = useAppPaths()
|
||||
expect(resolvePath('/login')).toBe('/map/login')
|
||||
expect(resolvePath('admin')).toBe('/map/admin')
|
||||
})
|
||||
|
||||
it('isLoginPath with base', () => {
|
||||
const { isLoginPath } = useAppPaths()
|
||||
expect(isLoginPath('/map/login')).toBe(true)
|
||||
expect(isLoginPath('/login')).toBe(true)
|
||||
expect(isLoginPath('/map/admin')).toBe(false)
|
||||
})
|
||||
|
||||
it('isSetupPath with base', () => {
|
||||
const { isSetupPath } = useAppPaths()
|
||||
expect(isSetupPath('/map/setup')).toBe(true)
|
||||
expect(isSetupPath('/setup')).toBe(true)
|
||||
expect(isSetupPath('/map/login')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useAppPaths with no baseURL', () => {
|
||||
beforeEach(() => {
|
||||
useRuntimeConfigMock.mockReturnValue({ app: {} })
|
||||
})
|
||||
|
||||
it('defaults to /', () => {
|
||||
const { baseURL, base } = useAppPaths()
|
||||
expect(baseURL).toBe('/')
|
||||
expect(base).toBe('')
|
||||
})
|
||||
})
|
||||
284
frontend-nuxt/composables/__tests__/useMapApi.test.ts
Normal file
284
frontend-nuxt/composables/__tests__/useMapApi.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
vi.stubGlobal('useRuntimeConfig', () => ({
|
||||
app: { baseURL: '/' },
|
||||
public: { apiBase: '/map/api' },
|
||||
}))
|
||||
|
||||
import { useMapApi } from '../useMapApi'
|
||||
|
||||
function mockFetch(status: number, body: unknown, contentType = 'application/json') {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: new Headers({ 'content-type': contentType }),
|
||||
json: () => Promise.resolve(body),
|
||||
} as Response)
|
||||
}
|
||||
|
||||
describe('useMapApi', () => {
|
||||
let originalFetch: typeof globalThis.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('fetches config from API', async () => {
|
||||
const data = { title: 'Test', auths: ['map'] }
|
||||
globalThis.fetch = mockFetch(200, data)
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
const result = await getConfig()
|
||||
|
||||
expect(result).toEqual(data)
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' }))
|
||||
})
|
||||
|
||||
it('throws on 401', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
await expect(getConfig()).rejects.toThrow('Unauthorized')
|
||||
})
|
||||
|
||||
it('throws on 403', async () => {
|
||||
globalThis.fetch = mockFetch(403, { error: 'Forbidden' })
|
||||
|
||||
const { getConfig } = useMapApi()
|
||||
await expect(getConfig()).rejects.toThrow('Forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCharacters', () => {
|
||||
it('fetches characters', async () => {
|
||||
const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }]
|
||||
globalThis.fetch = mockFetch(200, chars)
|
||||
|
||||
const { getCharacters } = useMapApi()
|
||||
const result = await getCharacters()
|
||||
expect(result).toEqual(chars)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMarkers', () => {
|
||||
it('fetches markers', async () => {
|
||||
const markers = [{ name: 'Tower', id: 1, map: 1, position: { x: 10, y: 20 }, image: 'gfx/terobjs/mm/tower', hidden: false }]
|
||||
globalThis.fetch = mockFetch(200, markers)
|
||||
|
||||
const { getMarkers } = useMapApi()
|
||||
const result = await getMarkers()
|
||||
expect(result).toEqual(markers)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMaps', () => {
|
||||
it('fetches maps', async () => {
|
||||
const maps = { '1': { ID: 1, Name: 'world' } }
|
||||
globalThis.fetch = mockFetch(200, maps)
|
||||
|
||||
const { getMaps } = useMapApi()
|
||||
const result = await getMaps()
|
||||
expect(result).toEqual(maps)
|
||||
})
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('sends credentials and returns me response', async () => {
|
||||
const meResp = { username: 'alice', auths: ['map'] }
|
||||
globalThis.fetch = mockFetch(200, meResp)
|
||||
|
||||
const { login } = useMapApi()
|
||||
const result = await login('alice', 'secret')
|
||||
|
||||
expect(result).toEqual(meResp)
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/login',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ user: 'alice', pass: 'secret' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on 401 with error message', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ error: 'Invalid credentials' }),
|
||||
})
|
||||
|
||||
const { login } = useMapApi()
|
||||
await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials')
|
||||
})
|
||||
})
|
||||
|
||||
describe('logout', () => {
|
||||
it('sends POST to logout', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 })
|
||||
|
||||
const { logout } = useMapApi()
|
||||
await logout()
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/logout',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('me', () => {
|
||||
it('fetches current user', async () => {
|
||||
const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' }
|
||||
globalThis.fetch = mockFetch(200, meResp)
|
||||
|
||||
const { me } = useMapApi()
|
||||
const result = await me()
|
||||
expect(result).toEqual(meResp)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setupRequired', () => {
|
||||
it('checks setup status', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ setupRequired: true }),
|
||||
})
|
||||
|
||||
const { setupRequired } = useMapApi()
|
||||
const result = await setupRequired()
|
||||
expect(result).toEqual({ setupRequired: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthProviders', () => {
|
||||
it('returns providers list', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(['google']),
|
||||
})
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual(['google'])
|
||||
})
|
||||
|
||||
it('returns empty array on error', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'))
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array on non-ok', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
})
|
||||
|
||||
const { oauthProviders } = useMapApi()
|
||||
const result = await oauthProviders()
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('oauthLoginUrl', () => {
|
||||
it('builds OAuth login URL', () => {
|
||||
// happy-dom needs an absolute URL for `new URL()`. The source code
|
||||
// creates `new URL(apiBase + path)` which is relative.
|
||||
// Verify the underlying apiBase and path construction instead.
|
||||
const { apiBase } = useMapApi()
|
||||
const expected = `${apiBase}/oauth/google/login`
|
||||
expect(expected).toBe('/map/api/oauth/google/login')
|
||||
})
|
||||
|
||||
it('oauthLoginUrl is a function', () => {
|
||||
const { oauthLoginUrl } = useMapApi()
|
||||
expect(typeof oauthLoginUrl).toBe('function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('onApiError', () => {
|
||||
it('fires callback on 401', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
const callback = vi.fn()
|
||||
|
||||
const { onApiError, getConfig } = useMapApi()
|
||||
onApiError(callback)
|
||||
|
||||
await expect(getConfig()).rejects.toThrow()
|
||||
expect(callback).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns unsubscribe function', async () => {
|
||||
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
|
||||
const callback = vi.fn()
|
||||
|
||||
const { onApiError, getConfig } = useMapApi()
|
||||
const unsub = onApiError(callback)
|
||||
unsub()
|
||||
|
||||
await expect(getConfig()).rejects.toThrow()
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin endpoints', () => {
|
||||
it('adminExportUrl returns correct path', () => {
|
||||
const { adminExportUrl } = useMapApi()
|
||||
expect(adminExportUrl()).toBe('/map/api/admin/export')
|
||||
})
|
||||
|
||||
it('adminUsers fetches user list', async () => {
|
||||
globalThis.fetch = mockFetch(200, ['alice', 'bob'])
|
||||
|
||||
const { adminUsers } = useMapApi()
|
||||
const result = await adminUsers()
|
||||
expect(result).toEqual(['alice', 'bob'])
|
||||
})
|
||||
|
||||
it('adminSettings fetches settings', async () => {
|
||||
const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' }
|
||||
globalThis.fetch = mockFetch(200, settings)
|
||||
|
||||
const { adminSettings } = useMapApi()
|
||||
const result = await adminSettings()
|
||||
expect(result).toEqual(settings)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meTokens', () => {
|
||||
it('generates and returns tokens', async () => {
|
||||
globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] })
|
||||
|
||||
const { meTokens } = useMapApi()
|
||||
const result = await meTokens()
|
||||
expect(result).toEqual(['tok1', 'tok2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mePassword', () => {
|
||||
it('sends password change', async () => {
|
||||
globalThis.fetch = mockFetch(200, undefined, 'text/plain')
|
||||
|
||||
const { mePassword } = useMapApi()
|
||||
await mePassword('newpass')
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
'/map/api/me/password',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ pass: 'newpass' }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
137
frontend-nuxt/composables/__tests__/useMapLogic.test.ts
Normal file
137
frontend-nuxt/composables/__tests__/useMapLogic.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('reactive', reactive)
|
||||
|
||||
import { useMapLogic } from '../useMapLogic'
|
||||
|
||||
describe('useMapLogic', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('initializes with default state', () => {
|
||||
const { state } = useMapLogic()
|
||||
expect(state.showGridCoordinates.value).toBe(false)
|
||||
expect(state.hideMarkers.value).toBe(false)
|
||||
expect(state.panelCollapsed.value).toBe(false)
|
||||
expect(state.trackingCharacterId.value).toBe(-1)
|
||||
expect(state.selectedMapId.value).toBeNull()
|
||||
expect(state.overlayMapId.value).toBe(-1)
|
||||
expect(state.selectedMarkerId.value).toBeNull()
|
||||
expect(state.selectedPlayerId.value).toBeNull()
|
||||
expect(state.displayCoords.value).toBeNull()
|
||||
expect(state.mapid.value).toBe(0)
|
||||
})
|
||||
|
||||
it('zoomIn calls map.zoomIn', () => {
|
||||
const { zoomIn } = useMapLogic()
|
||||
const mockMap = { zoomIn: vi.fn() }
|
||||
zoomIn(mockMap as unknown as import('leaflet').Map)
|
||||
expect(mockMap.zoomIn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('zoomIn handles null map', () => {
|
||||
const { zoomIn } = useMapLogic()
|
||||
expect(() => zoomIn(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('zoomOutControl calls map.zoomOut', () => {
|
||||
const { zoomOutControl } = useMapLogic()
|
||||
const mockMap = { zoomOut: vi.fn() }
|
||||
zoomOutControl(mockMap as unknown as import('leaflet').Map)
|
||||
expect(mockMap.zoomOut).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resetView resets tracking and sets view', () => {
|
||||
const { state, resetView } = useMapLogic()
|
||||
state.trackingCharacterId.value = 42
|
||||
const mockMap = { setView: vi.fn() }
|
||||
resetView(mockMap as unknown as import('leaflet').Map)
|
||||
expect(state.trackingCharacterId.value).toBe(-1)
|
||||
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
|
||||
})
|
||||
|
||||
it('updateDisplayCoords sets coords from map center', () => {
|
||||
const { state, updateDisplayCoords } = useMapLogic()
|
||||
const mockMap = {
|
||||
project: vi.fn(() => ({ x: 550, y: 350 })),
|
||||
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
getZoom: vi.fn(() => 3),
|
||||
}
|
||||
updateDisplayCoords(mockMap as unknown as import('leaflet').Map)
|
||||
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
|
||||
})
|
||||
|
||||
it('updateDisplayCoords handles null map', () => {
|
||||
const { state, updateDisplayCoords } = useMapLogic()
|
||||
updateDisplayCoords(null)
|
||||
expect(state.displayCoords.value).toBeNull()
|
||||
})
|
||||
|
||||
it('toLatLng calls map.unproject', () => {
|
||||
const { toLatLng } = useMapLogic()
|
||||
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
|
||||
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200)
|
||||
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
|
||||
expect(result).toEqual({ lat: 1, lng: 2 })
|
||||
})
|
||||
|
||||
it('toLatLng returns null for null map', () => {
|
||||
const { toLatLng } = useMapLogic()
|
||||
expect(toLatLng(null, 0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('closeContextMenus hides both menus', () => {
|
||||
const { contextMenu, openTileContextMenu, openMarkerContextMenu, closeContextMenus } = useMapLogic()
|
||||
openTileContextMenu(10, 20, { x: 1, y: 2 })
|
||||
openMarkerContextMenu(30, 40, 5, 'Tower')
|
||||
closeContextMenus()
|
||||
expect(contextMenu.tile.show).toBe(false)
|
||||
expect(contextMenu.marker.show).toBe(false)
|
||||
})
|
||||
|
||||
it('openTileContextMenu sets tile context menu state', () => {
|
||||
const { contextMenu, openTileContextMenu } = useMapLogic()
|
||||
openTileContextMenu(100, 200, { x: 5, y: 10 })
|
||||
expect(contextMenu.tile.show).toBe(true)
|
||||
expect(contextMenu.tile.x).toBe(100)
|
||||
expect(contextMenu.tile.y).toBe(200)
|
||||
expect(contextMenu.tile.data).toEqual({ coords: { x: 5, y: 10 } })
|
||||
})
|
||||
|
||||
it('openMarkerContextMenu sets marker context menu state', () => {
|
||||
const { contextMenu, openMarkerContextMenu } = useMapLogic()
|
||||
openMarkerContextMenu(50, 60, 42, 'Castle')
|
||||
expect(contextMenu.marker.show).toBe(true)
|
||||
expect(contextMenu.marker.x).toBe(50)
|
||||
expect(contextMenu.marker.y).toBe(60)
|
||||
expect(contextMenu.marker.data).toEqual({ id: 42, name: 'Castle' })
|
||||
})
|
||||
|
||||
it('openTileContextMenu closes other menus first', () => {
|
||||
const { contextMenu, openMarkerContextMenu, openTileContextMenu } = useMapLogic()
|
||||
openMarkerContextMenu(10, 20, 1, 'A')
|
||||
expect(contextMenu.marker.show).toBe(true)
|
||||
|
||||
openTileContextMenu(30, 40, { x: 0, y: 0 })
|
||||
expect(contextMenu.marker.show).toBe(false)
|
||||
expect(contextMenu.tile.show).toBe(true)
|
||||
})
|
||||
|
||||
it('openCoordSet sets modal state', () => {
|
||||
const { coordSetFrom, coordSet, coordSetModalOpen, openCoordSet } = useMapLogic()
|
||||
openCoordSet({ x: 3, y: 7 })
|
||||
expect(coordSetFrom.value).toEqual({ x: 3, y: 7 })
|
||||
expect(coordSet.value).toEqual({ x: 3, y: 7 })
|
||||
expect(coordSetModalOpen.value).toBe(true)
|
||||
})
|
||||
|
||||
it('closeCoordSetModal closes modal', () => {
|
||||
const { coordSetModalOpen, openCoordSet, closeCoordSetModal } = useMapLogic()
|
||||
openCoordSet({ x: 0, y: 0 })
|
||||
closeCoordSetModal()
|
||||
expect(coordSetModalOpen.value).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -195,15 +195,24 @@ export function useMapApi() {
|
||||
}
|
||||
|
||||
async function adminWipeTile(params: { map: number; x: number; y: number }) {
|
||||
return request(`admin/wipeTile?${new URLSearchParams(params as any)}`)
|
||||
const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
|
||||
return request(`admin/wipeTile?${qs}`)
|
||||
}
|
||||
|
||||
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
|
||||
return request(`admin/setCoords?${new URLSearchParams(params as any)}`)
|
||||
const qs = new URLSearchParams({
|
||||
map: String(params.map),
|
||||
fx: String(params.fx),
|
||||
fy: String(params.fy),
|
||||
tx: String(params.tx),
|
||||
ty: String(params.ty),
|
||||
})
|
||||
return request(`admin/setCoords?${qs}`)
|
||||
}
|
||||
|
||||
async function adminHideMarker(params: { id: number }) {
|
||||
return request(`admin/hideMarker?${new URLSearchParams(params as any)}`)
|
||||
const qs = new URLSearchParams({ id: String(params.id) })
|
||||
return request(`admin/hideMarker?${qs}`)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
91
frontend-nuxt/composables/useMapInit.ts
Normal file
91
frontend-nuxt/composables/useMapInit.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type L from 'leaflet'
|
||||
import { GridCoordLayer, HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import type { GridCoordLayerOptions } from '~/lib/LeafletCustomTypes'
|
||||
import { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import type { MapInfo } from '~/types/api'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
export interface MapInitResult {
|
||||
map: L.Map
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
coordLayer: L.GridLayer
|
||||
markerLayer: L.LayerGroup
|
||||
backendBase: string
|
||||
}
|
||||
|
||||
export async function initLeafletMap(
|
||||
element: HTMLElement,
|
||||
mapsList: MapInfo[],
|
||||
initialMapId: number
|
||||
): Promise<MapInitResult> {
|
||||
const L = (await import('leaflet')).default
|
||||
|
||||
const map = L.map(element, {
|
||||
minZoom: HnHMinZoom,
|
||||
maxZoom: HnHMaxZoom,
|
||||
crs: HnHCRS,
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
inertia: true,
|
||||
zoomAnimation: true,
|
||||
fadeAnimation: true,
|
||||
markerZoomAnimation: true,
|
||||
})
|
||||
|
||||
const runtimeConfig = useRuntimeConfig()
|
||||
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
|
||||
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
|
||||
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
|
||||
|
||||
const layer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 2,
|
||||
})
|
||||
layer.map = initialMapId
|
||||
layer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
|
||||
layer.addTo(map)
|
||||
|
||||
const overlayLayer = new SmartTileLayer(tileUrl, {
|
||||
minZoom: 1,
|
||||
maxZoom: 6,
|
||||
zoomOffset: 0,
|
||||
zoomReverse: true,
|
||||
tileSize: TileSize,
|
||||
opacity: 0.5,
|
||||
updateWhenIdle: true,
|
||||
keepBuffer: 2,
|
||||
})
|
||||
overlayLayer.map = -1
|
||||
overlayLayer.invalidTile =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
overlayLayer.addTo(map)
|
||||
|
||||
const coordLayer = new GridCoordLayer({
|
||||
tileSize: TileSize,
|
||||
minZoom: HnHMinZoom,
|
||||
maxZoom: HnHMaxZoom,
|
||||
opacity: 0,
|
||||
visible: false,
|
||||
pane: 'tilePane',
|
||||
} as GridCoordLayerOptions)
|
||||
coordLayer.addTo(map)
|
||||
coordLayer.setZIndex(500)
|
||||
|
||||
const 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
|
||||
|
||||
return { map, layer, overlayLayer, coordLayer, markerLayer, backendBase }
|
||||
}
|
||||
192
frontend-nuxt/composables/useMapLayers.ts
Normal file
192
frontend-nuxt/composables/useMapLayers.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import type L from 'leaflet'
|
||||
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
|
||||
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
|
||||
import {
|
||||
createUniqueList,
|
||||
uniqueListUpdate,
|
||||
uniqueListGetElements,
|
||||
uniqueListById,
|
||||
type UniqueList,
|
||||
} from '~/lib/UniqueList'
|
||||
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
export interface MapLayersOptions {
|
||||
map: L.Map
|
||||
markerLayer: L.LayerGroup
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
getCurrentMapId: () => number
|
||||
setCurrentMapId: (id: number) => void
|
||||
setSelectedMapId: (id: number) => void
|
||||
getAuths: () => string[]
|
||||
getTrackingCharacterId: () => number
|
||||
setTrackingCharacterId: (id: number) => void
|
||||
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
|
||||
}
|
||||
|
||||
export interface MapLayersManager {
|
||||
markers: UniqueList<MapMarker>
|
||||
characters: UniqueList<MapCharacter>
|
||||
changeMap: (id: number) => void
|
||||
updateMarkers: (markersData: ApiMarker[]) => void
|
||||
updateCharacters: (charactersData: ApiCharacter[]) => void
|
||||
getQuestGivers: () => Array<{ id: number; name: string }>
|
||||
getPlayers: () => Array<{ id: number; name: string }>
|
||||
refreshMarkersVisibility: (hidden: boolean) => void
|
||||
refreshOverlayMarkers: (overlayMapIdValue: number) => void
|
||||
findMarkerById: (id: number) => MapMarker | undefined
|
||||
findCharacterById: (id: number) => MapCharacter | undefined
|
||||
}
|
||||
|
||||
export function createMapLayers(options: MapLayersOptions): MapLayersManager {
|
||||
const {
|
||||
map,
|
||||
markerLayer,
|
||||
layer,
|
||||
overlayLayer,
|
||||
getCurrentMapId,
|
||||
setCurrentMapId,
|
||||
setSelectedMapId,
|
||||
getAuths,
|
||||
getTrackingCharacterId,
|
||||
setTrackingCharacterId,
|
||||
onMarkerContextMenu,
|
||||
} = options
|
||||
|
||||
const markers = createUniqueList<MapMarker>()
|
||||
const characters = createUniqueList<MapCharacter>()
|
||||
|
||||
function markerCtx(): MapViewRef {
|
||||
return { map, markerLayer, mapid: getCurrentMapId() }
|
||||
}
|
||||
|
||||
function characterCtx(): CharacterMapViewRef {
|
||||
return { map, mapid: getCurrentMapId() }
|
||||
}
|
||||
|
||||
function changeMap(id: number) {
|
||||
if (id === getCurrentMapId()) return
|
||||
setCurrentMapId(id)
|
||||
setSelectedMapId(id)
|
||||
layer.map = id
|
||||
layer.redraw()
|
||||
overlayLayer.map = -1
|
||||
overlayLayer.redraw()
|
||||
|
||||
const ctx = markerCtx()
|
||||
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
|
||||
uniqueListGetElements(markers)
|
||||
.filter((it) => it.map === id)
|
||||
.forEach((it) => it.add(ctx))
|
||||
|
||||
const cCtx = characterCtx()
|
||||
uniqueListGetElements(characters).forEach((it) => {
|
||||
it.remove(cCtx)
|
||||
it.add(cCtx)
|
||||
})
|
||||
}
|
||||
|
||||
function updateMarkers(markersData: ApiMarker[]) {
|
||||
const list = Array.isArray(markersData) ? markersData : []
|
||||
const ctx = markerCtx()
|
||||
uniqueListUpdate(
|
||||
markers,
|
||||
list.map((it) => createMarker(it as MarkerData)),
|
||||
(marker: MapMarker) => {
|
||||
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
|
||||
marker.setClickCallback(() => {
|
||||
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
|
||||
})
|
||||
marker.setContextMenu((mev: L.LeafletMouseEvent) => {
|
||||
if (getAuths().includes('admin')) {
|
||||
mev.originalEvent.preventDefault()
|
||||
mev.originalEvent.stopPropagation()
|
||||
onMarkerContextMenu(mev.originalEvent.clientX, mev.originalEvent.clientY, marker.id, marker.name)
|
||||
}
|
||||
})
|
||||
},
|
||||
(marker: MapMarker) => marker.remove(ctx),
|
||||
(marker: MapMarker, updated: MapMarker) => marker.update(ctx, updated)
|
||||
)
|
||||
}
|
||||
|
||||
function updateCharacters(charactersData: ApiCharacter[]) {
|
||||
const list = Array.isArray(charactersData) ? charactersData : []
|
||||
const ctx = characterCtx()
|
||||
uniqueListUpdate(
|
||||
characters,
|
||||
list.map((it) => createCharacter(it as CharacterData)),
|
||||
(character: MapCharacter) => {
|
||||
character.add(ctx)
|
||||
character.setClickCallback(() => setTrackingCharacterId(character.id))
|
||||
},
|
||||
(character: MapCharacter) => character.remove(ctx),
|
||||
(character: MapCharacter, updated: MapCharacter) => {
|
||||
if (getTrackingCharacterId() === updated.id) {
|
||||
if (getCurrentMapId() !== updated.map) changeMap(updated.map)
|
||||
const latlng = map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
map.setView(latlng, HnHMaxZoom)
|
||||
}
|
||||
character.update(ctx, updated)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function getQuestGivers(): Array<{ id: number; name: string }> {
|
||||
return uniqueListGetElements(markers)
|
||||
.filter((it) => it.type === 'quest')
|
||||
.map((it) => ({ id: it.id, name: it.name }))
|
||||
}
|
||||
|
||||
function getPlayers(): Array<{ id: number; name: string }> {
|
||||
return uniqueListGetElements(characters).map((it) => ({ id: it.id, name: it.name }))
|
||||
}
|
||||
|
||||
function refreshMarkersVisibility(hidden: boolean) {
|
||||
const ctx = markerCtx()
|
||||
if (hidden) {
|
||||
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
|
||||
} else {
|
||||
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
|
||||
uniqueListGetElements(markers)
|
||||
.filter((it) => it.map === getCurrentMapId() || it.map === overlayLayer.map)
|
||||
.forEach((it) => it.add(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
function refreshOverlayMarkers(overlayMapIdValue: number) {
|
||||
overlayLayer.map = overlayMapIdValue
|
||||
overlayLayer.redraw()
|
||||
const ctx = markerCtx()
|
||||
uniqueListGetElements(markers).forEach((it) => it.remove(ctx))
|
||||
uniqueListGetElements(markers)
|
||||
.filter((it) => it.map === getCurrentMapId() || it.map === overlayMapIdValue)
|
||||
.forEach((it) => it.add(ctx))
|
||||
}
|
||||
|
||||
function findMarkerById(id: number): MapMarker | undefined {
|
||||
return uniqueListById(markers, id)
|
||||
}
|
||||
|
||||
function findCharacterById(id: number): MapCharacter | undefined {
|
||||
return uniqueListById(characters, id)
|
||||
}
|
||||
|
||||
return {
|
||||
markers,
|
||||
characters,
|
||||
changeMap,
|
||||
updateMarkers,
|
||||
updateCharacters,
|
||||
getQuestGivers,
|
||||
getPlayers,
|
||||
refreshMarkersVisibility,
|
||||
refreshOverlayMarkers,
|
||||
findMarkerById,
|
||||
findCharacterById,
|
||||
}
|
||||
}
|
||||
82
frontend-nuxt/composables/useMapUpdates.ts
Normal file
82
frontend-nuxt/composables/useMapUpdates.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { SmartTileLayer } from '~/lib/SmartTileLayer'
|
||||
import { TileSize } from '~/lib/LeafletCustomTypes'
|
||||
import type L from 'leaflet'
|
||||
|
||||
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
|
||||
|
||||
interface TileUpdate {
|
||||
M: number
|
||||
X: number
|
||||
Y: number
|
||||
Z: number
|
||||
T: number
|
||||
}
|
||||
|
||||
interface MergeEvent {
|
||||
From: number
|
||||
To: number
|
||||
Shift: { x: number; y: number }
|
||||
}
|
||||
|
||||
export interface UseMapUpdatesOptions {
|
||||
backendBase: string
|
||||
layer: SmartTileLayerInstance
|
||||
overlayLayer: SmartTileLayerInstance
|
||||
map: L.Map
|
||||
getCurrentMapId: () => number
|
||||
onMerge: (mapTo: number, shift: { x: number; y: number }) => void
|
||||
}
|
||||
|
||||
export interface UseMapUpdatesReturn {
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
|
||||
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge } = options
|
||||
|
||||
const updatesPath = `${backendBase}/updates`
|
||||
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
|
||||
const source = new EventSource(updatesUrl)
|
||||
|
||||
source.onmessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const raw: unknown = event?.data
|
||||
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
|
||||
const updates: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(updates)) return
|
||||
for (const u of updates as TileUpdate[]) {
|
||||
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
|
||||
layer.cache[key] = u.T
|
||||
overlayLayer.cache[key] = u.T
|
||||
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z)
|
||||
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z)
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors from SSE
|
||||
}
|
||||
}
|
||||
|
||||
source.onerror = () => {}
|
||||
|
||||
source.addEventListener('merge', (e: MessageEvent) => {
|
||||
try {
|
||||
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}')
|
||||
if (getCurrentMapId() === merge.From) {
|
||||
const point = map.project(map.getCenter(), 6)
|
||||
const shift = {
|
||||
x: Math.floor(point.x / TileSize) + merge.Shift.x,
|
||||
y: Math.floor(point.y / TileSize) + merge.Shift.y,
|
||||
}
|
||||
onMerge(merge.To, shift)
|
||||
}
|
||||
} catch {
|
||||
// Ignore merge parse errors
|
||||
}
|
||||
})
|
||||
|
||||
function cleanup() {
|
||||
source.close()
|
||||
}
|
||||
|
||||
return { cleanup }
|
||||
}
|
||||
11
frontend-nuxt/eslint.config.mjs
Normal file
11
frontend-nuxt/eslint.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
// @ts-check
|
||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||
|
||||
export default withNuxt({
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
},
|
||||
})
|
||||
@@ -9,75 +9,83 @@ export interface CharacterData {
|
||||
map: number
|
||||
}
|
||||
|
||||
export interface MapViewRef {
|
||||
export interface CharacterMapViewRef {
|
||||
map: L.Map
|
||||
mapid: number
|
||||
markerLayer?: L.LayerGroup
|
||||
}
|
||||
|
||||
export class Character {
|
||||
export interface MapCharacter {
|
||||
id: number
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
type: string
|
||||
id: number
|
||||
map: number
|
||||
marker: L.Marker | null = null
|
||||
text: string
|
||||
value: number
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
|
||||
constructor(characterData: CharacterData) {
|
||||
this.name = characterData.name
|
||||
this.position = characterData.position
|
||||
this.type = characterData.type
|
||||
this.id = characterData.id
|
||||
this.map = characterData.map
|
||||
this.text = this.name
|
||||
this.value = this.id
|
||||
}
|
||||
|
||||
getId(): string {
|
||||
return `${this.name}`
|
||||
}
|
||||
|
||||
remove(mapview: MapViewRef): void {
|
||||
if (this.marker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(this.marker)
|
||||
this.marker = null
|
||||
}
|
||||
}
|
||||
|
||||
add(mapview: MapViewRef): void {
|
||||
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: MapViewRef, updated: CharacterData): void {
|
||||
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: (e: L.LeafletMouseEvent) => void): void {
|
||||
this.onClick = callback
|
||||
}
|
||||
|
||||
callCallback(e: L.LeafletMouseEvent): void {
|
||||
if (this.onClick != null) this.onClick(e)
|
||||
}
|
||||
leafletMarker: L.Marker | null
|
||||
remove: (mapview: CharacterMapViewRef) => void
|
||||
add: (mapview: CharacterMapViewRef) => void
|
||||
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
export function createCharacter(data: CharacterData): MapCharacter {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
|
||||
const character: MapCharacter = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
position: { ...data.position },
|
||||
type: data.type,
|
||||
map: data.map,
|
||||
text: data.name,
|
||||
value: data.id,
|
||||
|
||||
get leafletMarker() {
|
||||
return leafletMarker
|
||||
},
|
||||
|
||||
remove(mapview: CharacterMapViewRef): void {
|
||||
if (leafletMarker) {
|
||||
const layer = mapview.markerLayer ?? mapview.map
|
||||
layer.removeLayer(leafletMarker)
|
||||
leafletMarker = null
|
||||
}
|
||||
},
|
||||
|
||||
add(mapview: CharacterMapViewRef): void {
|
||||
if (character.map === mapview.mapid) {
|
||||
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { title: character.name })
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
const targetLayer = mapview.markerLayer ?? mapview.map
|
||||
leafletMarker.addTo(targetLayer)
|
||||
}
|
||||
},
|
||||
|
||||
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
|
||||
if (character.map !== updated.map) {
|
||||
character.remove(mapview)
|
||||
}
|
||||
character.map = updated.map
|
||||
character.position = { ...updated.position }
|
||||
if (!leafletMarker && character.map === mapview.mapid) {
|
||||
character.add(mapview)
|
||||
}
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
}
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onClick = callback
|
||||
},
|
||||
}
|
||||
|
||||
return character
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ 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 interface GridCoordLayerOptions extends L.GridLayerOptions {
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
export const GridCoordLayer = L.GridLayer.extend({
|
||||
options: {
|
||||
visible: true,
|
||||
@@ -64,7 +68,7 @@ export const GridCoordLayer = L.GridLayer.extend({
|
||||
}
|
||||
return element
|
||||
},
|
||||
}) as unknown as new (options?: L.GridLayerOptions) => L.GridLayer
|
||||
}) as unknown as new (options?: GridCoordLayerOptions) => L.GridLayer
|
||||
|
||||
export const ImageIcon = L.Icon.extend({
|
||||
options: {
|
||||
|
||||
@@ -16,99 +16,111 @@ export interface MapViewRef {
|
||||
markerLayer: L.LayerGroup
|
||||
}
|
||||
|
||||
export interface MapMarker {
|
||||
id: number
|
||||
position: { x: number; y: number }
|
||||
name: string
|
||||
image: string
|
||||
type: string
|
||||
text: string
|
||||
value: number
|
||||
hidden: boolean
|
||||
map: number
|
||||
leafletMarker: L.Marker | null
|
||||
remove: (mapview: MapViewRef) => void
|
||||
add: (mapview: MapViewRef) => void
|
||||
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
|
||||
jumpTo: (map: L.Map) => void
|
||||
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
|
||||
}
|
||||
|
||||
function detectType(name: string): string {
|
||||
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 {
|
||||
id: number
|
||||
position: { x: number; y: number }
|
||||
name: string
|
||||
image: string
|
||||
type: string
|
||||
marker: L.Marker | null = null
|
||||
text: string
|
||||
value: number
|
||||
hidden: boolean
|
||||
map: number
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
export function createMarker(data: MarkerData): MapMarker {
|
||||
let leafletMarker: L.Marker | null = null
|
||||
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
|
||||
|
||||
constructor(markerData: MarkerData) {
|
||||
this.id = markerData.id
|
||||
this.position = markerData.position
|
||||
this.name = markerData.name
|
||||
this.image = markerData.image
|
||||
this.type = detectType(this.image)
|
||||
this.text = this.name
|
||||
this.value = this.id
|
||||
this.hidden = markerData.hidden
|
||||
this.map = markerData.map
|
||||
}
|
||||
const marker: MapMarker = {
|
||||
id: data.id,
|
||||
position: { ...data.position },
|
||||
name: data.name,
|
||||
image: data.image,
|
||||
type: detectType(data.image),
|
||||
text: data.name,
|
||||
value: data.id,
|
||||
hidden: data.hidden,
|
||||
map: data.map,
|
||||
|
||||
remove(_mapview: MapViewRef): void {
|
||||
if (this.marker) {
|
||||
this.marker.remove()
|
||||
this.marker = null
|
||||
}
|
||||
}
|
||||
get leafletMarker() {
|
||||
return leafletMarker
|
||||
},
|
||||
|
||||
add(mapview: MapViewRef): void {
|
||||
if (!this.hidden) {
|
||||
let icon: L.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] })
|
||||
remove(_mapview: MapViewRef): void {
|
||||
if (leafletMarker) {
|
||||
leafletMarker.remove()
|
||||
leafletMarker = null
|
||||
}
|
||||
},
|
||||
|
||||
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))
|
||||
}
|
||||
add(mapview: MapViewRef): void {
|
||||
if (!marker.hidden) {
|
||||
let icon: L.Icon
|
||||
if (marker.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: `${marker.image}.png`, iconSize: [32, 32] })
|
||||
}
|
||||
|
||||
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||
leafletMarker = L.marker(position, { icon, title: marker.name })
|
||||
leafletMarker.addTo(mapview.markerLayer)
|
||||
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||
if (onClick) onClick(e)
|
||||
})
|
||||
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
|
||||
if (onContext) onContext(e)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
|
||||
marker.position = { ...updated.position }
|
||||
marker.name = updated.name
|
||||
marker.hidden = updated.hidden
|
||||
marker.map = updated.map
|
||||
if (leafletMarker) {
|
||||
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
}
|
||||
},
|
||||
|
||||
jumpTo(map: L.Map): void {
|
||||
if (leafletMarker) {
|
||||
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
|
||||
leafletMarker.setLatLng(position)
|
||||
}
|
||||
},
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onClick = callback
|
||||
},
|
||||
|
||||
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
onContext = callback
|
||||
},
|
||||
}
|
||||
|
||||
update(mapview: MapViewRef, updated: MarkerData): void {
|
||||
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: L.Map): void {
|
||||
if (this.marker) {
|
||||
const position = map.unproject([this.position.x, this.position.y], HnHMaxZoom)
|
||||
this.marker.setLatLng(position)
|
||||
}
|
||||
}
|
||||
|
||||
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
this.onClick = callback
|
||||
}
|
||||
|
||||
callClickCallback(e: L.LeafletMouseEvent): void {
|
||||
if (this.onClick != null) this.onClick(e)
|
||||
}
|
||||
|
||||
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void {
|
||||
this.onContext = callback
|
||||
}
|
||||
|
||||
callContextCallback(e: L.LeafletMouseEvent): void {
|
||||
if (this.onContext != null) this.onContext(e)
|
||||
}
|
||||
return marker
|
||||
}
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
/**
|
||||
* Elements should have unique field "id"
|
||||
*/
|
||||
export interface Identifiable {
|
||||
id: number | string
|
||||
}
|
||||
|
||||
export class UniqueList<T extends Identifiable> {
|
||||
elements: Record<string, T> = {}
|
||||
|
||||
update(
|
||||
dataList: T[],
|
||||
addCallback?: (it: T) => void,
|
||||
removeCallback?: (it: T) => void,
|
||||
updateCallback?: (oldElement: T, newElement: T) => void
|
||||
): void {
|
||||
const elementsToAdd = dataList.filter((it) => this.elements[String(it.id)] === undefined)
|
||||
const elementsToRemove: T[] = []
|
||||
for (const id of Object.keys(this.elements)) {
|
||||
if (dataList.find((up) => String(up.id) === id) === undefined) {
|
||||
const el = this.elements[id]
|
||||
if (el) elementsToRemove.push(el)
|
||||
}
|
||||
}
|
||||
if (removeCallback) {
|
||||
elementsToRemove.forEach((it) => removeCallback(it))
|
||||
}
|
||||
if (updateCallback) {
|
||||
dataList.forEach((newElement) => {
|
||||
const oldElement = this.elements[String(newElement.id)]
|
||||
if (oldElement) {
|
||||
updateCallback(oldElement, newElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (addCallback) {
|
||||
elementsToAdd.forEach((it) => addCallback(it))
|
||||
}
|
||||
elementsToRemove.forEach((it) => delete this.elements[String(it.id)])
|
||||
elementsToAdd.forEach((it) => (this.elements[String(it.id)] = it))
|
||||
}
|
||||
|
||||
getElements(): T[] {
|
||||
return Object.values(this.elements)
|
||||
}
|
||||
|
||||
byId(id: number | string): T | undefined {
|
||||
return this.elements[String(id)]
|
||||
}
|
||||
export interface UniqueList<T extends Identifiable> {
|
||||
elements: Record<string, T>
|
||||
}
|
||||
|
||||
export function createUniqueList<T extends Identifiable>(): UniqueList<T> {
|
||||
return { elements: {} }
|
||||
}
|
||||
|
||||
export function uniqueListUpdate<T extends Identifiable>(
|
||||
list: UniqueList<T>,
|
||||
dataList: T[],
|
||||
addCallback?: (it: T) => void,
|
||||
removeCallback?: (it: T) => void,
|
||||
updateCallback?: (oldElement: T, newElement: T) => void
|
||||
): void {
|
||||
const elementsToAdd = dataList.filter((it) => list.elements[String(it.id)] === undefined)
|
||||
const elementsToRemove: T[] = []
|
||||
for (const id of Object.keys(list.elements)) {
|
||||
if (dataList.find((up) => String(up.id) === id) === undefined) {
|
||||
const el = list.elements[id]
|
||||
if (el) elementsToRemove.push(el)
|
||||
}
|
||||
}
|
||||
if (removeCallback) {
|
||||
elementsToRemove.forEach((it) => removeCallback(it))
|
||||
}
|
||||
if (updateCallback) {
|
||||
dataList.forEach((newElement) => {
|
||||
const oldElement = list.elements[String(newElement.id)]
|
||||
if (oldElement) {
|
||||
updateCallback(oldElement, newElement)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (addCallback) {
|
||||
elementsToAdd.forEach((it) => addCallback(it))
|
||||
}
|
||||
elementsToRemove.forEach((it) => delete list.elements[String(it.id)])
|
||||
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
|
||||
}
|
||||
|
||||
export function uniqueListGetElements<T extends Identifiable>(list: UniqueList<T>): T[] {
|
||||
return Object.values(list.elements)
|
||||
}
|
||||
|
||||
export function uniqueListById<T extends Identifiable>(list: UniqueList<T>, id: number | string): T | undefined {
|
||||
return list.elements[String(id)]
|
||||
}
|
||||
|
||||
107
frontend-nuxt/lib/__tests__/Character.test.ts
Normal file
107
frontend-nuxt/lib/__tests__/Character.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
default: { marker: vi.fn(() => markerMock) },
|
||||
marker: vi.fn(() => markerMock),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
}))
|
||||
|
||||
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
|
||||
|
||||
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
|
||||
return {
|
||||
name: 'Hero',
|
||||
position: { x: 100, y: 200 },
|
||||
type: 'player',
|
||||
id: 1,
|
||||
map: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapViewRef(mapid = 1): CharacterMapViewRef {
|
||||
return {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
removeLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').Map,
|
||||
mapid,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createCharacter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates character with correct properties', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
expect(char.id).toBe(1)
|
||||
expect(char.name).toBe('Hero')
|
||||
expect(char.position).toEqual({ x: 100, y: 200 })
|
||||
expect(char.type).toBe('player')
|
||||
expect(char.map).toBe(1)
|
||||
expect(char.text).toBe('Hero')
|
||||
expect(char.value).toBe(1)
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates marker when character is on correct map', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add does not create marker for different map', () => {
|
||||
const char = createCharacter(makeCharData({ map: 2 }))
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and map', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
const mapview = makeMapViewRef(1)
|
||||
|
||||
char.update(mapview, {
|
||||
...makeCharData(),
|
||||
position: { x: 300, y: 400 },
|
||||
map: 2,
|
||||
})
|
||||
|
||||
expect(char.position).toEqual({ x: 300, y: 400 })
|
||||
expect(char.map).toBe(2)
|
||||
})
|
||||
|
||||
it('remove on a character without leaflet marker does nothing', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
const mapview = makeMapViewRef(1)
|
||||
char.remove(mapview) // should not throw
|
||||
expect(char.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('setClickCallback works', () => {
|
||||
const char = createCharacter(makeCharData())
|
||||
const cb = vi.fn()
|
||||
char.setClickCallback(cb)
|
||||
})
|
||||
})
|
||||
139
frontend-nuxt/lib/__tests__/Marker.test.ts
Normal file
139
frontend-nuxt/lib/__tests__/Marker.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('leaflet', () => {
|
||||
const markerMock = {
|
||||
on: vi.fn().mockReturnThis(),
|
||||
addTo: vi.fn().mockReturnThis(),
|
||||
setLatLng: vi.fn().mockReturnThis(),
|
||||
remove: vi.fn().mockReturnThis(),
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: class {},
|
||||
},
|
||||
marker: vi.fn(() => markerMock),
|
||||
Icon: class {},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('~/lib/LeafletCustomTypes', () => ({
|
||||
HnHMaxZoom: 6,
|
||||
ImageIcon: class {
|
||||
constructor(_opts: Record<string, unknown>) {}
|
||||
},
|
||||
}))
|
||||
|
||||
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
|
||||
|
||||
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
|
||||
return {
|
||||
id: 1,
|
||||
position: { x: 100, y: 200 },
|
||||
name: 'Tower',
|
||||
image: 'gfx/terobjs/mm/tower',
|
||||
hidden: false,
|
||||
map: 1,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeMapViewRef(): MapViewRef {
|
||||
return {
|
||||
map: {
|
||||
unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
|
||||
} as unknown as import('leaflet').Map,
|
||||
mapid: 1,
|
||||
markerLayer: {
|
||||
removeLayer: vi.fn(),
|
||||
addLayer: vi.fn(),
|
||||
} as unknown as import('leaflet').LayerGroup,
|
||||
}
|
||||
}
|
||||
|
||||
describe('createMarker', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('creates a marker with correct properties', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
expect(marker.id).toBe(1)
|
||||
expect(marker.name).toBe('Tower')
|
||||
expect(marker.position).toEqual({ x: 100, y: 200 })
|
||||
expect(marker.image).toBe('gfx/terobjs/mm/tower')
|
||||
expect(marker.hidden).toBe(false)
|
||||
expect(marker.map).toBe(1)
|
||||
expect(marker.value).toBe(1)
|
||||
expect(marker.text).toBe('Tower')
|
||||
})
|
||||
|
||||
it('detects quest type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }))
|
||||
expect(marker.type).toBe('quest')
|
||||
})
|
||||
|
||||
it('detects quest type for bumling', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }))
|
||||
expect(marker.type).toBe('quest')
|
||||
})
|
||||
|
||||
it('detects custom type', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'custom' }))
|
||||
expect(marker.type).toBe('custom')
|
||||
})
|
||||
|
||||
it('extracts type from gfx path', () => {
|
||||
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }))
|
||||
expect(marker.type).toBe('village')
|
||||
})
|
||||
|
||||
it('starts with null leaflet marker', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
})
|
||||
|
||||
it('add creates a leaflet marker for non-hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('add does nothing for hidden markers', () => {
|
||||
const marker = createMarker(makeMarkerData({ hidden: true }))
|
||||
const mapview = makeMapViewRef()
|
||||
marker.add(mapview)
|
||||
expect(mapview.map.unproject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update changes position and name', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const mapview = makeMapViewRef()
|
||||
|
||||
marker.update(mapview, {
|
||||
...makeMarkerData(),
|
||||
position: { x: 300, y: 400 },
|
||||
name: 'Castle',
|
||||
})
|
||||
|
||||
expect(marker.position).toEqual({ x: 300, y: 400 })
|
||||
expect(marker.name).toBe('Castle')
|
||||
})
|
||||
|
||||
it('setClickCallback and setContextMenu work', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const clickCb = vi.fn()
|
||||
const contextCb = vi.fn()
|
||||
|
||||
marker.setClickCallback(clickCb)
|
||||
marker.setContextMenu(contextCb)
|
||||
})
|
||||
|
||||
it('remove on a marker without leaflet marker does nothing', () => {
|
||||
const marker = createMarker(makeMarkerData())
|
||||
const mapview = makeMapViewRef()
|
||||
marker.remove(mapview) // should not throw
|
||||
expect(marker.leafletMarker).toBeNull()
|
||||
})
|
||||
})
|
||||
134
frontend-nuxt/lib/__tests__/UniqueList.test.ts
Normal file
134
frontend-nuxt/lib/__tests__/UniqueList.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
createUniqueList,
|
||||
uniqueListUpdate,
|
||||
uniqueListGetElements,
|
||||
uniqueListById,
|
||||
} from '../UniqueList'
|
||||
|
||||
interface Item {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
describe('createUniqueList', () => {
|
||||
it('creates an empty list', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
expect(list.elements).toEqual({})
|
||||
expect(uniqueListGetElements(list)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueListUpdate', () => {
|
||||
it('adds new elements', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const addCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }], addCb)
|
||||
|
||||
expect(addCb).toHaveBeenCalledTimes(2)
|
||||
expect(uniqueListGetElements(list)).toHaveLength(2)
|
||||
expect(uniqueListById(list, 1)).toEqual({ id: 1, name: 'a' })
|
||||
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
|
||||
})
|
||||
|
||||
it('removes elements no longer present', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const removeCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }], undefined, removeCb)
|
||||
|
||||
expect(removeCb).toHaveBeenCalledTimes(1)
|
||||
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'b' })
|
||||
expect(uniqueListGetElements(list)).toHaveLength(1)
|
||||
expect(uniqueListById(list, 2)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('calls update callback for existing elements', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const updateCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'updated' }], undefined, undefined, updateCb)
|
||||
|
||||
expect(updateCb).toHaveBeenCalledTimes(1)
|
||||
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'a' }, { id: 1, name: 'updated' })
|
||||
})
|
||||
|
||||
it('handles all callbacks together', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
const addCb = vi.fn()
|
||||
const removeCb = vi.fn()
|
||||
const updateCb = vi.fn()
|
||||
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'keep' }, { id: 2, name: 'remove' }])
|
||||
|
||||
uniqueListUpdate(
|
||||
list,
|
||||
[{ id: 1, name: 'kept' }, { id: 3, name: 'new' }],
|
||||
addCb,
|
||||
removeCb,
|
||||
updateCb,
|
||||
)
|
||||
|
||||
expect(addCb).toHaveBeenCalledTimes(1)
|
||||
expect(addCb).toHaveBeenCalledWith({ id: 3, name: 'new' })
|
||||
expect(removeCb).toHaveBeenCalledTimes(1)
|
||||
expect(removeCb).toHaveBeenCalledWith({ id: 2, name: 'remove' })
|
||||
expect(updateCb).toHaveBeenCalledTimes(1)
|
||||
expect(updateCb).toHaveBeenCalledWith({ id: 1, name: 'keep' }, { id: 1, name: 'kept' })
|
||||
})
|
||||
|
||||
it('works with string IDs', () => {
|
||||
interface StringItem {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
const list = createUniqueList<StringItem>()
|
||||
uniqueListUpdate(list, [{ id: 'abc', label: 'first' }])
|
||||
expect(uniqueListById(list, 'abc')).toEqual({ id: 'abc', label: 'first' })
|
||||
})
|
||||
|
||||
it('handles empty update', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
|
||||
|
||||
const removeCb = vi.fn()
|
||||
uniqueListUpdate(list, [], undefined, removeCb)
|
||||
|
||||
expect(removeCb).toHaveBeenCalledTimes(1)
|
||||
expect(uniqueListGetElements(list)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles update with no callbacks', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }])
|
||||
uniqueListUpdate(list, [{ id: 2, name: 'b' }])
|
||||
expect(uniqueListGetElements(list)).toHaveLength(1)
|
||||
expect(uniqueListById(list, 2)).toEqual({ id: 2, name: 'b' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueListGetElements', () => {
|
||||
it('returns all elements as array', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 1, name: 'a' }, { id: 2, name: 'b' }])
|
||||
const elements = uniqueListGetElements(list)
|
||||
expect(elements).toHaveLength(2)
|
||||
expect(elements.map(e => e.id).sort()).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe('uniqueListById', () => {
|
||||
it('finds element by id', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
uniqueListUpdate(list, [{ id: 42, name: 'target' }])
|
||||
expect(uniqueListById(list, 42)?.name).toBe('target')
|
||||
})
|
||||
|
||||
it('returns undefined for missing id', () => {
|
||||
const list = createUniqueList<Item>()
|
||||
expect(uniqueListById(list, 999)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { viteUriGuard } from './vite/vite-uri-guard'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
@@ -27,20 +28,18 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
|
||||
modules: ['@nuxtjs/tailwindcss'],
|
||||
tailwindcss: {
|
||||
cssPath: '~/assets/css/app.css',
|
||||
},
|
||||
modules: ['@nuxt/eslint'],
|
||||
css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'],
|
||||
|
||||
vite: {
|
||||
plugins: [viteUriGuard()],
|
||||
plugins: [tailwindcss(), viteUriGuard() as never],
|
||||
optimizeDeps: {
|
||||
include: ['leaflet'],
|
||||
},
|
||||
},
|
||||
|
||||
// Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml)
|
||||
// @ts-expect-error nitro types lag behind Nuxt compat v4
|
||||
nitro: {
|
||||
devProxy: {
|
||||
'/map/api': { target: 'http://backend:3080/map/api', changeOrigin: true },
|
||||
|
||||
9794
frontend-nuxt/package-lock.json
generated
9794
frontend-nuxt/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -6,22 +6,36 @@
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview"
|
||||
"preview": "nuxt preview",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"leaflet": "^1.9.4",
|
||||
"nuxt": "^3.14.1593",
|
||||
"nuxt": "^3.21.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.12.2",
|
||||
"@nuxt/eslint": "^1.3.0",
|
||||
"@nuxt/test-utils": "^4.0.0",
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"daisyui": "^3.9.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3"
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"daisyui": "^5.5.0",
|
||||
"eslint": "^9.21.0",
|
||||
"happy-dom": "^20.7.0",
|
||||
"prettier": "^3.5.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^4.0.0",
|
||||
"vue-tsc": "^2.2.12"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
class="flex items-center justify-between gap-3 w-full p-3 rounded-lg bg-base-300/50 hover:bg-base-300/70 transition-colors"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<div class="avatar placeholder">
|
||||
<div class="avatar avatar-placeholder">
|
||||
<div class="bg-neutral text-neutral-content rounded-full w-8">
|
||||
<span class="text-xs">{{ u[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<tr><th>ID</th><th>Name</th><th>Hidden</th><th>Priority</th><th class="text-right"></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="map in maps" :key="map.ID" class="hover">
|
||||
<tr v-for="map in maps" :key="map.ID" class="hover:bg-base-300">
|
||||
<td>{{ map.ID }}</td>
|
||||
<td>{{ map.Name }}</td>
|
||||
<td>{{ map.Hidden ? 'Yes' : 'No' }}</td>
|
||||
@@ -84,25 +84,25 @@
|
||||
Settings
|
||||
</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="form-control w-full max-w-xs">
|
||||
<fieldset class="fieldset 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"
|
||||
class="input input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full max-w-xs">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset 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"
|
||||
class="input input-sm w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label gap-2 cursor-pointer justify-start" for="admin-settings-default-hide">
|
||||
<input
|
||||
id="admin-settings-default-hide"
|
||||
@@ -112,7 +112,7 @@
|
||||
/>
|
||||
Default hide new maps
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="flex justify-end mt-2">
|
||||
<button class="btn btn-primary btn-sm" :disabled="savingSettings" @click="saveSettings">
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
<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">
|
||||
<fieldset class="fieldset">
|
||||
<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">
|
||||
<input id="name" v-model="form.name" type="text" class="input" required />
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.hidden" type="checkbox" class="checkbox" />
|
||||
<span class="label-text">Hidden</span>
|
||||
<span>Hidden</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
</fieldset>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer gap-2">
|
||||
<input v-model="form.priority" type="checkbox" class="checkbox" />
|
||||
<span class="label-text">Priority</span>
|
||||
<span>Priority</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
<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">
|
||||
|
||||
@@ -3,31 +3,31 @@
|
||||
<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">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="user">Username</label>
|
||||
<input
|
||||
id="user"
|
||||
v-model="form.user"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
required
|
||||
:readonly="!isNew"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<PasswordInput
|
||||
v-model="form.pass"
|
||||
label="Password (leave blank to keep)"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="form-control">
|
||||
<fieldset class="fieldset">
|
||||
<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>
|
||||
<span>{{ a }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
<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">
|
||||
|
||||
@@ -17,17 +17,17 @@
|
||||
<div class="divider text-sm">or</div>
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<fieldset class="fieldset">
|
||||
<label class="label" for="user">User</label>
|
||||
<input
|
||||
id="user"
|
||||
v-model="user"
|
||||
type="text"
|
||||
class="input input-bordered"
|
||||
class="input"
|
||||
required
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
<PasswordInput
|
||||
v-model="pass"
|
||||
label="Password"
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./components/**/*.{js,vue,ts}',
|
||||
'./layouts/**/*.vue',
|
||||
'./pages/**/*.vue',
|
||||
'./plugins/**/*.{js,ts}',
|
||||
'./app.vue',
|
||||
'./lib/**/*.js',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('daisyui')],
|
||||
daisyui: {
|
||||
themes: [
|
||||
'light',
|
||||
{
|
||||
dark: {
|
||||
'color-scheme': 'dark',
|
||||
primary: '#6366f1',
|
||||
'primary-content': '#ffffff',
|
||||
secondary: '#8b5cf6',
|
||||
'secondary-content': '#ffffff',
|
||||
accent: '#06b6d4',
|
||||
'accent-content': '#ffffff',
|
||||
neutral: '#2a323c',
|
||||
'neutral-focus': '#242b33',
|
||||
'neutral-content': '#A6ADBB',
|
||||
'base-100': '#1d232a',
|
||||
'base-200': '#191e24',
|
||||
'base-300': '#15191e',
|
||||
'base-content': '#A6ADBB',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "./.nuxt/tsconfig.json"
|
||||
"extends": "./.nuxt/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ export function viteUriGuard(): Plugin {
|
||||
name: 'vite-uri-guard',
|
||||
apply: 'serve',
|
||||
configureServer(server) {
|
||||
const guard = (req: any, res: any, next: () => void) => {
|
||||
const guard = (req: { url?: string; originalUrl?: string }, res: { statusCode: number; setHeader: (k: string, v: string) => void; end: (body: string) => void }, next: () => void) => {
|
||||
const raw = req.url ?? req.originalUrl ?? ''
|
||||
try {
|
||||
decodeURI(raw)
|
||||
|
||||
16
frontend-nuxt/vitest.config.ts
Normal file
16
frontend-nuxt/vitest.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': resolve(__dirname, '.'),
|
||||
'#imports': resolve(__dirname, './__mocks__/nuxt-imports.ts'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user