diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index 0e29803..5e47039 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -34,14 +34,20 @@ />
+
+ @@ -122,6 +136,7 @@ import MapControls from '~/components/map/MapControls.vue' import MapCoordsDisplay from '~/components/map/MapCoordsDisplay.vue' import MapContextMenu from '~/components/map/MapContextMenu.vue' import MapCoordSetModal from '~/components/map/MapCoordSetModal.vue' +import MapBookmarkNameModal from '~/components/map/MapBookmarkNameModal.vue' import MapShortcutsOverlay from '~/components/map/MapShortcutsOverlay.vue' import { HnHDefaultZoom, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { initLeafletMap, type MapInitResult } from '~/composables/useMapInit' @@ -146,10 +161,59 @@ const props = withDefaults( const mapContainerRef = ref(null) const mapRef = ref(null) const api = useMapApi() +const toast = useToast() /** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */ const mapLive = useState('mapLive', () => false) const mapLogic = useMapLogic() +const mapBookmarks = useMapBookmarks() const { resolvePath } = useAppPaths() + +/** Payload for bookmark name modal: add new bookmark or edit existing by id. */ +type BookmarkModalPayload = + | { kind: 'add'; mapId: number; x: number; y: number; zoom?: number } + | { kind: 'edit'; editId: string } + +const bookmarkNameModalOpen = ref(false) +const bookmarkNameModalDefaultName = ref('') +const bookmarkNameModalTitle = ref('Add bookmark') +const bookmarkNameModalPendingData = ref(null) + +function openBookmarkModal(defaultName: string, title: string, data: BookmarkModalPayload) { + bookmarkNameModalDefaultName.value = defaultName + bookmarkNameModalTitle.value = title + bookmarkNameModalPendingData.value = data + bookmarkNameModalOpen.value = true +} + +function onBookmarkNameModalSubmit(name: string) { + const data = bookmarkNameModalPendingData.value + bookmarkNameModalOpen.value = false + bookmarkNameModalPendingData.value = null + if (!data) return + if (data.kind === 'add') { + mapBookmarks.add({ name, mapId: data.mapId, x: data.x, y: data.y, zoom: data.zoom }) + } else { + mapBookmarks.update(data.editId, { name }) + } +} + +function onAddMarkerToBookmarks() { + mapLogic.closeContextMenus() + const id = mapLogic.contextMenu.marker.data?.id + if (id == null || !layersManager) return + const marker = layersManager.findMarkerById(id) + if (!marker) return + const x = Math.floor(marker.position.x / TileSize) + const y = Math.floor(marker.position.y / TileSize) + openBookmarkModal(marker.name, 'Add bookmark', { + kind: 'add', + mapId: marker.map, + x, + y, + }) +} + +provide('openBookmarkModal', openBookmarkModal) const mapNavigate = useMapNavigate() const fullscreen = useFullscreen(mapContainerRef) const showShortcutsOverlay = ref(false) @@ -185,12 +249,36 @@ const maps = ref([]) const mapsLoaded = ref(false) const questGivers = ref>([]) const players = ref>([]) -const auths = ref([]) +/** Single source of truth: layout updates me, we derive auths for context menu. */ +const me = useState('me', () => null) +const auths = computed(() => me.value?.auths ?? []) let leafletMap: L.Map | null = null let mapInit: MapInitResult | null = null let updatesHandle: UseMapUpdatesReturn | null = null let layersManager: MapLayersManager | null = null + +/** Selected marker as bookmark target: grid coords + name, or null. Passed to MapControls → MapBookmarks. */ +const selectedMarkerForBookmark = ref<{ mapId: number; x: number; y: number; name: string } | null>(null) + +function updateSelectedMarkerForBookmark() { + const id = mapLogic.state.selectedMarkerId.value + if (id == null || !layersManager) { + selectedMarkerForBookmark.value = null + return + } + const marker = layersManager.findMarkerById(id) + if (!marker) { + selectedMarkerForBookmark.value = null + return + } + selectedMarkerForBookmark.value = { + mapId: marker.map, + x: Math.floor(marker.position.x / TileSize), + y: Math.floor(marker.position.y / TileSize), + name: marker.name, + } +} let intervalId: ReturnType | null = null let autoMode = false let mapContainer: HTMLElement | null = null @@ -202,10 +290,16 @@ function toLatLng(x: number, y: number) { return leafletMap!.unproject([x, y], HnHMaxZoom) } -function onWipeTile(coords: { x: number; y: number } | undefined) { +async function onWipeTile(coords: { x: number; y: number } | undefined) { if (!coords) return mapLogic.closeContextMenus() - api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y }) + try { + await api.adminWipeTile({ map: mapLogic.state.mapid.value, x: coords.x, y: coords.y }) + toast.success('Tile wiped') + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to wipe tile' + toast.error(msg === 'Forbidden' ? 'No permission' : msg) + } } function onRewriteCoords(coords: { x: number; y: number } | undefined) { @@ -214,23 +308,39 @@ function onRewriteCoords(coords: { x: number; y: number } | undefined) { mapLogic.openCoordSet(coords) } -function onHideMarker(id: number | undefined) { +async function onHideMarker(id: number | undefined) { if (id == null) return mapLogic.closeContextMenus() - api.adminHideMarker({ id }) - const m = layersManager?.findMarkerById(id) - if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value }) + try { + await api.adminHideMarker({ id }) + const m = layersManager?.findMarkerById(id) + if (m) m.remove({ map: leafletMap!, markerLayer: mapInit!.markerLayer, mapid: mapLogic.state.mapid.value }) + toast.success('Marker hidden') + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to hide marker' + toast.error(msg === 'Forbidden' ? 'No permission' : msg) + } } -function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) { - api.adminSetCoords({ - map: mapLogic.state.mapid.value, - fx: from.x, - fy: from.y, - tx: to.x, - ty: to.y, - }) - mapLogic.closeCoordSetModal() +async function onSubmitCoordSet(from: { x: number; y: number }, to: { x: number; y: number }) { + try { + await api.adminSetCoords({ + map: mapLogic.state.mapid.value, + fx: from.x, + fy: from.y, + tx: to.x, + ty: to.y, + }) + mapLogic.closeCoordSetModal() + toast.success('Coordinates updated') + } catch (e) { + const msg = e instanceof Error ? e.message : 'Failed to set coordinates' + toast.error(msg === 'Forbidden' ? 'No permission' : msg) + } +} + +function reloadPage() { + if (import.meta.client) window.location.reload() } function onKeydown(e: KeyboardEvent) { @@ -253,13 +363,18 @@ function onKeydown(e: KeyboardEvent) { e.preventDefault() return } - if (e.key === 'G') { - mapLogic.state.showGridCoordinates.value = !mapLogic.state.showGridCoordinates.value + if (e.key === 'f' || e.key === 'F') { + mapNavigate.focusSearch() e.preventDefault() return } - if (e.key === 'f' || e.key === 'F') { - mapNavigate.focusSearch() + if (e.key === '+' || e.key === '=') { + mapLogic.zoomIn(leafletMap) + e.preventDefault() + return + } + if (e.key === '-') { + mapLogic.zoomOutControl(leafletMap) e.preventDefault() return } @@ -274,12 +389,11 @@ onMounted(async () => { const L = (await import('leaflet')).default - // Load maps, characters, config and me in parallel so map can show sooner. - const [charactersData, mapsData, config, user] = await Promise.all([ + // Load maps, characters and config in parallel. User (me) is loaded by layout. + const [charactersData, mapsData, config] = await Promise.all([ api.getCharacters().then((d) => (Array.isArray(d) ? d : [])).catch(() => []), api.getMaps().then((d) => (d && typeof d === 'object' ? d : {})).catch(() => ({})), api.getConfig().catch(() => ({})) as Promise, - api.me().catch(() => null) as Promise, ]) const mapsList: MapInfo[] = [] @@ -302,7 +416,6 @@ onMounted(async () => { mapsLoaded.value = true if (config?.title) document.title = config.title - auths.value = user?.auths ?? config?.auths ?? [] const initialMapId = props.mapId != null && props.mapId >= 1 ? props.mapId : mapsList.length > 0 ? (mapsList[0]?.ID ?? 0) : 0 @@ -316,8 +429,9 @@ onMounted(async () => { contextMenuHandler = (ev: MouseEvent) => { const target = ev.target as Node if (!mapContainer?.contains(target)) return + // Right-click on a marker: let the marker's context menu open instead of tile menu + if ((target as Element).closest?.('.leaflet-marker-icon')) return const isAdmin = auths.value.includes('admin') - if (import.meta.dev) console.log('[MapView contextmenu]', { isAdmin, auths: auths.value }) if (isAdmin) { ev.preventDefault() ev.stopPropagation() @@ -414,6 +528,7 @@ onMounted(async () => { if (!mounted) return layersManager!.updateMarkers(Array.isArray(body) ? body : []) questGivers.value = layersManager!.getQuestGivers() + updateSelectedMarkerForBookmark() }) const CHARACTER_POLL_MS = 4000 @@ -453,85 +568,73 @@ onMounted(async () => { document.addEventListener('visibilitychange', visibilityChangeHandler) } - watch(mapLogic.state.showGridCoordinates, (v) => { - if (!mapInit?.coordLayer || !leafletMap) return - const coordLayer = mapInit.coordLayer - const layerWithMap = coordLayer as L.GridLayer & { _map?: L.Map } - if (v) { - ;(coordLayer.options as { visible?: boolean }).visible = true - if (!layerWithMap._map) { - coordLayer.addTo(leafletMap) - coordLayer.setZIndex(500) - coordLayer.setOpacity(1) - coordLayer.bringToFront?.() - } - leafletMap.invalidateSize() - nextTick(() => { - coordLayer.redraw?.() - }) - } else { - coordLayer.setOpacity(0) - coordLayer.remove() - } - }) - watch( () => fullscreen.isFullscreen.value, () => { + if (!mounted) return nextTick(() => { requestAnimationFrame(() => { - if (leafletMap) leafletMap.invalidateSize() + if (mounted && leafletMap) leafletMap.invalidateSize() }) }) } ) watch(mapLogic.state.hideMarkers, (v) => { - layersManager?.refreshMarkersVisibility(v) + if (!mounted || !layersManager) return + layersManager.refreshMarkersVisibility(v) }) watch(mapLogic.state.trackingCharacterId, (value) => { + if (!mounted || !leafletMap || !layersManager) return if (value === -1) return - const character = layersManager?.findCharacterById(value) + const character = layersManager.findCharacterById(value) if (character) { - layersManager!.changeMap(character.map) - const latlng = leafletMap!.unproject([character.position.x, character.position.y], HnHMaxZoom) - leafletMap!.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 { - leafletMap!.setView([0, 0], HnHMinZoom) + leafletMap.setView([0, 0], HnHMinZoom) mapLogic.state.trackingCharacterId.value = -1 } }) watch(mapLogic.state.selectedMapId, (value) => { + if (!mounted || !leafletMap || !layersManager) return if (value == null) return - layersManager?.changeMap(value) - const zoom = leafletMap!.getZoom() - leafletMap!.setView([0, 0], zoom) + layersManager.changeMap(value) + const zoom = leafletMap.getZoom() + leafletMap.setView([0, 0], zoom) }) watch(mapLogic.state.overlayMapId, (value) => { - layersManager?.refreshOverlayMarkers(value ?? -1) + if (!mounted || !layersManager) return + layersManager.refreshOverlayMarkers(value ?? -1) }) watch(mapLogic.state.selectedMarkerId, (value) => { + if (!mounted) return + updateSelectedMarkerForBookmark() + if (!leafletMap || !layersManager) return if (value == null) return - const marker = layersManager?.findMarkerById(value) - if (marker?.leafletMarker) leafletMap!.setView(marker.leafletMarker.getLatLng(), leafletMap!.getZoom()) + const marker = layersManager.findMarkerById(value) + if (marker?.leafletMarker) leafletMap.setView(marker.leafletMarker.getLatLng(), leafletMap.getZoom()) }) watch(mapLogic.state.selectedPlayerId, (value) => { + if (!mounted) return if (value != null) mapLogic.state.trackingCharacterId.value = value }) mapNavigate.setGoTo((mapId: number, x: number, y: number, zoom?: number) => { + if (!mounted || !leafletMap) return if (mapLogic.state.mapid.value !== mapId && layersManager) { layersManager.changeMap(mapId) mapLogic.state.selectedMapId.value = mapId } const latLng = toLatLng(x * TileSize, y * TileSize) - leafletMap!.setView(latLng, zoom ?? leafletMap!.getZoom(), { animate: true }) + leafletMap.setView(latLng, zoom ?? leafletMap.getZoom(), { animate: true }) }) leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap)) @@ -567,6 +670,7 @@ onMounted(async () => { }) watch([measurePointA, measurePointB], () => { + if (!mounted || !leafletMap) return const layer = measureLayer.value if (!layer) return layer.clearLayers() @@ -611,23 +715,4 @@ onBeforeUnmount(() => { mix-blend-mode: normal; visibility: visible !important; } -:deep(.map-tile) { - width: 100px; - height: 100px; - min-width: 100px; - min-height: 100px; - position: relative; - border-bottom: 1px solid rgba(255, 255, 255, 0.3); - border-right: 1px solid rgba(255, 255, 255, 0.3); - color: #fff; - font-size: 11px; - line-height: 1.2; - pointer-events: none; - text-shadow: 0 0 2px #000, 0 1px 2px #000; -} -:deep(.map-tile-text) { - position: absolute; - left: 2px; - top: 2px; -} diff --git a/frontend-nuxt/components/map/MapBookmarkNameModal.vue b/frontend-nuxt/components/map/MapBookmarkNameModal.vue new file mode 100644 index 0000000..b1d7363 --- /dev/null +++ b/frontend-nuxt/components/map/MapBookmarkNameModal.vue @@ -0,0 +1,74 @@ + + + diff --git a/frontend-nuxt/components/map/MapBookmarks.vue b/frontend-nuxt/components/map/MapBookmarks.vue index 2896306..89c8384 100644 --- a/frontend-nuxt/components/map/MapBookmarks.vue +++ b/frontend-nuxt/components/map/MapBookmarks.vue @@ -22,6 +22,15 @@ > {{ b.name }} + +
+ + +
@@ -52,18 +74,27 @@ import type { MapInfo } from '~/types/api' import { useMapBookmarks } from '~/composables/useMapBookmarks' import { useMapNavigate } from '~/composables/useMapNavigate' +export type SelectedMarkerForBookmark = { mapId: number; x: number; y: number; name: string } | null + +type BookmarkModalPayload = + | { kind: 'add'; mapId: number; x: number; y: number; zoom?: number } + | { kind: 'edit'; editId: string } +type OpenBookmarkModalFn = (defaultName: string, title: string, data: BookmarkModalPayload) => void + const props = withDefaults( defineProps<{ maps: MapInfo[] currentMapId: number | null currentCoords: { x: number; y: number; z: number } | null + selectedMarkerForBookmark?: SelectedMarkerForBookmark touchFriendly?: boolean }>(), - { touchFriendly: false } + { selectedMarkerForBookmark: null, touchFriendly: false } ) -const { bookmarks, add, remove } = useMapBookmarks() +const { bookmarks, remove } = useMapBookmarks() const { goToCoords } = useMapNavigate() +const openBookmarkModal = inject('openBookmarkModal') const canAddCurrent = computed( () => @@ -80,17 +111,20 @@ function onRemove(id: string) { remove(id) } +function onAddSelectedMarker() { + const m = props.selectedMarkerForBookmark + if (!m || !openBookmarkModal) return + openBookmarkModal(m.name, 'Add bookmark', { kind: 'add', mapId: m.mapId, x: m.x, y: m.y }) +} + function onAddCurrent() { - if (!canAddCurrent.value) return + if (!canAddCurrent.value || !openBookmarkModal) return const mapId = props.currentMapId! const { x, y, z } = props.currentCoords! - const mapName = props.maps.find((m) => m.ID === mapId)?.Name ?? `Map ${mapId}` - add({ - name: `${mapName} ${x}, ${y}`, - mapId, - x, - y, - zoom: z, - }) + openBookmarkModal( + `${props.maps.find((map) => map.ID === mapId)?.Name ?? `Map ${mapId}`} ${x}, ${y}`, + 'Add bookmark', + { kind: 'add', mapId, x, y, zoom: z } + ) } diff --git a/frontend-nuxt/components/map/MapContextMenu.vue b/frontend-nuxt/components/map/MapContextMenu.vue index 366f3a3..3ae9eda 100644 --- a/frontend-nuxt/components/map/MapContextMenu.vue +++ b/frontend-nuxt/components/map/MapContextMenu.vue @@ -28,6 +28,14 @@ :style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }" > +