Compare commits

..

10 Commits

Author SHA1 Message Date
dc53b79d84 Enhance map update handling and connection stability
- Introduced mechanisms to detect stale connections in the map updates, allowing for automatic reconnection if no messages are received within a specified timeframe.
- Updated the `refresh` method in `SmartTileLayer` to return a boolean indicating whether the tile was refreshed, improving the handling of tile updates.
- Enhanced the `Send` method in the `Topic` struct to drop messages for full subscribers while keeping them subscribed, ensuring continuous delivery of future updates.
- Added a keepalive mechanism in the `WatchGridUpdates` handler to maintain the connection and prevent timeouts.
2026-03-04 21:29:53 +03:00
179357bc93 Refactor Dockerignore and enhance Leaflet styles for improved map functionality
- Updated .dockerignore to streamline build context by ensuring unnecessary files are excluded.
- Refined CSS styles in leaflet-overrides.css to enhance visual consistency and user experience for map tooltips and popups.
- Improved map initialization and update handling in useMapApi and useMapUpdates composables for better performance and reliability.
2026-03-04 18:16:41 +03:00
3968bdc76f Enhance API documentation and improve marker functionality
- Updated API documentation for clarity and consistency, including detailed descriptions of authentication and user account endpoints.
- Added a new cave marker image to enhance visual representation in the frontend.
- Implemented normalization for cave marker images during upload to ensure consistent storage format.
- Expanded test coverage for client services, including new tests for marker uploads and image normalization.
2026-03-04 16:57:43 +03:00
40945c818b Enhance character marker functionality and tooltip integration
- Updated character icon properties for improved visual representation.
- Introduced tooltip binding to character markers, displaying coordinates and character names.
- Implemented smooth position animation for character markers during updates.
- Added tests to verify tooltip content updates and animation cancellation on removal.
2026-03-04 16:02:54 +03:00
7fcdde3657 Remove coordinates display from MapSearch component for cleaner UI 2026-03-04 14:19:07 +03:00
1a0db9baf0 Update project conventions and introduce linting skill documentation
- Revised project-conventions.mdc to include a new section for running lint before tests, enhancing the development workflow.
- Added run-lint skill documentation in a new SKILL.md file, detailing usage for backend and frontend linting processes in the hnh-map monorepo.
2026-03-04 14:15:51 +03:00
337386caa8 Enhance Vitest configuration and improve Vue integration
- Added Vue plugin to vitest.config.ts for better component testing.
- Introduced vitest.setup.ts to expose Vue reactivity and lifecycle methods globally, ensuring compatibility with .vue components.
- Updated mock implementations in nuxt-imports.ts to include readonly for improved reactivity handling.
- Refactored useMapBookmarks and useToast composables to utilize readonly from Vue for better state management.
2026-03-04 14:12:48 +03:00
fd624c2357 Refactor frontend components for improved functionality and accessibility
- Consolidated global error handling in app.vue to redirect users to the login page on API authentication failure.
- Enhanced MapView component by reintroducing event listeners for selected map and marker updates, improving interactivity.
- Updated PasswordInput and various modal components to ensure proper input handling and accessibility compliance.
- Refactored MapControls and MapControlsContent to streamline prop management and enhance user experience.
- Improved error handling in local storage operations within useMapBookmarks and useRecentLocations composables.
- Standardized input elements across forms for consistency in user interaction.
2026-03-04 14:06:27 +03:00
761fbaed55 Refactor Docker and Makefile configurations for improved build processes
- Updated docker-compose.tools.yml to mount source code at /src and set working directory for backend tools, ensuring proper Go module caching.
- Modified Dockerfile.tools to install the latest golangci-lint version compatible with Go 1.24 and adjusted working directory for build-time operations.
- Enhanced Makefile to build backend tools before running tests and linting, ensuring dependencies are up-to-date and improving overall workflow efficiency.
- Refactored test and handler files to include error handling for database operations, enhancing reliability and debugging capabilities.
2026-03-04 13:59:00 +03:00
fc42d86ca0 Enhance map components and improve build processes
- Updated Makefile to include `--build` flag for `docker-compose.dev.yml` and `--no-cache` for `docker-compose.prod.yml` to ensure fresh builds.
- Added new CSS styles for Leaflet tooltips and popups to utilize theme colors, enhancing visual consistency.
- Enhanced MapView component with new props for markers and current zoom level, improving marker management and zoom functionality.
- Introduced new icons for copy and info actions to improve user interface clarity.
- Updated MapBookmarks and MapControls components to support new features and improve user experience with bookmarks and zoom controls.
- Refactored MapSearch to display coordinates and improve marker search functionality.
2026-03-04 12:49:31 +03:00
72 changed files with 5354 additions and 4621 deletions

View File

@@ -11,4 +11,5 @@ alwaysApply: true
- **Local run / build:** [docs/development.md](docs/development.md), [CONTRIBUTING.md](CONTRIBUTING.md). Dev ports: frontend 3000, backend 3080; prod: 8080. Build, test, lint, and format run via Docker (Makefile + docker-compose.tools.yml). - **Local run / build:** [docs/development.md](docs/development.md), [CONTRIBUTING.md](CONTRIBUTING.md). Dev ports: frontend 3000, backend 3080; prod: 8080. Build, test, lint, and format run via Docker (Makefile + docker-compose.tools.yml).
- **Docs:** [docs/](docs/) (architecture, API, configuration, development, deployment). Some docs are in Russian. - **Docs:** [docs/](docs/) (architecture, API, configuration, development, deployment). Some docs are in Russian.
- **Coding:** Write tests first before implementing any functionality. - **Coding:** Write tests first before implementing any functionality.
- **Running tests:** When the user asks to run tests or to verify changes, use the run-tests skill: [.cursor/skills/run-tests/SKILL.md](.cursor/skills/run-tests/SKILL.md). - **Running lint:** When the user asks to run the linter or to verify changes, use the run-lint skill: [.cursor/skills/run-lint/SKILL.md](.cursor/skills/run-lint/SKILL.md).
- **Running tests:** When the user asks to run tests or to verify changes, run lint first (run-lint skill), then use the run-tests skill: [.cursor/skills/run-tests/SKILL.md](.cursor/skills/run-tests/SKILL.md).

View File

@@ -0,0 +1,29 @@
---
name: run-lint
description: Runs backend (golangci-lint) and frontend (ESLint) linters for the hnh-map monorepo. Use when the user asks to run the linter, lint, or to check code style and lint errors.
---
# Run linter
## When to run
- User asks to run the linter, run lint, or check lint/style.
- After making code changes that should be validated by the project linters.
## What to run
Lint runs **in Docker** via the Makefile; no local Go or Node is required.
From the repo root:
- **Both backend and frontend:** `make lint` (runs golangci-lint then frontend ESLint in Docker).
Uses `docker-compose.tools.yml`; the first run may build the backend-tools and frontend-tools images.
## Scope
- **Backend-only changes** (e.g. `internal/`, `cmd/`): `make lint` still runs both; backend lint runs first.
- **Frontend-only changes** (e.g. `frontend-nuxt/`): `make lint` runs both; frontend lint runs second.
- **Both or unclear**: run `make lint`.
Report pass/fail and any linter errors or file/line references so the user can fix them.

View File

@@ -1,27 +1,27 @@
# Git and IDE # Git and IDE
.git .git
.gitignore .gitignore
.cursor .cursor
.cursorignore .cursorignore
*.md *.md
*.plan.md *.plan.md
# Old Vue 2 frontend (not used in build) # Old Vue 2 frontend (not used in build)
frontend/node_modules frontend/node_modules
frontend/dist frontend/dist
# Nuxt (built in frontendbuilder stage) # Nuxt (built in frontendbuilder stage)
frontend-nuxt/node_modules frontend-nuxt/node_modules
frontend-nuxt/.nuxt frontend-nuxt/.nuxt
frontend-nuxt/.output frontend-nuxt/.output
# Runtime data (mounted at run time, not needed for build) # Runtime data (mounted at run time, not needed for build)
grids grids
# Backup dir often has root-only permissions; exclude from build context # Backup dir often has root-only permissions; exclude from build context
backup backup
# Misc # Misc
*.log *.log
.env* .env*
.DS_Store .DS_Store

View File

@@ -1,7 +1,10 @@
# Backend tools image: Go + golangci-lint for test, fmt, lint. # Backend tools image: Go + golangci-lint for test, fmt, lint.
# Source is mounted at /hnh-map at run time via docker-compose.tools.yml. # Source is mounted at /src at run time; this WORKDIR is only for build-time go mod download.
FROM golang:1.24-alpine FROM golang:1.24-alpine
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.61.0 # v1.64+ required for Go 1.24 (export data format); see https://github.com/golangci/golangci-lint/issues/5225
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.0
WORKDIR /hnh-map WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download

View File

@@ -3,25 +3,31 @@
TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml TOOLS_COMPOSE = docker compose -f docker-compose.tools.yml
dev: dev:
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up --build
build: build:
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build --no-cache
test: test-backend test-frontend test: test-backend test-frontend
test-backend: test-backend:
$(TOOLS_COMPOSE) run --rm backend-tools go test ./... $(TOOLS_COMPOSE) build backend-tools
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && go test ./..."
test-frontend: test-frontend:
$(TOOLS_COMPOSE) build frontend-tools
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm test" $(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm test"
lint: lint:
$(TOOLS_COMPOSE) run --rm backend-tools golangci-lint run $(TOOLS_COMPOSE) build backend-tools
$(TOOLS_COMPOSE) build frontend-tools
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && golangci-lint run"
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run lint" $(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run lint"
fmt: fmt:
$(TOOLS_COMPOSE) run --rm backend-tools go fmt ./... $(TOOLS_COMPOSE) build backend-tools
$(TOOLS_COMPOSE) build frontend-tools
$(TOOLS_COMPOSE) run --rm backend-tools sh -c "go mod download && go fmt ./..."
$(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run format" $(TOOLS_COMPOSE) run --rm frontend-tools sh -c "npm ci && npm run format"
generate-frontend: generate-frontend:

View File

@@ -1,13 +1,18 @@
# One-off tools: test, lint, fmt. Use with: docker compose -f docker-compose.tools.yml run --rm <service> <cmd> # One-off tools: test, lint, fmt. Use with: docker compose -f docker-compose.tools.yml run --rm <service> <cmd>
# Source is mounted so commands run against current code. # Source is mounted so commands run against current code.
# Backend: mount at /src so the image's /go module cache (from build) is not overwritten.
services: services:
backend-tools: backend-tools:
build: build:
context: . context: .
dockerfile: Dockerfile.tools dockerfile: Dockerfile.tools
working_dir: /src
environment:
GOPATH: /go
GOMODCACHE: /go/pkg/mod
volumes: volumes:
- .:/hnh-map - .:/src
# Default command; override when running (e.g. go test ./..., golangci-lint run). # Default command; override when running (e.g. go test ./..., golangci-lint run).
command: ["go", "test", "./..."] command: ["go", "test", "./..."]

View File

@@ -1,79 +1,79 @@
# HTTP API # HTTP API
The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login). The API is available under the `/map/api/` prefix. Requests requiring authentication use a `session` cookie (set on login).
## Authentication ## Authentication
- **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`. - **POST /map/api/login** — sign in. Body: `{"user":"...","pass":"..."}`. On success returns JSON with user data and sets a session cookie. On first run, bootstrap is available: logging in as `admin` with the password from `HNHMAP_BOOTSTRAP_PASSWORD` creates the first admin user. For users created via OAuth (no password), returns 401 with `{"error":"Use OAuth to sign in"}`.
- **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display). - **GET /map/api/me** — current user (by session). Response: `username`, `auths`, and optionally `tokens`, `prefix`, `email` (string, optional — for Gravatar and display).
- **POST /map/api/logout** — sign out (invalidates the session). - **POST /map/api/logout** — sign out (invalidates the session).
- **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`. - **GET /map/api/setup** — check if initial setup is needed. Response: `{"setupRequired": true|false}`.
### OAuth ### OAuth
- **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`. - **GET /map/api/oauth/providers** — list of configured OAuth providers. Response: `["google", ...]`.
- **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`). - **GET /map/api/oauth/{provider}/login** — redirect to the provider's authorization page. Query: `redirect` — path to redirect to after successful login (e.g. `/profile`).
- **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state. - **GET /map/api/oauth/{provider}/callback** — callback from the provider (called automatically). Exchanges the `code` for tokens, creates or finds the user, creates a session, and redirects to `/profile` or the `redirect` from state.
## User account ## User account
- **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session. - **PATCH /map/api/me** — update current user. Body: `{"email": "..."}`. Used to set or change the user's email (for Gravatar and profile display). Requires a valid session.
- **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`. - **POST /map/api/me/tokens** — generate a new upload token (requires `upload` permission). Response: `{"tokens": ["...", ...]}`.
- **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`. - **POST /map/api/me/password** — change password. Body: `{"pass":"..."}`.
## Map data ## Map data
- **GET /map/api/config** — client configuration (title, auths). Requires a session. - **GET /map/api/config** — client configuration (title, auths). Requires a session.
- **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens. - **GET /map/api/v1/characters** — list of characters on the map (requires `map` permission; `markers` permission needed to see data). Each character object includes `ownedByMe` (boolean), which is true when the character was last updated by one of the current user's upload tokens.
- **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data). - **GET /map/api/v1/markers** — markers (requires `map` permission; `markers` permission needed to see data).
- **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed). - **GET /map/api/maps** — list of maps (filtered by permissions and hidden status). For non-admin users hidden maps are excluded; for admin, the response may include hidden maps (client should hide them in map selector if needed).
## Admin (all endpoints below require `admin` permission) ## Admin (all endpoints below require `admin` permission)
- **GET /map/api/admin/users** — list of usernames. - **GET /map/api/admin/users** — list of usernames.
- **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`. - **POST /map/api/admin/users** — create or update a user. Body: `{"user":"...","pass":"...","auths":["admin","map",...]}`.
- **GET /map/api/admin/users/:name** — user data. - **GET /map/api/admin/users/:name** — user data.
- **DELETE /map/api/admin/users/:name** — delete a user. - **DELETE /map/api/admin/users/:name** — delete a user.
- **GET /map/api/admin/settings** — settings (prefix, defaultHide, title). - **GET /map/api/admin/settings** — settings (prefix, defaultHide, title).
- **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional). - **POST /map/api/admin/settings** — save settings. Body: `{"prefix":"...","defaultHide":true|false,"title":"..."}` (all fields optional).
- **GET /map/api/admin/maps** — list of maps for the admin panel. - **GET /map/api/admin/maps** — list of maps for the admin panel.
- **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority). - **POST /map/api/admin/maps/:id** — update a map (name, hidden, priority).
- **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility. - **POST /map/api/admin/maps/:id/toggle-hidden** — toggle map visibility.
- **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database. - **POST /map/api/admin/wipe** — wipe grids, markers, tiles, and maps from the database.
- **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map. - **POST /map/api/admin/rebuildZooms** — start rebuilding tile zoom levels from base tiles in the background. Returns **202 Accepted** immediately; the operation can take minutes when there are many grids. The client may poll **GET /map/api/admin/rebuildZooms/status** until `{"running": false}` and then refresh the map.
- **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress. - **GET /map/api/admin/rebuildZooms/status** — returns `{"running": true|false}` indicating whether a rebuild started via POST rebuildZooms is still in progress.
- **GET /map/api/admin/export** — download data export (ZIP). - **GET /map/api/admin/export** — download data export (ZIP).
- **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers). - **POST /map/api/admin/merge** — upload and apply a merge (ZIP with grids and markers).
- **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`. - **GET /map/api/admin/wipeTile** — delete a tile. Query: `map`, `x`, `y`.
- **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`. - **GET /map/api/admin/setCoords** — shift grid coordinates. Query: `map`, `fx`, `fy`, `tx`, `ty`.
- **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`. - **GET /map/api/admin/hideMarker** — hide a marker. Query: `id`.
## Game client ## Game client
The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication. The game client (e.g. Purus Pasta) communicates via `/client/{token}/...` endpoints using token-based authentication.
- **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise. - **GET /client/{token}/checkVersion** — check client protocol version. Query: `version`. Returns 200 if matching, 400 otherwise.
- **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`. - **GET /client/{token}/locate** — get grid coordinates. Query: `gridID`. Response: `mapid;x;y`.
- **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests. - **POST /client/{token}/gridUpdate** — report visible grids and receive upload requests.
- **POST /client/{token}/gridUpload** — upload a tile image (multipart). - **POST /client/{token}/gridUpload** — upload a tile image (multipart).
- **POST /client/{token}/positionUpdate** — update character positions. - **POST /client/{token}/positionUpdate** — update character positions.
- **POST /client/{token}/markerUpdate** — upload markers. - **POST /client/{token}/markerUpdate** — upload markers.
## SSE (Server-Sent Events) ## SSE (Server-Sent Events)
- **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache. - **GET /map/updates** — real-time tile and merge updates. Requires a session with `map` permission. Sends an initial `data:` message with an empty tile cache array `[]`, then incremental `data:` messages with tile cache updates and `event: merge` messages for map merges. The client requests tiles with `cache=0` when not yet in cache.
## Tile images ## Tile images
- **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist. - **GET /map/grids/{mapid}/{zoom}/{x}_{y}.png** — tile image. Requires a session with `map` permission. Returns the tile image or a transparent 1×1 PNG if the tile does not exist.
## Response codes ## Response codes
- **200** — success. - **200** — success.
- **400** — bad request (wrong method, body, or parameters). - **400** — bad request (wrong method, body, or parameters).
- **401** — unauthorized (missing or invalid session). - **401** — unauthorized (missing or invalid session).
- **403** — forbidden (insufficient permissions). - **403** — forbidden (insufficient permissions).
- **404** — not found. - **404** — not found.
- **500** — internal error. - **500** — internal error.
Error format: JSON body `{"error": "message", "code": "CODE"}`. Error format: JSON body `{"error": "message", "code": "CODE"}`.

View File

@@ -1,6 +1,26 @@
import { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } from 'vue' import {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
} from 'vue'
export { ref, reactive, computed, watch, watchEffect, onMounted, onUnmounted, nextTick } export {
ref,
reactive,
computed,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
}
export function useRuntimeConfig() { export function useRuntimeConfig() {
return { return {

View File

@@ -4,6 +4,16 @@
</NuxtLayout> </NuxtLayout>
</template> </template>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>
<style> <style>
.page-enter-active, .page-enter-active,
.page-leave-active { .page-leave-active {
@@ -14,13 +24,3 @@
opacity: 0; opacity: 0;
} }
</style> </style>
<script setup lang="ts">
// Global error handling: on API auth failure, redirect to login
const { onApiError } = useMapApi()
const { fullUrl } = useAppPaths()
const unsubscribe = onApiError(() => {
if (import.meta.client) window.location.href = fullUrl('/login')
})
onUnmounted(() => unsubscribe())
</script>

View File

@@ -1,23 +1,59 @@
/* Map container background from theme (DaisyUI base-200) */ /* Map container background from theme (DaisyUI base-200) */
.leaflet-container { .leaflet-container {
background: var(--color-base-200); background: var(--color-base-200);
} }
/* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied /* Override Leaflet default: show tiles even when leaflet-tile-loaded is not applied
(e.g. due to cache, Nuxt hydration, or load event order). */ (e.g. due to cache, Nuxt hydration, or load event order). */
.leaflet-tile { .leaflet-tile {
visibility: visible !important; visibility: visible !important;
} }
/* Subtle highlight when a tile is updated via SSE (reduced intensity to limit flicker). */ /* Subtle highlight when a tile is updated via SSE (reduced intensity to limit flicker). */
@keyframes tile-fresh-glow { @keyframes tile-fresh-glow {
0% { 0% {
opacity: 0.92; opacity: 0.92;
} }
100% { 100% {
opacity: 1; opacity: 1;
} }
} }
.leaflet-tile.tile-fresh { .leaflet-tile.tile-fresh {
animation: tile-fresh-glow 0.4s ease-out; animation: tile-fresh-glow 0.4s ease-out;
} }
/* Leaflet tooltip: use theme colors (dark/light) */
.leaflet-tooltip {
background-color: var(--color-base-100);
color: var(--color-base-content);
border-color: var(--color-base-300);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
}
.leaflet-tooltip-top:before {
border-top-color: var(--color-base-100);
}
.leaflet-tooltip-bottom:before {
border-bottom-color: var(--color-base-100);
}
.leaflet-tooltip-left:before {
border-left-color: var(--color-base-100);
}
.leaflet-tooltip-right:before {
border-right-color: var(--color-base-100);
}
/* Leaflet popup: use theme colors (dark/light) */
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: var(--color-base-100);
color: var(--color-base-content);
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.35);
}
.leaflet-container a.leaflet-popup-close-button {
color: var(--color-base-content);
opacity: 0.7;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
opacity: 1;
}

View File

@@ -88,24 +88,27 @@
</div> </div>
<MapControls <MapControls
:hide-markers="mapLogic.state.hideMarkers.value" :hide-markers="mapLogic.state.hideMarkers.value"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
:selected-map-id="mapLogic.state.selectedMapId.value" :selected-map-id="mapLogic.state.selectedMapId.value"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
:overlay-map-id="mapLogic.state.overlayMapId.value" :overlay-map-id="mapLogic.state.overlayMapId.value"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
:selected-marker-id="mapLogic.state.selectedMarkerId.value" :selected-marker-id="mapLogic.state.selectedMarkerId.value"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
:selected-player-id="mapLogic.state.selectedPlayerId.value" :selected-player-id="mapLogic.state.selectedPlayerId.value"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="allMarkers"
:current-zoom="currentZoom"
:current-map-id="mapLogic.state.mapid.value" :current-map-id="mapLogic.state.mapid.value"
:current-coords="mapLogic.state.displayCoords.value" :current-coords="mapLogic.state.displayCoords.value"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:hide-markers="(v) => (mapLogic.state.hideMarkers.value = v)"
@update:selected-map-id="(v) => (mapLogic.state.selectedMapId.value = v)"
@update:overlay-map-id="(v) => (mapLogic.state.overlayMapId.value = v)"
@update:selected-marker-id="(v) => (mapLogic.state.selectedMarkerId.value = v)"
@update:selected-player-id="(v) => (mapLogic.state.selectedPlayerId.value = v)"
@zoom-in="mapLogic.zoomIn(leafletMap)" @zoom-in="mapLogic.zoomIn(leafletMap)"
@zoom-out="mapLogic.zoomOutControl(leafletMap)" @zoom-out="mapLogic.zoomOutControl(leafletMap)"
@reset-view="mapLogic.resetView(leafletMap)" @reset-view="mapLogic.resetView(leafletMap)"
@set-zoom="onSetZoom"
@jump-to-marker="mapLogic.state.selectedMarkerId.value = $event" @jump-to-marker="mapLogic.state.selectedMarkerId.value = $event"
/> />
<MapContextMenu <MapContextMenu
@@ -147,7 +150,7 @@ import { useMapNavigate } from '~/composables/useMapNavigate'
import { useFullscreen } from '~/composables/useFullscreen' import { useFullscreen } from '~/composables/useFullscreen'
import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates' import { startMapUpdates, type UseMapUpdatesReturn, type SseConnectionState } from '~/composables/useMapUpdates'
import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers' import { createMapLayers, type MapLayersManager } from '~/composables/useMapLayers'
import type { MapInfo, ConfigResponse, MeResponse } from '~/types/api' import type { MapInfo, ConfigResponse, MeResponse, Marker as ApiMarker } from '~/types/api'
import type L from 'leaflet' import type L from 'leaflet'
const props = withDefaults( const props = withDefaults(
@@ -252,6 +255,10 @@ const maps = ref<MapInfo[]>([])
const mapsLoaded = ref(false) const mapsLoaded = ref(false)
const questGivers = ref<Array<{ id: number; name: string }>>([]) const questGivers = ref<Array<{ id: number; name: string }>>([])
const players = ref<Array<{ id: number; name: string }>>([]) const players = ref<Array<{ id: number; name: string }>>([])
/** All markers from API for search suggestions (updated when markers load or on merge). */
const allMarkers = ref<ApiMarker[]>([])
/** Current map zoom level (16) for zoom slider. Updated on zoomend. */
const currentZoom = ref(HnHDefaultZoom)
/** Single source of truth: layout updates me, we derive auths for context menu. */ /** Single source of truth: layout updates me, we derive auths for context menu. */
const me = useState<MeResponse | null>('me', () => null) const me = useState<MeResponse | null>('me', () => null)
const auths = computed(() => me.value?.auths ?? []) const auths = computed(() => me.value?.auths ?? [])
@@ -347,6 +354,10 @@ function reloadPage() {
if (import.meta.client) window.location.reload() if (import.meta.client) window.location.reload()
} }
function onSetZoom(z: number) {
if (leafletMap) leafletMap.setZoom(z)
}
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement const target = e.target as HTMLElement
const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '') const inInput = /^(INPUT|TEXTAREA|SELECT)$/.test(target?.tagName ?? '')
@@ -462,6 +473,16 @@ onMounted(async () => {
getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value, getTrackingCharacterId: () => mapLogic.state.trackingCharacterId.value,
setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id }, setTrackingCharacterId: (id: number) => { mapLogic.state.trackingCharacterId.value = id },
onMarkerContextMenu: mapLogic.openMarkerContextMenu, onMarkerContextMenu: mapLogic.openMarkerContextMenu,
onAddMarkerToBookmark: (markerId, getMarkerById) => {
const m = getMarkerById(markerId)
if (!m) return
openBookmarkModal(m.name, 'Add bookmark', {
kind: 'add',
mapId: m.map,
x: Math.floor(m.position.x / TileSize),
y: Math.floor(m.position.y / TileSize),
})
},
resolveIconUrl: (path) => resolvePath(path), resolveIconUrl: (path) => resolvePath(path),
fallbackIconUrl: FALLBACK_MARKER_ICON, fallbackIconUrl: FALLBACK_MARKER_ICON,
}) })
@@ -479,7 +500,9 @@ onMounted(async () => {
layersManager!.changeMap(mapTo) layersManager!.changeMap(mapTo)
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
}) })
leafletMap!.setView(latLng, leafletMap!.getZoom()) leafletMap!.setView(latLng, leafletMap!.getZoom())
@@ -530,7 +553,9 @@ onMounted(async () => {
// Markers load asynchronously after map is visible. // Markers load asynchronously after map is visible.
api.getMarkers().then((body) => { api.getMarkers().then((body) => {
if (!mounted) return if (!mounted) return
layersManager!.updateMarkers(Array.isArray(body) ? body : []) const list = Array.isArray(body) ? body : []
allMarkers.value = list
layersManager!.updateMarkers(list)
questGivers.value = layersManager!.getQuestGivers() questGivers.value = layersManager!.getQuestGivers()
updateSelectedMarkerForBookmark() updateSelectedMarkerForBookmark()
}) })
@@ -650,6 +675,10 @@ onMounted(async () => {
leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap)) leafletMap.on('moveend', () => mapLogic.updateDisplayCoords(leafletMap))
mapLogic.updateDisplayCoords(leafletMap) mapLogic.updateDisplayCoords(leafletMap)
currentZoom.value = leafletMap.getZoom()
leafletMap.on('zoomend', () => {
if (leafletMap) currentZoom.value = leafletMap.getZoom()
})
leafletMap.on('drag', () => { leafletMap.on('drag', () => {
mapLogic.state.trackingCharacterId.value = -1 mapLogic.state.trackingCharacterId.value = -1
}) })

View File

@@ -15,7 +15,7 @@
:readonly="readonly" :readonly="readonly"
:aria-describedby="ariaDescribedby" :aria-describedby="ariaDescribedby"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)" @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/> >
<button <button
type="button" type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation" class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-square min-h-9 min-w-9 touch-manipulation"
@@ -41,7 +41,14 @@ const props = withDefaults(
inputId?: string inputId?: string
ariaDescribedby?: string ariaDescribedby?: string
}>(), }>(),
{ required: false, autocomplete: 'off', inputId: undefined, ariaDescribedby: undefined } {
required: false,
autocomplete: 'off',
inputId: undefined,
ariaDescribedby: undefined,
label: undefined,
placeholder: undefined,
}
) )
const emit = defineEmits<{ 'update:modelValue': [value: string] }>() const emit = defineEmits<{ 'update:modelValue': [value: string] }>()

View File

@@ -6,5 +6,5 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ inheritAttrs: false }) defineOptions({ name: 'AppSkeleton', inheritAttrs: false })
</script> </script>

View File

@@ -30,7 +30,7 @@ const props = withDefaults(
email?: string email?: string
size?: number size?: number
}>(), }>(),
{ size: 32 } { size: 32, email: undefined }
) )
const gravatarError = ref(false) const gravatarError = ref(false)

View File

@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
</template>

View File

@@ -18,7 +18,7 @@
class="input input-bordered w-full" class="input input-bordered w-full"
placeholder="Bookmark name" placeholder="Bookmark name"
@keydown.enter.prevent="onSubmit" @keydown.enter.prevent="onSubmit"
/> >
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">

View File

@@ -7,7 +7,7 @@
<div class="flex flex-col gap-1 max-h-40 overflow-y-auto"> <div class="flex flex-col gap-1 max-h-40 overflow-y-auto">
<template v-if="bookmarks.length === 0"> <template v-if="bookmarks.length === 0">
<p class="text-xs text-base-content/60 py-1">No saved locations.</p> <p class="text-xs text-base-content/60 py-1">No saved locations.</p>
<p class="text-xs text-base-content/50 py-0">Add current location or a selected quest giver below.</p> <p class="text-xs text-base-content/50 py-0">Add your first location below.</p>
</template> </template>
<template v-else> <template v-else>
<div <div
@@ -49,7 +49,7 @@
class="btn btn-primary btn-sm w-full" class="btn btn-primary btn-sm w-full"
:class="touchFriendly ? 'min-h-11' : ''" :class="touchFriendly ? 'min-h-11' : ''"
:disabled="!selectedMarkerForBookmark" :disabled="!selectedMarkerForBookmark"
title="Add selected quest giver as bookmark" :title="selectedMarkerForBookmark ? 'Add selected quest giver as bookmark' : 'Select a quest giver from the list above to add it as a bookmark.'"
@click="onAddSelectedMarker" @click="onAddSelectedMarker"
> >
<icons-icon-plus class="size-4" /> <icons-icon-plus class="size-4" />

View File

@@ -39,22 +39,25 @@
<MapControlsContent <MapControlsContent
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId" :overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect" :selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect" :selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="markers"
:current-zoom="currentZoom"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@update:overlay-map-id="(v) => (overlayMapId = v)"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@set-zoom="$emit('setZoom', $event)"
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
@@ -130,23 +133,26 @@
<MapControlsContent <MapControlsContent
v-model:hide-markers="hideMarkers" v-model:hide-markers="hideMarkers"
:selected-map-id-select="selectedMapIdSelect" :selected-map-id-select="selectedMapIdSelect"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
:overlay-map-id="overlayMapId" :overlay-map-id="overlayMapId"
@update:overlay-map-id="(v) => (overlayMapId = v)"
:selected-marker-id-select="selectedMarkerIdSelect" :selected-marker-id-select="selectedMarkerIdSelect"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
:selected-player-id-select="selectedPlayerIdSelect" :selected-player-id-select="selectedPlayerIdSelect"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:players="players" :players="players"
:markers="markers"
:current-zoom="currentZoom"
:current-map-id="currentMapId ?? undefined" :current-map-id="currentMapId ?? undefined"
:current-coords="currentCoords" :current-coords="currentCoords"
:selected-marker-for-bookmark="selectedMarkerForBookmark" :selected-marker-for-bookmark="selectedMarkerForBookmark"
:touch-friendly="true" :touch-friendly="true"
@update:selected-map-id-select="(v) => (selectedMapIdSelect = v)"
@update:overlay-map-id="(v) => (overlayMapId = v)"
@update:selected-marker-id-select="(v) => (selectedMarkerIdSelect = v)"
@update:selected-player-id-select="(v) => (selectedPlayerIdSelect = v)"
@zoom-in="$emit('zoomIn')" @zoom-in="$emit('zoomIn')"
@zoom-out="$emit('zoomOut')" @zoom-out="$emit('zoomOut')"
@reset-view="$emit('resetView')" @reset-view="$emit('resetView')"
@set-zoom="$emit('setZoom', $event)"
@jump-to-marker="$emit('jumpToMarker', $event)" @jump-to-marker="$emit('jumpToMarker', $event)"
/> />
</div> </div>
@@ -169,7 +175,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue' import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapControlsContent from '~/components/map/MapControlsContent.vue' import MapControlsContent from '~/components/map/MapControlsContent.vue'
@@ -185,9 +191,11 @@ interface Player {
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps?: MapInfo[]
questGivers: QuestGiver[] questGivers?: QuestGiver[]
players: Player[] players?: Player[]
markers?: ApiMarker[]
currentZoom?: number
currentMapId?: number | null currentMapId?: number | null
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark selectedMarkerForBookmark?: SelectedMarkerForBookmark
@@ -196,6 +204,8 @@ const props = withDefaults(
maps: () => [], maps: () => [],
questGivers: () => [], questGivers: () => [],
players: () => [], players: () => [],
markers: () => [],
currentZoom: 1,
currentMapId: null, currentMapId: null,
currentCoords: null, currentCoords: null,
selectedMarkerForBookmark: null, selectedMarkerForBookmark: null,
@@ -206,6 +216,7 @@ defineEmits<{
zoomIn: [] zoomIn: []
zoomOut: [] zoomOut: []
resetView: [] resetView: []
setZoom: [level: number]
jumpToMarker: [id: number] jumpToMarker: [id: number]
}>() }>()

View File

@@ -5,6 +5,8 @@
v-if="currentMapId != null && currentCoords != null" v-if="currentMapId != null && currentCoords != null"
:maps="maps" :maps="maps"
:quest-givers="questGivers" :quest-givers="questGivers"
:markers="markers"
:overlay-map-id="props.overlayMapId"
:current-map-id="currentMapId" :current-map-id="currentMapId"
:current-coords="currentCoords" :current-coords="currentCoords"
:touch-friendly="touchFriendly" :touch-friendly="touchFriendly"
@@ -48,6 +50,19 @@
<icons-icon-home /> <icons-icon-home />
</button> </button>
</div> </div>
<div class="flex items-center gap-2">
<input
type="range"
:min="zoomMin"
:max="zoomMax"
:value="currentZoom"
class="range range-primary range-sm flex-1"
:class="touchFriendly ? 'range-lg' : ''"
aria-label="Zoom level"
@input="onZoomSliderInput($event)"
>
<span class="text-xs font-mono w-6 text-right" aria-hidden="true">{{ currentZoom }}</span>
</div>
</section> </section>
<!-- Display --> <!-- Display -->
<section class="flex flex-col gap-2"> <section class="flex flex-col gap-2">
@@ -56,7 +71,7 @@
Display Display
</h3> </h3>
<label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''"> <label class="label cursor-pointer justify-start gap-2 py-0 hover:bg-base-200/50 rounded-lg px-2 -mx-2 touch-manipulation" :class="touchFriendly ? 'min-h-11' : ''">
<input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" /> <input v-model="hideMarkers" type="checkbox" class="checkbox checkbox-sm" >
<span>Hide markers</span> <span>Hide markers</span>
</label> </label>
</section> </section>
@@ -78,7 +93,16 @@
</select> </select>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label py-0"><span>Overlay Map</span></label> <label class="label py-0 flex items-center gap-1.5">
<span>Overlay Map</span>
<span
class="inline-flex text-base-content/60 cursor-help"
title="Overlay shows markers from another map on top of the current one."
aria-label="Overlay shows markers from another map on top of the current one."
>
<icons-icon-info class="size-3.5" />
</span>
</label>
<select <select
v-model="overlayMapId" v-model="overlayMapId"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation"
@@ -89,22 +113,43 @@
</select> </select>
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label py-0"><span>Jump to Quest Giver</span></label> <label class="label py-0"><span>Jump to</span></label>
<div class="join w-full flex">
<button
type="button"
class="btn btn-sm join-item flex-1 touch-manipulation"
:class="[jumpToTab === 'quest' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
aria-pressed="jumpToTab === 'quest'"
@click="jumpToTab = 'quest'"
>
Quest giver
</button>
<button
type="button"
class="btn btn-sm join-item flex-1 touch-manipulation"
:class="[jumpToTab === 'player' ? 'btn-active' : 'btn-ghost', touchFriendly ? 'min-h-11 text-base' : '']"
aria-pressed="jumpToTab === 'player'"
@click="jumpToTab = 'player'"
>
Player
</button>
</div>
<select <select
v-if="jumpToTab === 'quest'"
v-model="selectedMarkerIdSelect" v-model="selectedMarkerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
aria-label="Select quest giver"
> >
<option value="">Select quest giver</option> <option value="">Select quest giver</option>
<option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option> <option v-for="q in questGivers" :key="q.id" :value="String(q.id)">{{ q.name }}</option>
</select> </select>
</fieldset>
<fieldset class="fieldset">
<label class="label py-0"><span>Jump to Player</span></label>
<select <select
v-else
v-model="selectedPlayerIdSelect" v-model="selectedPlayerIdSelect"
class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation" class="select select-sm w-full focus:ring-2 focus:ring-primary touch-manipulation mt-1"
:class="touchFriendly ? 'min-h-11 text-base' : ''" :class="touchFriendly ? 'min-h-11 text-base' : ''"
aria-label="Select player"
> >
<option value="">Select player</option> <option value="">Select player</option>
<option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option> <option v-for="p in players" :key="p.id" :value="String(p.id)">{{ p.name }}</option>
@@ -126,9 +171,10 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue' import type { SelectedMarkerForBookmark } from '~/components/map/MapBookmarks.vue'
import MapBookmarks from '~/components/map/MapBookmarks.vue' import MapBookmarks from '~/components/map/MapBookmarks.vue'
import { HnHMinZoom, HnHMaxZoom } from '~/lib/LeafletCustomTypes'
interface QuestGiver { interface QuestGiver {
id: number id: number
@@ -140,27 +186,33 @@ interface Player {
name: string name: string
} }
const zoomMin = HnHMinZoom
const zoomMax = HnHMaxZoom
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
questGivers: QuestGiver[] questGivers: QuestGiver[]
players: Player[] players: Player[]
markers?: ApiMarker[]
touchFriendly?: boolean touchFriendly?: boolean
selectedMapIdSelect: string selectedMapIdSelect: string
overlayMapId: number overlayMapId: number
selectedMarkerIdSelect: string selectedMarkerIdSelect: string
selectedPlayerIdSelect: string selectedPlayerIdSelect: string
currentZoom?: number
currentMapId?: number currentMapId?: number
currentCoords?: { x: number; y: number; z: number } | null currentCoords?: { x: number; y: number; z: number } | null
selectedMarkerForBookmark?: SelectedMarkerForBookmark selectedMarkerForBookmark?: SelectedMarkerForBookmark
}>(), }>(),
{ touchFriendly: false, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null } { touchFriendly: false, markers: () => [], currentZoom: 1, currentMapId: 0, currentCoords: null, selectedMarkerForBookmark: null }
) )
const emit = defineEmits<{ const emit = defineEmits<{
zoomIn: [] zoomIn: []
zoomOut: [] zoomOut: []
resetView: [] resetView: []
setZoom: [level: number]
jumpToMarker: [id: number] jumpToMarker: [id: number]
'update:hideMarkers': [v: boolean] 'update:hideMarkers': [v: boolean]
'update:selectedMapIdSelect': [v: string] 'update:selectedMapIdSelect': [v: string]
@@ -169,8 +221,16 @@ const emit = defineEmits<{
'update:selectedPlayerIdSelect': [v: string] 'update:selectedPlayerIdSelect': [v: string]
}>() }>()
function onZoomSliderInput(event: Event) {
const value = (event.target as HTMLInputElement).value
const level = Number(value)
if (!Number.isNaN(level)) emit('setZoom', level)
}
const hideMarkers = defineModel<boolean>('hideMarkers', { required: true }) const hideMarkers = defineModel<boolean>('hideMarkers', { required: true })
const jumpToTab = ref<'quest' | 'player'>('quest')
const selectedMapIdSelect = computed({ const selectedMapIdSelect = computed({
get: () => props.selectedMapIdSelect, get: () => props.selectedMapIdSelect,
set: (v) => emit('update:selectedMapIdSelect', v), set: (v) => emit('update:selectedMapIdSelect', v),

View File

@@ -11,8 +11,8 @@
<h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3> <h3 id="coord-set-modal-title" class="font-bold text-lg">Rewrite tile coords</h3>
<p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p> <p class="py-2">From ({{ coordSetFrom.x }}, {{ coordSetFrom.y }}) to:</p>
<div class="flex gap-2"> <div class="flex gap-2">
<input ref="firstInputRef" v-model.number="localTo.x" type="number" class="input flex-1" placeholder="X" /> <input ref="firstInputRef" 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" /> <input v-model.number="localTo.y" type="number" class="input flex-1" placeholder="Y" >
</div> </div>
<div class="modal-action"> <div class="modal-action">
<form method="dialog" @submit.prevent="onSubmit"> <form method="dialog" @submit.prevent="onSubmit">

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
v-if="displayCoords" v-if="displayCoords"
class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-sm bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100" class="absolute bottom-2 right-2 z-[501] rounded-lg px-3 py-2 font-mono text-base bg-base-100/95 backdrop-blur-sm border border-base-300/50 shadow cursor-pointer select-none transition-all hover:border-primary/50 hover:bg-base-100 flex items-center gap-2"
aria-label="Current grid position and zoom click to copy share link" aria-label="Current grid position and zoom click to copy share link"
:title="copied ? 'Copied!' : 'Click to copy share link'" :title="copied ? 'Copied!' : 'Click to copy share link'"
role="button" role="button"
@@ -19,6 +19,7 @@
Copied! Copied!
</span> </span>
</span> </span>
<icons-icon-copy class="size-4 shrink-0 opacity-70" aria-hidden="true" />
</div> </div>
</template> </template>

View File

@@ -22,7 +22,7 @@
@keydown.enter="onEnter" @keydown.enter="onEnter"
@keydown.down.prevent="moveHighlight(1)" @keydown.down.prevent="moveHighlight(1)"
@keydown.up.prevent="moveHighlight(-1)" @keydown.up.prevent="moveHighlight(-1)"
/> >
<button <button
v-if="query" v-if="query"
type="button" type="button"
@@ -78,7 +78,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { MapInfo } from '~/types/api' import type { MapInfo, Marker as ApiMarker } from '~/types/api'
import { TileSize } from '~/lib/LeafletCustomTypes'
import { useMapNavigate } from '~/composables/useMapNavigate' import { useMapNavigate } from '~/composables/useMapNavigate'
import { useRecentLocations } from '~/composables/useRecentLocations' import { useRecentLocations } from '~/composables/useRecentLocations'
@@ -86,11 +87,13 @@ const props = withDefaults(
defineProps<{ defineProps<{
maps: MapInfo[] maps: MapInfo[]
questGivers: Array<{ id: number; name: string }> questGivers: Array<{ id: number; name: string }>
markers?: ApiMarker[]
overlayMapId?: number
currentMapId: number currentMapId: number
currentCoords: { x: number; y: number; z: number } | null currentCoords: { x: number; y: number; z: number } | null
touchFriendly?: boolean touchFriendly?: boolean
}>(), }>(),
{ touchFriendly: false } { touchFriendly: false, markers: () => [], overlayMapId: -1 }
) )
const { goToCoords } = useMapNavigate() const { goToCoords } = useMapNavigate()
@@ -147,22 +150,37 @@ const suggestions = computed<Suggestion[]>(() => {
} }
const list: Suggestion[] = [] const list: Suggestion[] = []
for (const qg of props.questGivers) { const overlayId = props.overlayMapId ?? -1
if (qg.name.toLowerCase().includes(q)) { const visibleMarkers = (props.markers ?? []).filter(
(m) =>
!m.hidden &&
(m.map === props.currentMapId || (overlayId >= 0 && m.map === overlayId))
)
const qLower = q.toLowerCase()
for (const m of visibleMarkers) {
if (m.name.toLowerCase().includes(qLower)) {
const gridX = Math.floor(m.position.x / TileSize)
const gridY = Math.floor(m.position.y / TileSize)
list.push({ list.push({
key: `qg-${qg.id}`, key: `marker-${m.id}`,
label: qg.name, label: `${m.name} · ${gridX}, ${gridY}`,
mapId: props.currentMapId, mapId: m.map,
x: 0, x: gridX,
y: 0, y: gridY,
zoom: undefined, zoom: undefined,
markerId: qg.id, markerId: m.id,
}) })
} }
} }
if (list.length > 0) return list.slice(0, 8) // Prefer quest givers (match by id in questGivers) so they appear first when query matches both
const qgIds = new Set(props.questGivers.map((qg) => qg.id))
return [] list.sort((a, b) => {
const aQg = a.markerId != null && qgIds.has(a.markerId) ? 1 : 0
const bQg = b.markerId != null && qgIds.has(b.markerId) ? 1 : 0
if (bQg !== aQg) return bQg - aQg
return 0
})
return list.slice(0, 8)
}) })
function scheduleCloseDropdown() { function scheduleCloseDropdown() {

View File

@@ -1,10 +1,10 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAppPaths } from '../useAppPaths'
const useRuntimeConfigMock = vi.fn() const useRuntimeConfigMock = vi.fn()
vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock) vi.stubGlobal('useRuntimeConfig', useRuntimeConfigMock)
import { useAppPaths } from '../useAppPaths'
describe('useAppPaths with default base /', () => { describe('useAppPaths with default base /', () => {
beforeEach(() => { beforeEach(() => {
useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } }) useRuntimeConfigMock.mockReturnValue({ app: { baseURL: '/' } })

View File

@@ -1,284 +1,284 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.stubGlobal('useRuntimeConfig', () => ({ import { useMapApi } from '../useMapApi'
app: { baseURL: '/' },
public: { apiBase: '/map/api' }, vi.stubGlobal('useRuntimeConfig', () => ({
})) app: { baseURL: '/' },
public: { apiBase: '/map/api' },
import { useMapApi } from '../useMapApi' }))
function mockFetch(status: number, body: unknown, contentType = 'application/json') { function mockFetch(status: number, body: unknown, contentType = 'application/json') {
return vi.fn().mockResolvedValue({ return vi.fn().mockResolvedValue({
ok: status >= 200 && status < 300, ok: status >= 200 && status < 300,
status, status,
headers: new Headers({ 'content-type': contentType }), headers: new Headers({ 'content-type': contentType }),
json: () => Promise.resolve(body), json: () => Promise.resolve(body),
} as Response) } as Response)
} }
describe('useMapApi', () => { describe('useMapApi', () => {
let originalFetch: typeof globalThis.fetch let originalFetch: typeof globalThis.fetch
beforeEach(() => { beforeEach(() => {
originalFetch = globalThis.fetch originalFetch = globalThis.fetch
}) })
afterEach(() => { afterEach(() => {
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
vi.restoreAllMocks() vi.restoreAllMocks()
}) })
describe('getConfig', () => { describe('getConfig', () => {
it('fetches config from API', async () => { it('fetches config from API', async () => {
const data = { title: 'Test', auths: ['map'] } const data = { title: 'Test', auths: ['map'] }
globalThis.fetch = mockFetch(200, data) globalThis.fetch = mockFetch(200, data)
const { getConfig } = useMapApi() const { getConfig } = useMapApi()
const result = await getConfig() const result = await getConfig()
expect(result).toEqual(data) expect(result).toEqual(data)
expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' })) expect(globalThis.fetch).toHaveBeenCalledWith('/map/api/config', expect.objectContaining({ credentials: 'include' }))
}) })
it('throws on 401', async () => { it('throws on 401', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' }) globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const { getConfig } = useMapApi() const { getConfig } = useMapApi()
await expect(getConfig()).rejects.toThrow('Unauthorized') await expect(getConfig()).rejects.toThrow('Unauthorized')
}) })
it('throws on 403', async () => { it('throws on 403', async () => {
globalThis.fetch = mockFetch(403, { error: 'Forbidden' }) globalThis.fetch = mockFetch(403, { error: 'Forbidden' })
const { getConfig } = useMapApi() const { getConfig } = useMapApi()
await expect(getConfig()).rejects.toThrow('Forbidden') await expect(getConfig()).rejects.toThrow('Forbidden')
}) })
}) })
describe('getCharacters', () => { describe('getCharacters', () => {
it('fetches characters', async () => { it('fetches characters', async () => {
const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }] const chars = [{ name: 'Hero', id: 1, map: 1, position: { x: 0, y: 0 }, type: 'player' }]
globalThis.fetch = mockFetch(200, chars) globalThis.fetch = mockFetch(200, chars)
const { getCharacters } = useMapApi() const { getCharacters } = useMapApi()
const result = await getCharacters() const result = await getCharacters()
expect(result).toEqual(chars) expect(result).toEqual(chars)
}) })
}) })
describe('getMarkers', () => { describe('getMarkers', () => {
it('fetches markers', async () => { it('fetches markers', async () => {
const markers = [{ name: 'Tower', id: 1, map: 1, position: { x: 10, y: 20 }, image: 'gfx/terobjs/mm/tower', hidden: false }] 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) globalThis.fetch = mockFetch(200, markers)
const { getMarkers } = useMapApi() const { getMarkers } = useMapApi()
const result = await getMarkers() const result = await getMarkers()
expect(result).toEqual(markers) expect(result).toEqual(markers)
}) })
}) })
describe('getMaps', () => { describe('getMaps', () => {
it('fetches maps', async () => { it('fetches maps', async () => {
const maps = { '1': { ID: 1, Name: 'world' } } const maps = { '1': { ID: 1, Name: 'world' } }
globalThis.fetch = mockFetch(200, maps) globalThis.fetch = mockFetch(200, maps)
const { getMaps } = useMapApi() const { getMaps } = useMapApi()
const result = await getMaps() const result = await getMaps()
expect(result).toEqual(maps) expect(result).toEqual(maps)
}) })
}) })
describe('login', () => { describe('login', () => {
it('sends credentials and returns me response', async () => { it('sends credentials and returns me response', async () => {
const meResp = { username: 'alice', auths: ['map'] } const meResp = { username: 'alice', auths: ['map'] }
globalThis.fetch = mockFetch(200, meResp) globalThis.fetch = mockFetch(200, meResp)
const { login } = useMapApi() const { login } = useMapApi()
const result = await login('alice', 'secret') const result = await login('alice', 'secret')
expect(result).toEqual(meResp) expect(result).toEqual(meResp)
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/login', '/map/api/login',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
body: JSON.stringify({ user: 'alice', pass: 'secret' }), body: JSON.stringify({ user: 'alice', pass: 'secret' }),
}), }),
) )
}) })
it('throws on 401 with error message', async () => { it('throws on 401 with error message', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: false, ok: false,
status: 401, status: 401,
json: () => Promise.resolve({ error: 'Invalid credentials' }), json: () => Promise.resolve({ error: 'Invalid credentials' }),
}) })
const { login } = useMapApi() const { login } = useMapApi()
await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials') await expect(login('alice', 'wrong')).rejects.toThrow('Invalid credentials')
}) })
}) })
describe('logout', () => { describe('logout', () => {
it('sends POST to logout', async () => { it('sends POST to logout', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 }) globalThis.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 })
const { logout } = useMapApi() const { logout } = useMapApi()
await logout() await logout()
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/logout', '/map/api/logout',
expect.objectContaining({ method: 'POST' }), expect.objectContaining({ method: 'POST' }),
) )
}) })
}) })
describe('me', () => { describe('me', () => {
it('fetches current user', async () => { it('fetches current user', async () => {
const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' } const meResp = { username: 'alice', auths: ['map', 'upload'], tokens: ['tok1'], prefix: 'pfx' }
globalThis.fetch = mockFetch(200, meResp) globalThis.fetch = mockFetch(200, meResp)
const { me } = useMapApi() const { me } = useMapApi()
const result = await me() const result = await me()
expect(result).toEqual(meResp) expect(result).toEqual(meResp)
}) })
}) })
describe('setupRequired', () => { describe('setupRequired', () => {
it('checks setup status', async () => { it('checks setup status', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: () => Promise.resolve({ setupRequired: true }), json: () => Promise.resolve({ setupRequired: true }),
}) })
const { setupRequired } = useMapApi() const { setupRequired } = useMapApi()
const result = await setupRequired() const result = await setupRequired()
expect(result).toEqual({ setupRequired: true }) expect(result).toEqual({ setupRequired: true })
}) })
}) })
describe('oauthProviders', () => { describe('oauthProviders', () => {
it('returns providers list', async () => { it('returns providers list', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
status: 200, status: 200,
json: () => Promise.resolve(['google']), json: () => Promise.resolve(['google']),
}) })
const { oauthProviders } = useMapApi() const { oauthProviders } = useMapApi()
const result = await oauthProviders() const result = await oauthProviders()
expect(result).toEqual(['google']) expect(result).toEqual(['google'])
}) })
it('returns empty array on error', async () => { it('returns empty array on error', async () => {
globalThis.fetch = vi.fn().mockRejectedValue(new Error('network')) globalThis.fetch = vi.fn().mockRejectedValue(new Error('network'))
const { oauthProviders } = useMapApi() const { oauthProviders } = useMapApi()
const result = await oauthProviders() const result = await oauthProviders()
expect(result).toEqual([]) expect(result).toEqual([])
}) })
it('returns empty array on non-ok', async () => { it('returns empty array on non-ok', async () => {
globalThis.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: false, ok: false,
status: 500, status: 500,
}) })
const { oauthProviders } = useMapApi() const { oauthProviders } = useMapApi()
const result = await oauthProviders() const result = await oauthProviders()
expect(result).toEqual([]) expect(result).toEqual([])
}) })
}) })
describe('oauthLoginUrl', () => { describe('oauthLoginUrl', () => {
it('builds OAuth login URL', () => { it('builds OAuth login URL', () => {
// happy-dom needs an absolute URL for `new URL()`. The source code // happy-dom needs an absolute URL for `new URL()`. The source code
// creates `new URL(apiBase + path)` which is relative. // creates `new URL(apiBase + path)` which is relative.
// Verify the underlying apiBase and path construction instead. // Verify the underlying apiBase and path construction instead.
const { apiBase } = useMapApi() const { apiBase } = useMapApi()
const expected = `${apiBase}/oauth/google/login` const expected = `${apiBase}/oauth/google/login`
expect(expected).toBe('/map/api/oauth/google/login') expect(expected).toBe('/map/api/oauth/google/login')
}) })
it('oauthLoginUrl is a function', () => { it('oauthLoginUrl is a function', () => {
const { oauthLoginUrl } = useMapApi() const { oauthLoginUrl } = useMapApi()
expect(typeof oauthLoginUrl).toBe('function') expect(typeof oauthLoginUrl).toBe('function')
}) })
}) })
describe('onApiError', () => { describe('onApiError', () => {
it('fires callback on 401', async () => { it('fires callback on 401', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' }) globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const callback = vi.fn() const callback = vi.fn()
const { onApiError, getConfig } = useMapApi() const { onApiError, getConfig } = useMapApi()
onApiError(callback) onApiError(callback)
await expect(getConfig()).rejects.toThrow() await expect(getConfig()).rejects.toThrow()
expect(callback).toHaveBeenCalled() expect(callback).toHaveBeenCalled()
}) })
it('returns unsubscribe function', async () => { it('returns unsubscribe function', async () => {
globalThis.fetch = mockFetch(401, { error: 'Unauthorized' }) globalThis.fetch = mockFetch(401, { error: 'Unauthorized' })
const callback = vi.fn() const callback = vi.fn()
const { onApiError, getConfig } = useMapApi() const { onApiError, getConfig } = useMapApi()
const unsub = onApiError(callback) const unsub = onApiError(callback)
unsub() unsub()
await expect(getConfig()).rejects.toThrow() await expect(getConfig()).rejects.toThrow()
expect(callback).not.toHaveBeenCalled() expect(callback).not.toHaveBeenCalled()
}) })
}) })
describe('admin endpoints', () => { describe('admin endpoints', () => {
it('adminExportUrl returns correct path', () => { it('adminExportUrl returns correct path', () => {
const { adminExportUrl } = useMapApi() const { adminExportUrl } = useMapApi()
expect(adminExportUrl()).toBe('/map/api/admin/export') expect(adminExportUrl()).toBe('/map/api/admin/export')
}) })
it('adminUsers fetches user list', async () => { it('adminUsers fetches user list', async () => {
globalThis.fetch = mockFetch(200, ['alice', 'bob']) globalThis.fetch = mockFetch(200, ['alice', 'bob'])
const { adminUsers } = useMapApi() const { adminUsers } = useMapApi()
const result = await adminUsers() const result = await adminUsers()
expect(result).toEqual(['alice', 'bob']) expect(result).toEqual(['alice', 'bob'])
}) })
it('adminSettings fetches settings', async () => { it('adminSettings fetches settings', async () => {
const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' } const settings = { prefix: 'pfx', defaultHide: false, title: 'Map' }
globalThis.fetch = mockFetch(200, settings) globalThis.fetch = mockFetch(200, settings)
const { adminSettings } = useMapApi() const { adminSettings } = useMapApi()
const result = await adminSettings() const result = await adminSettings()
expect(result).toEqual(settings) expect(result).toEqual(settings)
}) })
}) })
describe('meTokens', () => { describe('meTokens', () => {
it('generates and returns tokens', async () => { it('generates and returns tokens', async () => {
globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] }) globalThis.fetch = mockFetch(200, { tokens: ['tok1', 'tok2'] })
const { meTokens } = useMapApi() const { meTokens } = useMapApi()
const result = await meTokens() const result = await meTokens()
expect(result).toEqual(['tok1', 'tok2']) expect(result).toEqual(['tok1', 'tok2'])
}) })
}) })
describe('mePassword', () => { describe('mePassword', () => {
it('sends password change', async () => { it('sends password change', async () => {
globalThis.fetch = mockFetch(200, undefined, 'text/plain') globalThis.fetch = mockFetch(200, undefined, 'text/plain')
const { mePassword } = useMapApi() const { mePassword } = useMapApi()
await mePassword('newpass') await mePassword('newpass')
expect(globalThis.fetch).toHaveBeenCalledWith( expect(globalThis.fetch).toHaveBeenCalledWith(
'/map/api/me/password', '/map/api/me/password',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
body: JSON.stringify({ pass: 'newpass' }), body: JSON.stringify({ pass: 'newpass' }),
}), }),
) )
}) })
}) })
}) })

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useMapBookmarks } from '../useMapBookmarks'
const stateByKey: Record<string, ReturnType<typeof ref>> = {} const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => { const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) { if (!stateByKey[key]) {
@@ -18,15 +20,13 @@ const localStorageMock = {
storage[key] = value storage[key] = value
}), }),
clear: vi.fn(() => { clear: vi.fn(() => {
for (const k of Object.keys(storage)) delete storage[k] delete storage['hnh-map-bookmarks']
}), }),
} }
vi.stubGlobal('localStorage', localStorageMock) vi.stubGlobal('localStorage', localStorageMock)
vi.stubGlobal('import.meta.server', false) vi.stubGlobal('import.meta.server', false)
vi.stubGlobal('import.meta.client', true) vi.stubGlobal('import.meta.client', true)
import { useMapBookmarks } from '../useMapBookmarks'
describe('useMapBookmarks', () => { describe('useMapBookmarks', () => {
beforeEach(() => { beforeEach(() => {
storage['hnh-map-bookmarks'] = '[]' storage['hnh-map-bookmarks'] = '[]'

View File

@@ -1,11 +1,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import type { Map } from 'leaflet'
import { useMapLogic } from '../useMapLogic'
vi.stubGlobal('ref', ref) vi.stubGlobal('ref', ref)
vi.stubGlobal('reactive', reactive) vi.stubGlobal('reactive', reactive)
import { useMapLogic } from '../useMapLogic'
describe('useMapLogic', () => { describe('useMapLogic', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -27,7 +28,7 @@ describe('useMapLogic', () => {
it('zoomIn calls map.zoomIn', () => { it('zoomIn calls map.zoomIn', () => {
const { zoomIn } = useMapLogic() const { zoomIn } = useMapLogic()
const mockMap = { zoomIn: vi.fn() } const mockMap = { zoomIn: vi.fn() }
zoomIn(mockMap as unknown as import('leaflet').Map) zoomIn(mockMap as unknown as Map)
expect(mockMap.zoomIn).toHaveBeenCalled() expect(mockMap.zoomIn).toHaveBeenCalled()
}) })
@@ -39,7 +40,7 @@ describe('useMapLogic', () => {
it('zoomOutControl calls map.zoomOut', () => { it('zoomOutControl calls map.zoomOut', () => {
const { zoomOutControl } = useMapLogic() const { zoomOutControl } = useMapLogic()
const mockMap = { zoomOut: vi.fn() } const mockMap = { zoomOut: vi.fn() }
zoomOutControl(mockMap as unknown as import('leaflet').Map) zoomOutControl(mockMap as unknown as Map)
expect(mockMap.zoomOut).toHaveBeenCalled() expect(mockMap.zoomOut).toHaveBeenCalled()
}) })
@@ -47,7 +48,7 @@ describe('useMapLogic', () => {
const { state, resetView } = useMapLogic() const { state, resetView } = useMapLogic()
state.trackingCharacterId.value = 42 state.trackingCharacterId.value = 42
const mockMap = { setView: vi.fn() } const mockMap = { setView: vi.fn() }
resetView(mockMap as unknown as import('leaflet').Map) resetView(mockMap as unknown as Map)
expect(state.trackingCharacterId.value).toBe(-1) expect(state.trackingCharacterId.value).toBe(-1)
expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false }) expect(mockMap.setView).toHaveBeenCalledWith([0, 0], 1, { animate: false })
}) })
@@ -59,7 +60,7 @@ describe('useMapLogic', () => {
getCenter: vi.fn(() => ({ lat: 0, lng: 0 })), getCenter: vi.fn(() => ({ lat: 0, lng: 0 })),
getZoom: vi.fn(() => 3), getZoom: vi.fn(() => 3),
} }
updateDisplayCoords(mockMap as unknown as import('leaflet').Map) updateDisplayCoords(mockMap as unknown as Map)
expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 }) expect(state.displayCoords.value).toEqual({ x: 5, y: 3, z: 3 })
}) })
@@ -72,7 +73,7 @@ describe('useMapLogic', () => {
it('toLatLng calls map.unproject', () => { it('toLatLng calls map.unproject', () => {
const { toLatLng } = useMapLogic() const { toLatLng } = useMapLogic()
const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) } const mockMap = { unproject: vi.fn(() => ({ lat: 1, lng: 2 })) }
const result = toLatLng(mockMap as unknown as import('leaflet').Map, 100, 200) const result = toLatLng(mockMap as unknown as Map, 100, 200)
expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6) expect(mockMap.unproject).toHaveBeenCalledWith([100, 200], 6)
expect(result).toEqual({ lat: 1, lng: 2 }) expect(result).toEqual({ lat: 1, lng: 2 })
}) })

View File

@@ -1,6 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from '../useToast'
const stateByKey: Record<string, ReturnType<typeof ref>> = {} const stateByKey: Record<string, ReturnType<typeof ref>> = {}
const useStateMock = vi.fn((key: string, init: () => unknown) => { const useStateMock = vi.fn((key: string, init: () => unknown) => {
if (!stateByKey[key]) { if (!stateByKey[key]) {
@@ -10,8 +12,6 @@ const useStateMock = vi.fn((key: string, init: () => unknown) => {
}) })
vi.stubGlobal('useState', useStateMock) vi.stubGlobal('useState', useStateMock)
import { useToast } from '../useToast'
describe('useToast', () => { describe('useToast', () => {
beforeEach(() => { beforeEach(() => {
stateByKey['hnh-map-toasts'] = ref([]) stateByKey['hnh-map-toasts'] = ref([])

View File

@@ -1,295 +1,295 @@
import type { import type {
Character, Character,
ConfigResponse, ConfigResponse,
MapInfo, MapInfo,
MapInfoAdmin, MapInfoAdmin,
Marker, Marker,
MeResponse, MeResponse,
SettingsResponse, SettingsResponse,
} from '~/types/api' } from '~/types/api'
export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse } export type { Character, ConfigResponse, MapInfo, MapInfoAdmin, Marker, MeResponse, SettingsResponse }
// Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue) // Singleton: shared by all useMapApi() callers so 401 triggers one global handler (e.g. app.vue)
const onApiErrorCallbacks = new Map<symbol, () => void>() const onApiErrorCallbacks = new Map<symbol, () => void>()
// In-flight dedup: one me() request at a time; concurrent callers share the same promise. // In-flight dedup: one me() request at a time; concurrent callers share the same promise.
let mePromise: Promise<MeResponse> | null = null let mePromise: Promise<MeResponse> | null = null
// In-flight dedup for GET endpoints: same path + method shares one request across all callers. // In-flight dedup for GET endpoints: same path + method shares one request across all callers.
const inFlightByKey = new Map<string, Promise<unknown>>() const inFlightByKey = new Map<string, Promise<unknown>>()
export function useMapApi() { export function useMapApi() {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string const apiBase = config.public.apiBase as string
/** Subscribe to API auth errors (401). Returns unsubscribe function. */ /** Subscribe to API auth errors (401). Returns unsubscribe function. */
function onApiError(cb: () => void): () => void { function onApiError(cb: () => void): () => void {
const id = Symbol() const id = Symbol()
onApiErrorCallbacks.set(id, cb) onApiErrorCallbacks.set(id, cb)
return () => onApiErrorCallbacks.delete(id) return () => onApiErrorCallbacks.delete(id)
} }
async function request<T>(path: string, opts?: RequestInit): Promise<T> { async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}` const url = path.startsWith('http') ? path : `${apiBase}/${path.replace(/^\//, '')}`
const res = await fetch(url, { credentials: 'include', ...opts }) const res = await fetch(url, { credentials: 'include', ...opts })
// Only redirect to login on 401 (session invalid); 403 = forbidden (no permission) // Only redirect to login on 401 (session invalid); 403 = forbidden (no permission)
if (res.status === 401) { if (res.status === 401) {
onApiErrorCallbacks.forEach((cb) => cb()) onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
if (res.status === 403) throw new Error('Forbidden') if (res.status === 403) throw new Error('Forbidden')
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
if (res.headers.get('content-type')?.includes('application/json')) { if (res.headers.get('content-type')?.includes('application/json')) {
return res.json() as Promise<T> return res.json() as Promise<T>
} }
return undefined as T return undefined as T
} }
function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> { function requestDeduped<T>(path: string, opts?: RequestInit): Promise<T> {
const key = path + (opts?.method ?? 'GET') const key = path + (opts?.method ?? 'GET')
const existing = inFlightByKey.get(key) const existing = inFlightByKey.get(key)
if (existing) return existing as Promise<T> if (existing) return existing as Promise<T>
const p = request<T>(path, opts).finally(() => { const p = request<T>(path, opts).finally(() => {
inFlightByKey.delete(key) inFlightByKey.delete(key)
}) })
inFlightByKey.set(key, p) inFlightByKey.set(key, p)
return p return p
} }
async function getConfig() { async function getConfig() {
return requestDeduped<ConfigResponse>('config') return requestDeduped<ConfigResponse>('config')
} }
async function getCharacters() { async function getCharacters() {
return requestDeduped<Character[]>('v1/characters') return requestDeduped<Character[]>('v1/characters')
} }
async function getMarkers() { async function getMarkers() {
return requestDeduped<Marker[]>('v1/markers') return requestDeduped<Marker[]>('v1/markers')
} }
async function getMaps() { async function getMaps() {
return requestDeduped<Record<string, MapInfo>>('maps') return requestDeduped<Record<string, MapInfo>>('maps')
} }
// Auth // Auth
async function login(user: string, pass: string) { async function login(user: string, pass: string) {
const res = await fetch(`${apiBase}/login`, { const res = await fetch(`${apiBase}/login`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user, pass }), body: JSON.stringify({ user, pass }),
}) })
if (res.status === 401) { if (res.status === 401) {
const data = (await res.json().catch(() => ({}))) as { error?: string } const data = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(data.error || 'Unauthorized') throw new Error(data.error || 'Unauthorized')
} }
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<MeResponse> return res.json() as Promise<MeResponse>
} }
/** OAuth login URL for redirect (full page navigation). */ /** OAuth login URL for redirect (full page navigation). */
function oauthLoginUrl(provider: string, redirect?: string): string { function oauthLoginUrl(provider: string, redirect?: string): string {
const url = new URL(`${apiBase}/oauth/${provider}/login`) const url = new URL(`${apiBase}/oauth/${provider}/login`)
if (redirect) url.searchParams.set('redirect', redirect) if (redirect) url.searchParams.set('redirect', redirect)
return url.toString() return url.toString()
} }
/** List of configured OAuth providers. */ /** List of configured OAuth providers. */
async function oauthProviders(): Promise<string[]> { async function oauthProviders(): Promise<string[]> {
try { try {
const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' }) const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' })
if (!res.ok) return [] if (!res.ok) return []
const data = await res.json() const data = await res.json()
return Array.isArray(data) ? data : [] return Array.isArray(data) ? data : []
} catch { } catch {
return [] return []
} }
} }
async function logout() { async function logout() {
mePromise = null mePromise = null
inFlightByKey.clear() inFlightByKey.clear()
await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' }) await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' })
} }
async function me() { async function me() {
if (mePromise) return mePromise if (mePromise) return mePromise
mePromise = request<MeResponse>('me').finally(() => { mePromise = request<MeResponse>('me').finally(() => {
mePromise = null mePromise = null
}) })
return mePromise return mePromise
} }
async function meUpdate(body: { email?: string }) { async function meUpdate(body: { email?: string }) {
await request('me', { await request('me', {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
/** Public: whether first-time setup (no users) is required. */ /** Public: whether first-time setup (no users) is required. */
async function setupRequired(): Promise<{ setupRequired: boolean }> { async function setupRequired(): Promise<{ setupRequired: boolean }> {
const res = await fetch(`${apiBase}/setup`, { credentials: 'include' }) const res = await fetch(`${apiBase}/setup`, { credentials: 'include' })
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
return res.json() as Promise<{ setupRequired: boolean }> return res.json() as Promise<{ setupRequired: boolean }>
} }
// Profile // Profile
async function meTokens() { async function meTokens() {
const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' }) const data = await request<{ tokens: string[] }>('me/tokens', { method: 'POST' })
return data?.tokens ?? [] return data?.tokens ?? []
} }
async function mePassword(pass: string) { async function mePassword(pass: string) {
await request('me/password', { await request('me/password', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pass }), body: JSON.stringify({ pass }),
}) })
} }
// Admin // Admin
async function adminUsers() { async function adminUsers() {
return request<string[]>('admin/users') return request<string[]>('admin/users')
} }
async function adminUserByName(name: string) { async function adminUserByName(name: string) {
return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`) return request<{ username: string; auths: string[] }>(`admin/users/${encodeURIComponent(name)}`)
} }
async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) { async function adminUserPost(body: { user: string; pass?: string; auths: string[] }) {
await request('admin/users', { await request('admin/users', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
async function adminUserDelete(name: string) { async function adminUserDelete(name: string) {
await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' }) await request(`admin/users/${encodeURIComponent(name)}`, { method: 'DELETE' })
} }
async function adminSettings() { async function adminSettings() {
return request<SettingsResponse>('admin/settings') return request<SettingsResponse>('admin/settings')
} }
async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) { async function adminSettingsPost(body: { prefix?: string; defaultHide?: boolean; title?: string }) {
await request('admin/settings', { await request('admin/settings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
async function adminMaps() { async function adminMaps() {
return request<MapInfoAdmin[]>('admin/maps') return request<MapInfoAdmin[]>('admin/maps')
} }
async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) { async function adminMapPost(id: number, body: { name: string; hidden: boolean; priority: boolean }) {
await request(`admin/maps/${id}`, { await request(`admin/maps/${id}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: JSON.stringify(body),
}) })
} }
async function adminMapToggleHidden(id: number) { async function adminMapToggleHidden(id: number) {
return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' }) return request<MapInfoAdmin>(`admin/maps/${id}/toggle-hidden`, { method: 'POST' })
} }
async function adminWipe() { async function adminWipe() {
await request('admin/wipe', { method: 'POST' }) await request('admin/wipe', { method: 'POST' })
} }
async function adminRebuildZooms() { async function adminRebuildZooms() {
const res = await fetch(`${apiBase}/admin/rebuildZooms`, { const res = await fetch(`${apiBase}/admin/rebuildZooms`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
}) })
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
onApiErrorCallbacks.forEach((cb) => cb()) onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`) if (res.status !== 200 && res.status !== 202) throw new Error(`API ${res.status}`)
} }
async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> { async function adminRebuildZoomsStatus(): Promise<{ running: boolean }> {
return request<{ running: boolean }>('admin/rebuildZooms/status') return request<{ running: boolean }>('admin/rebuildZooms/status')
} }
function adminExportUrl() { function adminExportUrl() {
return `${apiBase}/admin/export` return `${apiBase}/admin/export`
} }
async function adminMerge(formData: FormData) { async function adminMerge(formData: FormData) {
const res = await fetch(`${apiBase}/admin/merge`, { const res = await fetch(`${apiBase}/admin/merge`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
body: formData, body: formData,
}) })
if (res.status === 401 || res.status === 403) { if (res.status === 401 || res.status === 403) {
onApiErrorCallbacks.forEach((cb) => cb()) onApiErrorCallbacks.forEach((cb) => cb())
throw new Error('Unauthorized') throw new Error('Unauthorized')
} }
if (!res.ok) throw new Error(`API ${res.status}`) if (!res.ok) throw new Error(`API ${res.status}`)
} }
async function adminWipeTile(params: { map: number; x: number; y: number }) { async function adminWipeTile(params: { map: number; x: number; y: number }) {
const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) }) const qs = new URLSearchParams({ map: String(params.map), x: String(params.x), y: String(params.y) })
return request(`admin/wipeTile?${qs}`) return request(`admin/wipeTile?${qs}`)
} }
async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) { async function adminSetCoords(params: { map: number; fx: number; fy: number; tx: number; ty: number }) {
const qs = new URLSearchParams({ const qs = new URLSearchParams({
map: String(params.map), map: String(params.map),
fx: String(params.fx), fx: String(params.fx),
fy: String(params.fy), fy: String(params.fy),
tx: String(params.tx), tx: String(params.tx),
ty: String(params.ty), ty: String(params.ty),
}) })
return request(`admin/setCoords?${qs}`) return request(`admin/setCoords?${qs}`)
} }
async function adminHideMarker(params: { id: number }) { async function adminHideMarker(params: { id: number }) {
const qs = new URLSearchParams({ id: String(params.id) }) const qs = new URLSearchParams({ id: String(params.id) })
return request(`admin/hideMarker?${qs}`) return request(`admin/hideMarker?${qs}`)
} }
return { return {
apiBase, apiBase,
onApiError, onApiError,
getConfig, getConfig,
getCharacters, getCharacters,
getMarkers, getMarkers,
getMaps, getMaps,
login, login,
logout, logout,
me, me,
meUpdate, meUpdate,
oauthLoginUrl, oauthLoginUrl,
oauthProviders, oauthProviders,
setupRequired, setupRequired,
meTokens, meTokens,
mePassword, mePassword,
adminUsers, adminUsers,
adminUserByName, adminUserByName,
adminUserPost, adminUserPost,
adminUserDelete, adminUserDelete,
adminSettings, adminSettings,
adminSettingsPost, adminSettingsPost,
adminMaps, adminMaps,
adminMapPost, adminMapPost,
adminMapToggleHidden, adminMapToggleHidden,
adminWipe, adminWipe,
adminRebuildZooms, adminRebuildZooms,
adminRebuildZoomsStatus, adminRebuildZoomsStatus,
adminExportUrl, adminExportUrl,
adminMerge, adminMerge,
adminWipeTile, adminWipeTile,
adminSetCoords, adminSetCoords,
adminHideMarker, adminHideMarker,
} }
} }

View File

@@ -1,3 +1,5 @@
import { readonly } from 'vue'
export interface MapBookmark { export interface MapBookmark {
id: string id: string
name: string name: string
@@ -29,7 +31,7 @@ function saveBookmarks(bookmarks: MapBookmark[]) {
if (import.meta.server) return if (import.meta.server) return
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS))) localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks.slice(0, MAX_BOOKMARKS)))
} catch (_) {} } catch { /* ignore */ }
} }
export function useMapBookmarks() { export function useMapBookmarks() {

View File

@@ -1,111 +1,111 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes' import { HnHCRS, HnHMaxZoom, HnHMinZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { SmartTileLayer } from '~/lib/SmartTileLayer' import { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { MapInfo } from '~/types/api' import type { MapInfo } from '~/types/api'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
/** Known marker icon paths (without .png) to preload so markers render without broken images. */ /** Known marker icon paths (without .png) to preload so markers render without broken images. */
const MARKER_ICON_PATHS = [ const MARKER_ICON_PATHS = [
'gfx/terobjs/mm/custom', 'gfx/terobjs/mm/custom',
'gfx/terobjs/mm/tower', 'gfx/terobjs/mm/tower',
'gfx/terobjs/mm/village', 'gfx/terobjs/mm/village',
'gfx/terobjs/mm/dungeon', 'gfx/terobjs/mm/dungeon',
'gfx/terobjs/mm/cave', 'gfx/terobjs/mm/cave',
'gfx/terobjs/mm/settlement', 'gfx/terobjs/mm/settlement',
'gfx/invobjs/small/bush', 'gfx/invobjs/small/bush',
'gfx/invobjs/small/bumling', 'gfx/invobjs/small/bumling',
] ]
/** /**
* Preloads marker icon images so they are in the browser cache before markers render. * Preloads marker icon images so they are in the browser cache before markers render.
* Call from client only. resolvePath should produce absolute URLs for static assets. * Call from client only. resolvePath should produce absolute URLs for static assets.
*/ */
export function preloadMarkerIcons(resolvePath: (path: string) => string): void { export function preloadMarkerIcons(resolvePath: (path: string) => string): void {
if (import.meta.server) return if (import.meta.server) return
for (const base of MARKER_ICON_PATHS) { for (const base of MARKER_ICON_PATHS) {
const url = resolvePath(`${base}.png`) const url = resolvePath(`${base}.png`)
const img = new Image() const img = new Image()
img.src = url img.src = url
} }
} }
export interface MapInitResult { export interface MapInitResult {
map: L.Map map: L.Map
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
backendBase: string backendBase: string
} }
export async function initLeafletMap( export async function initLeafletMap(
element: HTMLElement, element: HTMLElement,
mapsList: MapInfo[], mapsList: MapInfo[],
initialMapId: number initialMapId: number
): Promise<MapInitResult> { ): Promise<MapInitResult> {
const L = (await import('leaflet')).default const L = (await import('leaflet')).default
const map = L.map(element, { const map = L.map(element, {
minZoom: HnHMinZoom, minZoom: HnHMinZoom,
maxZoom: HnHMaxZoom, maxZoom: HnHMaxZoom,
crs: HnHCRS, crs: HnHCRS,
attributionControl: false, attributionControl: false,
zoomControl: false, zoomControl: false,
inertia: true, inertia: true,
zoomAnimation: true, zoomAnimation: true,
fadeAnimation: true, fadeAnimation: true,
markerZoomAnimation: true, markerZoomAnimation: true,
}) })
const runtimeConfig = useRuntimeConfig() const runtimeConfig = useRuntimeConfig()
const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api' const apiBase = (runtimeConfig.public.apiBase as string) ?? '/map/api'
const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map' const backendBase = apiBase.replace(/\/api\/?$/, '') || '/map'
const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}` const tileUrl = `${backendBase}/grids/{map}/{z}/{x}_{y}.png?{cache}`
const layer = new SmartTileLayer(tileUrl, { const layer = new SmartTileLayer(tileUrl, {
minZoom: 1, minZoom: 1,
maxZoom: 6, maxZoom: 6,
maxNativeZoom: 6, maxNativeZoom: 6,
zoomOffset: 0, zoomOffset: 0,
zoomReverse: true, zoomReverse: true,
tileSize: TileSize, tileSize: TileSize,
updateWhenIdle: true, updateWhenIdle: true,
keepBuffer: 4, keepBuffer: 4,
}) })
layer.map = initialMapId layer.map = initialMapId
layer.invalidTile = layer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
layer.addTo(map) layer.addTo(map)
const overlayLayer = new SmartTileLayer(tileUrl, { const overlayLayer = new SmartTileLayer(tileUrl, {
minZoom: 1, minZoom: 1,
maxZoom: 6, maxZoom: 6,
maxNativeZoom: 6, maxNativeZoom: 6,
zoomOffset: 0, zoomOffset: 0,
zoomReverse: true, zoomReverse: true,
tileSize: TileSize, tileSize: TileSize,
opacity: 0.5, opacity: 0.5,
updateWhenIdle: true, updateWhenIdle: true,
keepBuffer: 4, keepBuffer: 4,
}) })
overlayLayer.map = -1 overlayLayer.map = -1
overlayLayer.invalidTile = overlayLayer.invalidTile =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
overlayLayer.addTo(map) overlayLayer.addTo(map)
const markerLayer = L.layerGroup() const markerLayer = L.layerGroup()
markerLayer.addTo(map) markerLayer.addTo(map)
markerLayer.setZIndex(600) markerLayer.setZIndex(600)
const baseURL = useRuntimeConfig().app.baseURL ?? '/' const baseURL = useRuntimeConfig().app.baseURL ?? '/'
const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/' const markerIconPath = baseURL.endsWith('/') ? baseURL : baseURL + '/'
L.Icon.Default.imagePath = markerIconPath L.Icon.Default.imagePath = markerIconPath
const resolvePath = (path: string) => { const resolvePath = (path: string) => {
const p = path.startsWith('/') ? path : `/${path}` const p = path.startsWith('/') ? path : `/${path}`
return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}` return baseURL === '/' ? p : `${baseURL.replace(/\/$/, '')}${p}`
} }
preloadMarkerIcons(resolvePath) preloadMarkerIcons(resolvePath)
return { map, layer, overlayLayer, markerLayer, backendBase } return { map, layer, overlayLayer, markerLayer, backendBase }
} }

View File

@@ -1,5 +1,5 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker' import { createMarker, type MapMarker, type MarkerData, type MapViewRef } from '~/lib/Marker'
import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character' import { createCharacter, type MapCharacter, type CharacterData, type CharacterMapViewRef } from '~/lib/Character'
import { import {
@@ -12,11 +12,21 @@ import {
import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api' import type { Marker as ApiMarker, Character as ApiCharacter } from '~/types/api'
type LeafletModule = L
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
export interface MapLayersOptions { export interface MapLayersOptions {
/** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */ /** Leaflet API (from dynamic import). Required for creating markers and characters without static leaflet import. */
L: typeof import('leaflet') L: LeafletModule
map: L.Map map: L.Map
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
@@ -24,10 +34,12 @@ export interface MapLayersOptions {
getCurrentMapId: () => number getCurrentMapId: () => number
setCurrentMapId: (id: number) => void setCurrentMapId: (id: number) => void
setSelectedMapId: (id: number) => void setSelectedMapId: (id: number) => void
getAuths: () => string[] getAuths?: () => string[]
getTrackingCharacterId: () => number getTrackingCharacterId: () => number
setTrackingCharacterId: (id: number) => void setTrackingCharacterId: (id: number) => void
onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void onMarkerContextMenu: (clientX: number, clientY: number, id: number, name: string) => void
/** Called when user clicks "Add to saved locations" in marker popup. Receives marker id and getter to resolve marker. */
onAddMarkerToBookmark?: (markerId: number, getMarkerById: (id: number) => MapMarker | undefined) => void
/** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */ /** Resolves relative marker icon path to absolute URL. If omitted, relative paths are used. */
resolveIconUrl?: (path: string) => string resolveIconUrl?: (path: string) => string
/** Fallback icon URL when a marker image fails to load. */ /** Fallback icon URL when a marker image fails to load. */
@@ -58,10 +70,11 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
getCurrentMapId, getCurrentMapId,
setCurrentMapId, setCurrentMapId,
setSelectedMapId, setSelectedMapId,
getAuths, getAuths: _getAuths,
getTrackingCharacterId, getTrackingCharacterId,
setTrackingCharacterId, setTrackingCharacterId,
onMarkerContextMenu, onMarkerContextMenu,
onAddMarkerToBookmark,
resolveIconUrl, resolveIconUrl,
fallbackIconUrl, fallbackIconUrl,
} = options } = options
@@ -112,7 +125,30 @@ export function createMapLayers(options: MapLayersOptions): MapLayersManager {
(marker: MapMarker) => { (marker: MapMarker) => {
if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx) if (marker.map === getCurrentMapId() || marker.map === overlayLayer.map) marker.add(ctx)
marker.setClickCallback(() => { marker.setClickCallback(() => {
if (marker.leafletMarker) map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom) if (marker.leafletMarker) {
if (onAddMarkerToBookmark) {
const gridX = Math.floor(marker.position.x / TileSize)
const gridY = Math.floor(marker.position.y / TileSize)
const div = document.createElement('div')
div.className = 'map-marker-popup text-sm'
div.innerHTML = `
<p class="font-medium mb-1">${escapeHtml(marker.name)}</p>
<p class="text-base-content/70 text-xs mb-2 font-mono">${gridX}, ${gridY}</p>
<button type="button" class="btn btn-primary btn-xs w-full">Add to saved locations</button>
`
const btn = div.querySelector('button')
if (btn) {
btn.addEventListener('click', () => {
onAddMarkerToBookmark(marker.id, findMarkerById)
marker.leafletMarker?.closePopup()
})
}
marker.leafletMarker.unbindPopup()
marker.leafletMarker.bindPopup(div, { minWidth: 140, autoPan: true }).openPopup()
} else {
map.setView(marker.leafletMarker.getLatLng(), HnHMaxZoom)
}
}
}) })
marker.setContextMenu((mev: L.LeafletMouseEvent) => { marker.setContextMenu((mev: L.LeafletMouseEvent) => {
mev.originalEvent.preventDefault() mev.originalEvent.preventDefault()

View File

@@ -1,171 +1,206 @@
import type { Ref } from 'vue' import type { Ref } from 'vue'
import type { SmartTileLayer } from '~/lib/SmartTileLayer' import type { SmartTileLayer } from '~/lib/SmartTileLayer'
import { TileSize } from '~/lib/LeafletCustomTypes' import { TileSize } from '~/lib/LeafletCustomTypes'
import type L from 'leaflet' import type L from 'leaflet'
type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer> type SmartTileLayerInstance = InstanceType<typeof SmartTileLayer>
export type SseConnectionState = 'connecting' | 'open' | 'error' export type SseConnectionState = 'connecting' | 'open' | 'error'
interface TileUpdate { interface TileUpdate {
M: number M: number
X: number X: number
Y: number Y: number
Z: number Z: number
T: number T: number
} }
interface MergeEvent { interface MergeEvent {
From: number From: number
To: number To: number
Shift: { x: number; y: number } Shift: { x: number; y: number }
} }
export interface UseMapUpdatesOptions { export interface UseMapUpdatesOptions {
backendBase: string backendBase: string
layer: SmartTileLayerInstance layer: SmartTileLayerInstance
overlayLayer: SmartTileLayerInstance overlayLayer: SmartTileLayerInstance
map: L.Map map: L.Map
getCurrentMapId: () => number getCurrentMapId: () => number
onMerge: (mapTo: number, shift: { x: number; y: number }) => void onMerge: (mapTo: number, shift: { x: number; y: number }) => void
/** Optional ref updated with SSE connection state for reconnection indicator. */ /** Optional ref updated with SSE connection state for reconnection indicator. */
connectionStateRef?: Ref<SseConnectionState> connectionStateRef?: Ref<SseConnectionState>
} }
export interface UseMapUpdatesReturn { export interface UseMapUpdatesReturn {
cleanup: () => void cleanup: () => void
} }
const RECONNECT_INITIAL_MS = 1000 const RECONNECT_INITIAL_MS = 1000
const RECONNECT_MAX_MS = 30000 const RECONNECT_MAX_MS = 30000
/** If no SSE message received for this long, treat connection as stale and reconnect. */
export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn { const STALE_CONNECTION_MS = 65000
const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options const STALE_CHECK_INTERVAL_MS = 30000
const updatesPath = `${backendBase}/updates` export function startMapUpdates(options: UseMapUpdatesOptions): UseMapUpdatesReturn {
const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath const { backendBase, layer, overlayLayer, map, getCurrentMapId, onMerge, connectionStateRef } = options
const BATCH_MS = 50 const updatesPath = `${backendBase}/updates`
let batch: TileUpdate[] = [] const updatesUrl = import.meta.client ? `${window.location.origin}${updatesPath}` : updatesPath
let batchScheduled = false
let source: EventSource | null = null const BATCH_MS = 50
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null let batch: TileUpdate[] = []
let reconnectDelayMs = RECONNECT_INITIAL_MS let batchScheduled = false
let destroyed = false let source: EventSource | null = null
let reconnectTimeoutId: ReturnType<typeof setTimeout> | null = null
const VISIBLE_TILE_BUFFER = 1 let staleCheckIntervalId: ReturnType<typeof setInterval> | null = null
let lastMessageTime = 0
function getVisibleTileBounds() { let reconnectDelayMs = RECONNECT_INITIAL_MS
const zoom = map.getZoom() let destroyed = false
const px = map.getPixelBounds()
if (!px) return null const VISIBLE_TILE_BUFFER = 1
return {
zoom, function getVisibleTileBounds() {
minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER, const zoom = map.getZoom()
maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER, const px = map.getPixelBounds()
minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER, if (!px) return null
maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER, return {
} zoom,
} minX: Math.floor(px.min.x / TileSize) - VISIBLE_TILE_BUFFER,
maxX: Math.ceil(px.max.x / TileSize) + VISIBLE_TILE_BUFFER,
function applyBatch() { minY: Math.floor(px.min.y / TileSize) - VISIBLE_TILE_BUFFER,
batchScheduled = false maxY: Math.ceil(px.max.y / TileSize) + VISIBLE_TILE_BUFFER,
if (batch.length === 0) return }
const updates = batch }
batch = []
for (const u of updates) { function applyBatch() {
const key = `${u.M}:${u.X}:${u.Y}:${u.Z}` batchScheduled = false
layer.cache[key] = u.T if (batch.length === 0) return
overlayLayer.cache[key] = u.T const updates = batch
} batch = []
const visible = getVisibleTileBounds() for (const u of updates) {
for (const u of updates) { const key = `${u.M}:${u.X}:${u.Y}:${u.Z}`
if (visible && u.Z !== visible.zoom) continue layer.cache[key] = u.T
if ( overlayLayer.cache[key] = u.T
visible && }
(u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY) const visible = getVisibleTileBounds()
) // u.Z is backend storage zoom (0..5); visible.zoom is map zoom (1..6). With zoomReverse, current backend Z = maxZoom - mapZoom.
continue const currentBackendZ = visible ? layer.options.maxZoom - visible.zoom : null
if (layer.map === u.M) layer.refresh(u.X, u.Y, u.Z) let needRedraw = false
if (overlayLayer.map === u.M) overlayLayer.refresh(u.X, u.Y, u.Z) for (const u of updates) {
} if (visible && currentBackendZ != null && u.Z !== currentBackendZ) continue
} if (
visible &&
function scheduleBatch() { (u.X < visible.minX || u.X > visible.maxX || u.Y < visible.minY || u.Y > visible.maxY)
if (batchScheduled) return )
batchScheduled = true continue
setTimeout(applyBatch, BATCH_MS) if (layer.map === u.M && !layer.refresh(u.X, u.Y, u.Z)) needRedraw = true
} if (overlayLayer.map === u.M && !overlayLayer.refresh(u.X, u.Y, u.Z)) needRedraw = true
}
function connect() { if (needRedraw) {
if (destroyed || !import.meta.client) return layer.redraw()
source = new EventSource(updatesUrl) overlayLayer.redraw()
if (connectionStateRef) connectionStateRef.value = 'connecting' }
}
source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open' function scheduleBatch() {
reconnectDelayMs = RECONNECT_INITIAL_MS if (batchScheduled) return
} batchScheduled = true
setTimeout(applyBatch, BATCH_MS)
source.onerror = () => { }
if (destroyed || !source) return
if (connectionStateRef) connectionStateRef.value = 'error' function connect() {
source.close() if (destroyed || !import.meta.client) return
source = null if (staleCheckIntervalId != null) {
if (destroyed) return clearInterval(staleCheckIntervalId)
reconnectTimeoutId = setTimeout(() => { staleCheckIntervalId = null
reconnectTimeoutId = null }
connect() source = new EventSource(updatesUrl)
reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS) if (connectionStateRef) connectionStateRef.value = 'connecting'
}, reconnectDelayMs)
} source.onopen = () => {
if (connectionStateRef) connectionStateRef.value = 'open'
source.onmessage = (event: MessageEvent) => { lastMessageTime = Date.now()
if (connectionStateRef) connectionStateRef.value = 'open' reconnectDelayMs = RECONNECT_INITIAL_MS
try { staleCheckIntervalId = setInterval(() => {
const raw: unknown = event?.data if (destroyed || !source) return
if (raw == null || typeof raw !== 'string' || raw.trim() === '') return if (Date.now() - lastMessageTime > STALE_CONNECTION_MS) {
const updates: unknown = JSON.parse(raw) if (staleCheckIntervalId != null) {
if (!Array.isArray(updates)) return clearInterval(staleCheckIntervalId)
for (const u of updates as TileUpdate[]) { staleCheckIntervalId = null
batch.push(u) }
} source.close()
scheduleBatch() source = null
} catch { if (connectionStateRef) connectionStateRef.value = 'error'
// Ignore parse errors from SSE connect()
} }
} }, STALE_CHECK_INTERVAL_MS)
}
source.addEventListener('merge', (e: MessageEvent) => {
try { source.onerror = () => {
const merge: MergeEvent = JSON.parse((e?.data as string) ?? '{}') if (destroyed || !source) return
if (getCurrentMapId() === merge.From) { if (connectionStateRef) connectionStateRef.value = 'error'
const point = map.project(map.getCenter(), 6) source.close()
const shift = { source = null
x: Math.floor(point.x / TileSize) + merge.Shift.x, if (destroyed) return
y: Math.floor(point.y / TileSize) + merge.Shift.y, reconnectTimeoutId = setTimeout(() => {
} reconnectTimeoutId = null
onMerge(merge.To, shift) connect()
} reconnectDelayMs = Math.min(reconnectDelayMs * 2, RECONNECT_MAX_MS)
} catch { }, reconnectDelayMs)
// Ignore merge parse errors }
}
}) source.onmessage = (event: MessageEvent) => {
} lastMessageTime = Date.now()
if (connectionStateRef) connectionStateRef.value = 'open'
connect() try {
const raw: unknown = event?.data
function cleanup() { if (raw == null || typeof raw !== 'string' || raw.trim() === '') return
destroyed = true const updates: unknown = JSON.parse(raw)
if (reconnectTimeoutId != null) { if (!Array.isArray(updates)) return
clearTimeout(reconnectTimeoutId) for (const u of updates as TileUpdate[]) {
reconnectTimeoutId = null batch.push(u)
} }
if (source) { scheduleBatch()
source.close() } catch {
source = null // Ignore parse errors from SSE
} }
} }
return { cleanup } 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
}
})
}
connect()
function cleanup() {
destroyed = true
if (staleCheckIntervalId != null) {
clearInterval(staleCheckIntervalId)
staleCheckIntervalId = null
}
if (reconnectTimeoutId != null) {
clearTimeout(reconnectTimeoutId)
reconnectTimeoutId = null
}
if (source) {
source.close()
source = null
}
}
return { cleanup }
}

View File

@@ -26,7 +26,7 @@ function saveRecent(list: RecentLocation[]) {
if (import.meta.server) return if (import.meta.server) return
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT))) localStorage.setItem(STORAGE_KEY, JSON.stringify(list.slice(0, MAX_RECENT)))
} catch (_) {} } catch { /* ignore */ }
} }
export function useRecentLocations() { export function useRecentLocations() {

View File

@@ -1,3 +1,5 @@
import { readonly } from 'vue'
export type ToastType = 'success' | 'error' | 'info' export type ToastType = 'success' | 'error' | 'info'
export interface Toast { export interface Toast {

View File

@@ -1,11 +1,14 @@
// @ts-check // @ts-check
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({ export default withNuxt(
rules: { { ignores: ['eslint.config.mjs'] },
'@typescript-eslint/no-explicit-any': 'warn', {
'@typescript-eslint/no-unused-vars': 'warn', rules: {
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }], '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-require-imports': 'off', '@typescript-eslint/no-unused-vars': 'warn',
'@typescript-eslint/consistent-type-imports': ['warn', { prefer: 'type-imports' }],
'@typescript-eslint/no-require-imports': 'off',
},
}, },
}) )

View File

@@ -12,7 +12,7 @@
type="checkbox" type="checkbox"
class="drawer-toggle" class="drawer-toggle"
@change="onDrawerChange" @change="onDrawerChange"
/> >
<div class="drawer-content flex flex-col h-screen overflow-hidden"> <div class="drawer-content flex flex-col h-screen overflow-hidden">
<header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0"> <header class="navbar relative z-[1100] bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
<NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200"> <NuxtLink to="/" class="flex items-center gap-2 text-lg font-semibold hover:opacity-80 transition-all duration-200">
@@ -87,7 +87,7 @@
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
:checked="dark" :checked="dark"
@change="onThemeToggle" @change="onThemeToggle"
/> >
</label> </label>
</li> </li>
<li> <li>
@@ -177,7 +177,7 @@
class="toggle toggle-sm toggle-primary shrink-0" class="toggle toggle-sm toggle-primary shrink-0"
:checked="dark" :checked="dark"
@change="onThemeToggle" @change="onThemeToggle"
/> >
</label> </label>
</li> </li>
<li> <li>
@@ -296,7 +296,7 @@ async function loadConfig(loadToken: number) {
const config = await useMapApi().getConfig() const config = await useMapApi().getConfig()
if (loadToken !== loadId) return if (loadToken !== loadId) return
if (config?.title) title.value = config.title if (config?.title) title.value = config.title
} catch (_) {} } catch { /* ignore */ }
} }
onMounted(() => { onMounted(() => {

View File

@@ -1,130 +1,192 @@
import type L from 'leaflet' import type L from 'leaflet'
import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors' import { getColorForCharacterId, type CharacterColors } from '~/lib/characterColors'
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, TileSize } from '~/lib/LeafletCustomTypes'
export type LeafletApi = typeof import('leaflet') export type LeafletApi = L
function buildCharacterIconUrl(colors: CharacterColors): string { function buildCharacterIconUrl(colors: CharacterColors): string {
const svg = const svg =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' +
`<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` + `<path fill="${colors.fill}" stroke="${colors.stroke}" stroke-width="1" d="M12 2a6 6 0 0 1 6 6c0 4-6 10-6 10s-6-6-6-10a6 6 0 0 1 6-6z"/>` +
'<circle cx="12" cy="8" r="2.5" fill="white"/>' + '<circle cx="12" cy="8" r="2.5" fill="white"/>' +
'</svg>' '</svg>'
return 'data:image/svg+xml,' + encodeURIComponent(svg) return 'data:image/svg+xml,' + encodeURIComponent(svg)
} }
export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon { export function createCharacterIcon(L: LeafletApi, colors: CharacterColors): L.Icon {
return new L.Icon({ return new L.Icon({
iconUrl: buildCharacterIconUrl(colors), iconUrl: buildCharacterIconUrl(colors),
iconSize: [24, 32], iconSize: [25, 32],
iconAnchor: [12, 32], iconAnchor: [12, 17],
popupAnchor: [0, -32], popupAnchor: [0, -32],
}) tooltipAnchor: [12, 0],
} })
}
export interface CharacterData {
name: string export interface CharacterData {
position: { x: number; y: number } name: string
type: string position: { x: number; y: number }
id: number type: string
map: number id: number
/** True when this character was last updated by one of the current user's tokens. */ map: number
ownedByMe?: boolean /** True when this character was last updated by one of the current user's tokens. */
} ownedByMe?: boolean
}
export interface CharacterMapViewRef {
map: L.Map export interface CharacterMapViewRef {
mapid: number map: L.Map
markerLayer?: L.LayerGroup mapid: number
} markerLayer?: L.LayerGroup
}
export interface MapCharacter {
id: number export interface MapCharacter {
name: string id: number
position: { x: number; y: number } name: string
type: string position: { x: number; y: number }
map: number type: string
text: string map: number
value: number text: string
ownedByMe?: boolean value: number
leafletMarker: L.Marker | null ownedByMe?: boolean
remove: (mapview: CharacterMapViewRef) => void leafletMarker: L.Marker | null
add: (mapview: CharacterMapViewRef) => void remove: (mapview: CharacterMapViewRef) => void
update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void add: (mapview: CharacterMapViewRef) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void update: (mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter) => void
} setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
}
export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
let leafletMarker: L.Marker | null = null const CHARACTER_MOVE_DURATION_MS = 280
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let ownedByMe = data.ownedByMe ?? false function easeOutQuad(t: number): number {
const colors = getColorForCharacterId(data.id, { ownedByMe }) return t * (2 - t)
let characterIcon = createCharacterIcon(L, colors) }
const character: MapCharacter = { export function createCharacter(data: CharacterData, L: LeafletApi): MapCharacter {
id: data.id, let leafletMarker: L.Marker | null = null
name: data.name, let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
position: { ...data.position }, let ownedByMe = data.ownedByMe ?? false
type: data.type, let animationFrameId: number | null = null
map: data.map, const colors = getColorForCharacterId(data.id, { ownedByMe })
text: data.name, let characterIcon = createCharacterIcon(L, colors)
value: data.id,
get ownedByMe() { const character: MapCharacter = {
return ownedByMe id: data.id,
}, name: data.name,
set ownedByMe(v: boolean | undefined) { position: { ...data.position },
ownedByMe = v ?? false type: data.type,
}, map: data.map,
text: data.name,
get leafletMarker() { value: data.id,
return leafletMarker get ownedByMe() {
}, return ownedByMe
},
remove(mapview: CharacterMapViewRef): void { set ownedByMe(v: boolean | undefined) {
if (leafletMarker) { ownedByMe = v ?? false
const layer = mapview.markerLayer ?? mapview.map },
layer.removeLayer(leafletMarker)
leafletMarker = null get leafletMarker() {
} return leafletMarker
}, },
add(mapview: CharacterMapViewRef): void { remove(mapview: CharacterMapViewRef): void {
if (character.map === mapview.mapid) { if (animationFrameId !== null) {
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom) cancelAnimationFrame(animationFrameId)
leafletMarker = L.marker(position, { icon: characterIcon, title: character.name }) animationFrameId = null
leafletMarker.on('click', (e: L.LeafletMouseEvent) => { }
if (onClick) onClick(e) if (leafletMarker) {
}) const layer = mapview.markerLayer ?? mapview.map
const targetLayer = mapview.markerLayer ?? mapview.map layer.removeLayer(leafletMarker)
leafletMarker.addTo(targetLayer) leafletMarker = null
} }
}, },
update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void { add(mapview: CharacterMapViewRef): void {
const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false if (character.map === mapview.mapid) {
if (ownedByMe !== updatedOwnedByMe) { const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
ownedByMe = updatedOwnedByMe leafletMarker = L.marker(position, { icon: characterIcon })
characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe })) const gridX = Math.floor(character.position.x / TileSize)
if (leafletMarker) leafletMarker.setIcon(characterIcon) const gridY = Math.floor(character.position.y / TileSize)
} const tooltipContent = `${character.name} · ${gridX}, ${gridY}`
if (character.map !== updated.map) { leafletMarker.bindTooltip(tooltipContent, {
character.remove(mapview) direction: 'top',
} permanent: false,
character.map = updated.map offset: L.point(-10.5, -18),
character.position = { ...updated.position } })
if (!leafletMarker && character.map === mapview.mapid) { leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
character.add(mapview) if (onClick) onClick(e)
} })
if (leafletMarker) { const targetLayer = mapview.markerLayer ?? mapview.map
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) leafletMarker.addTo(targetLayer)
leafletMarker.setLatLng(position) const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
} if (markerEl) markerEl.setAttribute('aria-label', character.name)
}, }
},
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
onClick = callback update(mapview: CharacterMapViewRef, updated: CharacterData | MapCharacter): void {
}, const updatedOwnedByMe = (updated as { ownedByMe?: boolean }).ownedByMe ?? false
} if (ownedByMe !== updatedOwnedByMe) {
ownedByMe = updatedOwnedByMe
return character characterIcon = createCharacterIcon(L, getColorForCharacterId(character.id, { ownedByMe }))
} if (leafletMarker) leafletMarker.setIcon(characterIcon)
}
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)
return
}
if (!leafletMarker) return
const newLatLng = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
const updateTooltip = (): void => {
const gridX = Math.floor(character.position.x / TileSize)
const gridY = Math.floor(character.position.y / TileSize)
leafletMarker?.setTooltipContent(`${character.name} · ${gridX}, ${gridY}`)
}
const from = leafletMarker.getLatLng()
const latDelta = newLatLng.lat - from.lat
const lngDelta = newLatLng.lng - from.lng
const distSq = latDelta * latDelta + lngDelta * lngDelta
if (distSq < 1e-12) {
updateTooltip()
return
}
if (animationFrameId !== null) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
const start = typeof performance !== 'undefined' ? performance.now() : Date.now()
const duration = CHARACTER_MOVE_DURATION_MS
const tick = (): void => {
const elapsed = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - start
const t = Math.min(1, elapsed / duration)
const eased = easeOutQuad(t)
leafletMarker?.setLatLng({
lat: from.lat + latDelta * eased,
lng: from.lng + lngDelta * eased,
})
if (t >= 1) {
animationFrameId = null
leafletMarker?.setLatLng(newLatLng)
updateTooltip()
return
}
animationFrameId = requestAnimationFrame(tick)
}
animationFrameId = requestAnimationFrame(tick)
},
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void {
onClick = callback
},
}
return character
}

View File

@@ -1,148 +1,165 @@
import type L from 'leaflet' import type L from 'leaflet'
import { HnHMaxZoom, ImageIcon } from '~/lib/LeafletCustomTypes' import { HnHMaxZoom, ImageIcon, TileSize } from '~/lib/LeafletCustomTypes'
export interface MarkerData { export interface MarkerData {
id: number id: number
position: { x: number; y: number } position: { x: number; y: number }
name: string name: string
image: string image: string
hidden: boolean hidden: boolean
map: number map: number
} }
export interface MapViewRef { export interface MapViewRef {
map: L.Map map: L.Map
mapid: number mapid: number
markerLayer: L.LayerGroup markerLayer: L.LayerGroup
} }
export interface MapMarker { export interface MapMarker {
id: number id: number
position: { x: number; y: number } position: { x: number; y: number }
name: string name: string
image: string image: string
type: string type: string
text: string text: string
value: number value: number
hidden: boolean hidden: boolean
map: number map: number
leafletMarker: L.Marker | null leafletMarker: L.Marker | null
remove: (mapview: MapViewRef) => void remove: (mapview: MapViewRef) => void
add: (mapview: MapViewRef) => void add: (mapview: MapViewRef) => void
update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void update: (mapview: MapViewRef, updated: MarkerData | MapMarker) => void
jumpTo: (map: L.Map) => void jumpTo: (map: L.Map) => void
setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void setClickCallback: (callback: (e: L.LeafletMouseEvent) => void) => void
setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void setContextMenu: (callback: (e: L.LeafletMouseEvent) => void) => void
} }
function detectType(name: string): string { function detectType(name: string): string {
if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest' if (name === 'gfx/invobjs/small/bush' || name === 'gfx/invobjs/small/bumling') return 'quest'
if (name === 'custom') return 'custom' if (name === 'custom') return 'custom'
return name.substring('gfx/terobjs/mm/'.length) return name.substring('gfx/terobjs/mm/'.length)
} }
export interface MarkerIconOptions { export interface MarkerIconOptions {
/** Resolves relative icon path to absolute URL (e.g. with app base path). */ /** Resolves relative icon path to absolute URL (e.g. with app base path). */
resolveIconUrl: (path: string) => string resolveIconUrl: (path: string) => string
/** Optional fallback URL when the icon image fails to load. */ /** Optional fallback URL when the icon image fails to load. */
fallbackIconUrl?: string fallbackIconUrl?: string
} }
export type LeafletApi = typeof import('leaflet') export type LeafletApi = L
export function createMarker( export function createMarker(
data: MarkerData, data: MarkerData,
iconOptions: MarkerIconOptions | undefined, iconOptions: MarkerIconOptions | undefined,
L: LeafletApi L: LeafletApi
): MapMarker { ): MapMarker {
let leafletMarker: L.Marker | null = null let leafletMarker: L.Marker | null = null
let onClick: ((e: L.LeafletMouseEvent) => void) | null = null let onClick: ((e: L.LeafletMouseEvent) => void) | null = null
let onContext: ((e: L.LeafletMouseEvent) => void) | null = null let onContext: ((e: L.LeafletMouseEvent) => void) | null = null
const marker: MapMarker = { const marker: MapMarker = {
id: data.id, id: data.id,
position: { ...data.position }, position: { ...data.position },
name: data.name, name: data.name,
image: data.image, image: data.image,
type: detectType(data.image), type: detectType(data.image),
text: data.name, text: data.name,
value: data.id, value: data.id,
hidden: data.hidden, hidden: data.hidden,
map: data.map, map: data.map,
get leafletMarker() { get leafletMarker() {
return leafletMarker return leafletMarker
}, },
remove(_mapview: MapViewRef): void { remove(_mapview: MapViewRef): void {
if (leafletMarker) { if (leafletMarker) {
leafletMarker.remove() leafletMarker.remove()
leafletMarker = null leafletMarker = null
} }
}, },
add(mapview: MapViewRef): void { add(mapview: MapViewRef): void {
if (!marker.hidden) { if (!marker.hidden) {
const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path) const resolve = iconOptions?.resolveIconUrl ?? ((path: string) => path)
const fallback = iconOptions?.fallbackIconUrl const fallback = iconOptions?.fallbackIconUrl
let icon: L.Icon const iconUrl =
if (marker.image === 'gfx/terobjs/mm/custom') { marker.name === 'Cave' && marker.image === 'gfx/terobjs/mm/custom'
icon = new ImageIcon({ ? resolve('gfx/terobjs/mm/cave.png')
iconUrl: resolve('gfx/terobjs/mm/custom.png'), : marker.image === 'gfx/terobjs/mm/custom'
iconSize: [21, 23], ? resolve('gfx/terobjs/mm/custom.png')
iconAnchor: [11, 21], : resolve(`${marker.image}.png`)
popupAnchor: [1, 3], let icon: L.Icon
tooltipAnchor: [1, 3], if (marker.image === 'gfx/terobjs/mm/custom' && marker.name !== 'Cave') {
fallbackIconUrl: fallback, icon = new ImageIcon({
}) iconUrl,
} else { iconSize: [21, 23],
icon = new ImageIcon({ iconAnchor: [11, 21],
iconUrl: resolve(`${marker.image}.png`), popupAnchor: [1, 3],
iconSize: [32, 32], tooltipAnchor: [1, 3],
fallbackIconUrl: fallback, fallbackIconUrl: fallback,
}) })
} } else {
icon = new ImageIcon({
const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom) iconUrl,
leafletMarker = L.marker(position, { icon, title: marker.name }) iconSize: [32, 32],
leafletMarker.addTo(mapview.markerLayer) fallbackIconUrl: fallback,
const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.() })
if (markerEl) markerEl.setAttribute('aria-label', marker.name) }
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
if (onClick) onClick(e) const position = mapview.map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
}) leafletMarker = L.marker(position, { icon })
leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => { const gridX = Math.floor(marker.position.x / TileSize)
if (onContext) onContext(e) const gridY = Math.floor(marker.position.y / TileSize)
}) const tooltipContent = `${marker.name} · ${gridX}, ${gridY}`
} leafletMarker.bindTooltip(tooltipContent, {
}, direction: 'top',
permanent: false,
update(mapview: MapViewRef, updated: MarkerData | MapMarker): void { offset: L.point(0, -14),
marker.position = { ...updated.position } })
marker.name = updated.name leafletMarker.addTo(mapview.markerLayer)
marker.hidden = updated.hidden const markerEl = (leafletMarker as unknown as { getElement?: () => HTMLElement }).getElement?.()
marker.map = updated.map if (markerEl) markerEl.setAttribute('aria-label', marker.name)
if (leafletMarker) { leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom) if (onClick) onClick(e)
leafletMarker.setLatLng(position) })
} leafletMarker.on('contextmenu', (e: L.LeafletMouseEvent) => {
}, if (onContext) onContext(e)
})
jumpTo(map: L.Map): void { }
if (leafletMarker) { },
const position = map.unproject([marker.position.x, marker.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position) update(mapview: MapViewRef, updated: MarkerData | MapMarker): void {
} marker.position = { ...updated.position }
}, marker.name = updated.name
marker.hidden = updated.hidden
setClickCallback(callback: (e: L.LeafletMouseEvent) => void): void { marker.map = updated.map
onClick = callback if (leafletMarker) {
}, const position = mapview.map.unproject([updated.position.x, updated.position.y], HnHMaxZoom)
leafletMarker.setLatLng(position)
setContextMenu(callback: (e: L.LeafletMouseEvent) => void): void { const gridX = Math.floor(updated.position.x / TileSize)
onContext = callback const gridY = Math.floor(updated.position.y / TileSize)
}, leafletMarker.setTooltipContent(`${marker.name} · ${gridX}, ${gridY}`)
} }
},
return marker
} 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
},
}
return marker
}

View File

@@ -58,7 +58,7 @@ export const SmartTileLayer = L.TileLayer.extend({
return Util.template(this._url, Util.extend(data, this.options)) return Util.template(this._url, Util.extend(data, this.options))
}, },
refresh(x: number, y: number, z: number) { refresh(x: number, y: number, z: number): boolean {
let zoom = z let zoom = z
const maxZoom = this.options.maxZoom const maxZoom = this.options.maxZoom
const zoomReverse = this.options.zoomReverse const zoomReverse = this.options.zoomReverse
@@ -71,19 +71,20 @@ export const SmartTileLayer = L.TileLayer.extend({
const key = `${x}:${y}:${zoom}` const key = `${x}:${y}:${zoom}`
const tile = this._tiles[key] const tile = this._tiles[key]
if (!tile?.el) return if (!tile?.el) return false
const newUrl = this.getTrueTileUrl({ x, y }, z) const newUrl = this.getTrueTileUrl({ x, y }, z)
if (tile.el.dataset.tileUrl === newUrl) return if (tile.el.dataset.tileUrl === newUrl) return true
tile.el.dataset.tileUrl = newUrl tile.el.dataset.tileUrl = newUrl
tile.el.src = newUrl tile.el.src = newUrl
tile.el.classList.add('tile-fresh') tile.el.classList.add('tile-fresh')
const el = tile.el const el = tile.el
setTimeout(() => el.classList.remove('tile-fresh'), 400) setTimeout(() => el.classList.remove('tile-fresh'), 400)
return true
}, },
}) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & { }) as unknown as new (urlTemplate: string, options?: L.TileLayerOptions) => L.TileLayer & {
cache: SmartTileLayerCache cache: SmartTileLayerCache
invalidTile: string invalidTile: string
map: number map: number
getTrueTileUrl: (coords: { x: number; y: number }, zoom: number) => string getTrueTileUrl: (coords: { x: number; y: number }, zoom: number) => string
refresh: (x: number, y: number, z: number) => void refresh: (x: number, y: number, z: number) => boolean
} }

View File

@@ -39,7 +39,10 @@ export function uniqueListUpdate<T extends Identifiable>(
if (addCallback) { if (addCallback) {
elementsToAdd.forEach((it) => addCallback(it)) elementsToAdd.forEach((it) => addCallback(it))
} }
elementsToRemove.forEach((it) => delete list.elements[String(it.id)]) const toRemove = new Set(elementsToRemove.map((it) => String(it.id)))
list.elements = Object.fromEntries(
Object.entries(list.elements).filter(([id]) => !toRemove.has(id))
) as Record<string, T>
elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it)) elementsToAdd.forEach((it) => (list.elements[String(it.id)] = it))
} }

View File

@@ -1,127 +1,187 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('leaflet', () => { import type L from 'leaflet'
const markerMock = { import type { Map, LayerGroup } from 'leaflet'
on: vi.fn().mockReturnThis(), import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character'
addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), const { leafletMock } = vi.hoisted(() => {
setIcon: vi.fn().mockReturnThis(), const markerMock = {
} on: vi.fn().mockReturnThis(),
return { addTo: vi.fn().mockReturnThis(),
default: { setLatLng: vi.fn().mockReturnThis(),
marker: vi.fn(() => markerMock), setIcon: vi.fn().mockReturnThis(),
Icon: vi.fn().mockImplementation(() => ({})), bindTooltip: vi.fn().mockReturnThis(),
}, setTooltipContent: vi.fn().mockReturnThis(),
marker: vi.fn(() => markerMock), getLatLng: vi.fn().mockReturnValue({ lat: 0, lng: 0 }),
Icon: vi.fn().mockImplementation(() => ({})), }
} const Icon = vi.fn().mockImplementation(function (this: unknown) {
}) return {}
})
vi.mock('~/lib/LeafletCustomTypes', () => ({ const L = {
HnHMaxZoom: 6, marker: vi.fn(() => markerMock),
})) Icon,
point: vi.fn((x: number, y: number) => ({ x, y })),
import type L from 'leaflet' }
import { createCharacter, type CharacterData, type CharacterMapViewRef } from '../Character' return { leafletMock: L }
})
function getL(): L {
return require('leaflet').default vi.mock('leaflet', () => ({
} __esModule: true,
default: leafletMock,
function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData { marker: leafletMock.marker,
return { Icon: leafletMock.Icon,
name: 'Hero', }))
position: { x: 100, y: 200 },
type: 'player', vi.mock('~/lib/LeafletCustomTypes', () => ({
id: 1, HnHMaxZoom: 6,
map: 1, TileSize: 100,
...overrides, }))
}
} function getL(): L {
return leafletMock as unknown as L
function makeMapViewRef(mapid = 1): CharacterMapViewRef { }
return {
map: { function makeCharData(overrides: Partial<CharacterData> = {}): CharacterData {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), return {
removeLayer: vi.fn(), name: 'Hero',
} as unknown as import('leaflet').Map, position: { x: 100, y: 200 },
mapid, type: 'player',
markerLayer: { id: 1,
removeLayer: vi.fn(), map: 1,
addLayer: vi.fn(), ...overrides,
} as unknown as import('leaflet').LayerGroup, }
} }
}
function makeMapViewRef(mapid = 1): CharacterMapViewRef {
describe('createCharacter', () => { return {
beforeEach(() => { map: {
vi.clearAllMocks() unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
}) removeLayer: vi.fn(),
} as unknown as Map,
it('creates character with correct properties', () => { mapid,
const char = createCharacter(makeCharData(), getL()) markerLayer: {
expect(char.id).toBe(1) removeLayer: vi.fn(),
expect(char.name).toBe('Hero') addLayer: vi.fn(),
expect(char.position).toEqual({ x: 100, y: 200 }) } as unknown as LayerGroup,
expect(char.type).toBe('player') }
expect(char.map).toBe(1) }
expect(char.text).toBe('Hero')
expect(char.value).toBe(1) describe('createCharacter', () => {
}) beforeEach(() => {
vi.clearAllMocks()
it('starts with null leaflet marker', () => { })
const char = createCharacter(makeCharData(), getL())
expect(char.leafletMarker).toBeNull() it('creates character with correct properties', () => {
}) const char = createCharacter(makeCharData(), getL())
expect(char.id).toBe(1)
it('add creates marker when character is on correct map', () => { expect(char.name).toBe('Hero')
const char = createCharacter(makeCharData(), getL()) expect(char.position).toEqual({ x: 100, y: 200 })
const mapview = makeMapViewRef(1) expect(char.type).toBe('player')
char.add(mapview) expect(char.map).toBe(1)
expect(mapview.map.unproject).toHaveBeenCalled() expect(char.text).toBe('Hero')
}) expect(char.value).toBe(1)
})
it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData({ map: 2 }), getL()) it('starts with null leaflet marker', () => {
const mapview = makeMapViewRef(1) const char = createCharacter(makeCharData(), getL())
char.add(mapview) expect(char.leafletMarker).toBeNull()
expect(mapview.map.unproject).not.toHaveBeenCalled() })
})
it('add creates marker when character is on correct map', () => {
it('update changes position and map', () => { const char = createCharacter(makeCharData(), getL())
const char = createCharacter(makeCharData(), getL()) const mapview = makeMapViewRef(1)
const mapview = makeMapViewRef(1) char.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled()
char.update(mapview, { })
...makeCharData(),
position: { x: 300, y: 400 }, it('add creates marker without title and binds Leaflet tooltip', () => {
map: 2, const char = createCharacter(makeCharData({ position: { x: 100, y: 200 } }), getL())
}) const mapview = makeMapViewRef(1)
char.add(mapview)
expect(char.position).toEqual({ x: 300, y: 400 }) expect(leafletMock.marker).toHaveBeenCalledWith(
expect(char.map).toBe(2) expect.anything(),
}) expect.not.objectContaining({ title: expect.anything() })
)
it('remove on a character without leaflet marker does nothing', () => { const marker = char.leafletMarker as { bindTooltip: ReturnType<typeof vi.fn> }
const char = createCharacter(makeCharData(), getL()) expect(marker.bindTooltip).toHaveBeenCalledWith(
const mapview = makeMapViewRef(1) 'Hero · 1, 2',
char.remove(mapview) // should not throw expect.objectContaining({ direction: 'top', permanent: false })
expect(char.leafletMarker).toBeNull() )
}) })
it('setClickCallback works', () => { it('add does not create marker for different map', () => {
const char = createCharacter(makeCharData(), getL()) const char = createCharacter(makeCharData({ map: 2 }), getL())
const cb = vi.fn() const mapview = makeMapViewRef(1)
char.setClickCallback(cb) char.add(mapview)
}) expect(mapview.map.unproject).not.toHaveBeenCalled()
})
it('update with changed ownedByMe updates marker icon', () => {
const char = createCharacter(makeCharData({ ownedByMe: false }), getL()) it('update changes position and map', () => {
const mapview = makeMapViewRef(1) const char = createCharacter(makeCharData(), getL())
char.add(mapview) const mapview = makeMapViewRef(1)
const marker = char.leafletMarker as { setIcon: ReturnType<typeof vi.fn> }
expect(marker.setIcon).not.toHaveBeenCalled() char.update(mapview, {
char.update(mapview, makeCharData({ ownedByMe: true })) ...makeCharData(),
expect(marker.setIcon).toHaveBeenCalledTimes(1) 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(), getL())
const mapview = makeMapViewRef(1)
char.remove(mapview) // should not throw
expect(char.leafletMarker).toBeNull()
})
it('setClickCallback works', () => {
const char = createCharacter(makeCharData(), getL())
const cb = vi.fn()
char.setClickCallback(cb)
})
it('update with changed ownedByMe updates marker icon', () => {
const char = createCharacter(makeCharData({ ownedByMe: false }), getL())
const mapview = makeMapViewRef(1)
char.add(mapview)
const marker = char.leafletMarker as { setIcon: ReturnType<typeof vi.fn> }
expect(marker.setIcon).not.toHaveBeenCalled()
char.update(mapview, makeCharData({ ownedByMe: true }))
expect(marker.setIcon).toHaveBeenCalledTimes(1)
})
it('update with position change updates tooltip content when marker exists', () => {
const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1)
char.add(mapview)
const marker = char.leafletMarker as { setTooltipContent: ReturnType<typeof vi.fn> }
marker.setTooltipContent.mockClear()
char.update(mapview, makeCharData({ position: { x: 350, y: 450 } }))
expect(marker.setTooltipContent).toHaveBeenCalledWith('Hero · 3, 4')
})
it('remove cancels active position animation', () => {
const cancelSpy = vi.spyOn(global, 'cancelAnimationFrame').mockImplementation(() => {})
let rafCallback: (() => void) | null = null
vi.spyOn(global, 'requestAnimationFrame').mockImplementation((cb: (() => void) | (FrameRequestCallback)) => {
rafCallback = typeof cb === 'function' ? cb : () => {}
return 1
})
const char = createCharacter(makeCharData(), getL())
const mapview = makeMapViewRef(1)
mapview.map.unproject = vi.fn(() => ({ lat: 1, lng: 1 }))
char.add(mapview)
const marker = char.leafletMarker as { getLatLng: ReturnType<typeof vi.fn> }
marker.getLatLng.mockReturnValue({ lat: 0, lng: 0 })
char.update(mapview, makeCharData({ position: { x: 200, y: 200 } }))
expect(rafCallback).not.toBeNull()
cancelSpy.mockClear()
char.remove(mapview)
expect(cancelSpy).toHaveBeenCalledWith(1)
cancelSpy.mockRestore()
vi.restoreAllMocks()
})
})

View File

@@ -1,31 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest' import { describe, it, expect, vi, beforeEach } from 'vitest'
import L from 'leaflet'
import type { Map, LayerGroup } from 'leaflet'
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
vi.mock('leaflet', () => { vi.mock('leaflet', () => {
const markerMock = { const markerMock = {
on: vi.fn().mockReturnThis(), on: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(), addTo: vi.fn().mockReturnThis(),
setLatLng: vi.fn().mockReturnThis(), setLatLng: vi.fn().mockReturnThis(),
remove: vi.fn().mockReturnThis(), remove: vi.fn().mockReturnThis(),
bindTooltip: vi.fn().mockReturnThis(),
setTooltipContent: vi.fn().mockReturnThis(),
openPopup: vi.fn().mockReturnThis(),
closePopup: vi.fn().mockReturnThis(),
} }
return { const point = (x: number, y: number) => ({ x, y })
default: { const L = {
marker: vi.fn(() => markerMock),
Icon: class {},
},
marker: vi.fn(() => markerMock), marker: vi.fn(() => markerMock),
Icon: class {}, Icon: vi.fn(),
point,
} }
return { __esModule: true, default: L, ...L }
}) })
vi.mock('~/lib/LeafletCustomTypes', () => ({ vi.mock('~/lib/LeafletCustomTypes', () => ({
HnHMaxZoom: 6, HnHMaxZoom: 6,
ImageIcon: class { TileSize: 100,
constructor(_opts: Record<string, unknown>) {} ImageIcon: vi.fn(),
},
})) }))
import { createMarker, type MarkerData, type MapViewRef } from '../Marker'
function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData { function makeMarkerData(overrides: Partial<MarkerData> = {}): MarkerData {
return { return {
id: 1, id: 1,
@@ -42,12 +46,12 @@ function makeMapViewRef(): MapViewRef {
return { return {
map: { map: {
unproject: vi.fn(() => ({ lat: 0, lng: 0 })), unproject: vi.fn(() => ({ lat: 0, lng: 0 })),
} as unknown as import('leaflet').Map, } as unknown as Map,
mapid: 1, mapid: 1,
markerLayer: { markerLayer: {
removeLayer: vi.fn(), removeLayer: vi.fn(),
addLayer: vi.fn(), addLayer: vi.fn(),
} as unknown as import('leaflet').LayerGroup, } as unknown as LayerGroup,
} }
} }
@@ -57,7 +61,7 @@ describe('createMarker', () => {
}) })
it('creates a marker with correct properties', () => { it('creates a marker with correct properties', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
expect(marker.id).toBe(1) expect(marker.id).toBe(1)
expect(marker.name).toBe('Tower') expect(marker.name).toBe('Tower')
expect(marker.position).toEqual({ x: 100, y: 200 }) expect(marker.position).toEqual({ x: 100, y: 200 })
@@ -69,46 +73,46 @@ describe('createMarker', () => {
}) })
it('detects quest type', () => { it('detects quest type', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' })) const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bush' }), undefined, L)
expect(marker.type).toBe('quest') expect(marker.type).toBe('quest')
}) })
it('detects quest type for bumling', () => { it('detects quest type for bumling', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' })) const marker = createMarker(makeMarkerData({ image: 'gfx/invobjs/small/bumling' }), undefined, L)
expect(marker.type).toBe('quest') expect(marker.type).toBe('quest')
}) })
it('detects custom type', () => { it('detects custom type', () => {
const marker = createMarker(makeMarkerData({ image: 'custom' })) const marker = createMarker(makeMarkerData({ image: 'custom' }), undefined, L)
expect(marker.type).toBe('custom') expect(marker.type).toBe('custom')
}) })
it('extracts type from gfx path', () => { it('extracts type from gfx path', () => {
const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' })) const marker = createMarker(makeMarkerData({ image: 'gfx/terobjs/mm/village' }), undefined, L)
expect(marker.type).toBe('village') expect(marker.type).toBe('village')
}) })
it('starts with null leaflet marker', () => { it('starts with null leaflet marker', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
expect(marker.leafletMarker).toBeNull() expect(marker.leafletMarker).toBeNull()
}) })
it('add creates a leaflet marker for non-hidden markers', () => { it('add creates a leaflet marker for non-hidden markers', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.add(mapview) marker.add(mapview)
expect(mapview.map.unproject).toHaveBeenCalled() expect(mapview.map.unproject).toHaveBeenCalled()
}) })
it('add does nothing for hidden markers', () => { it('add does nothing for hidden markers', () => {
const marker = createMarker(makeMarkerData({ hidden: true })) const marker = createMarker(makeMarkerData({ hidden: true }), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.add(mapview) marker.add(mapview)
expect(mapview.map.unproject).not.toHaveBeenCalled() expect(mapview.map.unproject).not.toHaveBeenCalled()
}) })
it('update changes position and name', () => { it('update changes position and name', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.update(mapview, { marker.update(mapview, {
@@ -122,7 +126,7 @@ describe('createMarker', () => {
}) })
it('setClickCallback and setContextMenu work', () => { it('setClickCallback and setContextMenu work', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const clickCb = vi.fn() const clickCb = vi.fn()
const contextCb = vi.fn() const contextCb = vi.fn()
@@ -131,7 +135,7 @@ describe('createMarker', () => {
}) })
it('remove on a marker without leaflet marker does nothing', () => { it('remove on a marker without leaflet marker does nothing', () => {
const marker = createMarker(makeMarkerData()) const marker = createMarker(makeMarkerData(), undefined, L)
const mapview = makeMapViewRef() const mapview = makeMapViewRef()
marker.remove(mapview) // should not throw marker.remove(mapview) // should not throw
expect(marker.leafletMarker).toBeNull() expect(marker.leafletMarker).toBeNull()

View File

@@ -58,7 +58,7 @@
placeholder="Search users…" placeholder="Search users…"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation" class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search users" aria-label="Search users"
/> >
</div> </div>
<div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto"> <div class="flex flex-col gap-2 max-h-[60vh] overflow-y-auto">
<div <div
@@ -101,7 +101,7 @@
placeholder="Search maps…" placeholder="Search maps…"
class="input input-sm input-bordered w-full min-h-11 touch-manipulation" class="input input-sm input-bordered w-full min-h-11 touch-manipulation"
aria-label="Search maps" aria-label="Search maps"
/> >
</div> </div>
<div class="overflow-x-auto max-h-[60vh] overflow-y-auto"> <div class="overflow-x-auto max-h-[60vh] overflow-y-auto">
<table class="table table-sm table-zebra min-w-[32rem]"> <table class="table table-sm table-zebra min-w-[32rem]">
@@ -121,7 +121,7 @@
</th> </th>
<th scope="col">Hidden</th> <th scope="col">Hidden</th>
<th scope="col">Priority</th> <th scope="col">Priority</th>
<th scope="col" class="text-right"></th> <th scope="col" class="text-right"/>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -159,7 +159,7 @@
v-model="settings.prefix" v-model="settings.prefix"
type="text" type="text"
class="input input-sm w-full min-h-11 touch-manipulation" class="input input-sm w-full min-h-11 touch-manipulation"
/> >
</fieldset> </fieldset>
<fieldset class="fieldset w-full max-w-xs"> <fieldset class="fieldset w-full max-w-xs">
<label class="label" for="admin-settings-title">Title</label> <label class="label" for="admin-settings-title">Title</label>
@@ -168,7 +168,7 @@
v-model="settings.title" v-model="settings.title"
type="text" type="text"
class="input input-sm w-full min-h-11 touch-manipulation" class="input input-sm w-full min-h-11 touch-manipulation"
/> >
</fieldset> </fieldset>
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide"> <label class="label gap-2 cursor-pointer justify-start min-h-11 touch-manipulation" for="admin-settings-default-hide">
@@ -177,7 +177,7 @@
v-model="settings.defaultHide" v-model="settings.defaultHide"
type="checkbox" type="checkbox"
class="checkbox checkbox-sm" class="checkbox checkbox-sm"
/> >
Default hide new maps Default hide new maps
</label> </label>
</fieldset> </fieldset>
@@ -211,7 +211,7 @@
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" /> <input ref="mergeFileRef" type="file" accept=".zip" class="hidden" @change="onMergeFile" >
<button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()"> <button type="button" class="btn btn-sm min-h-11 touch-manipulation" @click="mergeFileRef?.click()">
Choose merge file Choose merge file
</button> </button>

View File

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

View File

@@ -2,7 +2,7 @@
<div class="container mx-auto p-4 max-w-2xl min-w-0"> <div class="container mx-auto p-4 max-w-2xl min-w-0">
<h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1> <h1 class="text-2xl font-bold mb-6">{{ isNew ? 'New user' : `Edit ${username}` }}</h1>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">Username</label> <label class="label" for="user">Username</label>
<input <input
@@ -12,7 +12,7 @@
class="input min-h-11 touch-manipulation" class="input min-h-11 touch-manipulation"
required required
:readonly="!isNew" :readonly="!isNew"
/> >
</fieldset> </fieldset>
<p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p> <p id="admin-user-password-hint" class="text-sm text-base-content/60 mb-1">Leave blank to keep current password.</p>
<PasswordInput <PasswordInput
@@ -25,7 +25,7 @@
<label class="label">Auths</label> <label class="label">Auths</label>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<label v-for="a of authOptions" :key="a" class="label cursor-pointer gap-2" :for="`auth-${a}`"> <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" /> <input :id="`auth-${a}`" v-model="form.auths" type="checkbox" :value="a" class="checkbox checkbox-sm" >
<span>{{ a }}</span> <span>{{ a }}</span>
</label> </label>
</div> </div>

View File

@@ -14,18 +14,18 @@
</a> </a>
<div class="divider text-sm">or</div> <div class="divider text-sm">or</div>
</div> </div>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<fieldset class="fieldset"> <fieldset class="fieldset">
<label class="label" for="user">User</label> <label class="label" for="user">User</label>
<input <input
ref="userInputRef"
id="user" id="user"
ref="userInputRef"
v-model="user" v-model="user"
type="text" type="text"
class="input min-h-11 touch-manipulation" class="input min-h-11 touch-manipulation"
required required
autocomplete="username" autocomplete="username"
/> >
</fieldset> </fieldset>
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"

View File

@@ -115,7 +115,7 @@
<icons-icon-settings /> <icons-icon-settings />
Change password Change password
</h2> </h2>
<form @submit.prevent="changePass" class="flex flex-col gap-2"> <form class="flex flex-col gap-2" @submit.prevent="changePass">
<PasswordInput <PasswordInput
v-model="newPass" v-model="newPass"
placeholder="New password" placeholder="New password"

View File

@@ -6,7 +6,7 @@
This is the first run. Create the administrator account using the bootstrap password This is the first run. Create the administrator account using the bootstrap password
from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>). from the server configuration (e.g. <code class="text-xs">HNHMAP_BOOTSTRAP_PASSWORD</code>).
</p> </p>
<form @submit.prevent="submit" class="flex flex-col gap-4"> <form class="flex flex-col gap-4" @submit.prevent="submit">
<PasswordInput <PasswordInput
v-model="pass" v-model="pass"
label="Bootstrap password" label="Bootstrap password"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,11 +1,14 @@
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import Vue from '@vitejs/plugin-vue'
import { resolve } from 'path' import { resolve } from 'path'
export default defineConfig({ export default defineConfig({
plugins: [Vue()],
test: { test: {
environment: 'happy-dom', environment: 'happy-dom',
include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'], include: ['**/__tests__/**/*.test.ts', '**/*.test.ts'],
globals: true, globals: true,
setupFiles: ['./vitest.setup.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
reporter: ['text', 'html'], reporter: ['text', 'html'],

View File

@@ -0,0 +1,27 @@
/**
* Expose Vue reactivity and lifecycle on globalThis so that .vue components
* that rely on Nuxt auto-imports (ref, computed, etc.) work in Vitest.
*/
import {
ref,
computed,
reactive,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
} from 'vue'
Object.assign(globalThis, {
ref,
computed,
reactive,
watch,
watchEffect,
onMounted,
onUnmounted,
nextTick,
readonly,
})

View File

@@ -26,9 +26,10 @@ const (
MultipartMaxMemory = 100 << 20 // 100 MB MultipartMaxMemory = 100 << 20 // 100 MB
MergeMaxMemory = 500 << 20 // 500 MB MergeMaxMemory = 500 << 20 // 500 MB
ClientVersion = "4" ClientVersion = "4"
SSETickInterval = 5 * time.Second SSETickInterval = 1 * time.Second
SSETileChannelSize = 1000 SSEKeepaliveInterval = 30 * time.Second
SSEMergeChannelSize = 5 SSETileChannelSize = 2000
SSEMergeChannelSize = 5
) )
// App is the main application (map server) state. // App is the main application (map server) state.

View File

@@ -93,16 +93,41 @@ func TestTopicClose(t *testing.T) {
} }
} }
func TestTopicDropsSlowSubscriber(t *testing.T) { func TestTopicSkipsFullChannel(t *testing.T) {
topic := &app.Topic[int]{} topic := &app.Topic[int]{}
slow := make(chan *int) // unbuffered, will block slow := make(chan *int) // unbuffered, so Send will skip this subscriber
fast := make(chan *int, 10)
topic.Watch(slow) topic.Watch(slow)
topic.Watch(fast)
val := 42 val := 42
topic.Send(&val) // should drop the slow subscriber topic.Send(&val) // slow is full (unbuffered), message dropped for slow only; fast receives
topic.Send(&val)
// Fast subscriber got both messages
for i := 0; i < 2; i++ {
select {
case got := <-fast:
if *got != 42 {
t.Fatalf("fast got %d", *got)
}
default:
t.Fatalf("expected fast to have message %d", i+1)
}
}
// Slow subscriber was skipped (channel full), not closed - channel still open and empty
select {
case _, ok := <-slow:
if !ok {
t.Fatal("slow channel should not be closed when subscriber is skipped")
}
t.Fatal("slow should have received no message")
default:
// slow is open and empty, which is correct
}
topic.Close()
_, ok := <-slow _, ok := <-slow
if ok { if ok {
t.Fatal("expected slow subscriber channel to be closed") t.Fatal("expected slow channel closed after topic.Close()")
} }
} }

View File

@@ -1,450 +1,450 @@
package handlers package handlers
import ( import (
"archive/zip" "archive/zip"
"encoding/json" "encoding/json"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
) )
type mapInfoJSON struct { type mapInfoJSON struct {
ID int `json:"ID"` ID int `json:"ID"`
Name string `json:"Name"` Name string `json:"Name"`
Hidden bool `json:"Hidden"` Hidden bool `json:"Hidden"`
Priority bool `json:"Priority"` Priority bool `json:"Priority"`
} }
// APIAdminUsers handles GET/POST /map/api/admin/users. // APIAdminUsers handles GET/POST /map/api/admin/users.
func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminUsers(rw http.ResponseWriter, req *http.Request) {
ctx := req.Context() ctx := req.Context()
if req.Method == http.MethodGet { if req.Method == http.MethodGet {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
list, err := h.Admin.ListUsers(ctx) list, err := h.Admin.ListUsers(ctx)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
JSON(rw, http.StatusOK, list) JSON(rw, http.StatusOK, list)
return return
} }
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
s := h.requireAdmin(rw, req) s := h.requireAdmin(rw, req)
if s == nil { if s == nil {
return return
} }
var body struct { var body struct {
User string `json:"user"` User string `json:"user"`
Pass string `json:"pass"` Pass string `json:"pass"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" { if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.User == "" {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths) adminCreated, err := h.Admin.CreateOrUpdateUser(ctx, body.User, body.Pass, body.Auths)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
if body.User == s.Username { if body.User == s.Username {
s.Auths = body.Auths s.Auths = body.Auths
} }
if adminCreated && s.Username == "admin" { if adminCreated && s.Username == "admin" {
h.Auth.DeleteSession(ctx, s) h.Auth.DeleteSession(ctx, s)
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminUserByName handles GET /map/api/admin/users/:name. // APIAdminUserByName handles GET /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) { func (h *Handlers) APIAdminUserByName(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
auths, found, err := h.Admin.GetUser(req.Context(), name) auths, found, err := h.Admin.GetUser(req.Context(), name)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
out := struct { out := struct {
Username string `json:"username"` Username string `json:"username"`
Auths []string `json:"auths"` Auths []string `json:"auths"`
}{Username: name} }{Username: name}
if found { if found {
out.Auths = auths out.Auths = auths
} }
JSON(rw, http.StatusOK, out) JSON(rw, http.StatusOK, out)
} }
// APIAdminUserDelete handles DELETE /map/api/admin/users/:name. // APIAdminUserDelete handles DELETE /map/api/admin/users/:name.
func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) { func (h *Handlers) APIAdminUserDelete(rw http.ResponseWriter, req *http.Request, name string) {
if !h.requireMethod(rw, req, http.MethodDelete) { if !h.requireMethod(rw, req, http.MethodDelete) {
return return
} }
s := h.requireAdmin(rw, req) s := h.requireAdmin(rw, req)
if s == nil { if s == nil {
return return
} }
ctx := req.Context() ctx := req.Context()
if err := h.Admin.DeleteUser(ctx, name); err != nil { if err := h.Admin.DeleteUser(ctx, name); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
if name == s.Username { if name == s.Username {
h.Auth.DeleteSession(ctx, s) h.Auth.DeleteSession(ctx, s)
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminSettingsGet handles GET /map/api/admin/settings. // APIAdminSettingsGet handles GET /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSettingsGet(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context()) prefix, defaultHide, title, err := h.Admin.GetSettings(req.Context())
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
JSON(rw, http.StatusOK, struct { JSON(rw, http.StatusOK, struct {
Prefix string `json:"prefix"` Prefix string `json:"prefix"`
DefaultHide bool `json:"defaultHide"` DefaultHide bool `json:"defaultHide"`
Title string `json:"title"` Title string `json:"title"`
}{Prefix: prefix, DefaultHide: defaultHide, Title: title}) }{Prefix: prefix, DefaultHide: defaultHide, Title: title})
} }
// APIAdminSettingsPost handles POST /map/api/admin/settings. // APIAdminSettingsPost handles POST /map/api/admin/settings.
func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSettingsPost(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
var body struct { var body struct {
Prefix *string `json:"prefix"` Prefix *string `json:"prefix"`
DefaultHide *bool `json:"defaultHide"` DefaultHide *bool `json:"defaultHide"`
Title *string `json:"title"` Title *string `json:"title"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil { if err := h.Admin.UpdateSettings(req.Context(), body.Prefix, body.DefaultHide, body.Title); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminMaps handles GET /map/api/admin/maps. // APIAdminMaps handles GET /map/api/admin/maps.
func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminMaps(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
maps, err := h.Admin.ListMaps(req.Context()) maps, err := h.Admin.ListMaps(req.Context())
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
out := make([]mapInfoJSON, len(maps)) out := make([]mapInfoJSON, len(maps))
for i, m := range maps { for i, m := range maps {
out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority} out[i] = mapInfoJSON{ID: m.ID, Name: m.Name, Hidden: m.Hidden, Priority: m.Priority}
} }
JSON(rw, http.StatusOK, out) JSON(rw, http.StatusOK, out)
} }
// APIAdminMapByID handles POST /map/api/admin/maps/:id. // APIAdminMapByID handles POST /map/api/admin/maps/:id.
func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) { func (h *Handlers) APIAdminMapByID(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
var body struct { var body struct {
Name string `json:"name"` Name string `json:"name"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
Priority bool `json:"priority"` Priority bool `json:"priority"`
} }
if err := json.NewDecoder(req.Body).Decode(&body); err != nil { if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil { if err := h.Admin.UpdateMap(req.Context(), id, body.Name, body.Hidden, body.Priority); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden. // APIAdminMapToggleHidden handles POST /map/api/admin/maps/:id/toggle-hidden.
func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) { func (h *Handlers) APIAdminMapToggleHidden(rw http.ResponseWriter, req *http.Request, idStr string) {
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "bad request", "BAD_REQUEST")
return return
} }
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
mi, err := h.Admin.ToggleMapHidden(req.Context(), id) mi, err := h.Admin.ToggleMapHidden(req.Context(), id)
if err != nil { if err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
JSON(rw, http.StatusOK, mapInfoJSON{ JSON(rw, http.StatusOK, mapInfoJSON{
ID: mi.ID, ID: mi.ID,
Name: mi.Name, Name: mi.Name,
Hidden: mi.Hidden, Hidden: mi.Hidden,
Priority: mi.Priority, Priority: mi.Priority,
}) })
} }
// APIAdminWipe handles POST /map/api/admin/wipe. // APIAdminWipe handles POST /map/api/admin/wipe.
func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminWipe(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
if err := h.Admin.Wipe(req.Context()); err != nil { if err := h.Admin.Wipe(req.Context()); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminWipeTile handles POST /map/api/admin/wipeTile. // APIAdminWipeTile handles POST /map/api/admin/wipeTile.
func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminWipeTile(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
mapid, err := strconv.Atoi(req.FormValue("map")) mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
x, err := strconv.Atoi(req.FormValue("x")) x, err := strconv.Atoi(req.FormValue("x"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
y, err := strconv.Atoi(req.FormValue("y")) y, err := strconv.Atoi(req.FormValue("y"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil { if err := h.Admin.WipeTile(req.Context(), mapid, x, y); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminSetCoords handles POST /map/api/admin/setCoords. // APIAdminSetCoords handles POST /map/api/admin/setCoords.
func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminSetCoords(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
mapid, err := strconv.Atoi(req.FormValue("map")) mapid, err := strconv.Atoi(req.FormValue("map"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
fx, err := strconv.Atoi(req.FormValue("fx")) fx, err := strconv.Atoi(req.FormValue("fx"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
fy, err := strconv.Atoi(req.FormValue("fy")) fy, err := strconv.Atoi(req.FormValue("fy"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
tx, err := strconv.Atoi(req.FormValue("tx")) tx, err := strconv.Atoi(req.FormValue("tx"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
ty, err := strconv.Atoi(req.FormValue("ty")) ty, err := strconv.Atoi(req.FormValue("ty"))
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "coord parse failed", "BAD_REQUEST")
return return
} }
if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil { if err := h.Admin.SetCoords(req.Context(), mapid, fx, fy, tx, ty); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminHideMarker handles POST /map/api/admin/hideMarker. // APIAdminHideMarker handles POST /map/api/admin/hideMarker.
func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminHideMarker(rw http.ResponseWriter, req *http.Request) {
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
markerID := req.FormValue("id") markerID := req.FormValue("id")
if markerID == "" { if markerID == "" {
JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "missing id", "BAD_REQUEST")
return return
} }
if err := h.Admin.HideMarker(req.Context(), markerID); err != nil { if err := h.Admin.HideMarker(req.Context(), markerID); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms. // APIAdminRebuildZooms handles POST /map/api/admin/rebuildZooms.
// It starts the rebuild in the background and returns 202 Accepted immediately. // It starts the rebuild in the background and returns 202 Accepted immediately.
func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminRebuildZooms(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
h.Admin.StartRebuildZooms() h.Admin.StartRebuildZooms()
rw.WriteHeader(http.StatusAccepted) rw.WriteHeader(http.StatusAccepted)
} }
// APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status. // APIAdminRebuildZoomsStatus handles GET /map/api/admin/rebuildZooms/status.
// Returns {"running": true|false} so the client can poll until the rebuild finishes. // Returns {"running": true|false} so the client can poll until the rebuild finishes.
func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminRebuildZoomsStatus(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet { if req.Method != http.MethodGet {
JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED") JSONError(rw, http.StatusMethodNotAllowed, "method not allowed", "METHOD_NOT_ALLOWED")
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
running := h.Admin.RebuildZoomsRunning() running := h.Admin.RebuildZoomsRunning()
JSON(rw, http.StatusOK, map[string]bool{"running": running}) JSON(rw, http.StatusOK, map[string]bool{"running": running})
} }
// APIAdminExport handles GET /map/api/admin/export. // APIAdminExport handles GET /map/api/admin/export.
func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminExport(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodGet) { if !h.requireMethod(rw, req, http.MethodGet) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
rw.Header().Set("Content-Type", "application/zip") rw.Header().Set("Content-Type", "application/zip")
rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`) rw.Header().Set("Content-Disposition", `attachment; filename="griddata.zip"`)
if err := h.Export.Export(req.Context(), rw); err != nil { if err := h.Export.Export(req.Context(), rw); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
} }
} }
// APIAdminMerge handles POST /map/api/admin/merge. // APIAdminMerge handles POST /map/api/admin/merge.
func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) APIAdminMerge(rw http.ResponseWriter, req *http.Request) {
if !h.requireMethod(rw, req, http.MethodPost) { if !h.requireMethod(rw, req, http.MethodPost) {
return return
} }
if h.requireAdmin(rw, req) == nil { if h.requireAdmin(rw, req) == nil {
return return
} }
if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil { if err := req.ParseMultipartForm(app.MergeMaxMemory); err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return return
} }
mergef, hdr, err := req.FormFile("merge") mergef, hdr, err := req.FormFile("merge")
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return return
} }
zr, err := zip.NewReader(mergef, hdr.Size) zr, err := zip.NewReader(mergef, hdr.Size)
if err != nil { if err != nil {
JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST") JSONError(rw, http.StatusBadRequest, "request error", "BAD_REQUEST")
return return
} }
if err := h.Export.Merge(req.Context(), zr); err != nil { if err := h.Export.Merge(req.Context(), zr); err != nil {
HandleServiceError(rw, err) HandleServiceError(rw, err)
return return
} }
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
} }
// APIAdminRoute routes /map/api/admin/* sub-paths. // APIAdminRoute routes /map/api/admin/* sub-paths.
func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) { func (h *Handlers) APIAdminRoute(rw http.ResponseWriter, req *http.Request, path string) {
switch { switch {
case path == "wipeTile": case path == "wipeTile":
h.APIAdminWipeTile(rw, req) h.APIAdminWipeTile(rw, req)
case path == "setCoords": case path == "setCoords":
h.APIAdminSetCoords(rw, req) h.APIAdminSetCoords(rw, req)
case path == "hideMarker": case path == "hideMarker":
h.APIAdminHideMarker(rw, req) h.APIAdminHideMarker(rw, req)
case path == "users": case path == "users":
h.APIAdminUsers(rw, req) h.APIAdminUsers(rw, req)
case strings.HasPrefix(path, "users/"): case strings.HasPrefix(path, "users/"):
name := strings.TrimPrefix(path, "users/") name := strings.TrimPrefix(path, "users/")
if name == "" { if name == "" {
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
return return
} }
if req.Method == http.MethodDelete { if req.Method == http.MethodDelete {
h.APIAdminUserDelete(rw, req, name) h.APIAdminUserDelete(rw, req, name)
} else { } else {
h.APIAdminUserByName(rw, req, name) h.APIAdminUserByName(rw, req, name)
} }
case path == "settings": case path == "settings":
if req.Method == http.MethodGet { if req.Method == http.MethodGet {
h.APIAdminSettingsGet(rw, req) h.APIAdminSettingsGet(rw, req)
} else { } else {
h.APIAdminSettingsPost(rw, req) h.APIAdminSettingsPost(rw, req)
} }
case path == "maps": case path == "maps":
h.APIAdminMaps(rw, req) h.APIAdminMaps(rw, req)
case strings.HasPrefix(path, "maps/"): case strings.HasPrefix(path, "maps/"):
rest := strings.TrimPrefix(path, "maps/") rest := strings.TrimPrefix(path, "maps/")
parts := strings.SplitN(rest, "/", 2) parts := strings.SplitN(rest, "/", 2)
idStr := parts[0] idStr := parts[0]
if len(parts) == 2 && parts[1] == "toggle-hidden" { if len(parts) == 2 && parts[1] == "toggle-hidden" {
h.APIAdminMapToggleHidden(rw, req, idStr) h.APIAdminMapToggleHidden(rw, req, idStr)
return return
} }
if len(parts) == 1 { if len(parts) == 1 {
h.APIAdminMapByID(rw, req, idStr) h.APIAdminMapByID(rw, req, idStr)
return return
} }
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
case path == "wipe": case path == "wipe":
h.APIAdminWipe(rw, req) h.APIAdminWipe(rw, req)
case path == "rebuildZooms": case path == "rebuildZooms":
h.APIAdminRebuildZooms(rw, req) h.APIAdminRebuildZooms(rw, req)
case path == "rebuildZooms/status": case path == "rebuildZooms/status":
h.APIAdminRebuildZoomsStatus(rw, req) h.APIAdminRebuildZoomsStatus(rw, req)
case path == "export": case path == "export":
h.APIAdminExport(rw, req) h.APIAdminExport(rw, req)
case path == "merge": case path == "merge":
h.APIAdminMerge(rw, req) h.APIAdminMerge(rw, req)
default: default:
JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND") JSONError(rw, http.StatusNotFound, "not found", "NOT_FOUND")
} }
} }

View File

@@ -66,7 +66,7 @@ func (h *Handlers) clientLocate(rw http.ResponseWriter, req *http.Request) {
} }
rw.Header().Set("Content-Type", "text/plain") rw.Header().Set("Content-Type", "text/plain")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
rw.Write([]byte(result)) _, _ = rw.Write([]byte(result))
} }
func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
@@ -85,7 +85,7 @@ func (h *Handlers) clientGridUpdate(rw http.ResponseWriter, req *http.Request) {
} }
rw.Header().Set("Content-Type", "application/json") rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
json.NewEncoder(rw).Encode(result.Response) _ = json.NewEncoder(rw).Encode(result.Response)
} }
func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) { func (h *Handlers) clientGridUpload(rw http.ResponseWriter, req *http.Request) {

File diff suppressed because it is too large Load Diff

View File

@@ -55,14 +55,23 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
tileCache := []services.TileCache{} tileCache := []services.TileCache{}
raw, _ := json.Marshal(tileCache) raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ") fmt.Fprint(rw, "data: ")
rw.Write(raw) _, _ = rw.Write(raw)
fmt.Fprint(rw, "\n\n") fmt.Fprint(rw, "\n\n")
flusher.Flush() flusher.Flush()
ticker := time.NewTicker(app.SSETickInterval) ticker := time.NewTicker(app.SSETickInterval)
defer ticker.Stop() defer ticker.Stop()
keepaliveTicker := time.NewTicker(app.SSEKeepaliveInterval)
defer keepaliveTicker.Stop()
for { for {
select { select {
case <-ctx.Done():
return
case <-keepaliveTicker.C:
if _, err := fmt.Fprint(rw, ": keepalive\n\n"); err != nil {
return
}
flusher.Flush()
case e, ok := <-c: case e, ok := <-c:
if !ok { if !ok {
return return
@@ -93,13 +102,15 @@ func (h *Handlers) WatchGridUpdates(rw http.ResponseWriter, req *http.Request) {
} }
fmt.Fprint(rw, "event: merge\n") fmt.Fprint(rw, "event: merge\n")
fmt.Fprint(rw, "data: ") fmt.Fprint(rw, "data: ")
rw.Write(raw) _, _ = rw.Write(raw)
fmt.Fprint(rw, "\n\n") fmt.Fprint(rw, "\n\n")
flusher.Flush() flusher.Flush()
case <-ticker.C: case <-ticker.C:
raw, _ := json.Marshal(tileCache) raw, _ := json.Marshal(tileCache)
fmt.Fprint(rw, "data: ") fmt.Fprint(rw, "data: ")
rw.Write(raw) if _, err := rw.Write(raw); err != nil {
return
}
fmt.Fprint(rw, "\n\n") fmt.Fprint(rw, "\n\n")
tileCache = tileCache[:0] tileCache = tileCache[:0]
flusher.Flush() flusher.Flush()
@@ -152,7 +163,7 @@ func (h *Handlers) GridTile(rw http.ResponseWriter, req *http.Request) {
rw.Header().Set("Content-Type", "image/png") rw.Header().Set("Content-Type", "image/png")
rw.Header().Set("Cache-Control", "private, max-age=3600") rw.Header().Set("Cache-Control", "private, max-age=3600")
rw.WriteHeader(http.StatusOK) rw.WriteHeader(http.StatusOK)
rw.Write(transparentPNG) _, _ = rw.Write(transparentPNG)
return return
} }

View File

@@ -172,7 +172,9 @@ var migrations = []func(tx *bbolt.Tx) error{
if err != nil { if err != nil {
return err return err
} }
users.Put(k, raw) if err := users.Put(k, raw); err != nil {
return err
}
} }
return nil return nil
}) })

View File

@@ -26,7 +26,7 @@ func TestRunMigrations_FreshDB(t *testing.T) {
t.Fatalf("migrations failed on fresh DB: %v", err) t.Fatalf("migrations failed on fresh DB: %v", err)
} }
db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig) b := tx.Bucket(store.BucketConfig)
if b == nil { if b == nil {
t.Fatal("expected config bucket after migrations") t.Fatal("expected config bucket after migrations")
@@ -40,13 +40,15 @@ func TestRunMigrations_FreshDB(t *testing.T) {
t.Fatalf("expected default title, got %s", title) t.Fatalf("expected default title, got %s", title)
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
if tx, _ := db.Begin(false); tx != nil { if tx, _ := db.Begin(false); tx != nil {
if tx.Bucket(store.BucketOAuthStates) == nil { if tx.Bucket(store.BucketOAuthStates) == nil {
t.Fatal("expected oauth_states bucket after migrations") t.Fatal("expected oauth_states bucket after migrations")
} }
tx.Rollback() _ = tx.Rollback()
} }
} }
@@ -61,7 +63,7 @@ func TestRunMigrations_Idempotent(t *testing.T) {
t.Fatalf("second run failed: %v", err) t.Fatalf("second run failed: %v", err)
} }
db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig) b := tx.Bucket(store.BucketConfig)
if b == nil { if b == nil {
t.Fatal("expected config bucket") t.Fatal("expected config bucket")
@@ -71,7 +73,9 @@ func TestRunMigrations_Idempotent(t *testing.T) {
t.Fatal("expected version key") t.Fatal("expected version key")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestRunMigrations_SetsVersion(t *testing.T) { func TestRunMigrations_SetsVersion(t *testing.T) {
@@ -81,11 +85,13 @@ func TestRunMigrations_SetsVersion(t *testing.T) {
} }
var version string var version string
db.View(func(tx *bbolt.Tx) error { if err := db.View(func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketConfig) b := tx.Bucket(store.BucketConfig)
version = string(b.Get([]byte("version"))) version = string(b.Get([]byte("version")))
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
if version == "" || version == "0" { if version == "" || version == "0" {
t.Fatalf("expected non-zero version, got %q", version) t.Fatalf("expected non-zero version, got %q", version)

View File

@@ -1,407 +1,423 @@
package services package services
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "strconv"
"sync" "sync"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
// AdminService handles admin business logic (users, settings, maps, wipe, tile ops). // AdminService handles admin business logic (users, settings, maps, wipe, tile ops).
type AdminService struct { type AdminService struct {
st *store.Store st *store.Store
mapSvc *MapService mapSvc *MapService
rebuildMu sync.Mutex rebuildMu sync.Mutex
rebuildRunning bool rebuildRunning bool
} }
// NewAdminService creates an AdminService with the given store and map service. // NewAdminService creates an AdminService with the given store and map service.
// Uses direct args (two dependencies) rather than a deps struct. // Uses direct args (two dependencies) rather than a deps struct.
func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService { func NewAdminService(st *store.Store, mapSvc *MapService) *AdminService {
return &AdminService{st: st, mapSvc: mapSvc} return &AdminService{st: st, mapSvc: mapSvc}
} }
// ListUsers returns all usernames. // ListUsers returns all usernames.
func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) { func (s *AdminService) ListUsers(ctx context.Context) ([]string, error) {
var list []string var list []string
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachUser(tx, func(k, _ []byte) error { return s.st.ForEachUser(tx, func(k, _ []byte) error {
list = append(list, string(k)) list = append(list, string(k))
return nil return nil
}) })
}) })
return list, err return list, err
} }
// GetUser returns a user's permissions by username. // GetUser returns a user's permissions by username.
func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) { func (s *AdminService) GetUser(ctx context.Context, username string) (auths app.Auths, found bool, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error { err = s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw == nil { if raw == nil {
return nil return nil
} }
var u app.User var u app.User
if err := json.Unmarshal(raw, &u); err != nil { if err := json.Unmarshal(raw, &u); err != nil {
return err return err
} }
auths = u.Auths auths = u.Auths
found = true found = true
return nil return nil
}) })
return auths, found, err return auths, found, err
} }
// CreateOrUpdateUser creates or updates a user. // CreateOrUpdateUser creates or updates a user.
// Returns (true, nil) when admin user was created fresh (temp admin bootstrap). // Returns (true, nil) when admin user was created fresh (temp admin bootstrap).
func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) { func (s *AdminService) CreateOrUpdateUser(ctx context.Context, username string, pass string, auths app.Auths) (adminCreated bool, err error) {
err = s.st.Update(ctx, func(tx *bbolt.Tx) error { err = s.st.Update(ctx, func(tx *bbolt.Tx) error {
existed := s.st.GetUser(tx, username) != nil existed := s.st.GetUser(tx, username) != nil
u := app.User{} u := app.User{}
raw := s.st.GetUser(tx, username) raw := s.st.GetUser(tx, username)
if raw != nil { if raw != nil {
if err := json.Unmarshal(raw, &u); err != nil { if err := json.Unmarshal(raw, &u); err != nil {
return err return err
} }
} }
if pass != "" { if pass != "" {
hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) hash, e := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost)
if e != nil { if e != nil {
return e return e
} }
u.Pass = hash u.Pass = hash
} }
u.Auths = auths u.Auths = auths
raw, _ = json.Marshal(u) raw, _ = json.Marshal(u)
if e := s.st.PutUser(tx, username, raw); e != nil { if e := s.st.PutUser(tx, username, raw); e != nil {
return e return e
} }
if username == "admin" && !existed { if username == "admin" && !existed {
adminCreated = true adminCreated = true
} }
return nil return nil
}) })
return adminCreated, err return adminCreated, err
} }
// DeleteUser removes a user and their tokens. // DeleteUser removes a user and their tokens.
func (s *AdminService) DeleteUser(ctx context.Context, username string) error { func (s *AdminService) DeleteUser(ctx context.Context, username string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
if uRaw != nil { if uRaw != nil {
var u app.User var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil { if err := json.Unmarshal(uRaw, &u); err != nil {
return err return err
} }
for _, tok := range u.Tokens { for _, tok := range u.Tokens {
s.st.DeleteToken(tx, tok) if err := s.st.DeleteToken(tx, tok); err != nil {
} return err
} }
return s.st.DeleteUser(tx, username) }
}) }
} return s.st.DeleteUser(tx, username)
})
// GetSettings returns the current server settings. }
func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
err = s.st.View(ctx, func(tx *bbolt.Tx) error { // GetSettings returns the current server settings.
if v := s.st.GetConfig(tx, "prefix"); v != nil { func (s *AdminService) GetSettings(ctx context.Context) (prefix string, defaultHide bool, title string, err error) {
prefix = string(v) err = s.st.View(ctx, func(tx *bbolt.Tx) error {
} if v := s.st.GetConfig(tx, "prefix"); v != nil {
if v := s.st.GetConfig(tx, "defaultHide"); v != nil { prefix = string(v)
defaultHide = true }
} if v := s.st.GetConfig(tx, "defaultHide"); v != nil {
if v := s.st.GetConfig(tx, "title"); v != nil { defaultHide = true
title = string(v) }
} if v := s.st.GetConfig(tx, "title"); v != nil {
return nil title = string(v)
}) }
return prefix, defaultHide, title, err return nil
} })
return prefix, defaultHide, title, err
// UpdateSettings updates the specified server settings (nil fields are skipped). }
func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { // UpdateSettings updates the specified server settings (nil fields are skipped).
if prefix != nil { func (s *AdminService) UpdateSettings(ctx context.Context, prefix *string, defaultHide *bool, title *string) error {
s.st.PutConfig(tx, "prefix", []byte(*prefix)) return s.st.Update(ctx, func(tx *bbolt.Tx) error {
} if prefix != nil {
if defaultHide != nil { if err := s.st.PutConfig(tx, "prefix", []byte(*prefix)); err != nil {
if *defaultHide { return err
s.st.PutConfig(tx, "defaultHide", []byte("1")) }
} else { }
s.st.DeleteConfig(tx, "defaultHide") if defaultHide != nil {
} if *defaultHide {
} if err := s.st.PutConfig(tx, "defaultHide", []byte("1")); err != nil {
if title != nil { return err
s.st.PutConfig(tx, "title", []byte(*title)) }
} } else {
return nil if err := s.st.DeleteConfig(tx, "defaultHide"); err != nil {
}) return err
} }
}
// ListMaps returns all maps for the admin panel. }
func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) { if title != nil {
var maps []app.MapInfo if err := s.st.PutConfig(tx, "title", []byte(*title)); err != nil {
err := s.st.View(ctx, func(tx *bbolt.Tx) error { return err
return s.st.ForEachMap(tx, func(k, v []byte) error { }
mi := app.MapInfo{} }
if err := json.Unmarshal(v, &mi); err != nil { return nil
return err })
} }
if id, err := strconv.Atoi(string(k)); err == nil {
mi.ID = id // ListMaps returns all maps for the admin panel.
} func (s *AdminService) ListMaps(ctx context.Context) ([]app.MapInfo, error) {
maps = append(maps, mi) var maps []app.MapInfo
return nil err := s.st.View(ctx, func(tx *bbolt.Tx) error {
}) return s.st.ForEachMap(tx, func(k, v []byte) error {
}) mi := app.MapInfo{}
return maps, err if err := json.Unmarshal(v, &mi); err != nil {
} return err
}
// GetMap returns a map by ID. if id, err := strconv.Atoi(string(k)); err == nil {
func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) { mi.ID = id
var mi *app.MapInfo }
err := s.st.View(ctx, func(tx *bbolt.Tx) error { maps = append(maps, mi)
raw := s.st.GetMap(tx, id) return nil
if raw != nil { })
mi = &app.MapInfo{} })
return json.Unmarshal(raw, mi) return maps, err
} }
return nil
}) // GetMap returns a map by ID.
if err != nil { func (s *AdminService) GetMap(ctx context.Context, id int) (*app.MapInfo, bool, error) {
return nil, false, err var mi *app.MapInfo
} err := s.st.View(ctx, func(tx *bbolt.Tx) error {
if mi != nil { raw := s.st.GetMap(tx, id)
mi.ID = id if raw != nil {
} mi = &app.MapInfo{}
return mi, mi != nil, nil return json.Unmarshal(raw, mi)
} }
return nil
// UpdateMap updates a map's name, hidden, and priority fields. })
func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error { if err != nil {
return s.st.Update(ctx, func(tx *bbolt.Tx) error { return nil, false, err
mi := app.MapInfo{} }
raw := s.st.GetMap(tx, id) if mi != nil {
if raw != nil { mi.ID = id
if err := json.Unmarshal(raw, &mi); err != nil { }
return err return mi, mi != nil, nil
} }
}
mi.ID = id // UpdateMap updates a map's name, hidden, and priority fields.
mi.Name = name func (s *AdminService) UpdateMap(ctx context.Context, id int, name string, hidden, priority bool) error {
mi.Hidden = hidden return s.st.Update(ctx, func(tx *bbolt.Tx) error {
mi.Priority = priority mi := app.MapInfo{}
raw, _ = json.Marshal(mi) raw := s.st.GetMap(tx, id)
return s.st.PutMap(tx, id, raw) if raw != nil {
}) if err := json.Unmarshal(raw, &mi); err != nil {
} return err
}
// ToggleMapHidden toggles the hidden flag of a map and returns the updated map. }
func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) { mi.ID = id
var mi *app.MapInfo mi.Name = name
err := s.st.Update(ctx, func(tx *bbolt.Tx) error { mi.Hidden = hidden
raw := s.st.GetMap(tx, id) mi.Priority = priority
mi = &app.MapInfo{} raw, _ = json.Marshal(mi)
if raw != nil { return s.st.PutMap(tx, id, raw)
if err := json.Unmarshal(raw, mi); err != nil { })
return err }
}
} // ToggleMapHidden toggles the hidden flag of a map and returns the updated map.
mi.ID = id func (s *AdminService) ToggleMapHidden(ctx context.Context, id int) (*app.MapInfo, error) {
mi.Hidden = !mi.Hidden var mi *app.MapInfo
raw, _ = json.Marshal(mi) err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
return s.st.PutMap(tx, id, raw) raw := s.st.GetMap(tx, id)
}) mi = &app.MapInfo{}
return mi, err if raw != nil {
} if err := json.Unmarshal(raw, mi); err != nil {
return err
// Wipe deletes all grids, markers, tiles, and maps from the database. }
func (s *AdminService) Wipe(ctx context.Context) error { }
return s.st.Update(ctx, func(tx *bbolt.Tx) error { mi.ID = id
for _, b := range [][]byte{ mi.Hidden = !mi.Hidden
store.BucketGrids, raw, _ = json.Marshal(mi)
store.BucketMarkers, return s.st.PutMap(tx, id, raw)
store.BucketTiles, })
store.BucketMaps, return mi, err
} { }
if s.st.BucketExists(tx, b) {
if err := s.st.DeleteBucket(tx, b); err != nil { // Wipe deletes all grids, markers, tiles, and maps from the database.
return err func (s *AdminService) Wipe(ctx context.Context) error {
} return s.st.Update(ctx, func(tx *bbolt.Tx) error {
} for _, b := range [][]byte{
} store.BucketGrids,
return nil store.BucketMarkers,
}) store.BucketTiles,
} store.BucketMaps,
} {
// WipeTile removes a tile at the given coordinates and rebuilds zoom levels. if s.st.BucketExists(tx, b) {
func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error { if err := s.st.DeleteBucket(tx, b); err != nil {
c := app.Coord{X: x, Y: y} return err
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { }
grids := tx.Bucket(store.BucketGrids) }
if grids == nil { }
return nil return nil
} })
var ids [][]byte }
err := grids.ForEach(func(k, v []byte) error {
g := app.GridData{} // WipeTile removes a tile at the given coordinates and rebuilds zoom levels.
if err := json.Unmarshal(v, &g); err != nil { func (s *AdminService) WipeTile(ctx context.Context, mapid, x, y int) error {
return err c := app.Coord{X: x, Y: y}
} if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
if g.Coord == c && g.Map == mapid { grids := tx.Bucket(store.BucketGrids)
ids = append(ids, k) if grids == nil {
} return nil
return nil }
}) var ids [][]byte
if err != nil { err := grids.ForEach(func(k, v []byte) error {
return err g := app.GridData{}
} if err := json.Unmarshal(v, &g); err != nil {
for _, id := range ids { return err
grids.Delete(id) }
} if g.Coord == c && g.Map == mapid {
return nil ids = append(ids, k)
}); err != nil { }
return err return nil
} })
if err != nil {
s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1) return err
zc := c }
for z := 1; z <= app.MaxZoomLevel; z++ { for _, id := range ids {
zc = zc.Parent() if err := grids.Delete(id); err != nil {
s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z) return err
} }
return nil }
} return nil
}); err != nil {
// SetCoords shifts all grid and tile coordinates by a delta. return err
func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error { }
fc := app.Coord{X: fx, Y: fy}
tc := app.Coord{X: tx2, Y: ty} s.mapSvc.SaveTile(ctx, mapid, c, 0, "", -1)
diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y} zc := c
for z := 1; z <= app.MaxZoomLevel; z++ {
var tds []*app.TileData zc = zc.Parent()
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { s.mapSvc.UpdateZoomLevel(ctx, mapid, zc, z)
grids := tx.Bucket(store.BucketGrids) }
if grids == nil { return nil
return nil }
}
tiles := tx.Bucket(store.BucketTiles) // SetCoords shifts all grid and tile coordinates by a delta.
if tiles == nil { func (s *AdminService) SetCoords(ctx context.Context, mapid, fx, fy, tx2, ty int) error {
return nil fc := app.Coord{X: fx, Y: fy}
} tc := app.Coord{X: tx2, Y: ty}
mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid))) diff := app.Coord{X: tc.X - fc.X, Y: tc.Y - fc.Y}
if mapZooms == nil {
return nil var tds []*app.TileData
} if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
mapTiles := mapZooms.Bucket([]byte("0")) grids := tx.Bucket(store.BucketGrids)
if err := grids.ForEach(func(k, v []byte) error { if grids == nil {
g := app.GridData{} return nil
if err := json.Unmarshal(v, &g); err != nil { }
return err tiles := tx.Bucket(store.BucketTiles)
} if tiles == nil {
if g.Map == mapid { return nil
g.Coord.X += diff.X }
g.Coord.Y += diff.Y mapZooms := tiles.Bucket([]byte(strconv.Itoa(mapid)))
raw, _ := json.Marshal(g) if mapZooms == nil {
grids.Put(k, raw) return nil
} }
return nil mapTiles := mapZooms.Bucket([]byte("0"))
}); err != nil { if err := grids.ForEach(func(k, v []byte) error {
return err g := app.GridData{}
} if err := json.Unmarshal(v, &g); err != nil {
if err := mapTiles.ForEach(func(k, v []byte) error { return err
td := &app.TileData{} }
if err := json.Unmarshal(v, td); err != nil { if g.Map == mapid {
return err g.Coord.X += diff.X
} g.Coord.Y += diff.Y
td.Coord.X += diff.X raw, _ := json.Marshal(g)
td.Coord.Y += diff.Y if err := grids.Put(k, raw); err != nil {
tds = append(tds, td) return err
return nil }
}); err != nil { }
return err return nil
} }); err != nil {
return tiles.DeleteBucket([]byte(strconv.Itoa(mapid))) return err
}); err != nil { }
return err if err := mapTiles.ForEach(func(k, v []byte) error {
} td := &app.TileData{}
if err := json.Unmarshal(v, td); err != nil {
ops := make([]TileOp, len(tds)) return err
for i, td := range tds { }
ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File} td.Coord.X += diff.X
} td.Coord.Y += diff.Y
s.mapSvc.ProcessZoomLevels(ctx, ops) tds = append(tds, td)
return nil return nil
} }); err != nil {
return err
// HideMarker marks a marker as hidden. }
func (s *AdminService) HideMarker(ctx context.Context, markerID string) error { return tiles.DeleteBucket([]byte(strconv.Itoa(mapid)))
return s.st.Update(ctx, func(tx *bbolt.Tx) error { }); err != nil {
_, idB, err := s.st.CreateMarkersBuckets(tx) return err
if err != nil { }
return err
} ops := make([]TileOp, len(tds))
grid := s.st.GetMarkersGridBucket(tx) for i, td := range tds {
if grid == nil { ops[i] = TileOp{MapID: td.MapID, X: td.Coord.X, Y: td.Coord.Y, File: td.File}
return fmt.Errorf("markers grid bucket not found") }
} s.mapSvc.ProcessZoomLevels(ctx, ops)
key := idB.Get([]byte(markerID)) return nil
if key == nil { }
slog.Warn("marker not found", "id", markerID)
return nil // HideMarker marks a marker as hidden.
} func (s *AdminService) HideMarker(ctx context.Context, markerID string) error {
raw := grid.Get(key) return s.st.Update(ctx, func(tx *bbolt.Tx) error {
if raw == nil { _, idB, err := s.st.CreateMarkersBuckets(tx)
return nil if err != nil {
} return err
m := app.Marker{} }
if err := json.Unmarshal(raw, &m); err != nil { grid := s.st.GetMarkersGridBucket(tx)
return err if grid == nil {
} return fmt.Errorf("markers grid bucket not found")
m.Hidden = true }
raw, _ = json.Marshal(m) key := idB.Get([]byte(markerID))
grid.Put(key, raw) if key == nil {
return nil slog.Warn("marker not found", "id", markerID)
}) return nil
} }
raw := grid.Get(key)
// RebuildZooms delegates to MapService. if raw == nil {
func (s *AdminService) RebuildZooms(ctx context.Context) error { return nil
return s.mapSvc.RebuildZooms(ctx) }
} m := app.Marker{}
if err := json.Unmarshal(raw, &m); err != nil {
// StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately. return err
// RebuildZoomsRunning returns true while the rebuild is in progress. }
func (s *AdminService) StartRebuildZooms() { m.Hidden = true
s.rebuildMu.Lock() raw, _ = json.Marshal(m)
if s.rebuildRunning { if err := grid.Put(key, raw); err != nil {
s.rebuildMu.Unlock() return err
return }
} return nil
s.rebuildRunning = true })
s.rebuildMu.Unlock() }
go func() {
defer func() { // RebuildZooms delegates to MapService.
s.rebuildMu.Lock() func (s *AdminService) RebuildZooms(ctx context.Context) error {
s.rebuildRunning = false return s.mapSvc.RebuildZooms(ctx)
s.rebuildMu.Unlock() }
}()
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil { // StartRebuildZooms starts RebuildZooms in a goroutine and returns immediately.
slog.Error("RebuildZooms background failed", "error", err) // RebuildZoomsRunning returns true while the rebuild is in progress.
} func (s *AdminService) StartRebuildZooms() {
}() s.rebuildMu.Lock()
} if s.rebuildRunning {
s.rebuildMu.Unlock()
// RebuildZoomsRunning returns true if a rebuild is currently in progress. return
func (s *AdminService) RebuildZoomsRunning() bool { }
s.rebuildMu.Lock() s.rebuildRunning = true
defer s.rebuildMu.Unlock() s.rebuildMu.Unlock()
return s.rebuildRunning go func() {
} defer func() {
s.rebuildMu.Lock()
s.rebuildRunning = false
s.rebuildMu.Unlock()
}()
if err := s.mapSvc.RebuildZooms(context.Background()); err != nil {
slog.Error("RebuildZooms background failed", "error", err)
}
}()
}
// RebuildZoomsRunning returns true if a rebuild is currently in progress.
func (s *AdminService) RebuildZoomsRunning() bool {
s.rebuildMu.Lock()
defer s.rebuildMu.Unlock()
return s.rebuildRunning
}

View File

@@ -1,298 +1,308 @@
package services_test package services_test
import ( import (
"context" "context"
"testing" "testing"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services" "github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) { func newTestAdmin(t *testing.T) (*services.AdminService, *store.Store) {
t.Helper() t.Helper()
db := newTestDB(t) db := newTestDB(t)
st := store.New(db) st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{ mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st, Store: st,
GridStorage: t.TempDir(), GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{}, GridUpdates: &app.Topic[app.TileData]{},
}) })
return services.NewAdminService(st, mapSvc), st return services.NewAdminService(st, mapSvc), st
} }
func TestListUsers_Empty(t *testing.T) { func TestListUsers_Empty(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
users, err := admin.ListUsers(context.Background()) users, err := admin.ListUsers(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(users) != 0 { if len(users) != 0 {
t.Fatalf("expected 0 users, got %d", len(users)) t.Fatalf("expected 0 users, got %d", len(users))
} }
} }
func TestListUsers_WithUsers(t *testing.T) { func TestListUsers_WithUsers(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", nil) createUser(t, st, "alice", "pass", nil)
createUser(t, st, "bob", "pass", nil) createUser(t, st, "bob", "pass", nil)
users, err := admin.ListUsers(ctx) users, err := admin.ListUsers(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(users) != 2 { if len(users) != 2 {
t.Fatalf("expected 2 users, got %d", len(users)) t.Fatalf("expected 2 users, got %d", len(users))
} }
} }
func TestAdminGetUser_Found(t *testing.T) { func TestAdminGetUser_Found(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP, app.AUTH_UPLOAD})
auths, found, err := admin.GetUser(context.Background(), "alice") auths, found, err := admin.GetUser(context.Background(), "alice")
if err != nil || !found { if err != nil || !found {
t.Fatalf("expected found, err=%v", err) t.Fatalf("expected found, err=%v", err)
} }
if !auths.Has(app.AUTH_MAP) { if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth") t.Fatal("expected map auth")
} }
} }
func TestAdminGetUser_NotFound(t *testing.T) { func TestAdminGetUser_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
_, found, err := admin.GetUser(context.Background(), "ghost") _, found, err := admin.GetUser(context.Background(), "ghost")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if found { if found {
t.Fatal("expected not found") t.Fatal("expected not found")
} }
} }
func TestCreateOrUpdateUser_New(t *testing.T) { func TestCreateOrUpdateUser_New(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
_, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP}) _, err := admin.CreateOrUpdateUser(ctx, "bob", "secret", app.Auths{app.AUTH_MAP})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
auths, found, err := admin.GetUser(ctx, "bob") auths, found, err := admin.GetUser(ctx, "bob")
if err != nil || !found { if err != nil || !found {
t.Fatalf("expected user to exist, err=%v", err) t.Fatalf("expected user to exist, err=%v", err)
} }
if !auths.Has(app.AUTH_MAP) { if !auths.Has(app.AUTH_MAP) {
t.Fatal("expected map auth") t.Fatal("expected map auth")
} }
} }
func TestCreateOrUpdateUser_Update(t *testing.T) { func TestCreateOrUpdateUser_Update(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP}) createUser(t, st, "alice", "old", app.Auths{app.AUTH_MAP})
_, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP}) _, err := admin.CreateOrUpdateUser(ctx, "alice", "new", app.Auths{app.AUTH_ADMIN, app.AUTH_MAP})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
auths, found, err := admin.GetUser(ctx, "alice") auths, found, err := admin.GetUser(ctx, "alice")
if err != nil || !found { if err != nil || !found {
t.Fatalf("expected user, err=%v", err) t.Fatalf("expected user, err=%v", err)
} }
if !auths.Has(app.AUTH_ADMIN) { if !auths.Has(app.AUTH_ADMIN) {
t.Fatal("expected admin auth after update") t.Fatal("expected admin auth after update")
} }
} }
func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) { func TestCreateOrUpdateUser_AdminBootstrap(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN}) adminCreated, err := admin.CreateOrUpdateUser(ctx, "admin", "pass", app.Auths{app.AUTH_ADMIN})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !adminCreated { if !adminCreated {
t.Fatal("expected adminCreated=true for new admin user") t.Fatal("expected adminCreated=true for new admin user")
} }
adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN}) adminCreated, err = admin.CreateOrUpdateUser(ctx, "admin", "pass2", app.Auths{app.AUTH_ADMIN})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if adminCreated { if adminCreated {
t.Fatal("expected adminCreated=false for existing admin user") t.Fatal("expected adminCreated=false for existing admin user")
} }
} }
func TestDeleteUser(t *testing.T) { func TestDeleteUser(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
auth := services.NewAuthService(st) auth := services.NewAuthService(st)
auth.GenerateTokenForUser(ctx, "alice") auth.GenerateTokenForUser(ctx, "alice")
if err := admin.DeleteUser(ctx, "alice"); err != nil { if err := admin.DeleteUser(ctx, "alice"); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, found, err := admin.GetUser(ctx, "alice") _, found, err := admin.GetUser(ctx, "alice")
if err != nil || found { if err != nil || found {
t.Fatalf("expected user to be deleted, err=%v", err) t.Fatalf("expected user to be deleted, err=%v", err)
} }
} }
func TestGetSettings_Defaults(t *testing.T) { func TestGetSettings_Defaults(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
prefix, defaultHide, title, err := admin.GetSettings(context.Background()) prefix, defaultHide, title, err := admin.GetSettings(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prefix != "" || defaultHide || title != "" { if prefix != "" || defaultHide || title != "" {
t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title) t.Fatalf("expected empty defaults, got prefix=%q defaultHide=%v title=%q", prefix, defaultHide, title)
} }
} }
func TestUpdateSettings(t *testing.T) { func TestUpdateSettings(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
p := "pfx" p := "pfx"
dh := true dh := true
ti := "My Map" ti := "My Map"
if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil { if err := admin.UpdateSettings(ctx, &p, &dh, &ti); err != nil {
t.Fatal(err) t.Fatal(err)
} }
prefix, defaultHide, title, err := admin.GetSettings(ctx) prefix, defaultHide, title, err := admin.GetSettings(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prefix != "pfx" { if prefix != "pfx" {
t.Fatalf("expected pfx, got %s", prefix) t.Fatalf("expected pfx, got %s", prefix)
} }
if !defaultHide { if !defaultHide {
t.Fatal("expected defaultHide=true") t.Fatal("expected defaultHide=true")
} }
if title != "My Map" { if title != "My Map" {
t.Fatalf("expected My Map, got %s", title) t.Fatalf("expected My Map, got %s", title)
} }
dh2 := false dh2 := false
if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil { if err := admin.UpdateSettings(ctx, nil, &dh2, nil); err != nil {
t.Fatal(err) t.Fatal(err)
} }
_, defaultHide2, _, _ := admin.GetSettings(ctx) _, defaultHide2, _, _ := admin.GetSettings(ctx)
if defaultHide2 { if defaultHide2 {
t.Fatal("expected defaultHide=false after update") t.Fatal("expected defaultHide=false after update")
} }
} }
func TestListMaps_Empty(t *testing.T) { func TestListMaps_Empty(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
maps, err := admin.ListMaps(context.Background()) maps, err := admin.ListMaps(context.Background())
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(maps) != 0 { if len(maps) != 0 {
t.Fatalf("expected 0 maps, got %d", len(maps)) t.Fatalf("expected 0 maps, got %d", len(maps))
} }
} }
func TestMapCRUD(t *testing.T) { func TestMapCRUD(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil { if err := admin.UpdateMap(ctx, 1, "world", false, false); err != nil {
t.Fatal(err) t.Fatal(err)
} }
mi, found, err := admin.GetMap(ctx, 1) mi, found, err := admin.GetMap(ctx, 1)
if err != nil || !found || mi == nil { if err != nil || !found || mi == nil {
t.Fatalf("expected map, err=%v", err) t.Fatalf("expected map, err=%v", err)
} }
if mi.Name != "world" { if mi.Name != "world" {
t.Fatalf("expected world, got %s", mi.Name) t.Fatalf("expected world, got %s", mi.Name)
} }
maps, err := admin.ListMaps(ctx) maps, err := admin.ListMaps(ctx)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(maps) != 1 { if len(maps) != 1 {
t.Fatalf("expected 1 map, got %d", len(maps)) t.Fatalf("expected 1 map, got %d", len(maps))
} }
} }
func TestToggleMapHidden(t *testing.T) { func TestToggleMapHidden(t *testing.T) {
admin, _ := newTestAdmin(t) admin, _ := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
admin.UpdateMap(ctx, 1, "world", false, false) _ = admin.UpdateMap(ctx, 1, "world", false, false)
mi, err := admin.ToggleMapHidden(ctx, 1) mi, err := admin.ToggleMapHidden(ctx, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !mi.Hidden { if !mi.Hidden {
t.Fatal("expected hidden=true after toggle") t.Fatal("expected hidden=true after toggle")
} }
mi, err = admin.ToggleMapHidden(ctx, 1) mi, err = admin.ToggleMapHidden(ctx, 1)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if mi.Hidden { if mi.Hidden {
t.Fatal("expected hidden=false after second toggle") t.Fatal("expected hidden=false after second toggle")
} }
} }
func TestWipe(t *testing.T) { func TestWipe(t *testing.T) {
admin, st := newTestAdmin(t) admin, st := newTestAdmin(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutGrid(tx, "g1", []byte("data")) if err := st.PutGrid(tx, "g1", []byte("data")); err != nil {
st.PutMap(tx, 1, []byte("data")) return err
st.PutTile(tx, 1, 0, "0_0", []byte("data")) }
st.CreateMarkersBuckets(tx) if err := st.PutMap(tx, 1, []byte("data")); err != nil {
return nil return err
}) }
if err := st.PutTile(tx, 1, 0, "0_0", []byte("data")); err != nil {
if err := admin.Wipe(ctx); err != nil { return err
t.Fatal(err) }
} _, _, err := st.CreateMarkersBuckets(tx)
return err
st.View(ctx, func(tx *bbolt.Tx) error { }); err != nil {
if st.GetGrid(tx, "g1") != nil { t.Fatal(err)
t.Fatal("expected grids wiped") }
}
if st.GetMap(tx, 1) != nil { if err := admin.Wipe(ctx); err != nil {
t.Fatal("expected maps wiped") t.Fatal(err)
} }
if st.GetTile(tx, 1, 0, "0_0") != nil {
t.Fatal("expected tiles wiped") if err := st.View(ctx, func(tx *bbolt.Tx) error {
} if st.GetGrid(tx, "g1") != nil {
if st.GetMarkersGridBucket(tx) != nil { t.Fatal("expected grids wiped")
t.Fatal("expected markers wiped") }
} if st.GetMap(tx, 1) != nil {
return nil t.Fatal("expected maps wiped")
}) }
} if st.GetTile(tx, 1, 0, "0_0") != nil {
t.Fatal("expected tiles wiped")
func TestGetMap_NotFound(t *testing.T) { }
admin, _ := newTestAdmin(t) if st.GetMarkersGridBucket(tx) != nil {
_, found, err := admin.GetMap(context.Background(), 999) t.Fatal("expected markers wiped")
if err != nil { }
t.Fatal(err) return nil
} }); err != nil {
if found { t.Fatal(err)
t.Fatal("expected not found") }
} }
}
func TestGetMap_NotFound(t *testing.T) {
admin, _ := newTestAdmin(t)
_, found, err := admin.GetMap(context.Background(), 999)
if err != nil {
t.Fatal(err)
}
if found {
t.Fatal("expected not found")
}
}

View File

@@ -53,7 +53,7 @@ func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Se
return nil return nil
} }
var sess *app.Session var sess *app.Session
s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetSession(tx, c.Value) raw := s.st.GetSession(tx, c.Value)
if raw == nil { if raw == nil {
return nil return nil
@@ -77,7 +77,9 @@ func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Se
} }
sess.Auths = u.Auths sess.Auths = u.Auths
return nil return nil
}) }); err != nil {
return nil
}
return sess return sess
} }
@@ -165,7 +167,7 @@ func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *a
// SetupRequired returns true if no users exist (first run). // SetupRequired returns true if no users exist (first run).
func (s *AuthService) SetupRequired(ctx context.Context) bool { func (s *AuthService) SetupRequired(ctx context.Context) bool {
var required bool var required bool
s.st.View(ctx, func(tx *bbolt.Tx) error { _ = s.st.View(ctx, func(tx *bbolt.Tx) error {
if s.st.UserCount(tx) == 0 { if s.st.UserCount(tx) == 0 {
required = true required = true
} }
@@ -181,7 +183,7 @@ func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootst
} }
var created bool var created bool
var u *app.User var u *app.User
s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
if s.st.GetUser(tx, "admin") != nil { if s.st.GetUser(tx, "admin") != nil {
return nil return nil
} }
@@ -200,7 +202,9 @@ func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootst
created = true created = true
u = &user u = &user
return nil return nil
}) }); err != nil {
return nil
}
if created { if created {
return u return u
} }
@@ -239,7 +243,7 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
} }
token := hex.EncodeToString(tokenRaw) token := hex.EncodeToString(tokenRaw)
var tokens []string var tokens []string
s.st.Update(ctx, func(tx *bbolt.Tx) error { _ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username) uRaw := s.st.GetUser(tx, username)
u := app.User{} u := app.User{}
if uRaw != nil { if uRaw != nil {
@@ -250,7 +254,9 @@ func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string)
u.Tokens = append(u.Tokens, token) u.Tokens = append(u.Tokens, token)
tokens = u.Tokens tokens = u.Tokens
buf, _ := json.Marshal(u) buf, _ := json.Marshal(u)
s.st.PutUser(tx, username, buf) if err := s.st.PutUser(tx, username, buf); err != nil {
return err
}
return s.st.PutToken(tx, token, username) return s.st.PutToken(tx, token, username)
}) })
return tokens return tokens
@@ -522,7 +528,9 @@ func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub,
user.Email = email user.Email = email
} }
raw, _ = json.Marshal(user) raw, _ = json.Marshal(user)
s.st.PutUser(tx, username, raw) if err := s.st.PutUser(tx, username, raw); err != nil {
return err
}
} }
return nil return nil
} }

View File

@@ -41,9 +41,11 @@ func createUser(t *testing.T, st *store.Store, username, password string, auths
} }
u := app.User{Pass: hash, Auths: auths} u := app.User{Pass: hash, Auths: auths}
raw, _ := json.Marshal(u) raw, _ := json.Marshal(u)
st.Update(context.Background(), func(tx *bbolt.Tx) error { if err := st.Update(context.Background(), func(tx *bbolt.Tx) error {
return st.PutUser(tx, username, raw) return st.PutUser(tx, username, raw)
}) }); err != nil {
t.Fatal(err)
}
} }
func TestSetupRequired_EmptyDB(t *testing.T) { func TestSetupRequired_EmptyDB(t *testing.T) {
@@ -246,9 +248,11 @@ func TestGetUserTokensAndPrefix(t *testing.T) {
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_UPLOAD})
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "prefix", []byte("myprefix")) return st.PutConfig(tx, "prefix", []byte("myprefix"))
}) }); err != nil {
t.Fatal(err)
}
auth.GenerateTokenForUser(ctx, "alice") auth.GenerateTokenForUser(ctx, "alice")
tokens, prefix := auth.GetUserTokensAndPrefix(ctx, "alice") tokens, prefix := auth.GetUserTokensAndPrefix(ctx, "alice")
@@ -288,9 +292,11 @@ func TestValidateClientToken_NoUploadPerm(t *testing.T) {
ctx := context.Background() ctx := context.Background()
createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP}) createUser(t, st, "alice", "pass", app.Auths{app.AUTH_MAP})
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutToken(tx, "tok123", "alice") return st.PutToken(tx, "tok123", "alice")
}) }); err != nil {
t.Fatal(err)
}
_, err := auth.ValidateClientToken(ctx, "tok123") _, err := auth.ValidateClientToken(ctx, "tok123")
if err == nil { if err == nil {

File diff suppressed because it is too large Load Diff

View File

@@ -1,91 +1,121 @@
package services_test package services_test
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"testing" "testing"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/services" "github.com/andyleap/hnh-map/internal/app/services"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
func TestFixMultipartContentType_NeedsQuoting(t *testing.T) { func TestFixMultipartContentType_NeedsQuoting(t *testing.T) {
ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123" ct := "multipart/form-data; boundary=----WebKitFormBoundary=abc123"
got := services.FixMultipartContentType(ct) got := services.FixMultipartContentType(ct)
want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"` want := `multipart/form-data; boundary="----WebKitFormBoundary=abc123"`
if got != want { if got != want {
t.Fatalf("expected %q, got %q", want, got) t.Fatalf("expected %q, got %q", want, got)
} }
} }
func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) { func TestFixMultipartContentType_AlreadyQuoted(t *testing.T) {
ct := `multipart/form-data; boundary="----WebKitFormBoundary"` ct := `multipart/form-data; boundary="----WebKitFormBoundary"`
got := services.FixMultipartContentType(ct) got := services.FixMultipartContentType(ct)
if got != ct { if got != ct {
t.Fatalf("expected unchanged, got %q", got) t.Fatalf("expected unchanged, got %q", got)
} }
} }
func TestFixMultipartContentType_Normal(t *testing.T) { func TestFixMultipartContentType_Normal(t *testing.T) {
ct := "multipart/form-data; boundary=----WebKitFormBoundary" ct := "multipart/form-data; boundary=----WebKitFormBoundary"
got := services.FixMultipartContentType(ct) got := services.FixMultipartContentType(ct)
if got != ct { if got != ct {
t.Fatalf("expected unchanged, got %q", got) t.Fatalf("expected unchanged, got %q", got)
} }
} }
func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) { func newTestClientService(t *testing.T) (*services.ClientService, *store.Store) {
t.Helper() t.Helper()
db := newTestDB(t) db := newTestDB(t)
st := store.New(db) st := store.New(db)
mapSvc := services.NewMapService(services.MapServiceDeps{ mapSvc := services.NewMapService(services.MapServiceDeps{
Store: st, Store: st,
GridStorage: t.TempDir(), GridStorage: t.TempDir(),
GridUpdates: &app.Topic[app.TileData]{}, GridUpdates: &app.Topic[app.TileData]{},
}) })
client := services.NewClientService(services.ClientServiceDeps{ client := services.NewClientService(services.ClientServiceDeps{
Store: st, Store: st,
MapSvc: mapSvc, MapSvc: mapSvc,
WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) }, WithChars: func(fn func(chars map[string]app.Character)) { fn(map[string]app.Character{}) },
}) })
return client, st return client, st
} }
func TestClientLocate_Found(t *testing.T) { func TestClientLocate_Found(t *testing.T) {
client, st := newTestClientService(t) client, st := newTestClientService(t)
ctx := context.Background() ctx := context.Background()
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}} gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 2, Y: 3}}
raw, _ := json.Marshal(gd) raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw) return st.PutGrid(tx, "g1", raw)
}) }); err != nil {
result, err := client.Locate(ctx, "g1") t.Fatal(err)
if err != nil { }
t.Fatal(err) result, err := client.Locate(ctx, "g1")
} if err != nil {
if result != "1;2;3" { t.Fatal(err)
t.Fatalf("expected 1;2;3, got %q", result) }
} if result != "1;2;3" {
} t.Fatalf("expected 1;2;3, got %q", result)
}
func TestClientLocate_NotFound(t *testing.T) { }
client, _ := newTestClientService(t)
_, err := client.Locate(context.Background(), "ghost") func TestClientLocate_NotFound(t *testing.T) {
if err == nil { client, _ := newTestClientService(t)
t.Fatal("expected error for unknown grid") _, err := client.Locate(context.Background(), "ghost")
} if err == nil {
} t.Fatal("expected error for unknown grid")
}
func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) { }
client, _ := newTestClientService(t)
ctx := context.Background() func TestClientProcessGridUpdate_EmptyGrids(t *testing.T) {
result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}}) client, _ := newTestClientService(t)
if err != nil { ctx := context.Background()
t.Fatal(err) result, err := client.ProcessGridUpdate(ctx, services.GridUpdate{Grids: [][]string{}})
} if err != nil {
if result == nil { t.Fatal(err)
t.Fatal("expected non-nil result") }
} if result == nil {
} t.Fatal("expected non-nil result")
}
}
func TestUploadMarkers_NormalizesCaveImage(t *testing.T) {
client, st := newTestClientService(t)
ctx := context.Background()
body := []byte(`[{"Name":"Cave","GridID":"g1","X":10,"Y":20,"Image":"gfx/terobjs/mm/custom"}]`)
if err := client.UploadMarkers(ctx, body); err != nil {
t.Fatal(err)
}
var stored app.Marker
if err := st.View(ctx, func(tx *bbolt.Tx) error {
grid := st.GetMarkersGridBucket(tx)
if grid == nil {
t.Fatal("markers grid bucket not found")
return nil
}
v := grid.Get([]byte("g1_10_20"))
if v == nil {
t.Fatal("marker g1_10_20 not found")
return nil
}
return json.Unmarshal(v, &stored)
}); err != nil {
t.Fatal(err)
}
if stored.Image != "gfx/terobjs/mm/cave" {
t.Fatalf("expected stored marker Image gfx/terobjs/mm/cave, got %q", stored.Image)
}
}

View File

@@ -111,7 +111,7 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
if markersb != nil { if markersb != nil {
markersgrid := markersb.Bucket(store.BucketMarkersGrid) markersgrid := markersb.Bucket(store.BucketMarkersGrid)
if markersgrid != nil { if markersgrid != nil {
markersgrid.ForEach(func(k, v []byte) error { if err := markersgrid.ForEach(func(k, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
@@ -125,7 +125,9 @@ func (s *ExportService) Export(ctx context.Context, w io.Writer) error {
maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker) maps[gridMap[marker.GridID]].Markers[marker.GridID] = append(maps[gridMap[marker.GridID]].Markers[marker.GridID], marker)
} }
return nil return nil
}) }); err != nil {
return err
}
} }
} }
return nil return nil
@@ -218,7 +220,11 @@ func (s *ExportService) Merge(ctx context.Context, zr *zip.Reader) error {
f.Close() f.Close()
return err return err
} }
io.Copy(f, r) if _, err := io.Copy(f, r); err != nil {
r.Close()
f.Close()
return err
}
r.Close() r.Close()
f.Close() f.Close()
newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{} newTiles[strings.TrimSuffix(filepath.Base(fhdr.Name), ".png")] = struct{}{}
@@ -290,8 +296,12 @@ func (s *ExportService) processMergeJSON(
Image: img, Image: img,
} }
raw, _ := json.Marshal(m) raw, _ := json.Marshal(m)
mgrid.Put(key, raw) if err := mgrid.Put(key, raw); err != nil {
idB.Put(idKey, key) return err
}
if err := idB.Put(idKey, key); err != nil {
return err
}
} }
} }
@@ -333,7 +343,9 @@ func (s *ExportService) processMergeJSON(
if err != nil { if err != nil {
return err return err
} }
grids.Put([]byte(grid), raw) if err := grids.Put([]byte(grid), raw); err != nil {
return err
}
} }
return nil return nil
} }
@@ -372,11 +384,13 @@ func (s *ExportService) processMergeJSON(
if err != nil { if err != nil {
return err return err
} }
grids.Put([]byte(grid), raw) if err := grids.Put([]byte(grid), raw); err != nil {
return err
}
} }
if len(existingMaps) > 1 { if len(existingMaps) > 1 {
grids.ForEach(func(k, v []byte) error { if err := grids.ForEach(func(k, v []byte) error {
gd := app.GridData{} gd := app.GridData{}
if err := json.Unmarshal(v, &gd); err != nil { if err := json.Unmarshal(v, &gd); err != nil {
return err return err
@@ -413,16 +427,22 @@ func (s *ExportService) processMergeJSON(
File: td.File, File: td.File,
}) })
} }
grids.Put(k, raw) if err := grids.Put(k, raw); err != nil {
return err
}
} }
return nil return nil
}) }); err != nil {
return err
}
} }
for mergeid, merge := range existingMaps { for mergeid, merge := range existingMaps {
if mapid == mergeid { if mapid == mergeid {
continue continue
} }
mapB.Delete([]byte(strconv.Itoa(mergeid))) if err := mapB.Delete([]byte(strconv.Itoa(mergeid))); err != nil {
return err
}
slog.Info("reporting merge", "from", mergeid, "to", mapid) slog.Info("reporting merge", "from", mergeid, "to", mapid)
s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y}) s.mapSvc.ReportMerge(mergeid, mapid, app.Coord{X: offset.X - merge.X, Y: offset.Y - merge.Y})
} }

View File

@@ -47,12 +47,14 @@ func TestExport_WithGrid(t *testing.T) {
gdRaw, _ := json.Marshal(gd) gdRaw, _ := json.Marshal(gd)
mi := app.MapInfo{ID: 1, Name: "test", Hidden: false} mi := app.MapInfo{ID: 1, Name: "test", Hidden: false}
miRaw, _ := json.Marshal(mi) miRaw, _ := json.Marshal(mi)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if err := st.PutGrid(tx, "g1", gdRaw); err != nil { if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
return err return err
} }
return st.PutMap(tx, 1, miRaw) return st.PutMap(tx, 1, miRaw)
}) }); err != nil {
t.Fatal(err)
}
var buf bytes.Buffer var buf bytes.Buffer
err := export.Export(ctx, &buf) err := export.Export(ctx, &buf)
if err != nil { if err != nil {

View File

@@ -1,418 +1,422 @@
package services package services
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"image" "image"
"image/png" "image/png"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
"github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app"
"github.com/andyleap/hnh-map/internal/app/store" "github.com/andyleap/hnh-map/internal/app/store"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"golang.org/x/image/draw" "golang.org/x/image/draw"
) )
type zoomproc struct { type zoomproc struct {
c app.Coord c app.Coord
m int m int
} }
// MapService handles map, markers, grids, tiles business logic. // MapService handles map, markers, grids, tiles business logic.
type MapService struct { type MapService struct {
st *store.Store st *store.Store
gridStorage string gridStorage string
gridUpdates *app.Topic[app.TileData] gridUpdates *app.Topic[app.TileData]
mergeUpdates *app.Topic[app.Merge] mergeUpdates *app.Topic[app.Merge]
getChars func() []app.Character getChars func() []app.Character
} }
// MapServiceDeps holds dependencies for MapService construction. // MapServiceDeps holds dependencies for MapService construction.
type MapServiceDeps struct { type MapServiceDeps struct {
Store *store.Store Store *store.Store
GridStorage string GridStorage string
GridUpdates *app.Topic[app.TileData] GridUpdates *app.Topic[app.TileData]
MergeUpdates *app.Topic[app.Merge] MergeUpdates *app.Topic[app.Merge]
GetChars func() []app.Character GetChars func() []app.Character
} }
// NewMapService creates a MapService with the given dependencies. // NewMapService creates a MapService with the given dependencies.
func NewMapService(d MapServiceDeps) *MapService { func NewMapService(d MapServiceDeps) *MapService {
return &MapService{ return &MapService{
st: d.Store, st: d.Store,
gridStorage: d.GridStorage, gridStorage: d.GridStorage,
gridUpdates: d.GridUpdates, gridUpdates: d.GridUpdates,
mergeUpdates: d.MergeUpdates, mergeUpdates: d.MergeUpdates,
getChars: d.GetChars, getChars: d.GetChars,
} }
} }
// GridStorage returns the grid storage directory path. // GridStorage returns the grid storage directory path.
func (s *MapService) GridStorage() string { return s.gridStorage } func (s *MapService) GridStorage() string { return s.gridStorage }
// GetCharacters returns all current characters. // GetCharacters returns all current characters.
func (s *MapService) GetCharacters() []app.Character { func (s *MapService) GetCharacters() []app.Character {
if s.getChars == nil { if s.getChars == nil {
return nil return nil
} }
return s.getChars() return s.getChars()
} }
// GetMarkers returns all markers with computed map positions. // GetMarkers returns all markers with computed map positions.
func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) { func (s *MapService) GetMarkers(ctx context.Context) ([]app.FrontendMarker, error) {
var markers []app.FrontendMarker var markers []app.FrontendMarker
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
grid := s.st.GetMarkersGridBucket(tx) grid := s.st.GetMarkersGridBucket(tx)
if grid == nil { if grid == nil {
return nil return nil
} }
grids := tx.Bucket(store.BucketGrids) grids := tx.Bucket(store.BucketGrids)
if grids == nil { if grids == nil {
return nil return nil
} }
return grid.ForEach(func(k, v []byte) error { return grid.ForEach(func(k, v []byte) error {
marker := app.Marker{} marker := app.Marker{}
if err := json.Unmarshal(v, &marker); err != nil { if err := json.Unmarshal(v, &marker); err != nil {
return err return err
} }
graw := grids.Get([]byte(marker.GridID)) graw := grids.Get([]byte(marker.GridID))
if graw == nil { if graw == nil {
return nil return nil
} }
g := app.GridData{} g := app.GridData{}
if err := json.Unmarshal(graw, &g); err != nil { if err := json.Unmarshal(graw, &g); err != nil {
return err return err
} }
markers = append(markers, app.FrontendMarker{ markers = append(markers, app.FrontendMarker{
Image: marker.Image, Image: marker.Image,
Hidden: marker.Hidden, Hidden: marker.Hidden,
ID: marker.ID, ID: marker.ID,
Name: marker.Name, Name: marker.Name,
Map: g.Map, Map: g.Map,
Position: app.Position{ Position: app.Position{
X: marker.Position.X + g.Coord.X*app.GridSize, X: marker.Position.X + g.Coord.X*app.GridSize,
Y: marker.Position.Y + g.Coord.Y*app.GridSize, Y: marker.Position.Y + g.Coord.Y*app.GridSize,
}, },
}) })
return nil return nil
}) })
}) })
return markers, err return markers, err
} }
// GetMaps returns all maps, optionally including hidden ones. // GetMaps returns all maps, optionally including hidden ones.
func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) { func (s *MapService) GetMaps(ctx context.Context, showHidden bool) (map[int]*app.MapInfo, error) {
maps := make(map[int]*app.MapInfo) maps := make(map[int]*app.MapInfo)
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachMap(tx, func(k, v []byte) error { return s.st.ForEachMap(tx, func(k, v []byte) error {
mapid, err := strconv.Atoi(string(k)) mapid, err := strconv.Atoi(string(k))
if err != nil { if err != nil {
return nil return nil
} }
mi := &app.MapInfo{} mi := &app.MapInfo{}
if err := json.Unmarshal(v, mi); err != nil { if err := json.Unmarshal(v, mi); err != nil {
return err return err
} }
if mi.Hidden && !showHidden { if mi.Hidden && !showHidden {
return nil return nil
} }
maps[mapid] = mi maps[mapid] = mi
return nil return nil
}) })
}) })
return maps, err return maps, err
} }
// GetConfig returns the application config for the frontend. // GetConfig returns the application config for the frontend.
func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) { func (s *MapService) GetConfig(ctx context.Context, auths app.Auths) (app.Config, error) {
config := app.Config{Auths: auths} config := app.Config{Auths: auths}
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title") title := s.st.GetConfig(tx, "title")
if title != nil { if title != nil {
config.Title = string(title) config.Title = string(title)
} }
return nil return nil
}) })
return config, err return config, err
} }
// GetPage returns page metadata (title). // GetPage returns page metadata (title).
func (s *MapService) GetPage(ctx context.Context) (app.Page, error) { func (s *MapService) GetPage(ctx context.Context) (app.Page, error) {
p := app.Page{} p := app.Page{}
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
title := s.st.GetConfig(tx, "title") title := s.st.GetConfig(tx, "title")
if title != nil { if title != nil {
p.Title = string(title) p.Title = string(title)
} }
return nil return nil
}) })
return p, err return p, err
} }
// GetGrid returns a grid by its ID. // GetGrid returns a grid by its ID.
func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) { func (s *MapService) GetGrid(ctx context.Context, id string) (*app.GridData, error) {
var gd *app.GridData var gd *app.GridData
err := s.st.View(ctx, func(tx *bbolt.Tx) error { err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetGrid(tx, id) raw := s.st.GetGrid(tx, id)
if raw == nil { if raw == nil {
return nil return nil
} }
gd = &app.GridData{} gd = &app.GridData{}
return json.Unmarshal(raw, gd) return json.Unmarshal(raw, gd)
}) })
return gd, err return gd, err
} }
// GetTile returns a tile by map ID, coordinate, and zoom level. // GetTile returns a tile by map ID, coordinate, and zoom level.
func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData { func (s *MapService) GetTile(ctx context.Context, mapID int, c app.Coord, zoom int) *app.TileData {
var td *app.TileData var td *app.TileData
if err := s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
raw := s.st.GetTile(tx, mapID, zoom, c.Name()) raw := s.st.GetTile(tx, mapID, zoom, c.Name())
if raw != nil { if raw != nil {
td = &app.TileData{} td = &app.TileData{}
return json.Unmarshal(raw, td) return json.Unmarshal(raw, td)
} }
return nil return nil
}); err != nil { }); err != nil {
return nil return nil
} }
return td return td
} }
// getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z). // getSubTiles returns up to 4 tile data for the given parent coord at zoom z-1 (sub-tiles at z).
// Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel. // Order: (0,0), (1,0), (0,1), (1,1) to match the 2x2 loop in UpdateZoomLevel.
func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData { func (s *MapService) getSubTiles(ctx context.Context, mapid int, c app.Coord, z int) []*app.TileData {
coords := []app.Coord{ coords := []app.Coord{
{X: c.X*2 + 0, Y: c.Y*2 + 0}, {X: c.X*2 + 0, Y: c.Y*2 + 0},
{X: c.X*2 + 1, Y: c.Y*2 + 0}, {X: c.X*2 + 1, Y: c.Y*2 + 0},
{X: c.X*2 + 0, Y: c.Y*2 + 1}, {X: c.X*2 + 0, Y: c.Y*2 + 1},
{X: c.X*2 + 1, Y: c.Y*2 + 1}, {X: c.X*2 + 1, Y: c.Y*2 + 1},
} }
keys := make([]string, len(coords)) keys := make([]string, len(coords))
for i := range coords { for i := range coords {
keys[i] = coords[i].Name() keys[i] = coords[i].Name()
} }
var rawMap map[string][]byte var rawMap map[string][]byte
if err := s.st.View(ctx, func(tx *bbolt.Tx) error { if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
rawMap = s.st.GetTiles(tx, mapid, z-1, keys) rawMap = s.st.GetTiles(tx, mapid, z-1, keys)
return nil return nil
}); err != nil { }); err != nil {
return nil return nil
} }
result := make([]*app.TileData, 4) result := make([]*app.TileData, 4)
for i, k := range keys { for i, k := range keys {
if raw, ok := rawMap[k]; ok && len(raw) > 0 { if raw, ok := rawMap[k]; ok && len(raw) > 0 {
td := &app.TileData{} td := &app.TileData{}
if json.Unmarshal(raw, td) == nil { if json.Unmarshal(raw, td) == nil {
result[i] = td result[i] = td
} }
} }
} }
return result return result
} }
// SaveTile persists a tile and broadcasts the update. // SaveTile persists a tile and broadcasts the update.
func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) { func (s *MapService) SaveTile(ctx context.Context, mapid int, c app.Coord, z int, f string, t int64) {
s.st.Update(ctx, func(tx *bbolt.Tx) error { _ = s.st.Update(ctx, func(tx *bbolt.Tx) error {
td := &app.TileData{ td := &app.TileData{
MapID: mapid, MapID: mapid,
Coord: c, Coord: c,
Zoom: z, Zoom: z,
File: f, File: f,
Cache: t, Cache: t,
} }
raw, err := json.Marshal(td) raw, err := json.Marshal(td)
if err != nil { if err != nil {
return err return err
} }
s.gridUpdates.Send(td) s.gridUpdates.Send(td)
return s.st.PutTile(tx, mapid, z, c.Name(), raw) return s.st.PutTile(tx, mapid, z, c.Name(), raw)
}) })
} }
// UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads). // UpdateZoomLevel composes a zoom tile from 4 sub-tiles (one View for all 4 tile reads).
func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) { func (s *MapService) UpdateZoomLevel(ctx context.Context, mapid int, c app.Coord, z int) {
subTiles := s.getSubTiles(ctx, mapid, c, z) subTiles := s.getSubTiles(ctx, mapid, c, z)
img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize)) img := image.NewNRGBA(image.Rect(0, 0, app.GridSize, app.GridSize))
draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src) draw.Draw(img, img.Bounds(), image.Transparent, image.Point{}, draw.Src)
for i := 0; i < 4; i++ { for i := 0; i < 4; i++ {
td := subTiles[i] td := subTiles[i]
if td == nil || td.File == "" { if td == nil || td.File == "" {
continue continue
} }
x := i % 2 x := i % 2
y := i / 2 y := i / 2
subf, err := os.Open(filepath.Join(s.gridStorage, td.File)) subf, err := os.Open(filepath.Join(s.gridStorage, td.File))
if err != nil { if err != nil {
continue continue
} }
subimg, _, err := image.Decode(subf) subimg, _, err := image.Decode(subf)
subf.Close() subf.Close()
if err != nil { if err != nil {
continue continue
} }
draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil) draw.BiLinear.Scale(img, image.Rect(50*x, 50*y, 50*x+50, 50*y+50), subimg, subimg.Bounds(), draw.Src, nil)
} }
if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil { if err := os.MkdirAll(fmt.Sprintf("%s/%d/%d", s.gridStorage, mapid, z), 0755); err != nil {
slog.Error("failed to create zoom dir", "error", err) slog.Error("failed to create zoom dir", "error", err)
return return
} }
path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name()) path := fmt.Sprintf("%s/%d/%d/%s.png", s.gridStorage, mapid, z, c.Name())
relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name()) relPath := fmt.Sprintf("%d/%d/%s.png", mapid, z, c.Name())
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
slog.Error("failed to create tile file", "path", path, "error", err) slog.Error("failed to create tile file", "path", path, "error", err)
return return
} }
if err := png.Encode(f, img); err != nil { if err := png.Encode(f, img); err != nil {
f.Close() f.Close()
os.Remove(path) os.Remove(path)
slog.Error("failed to encode tile PNG", "path", path, "error", err) slog.Error("failed to encode tile PNG", "path", path, "error", err)
return return
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
slog.Error("failed to close tile file", "path", path, "error", err) slog.Error("failed to close tile file", "path", path, "error", err)
return return
} }
s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano()) s.SaveTile(ctx, mapid, c, z, relPath, time.Now().UnixNano())
} }
// RebuildZooms rebuilds all zoom levels from base tiles. // RebuildZooms rebuilds all zoom levels from base tiles.
// It can take a long time for many grids; the client should account for request timeouts. // It can take a long time for many grids; the client should account for request timeouts.
func (s *MapService) RebuildZooms(ctx context.Context) error { func (s *MapService) RebuildZooms(ctx context.Context) error {
needProcess := map[zoomproc]struct{}{} needProcess := map[zoomproc]struct{}{}
saveGrid := map[zoomproc]string{} saveGrid := map[zoomproc]string{}
if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error {
b := tx.Bucket(store.BucketGrids) b := tx.Bucket(store.BucketGrids)
if b == nil { if b == nil {
return nil return nil
} }
b.ForEach(func(k, v []byte) error { if err := b.ForEach(func(k, v []byte) error {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()
default: default:
} }
grid := app.GridData{} grid := app.GridData{}
if err := json.Unmarshal(v, &grid); err != nil { if err := json.Unmarshal(v, &grid); err != nil {
return err return err
} }
needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{} needProcess[zoomproc{grid.Coord.Parent(), grid.Map}] = struct{}{}
saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID saveGrid[zoomproc{grid.Coord, grid.Map}] = grid.ID
return nil return nil
}) }); err != nil {
tx.DeleteBucket(store.BucketTiles) return err
return nil }
}); err != nil { if err := tx.DeleteBucket(store.BucketTiles); err != nil {
slog.Error("RebuildZooms: failed to update store", "error", err) return err
return err }
} return nil
}); err != nil {
for g, id := range saveGrid { slog.Error("RebuildZooms: failed to update store", "error", err)
if ctx.Err() != nil { return err
return ctx.Err() }
}
f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id) for g, id := range saveGrid {
if _, err := os.Stat(f); err != nil { if ctx.Err() != nil {
continue return ctx.Err()
} }
s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano()) f := fmt.Sprintf("%s/grids/%s.png", s.gridStorage, id)
} if _, err := os.Stat(f); err != nil {
for z := 1; z <= app.MaxZoomLevel; z++ { continue
if ctx.Err() != nil { }
return ctx.Err() s.SaveTile(ctx, g.m, g.c, 0, fmt.Sprintf("grids/%s.png", id), time.Now().UnixNano())
} }
process := needProcess for z := 1; z <= app.MaxZoomLevel; z++ {
needProcess = map[zoomproc]struct{}{} if ctx.Err() != nil {
for p := range process { return ctx.Err()
s.UpdateZoomLevel(ctx, p.m, p.c, z) }
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{} process := needProcess
} needProcess = map[zoomproc]struct{}{}
} for p := range process {
return nil s.UpdateZoomLevel(ctx, p.m, p.c, z)
} needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
}
// ReportMerge sends a merge event. }
func (s *MapService) ReportMerge(from, to int, shift app.Coord) { return nil
s.mergeUpdates.Send(&app.Merge{ }
From: from,
To: to, // ReportMerge sends a merge event.
Shift: shift, func (s *MapService) ReportMerge(from, to int, shift app.Coord) {
}) s.mergeUpdates.Send(&app.Merge{
} From: from,
To: to,
// WatchTiles creates a channel that receives tile updates. Shift: shift,
func (s *MapService) WatchTiles() chan *app.TileData { })
c := make(chan *app.TileData, app.SSETileChannelSize) }
s.gridUpdates.Watch(c)
return c // WatchTiles creates a channel that receives tile updates.
} func (s *MapService) WatchTiles() chan *app.TileData {
c := make(chan *app.TileData, app.SSETileChannelSize)
// WatchMerges creates a channel that receives merge updates. s.gridUpdates.Watch(c)
func (s *MapService) WatchMerges() chan *app.Merge { return c
c := make(chan *app.Merge, app.SSEMergeChannelSize) }
s.mergeUpdates.Watch(c)
return c // WatchMerges creates a channel that receives merge updates.
} func (s *MapService) WatchMerges() chan *app.Merge {
c := make(chan *app.Merge, app.SSEMergeChannelSize)
// GetAllTileCache returns all tiles for the initial SSE cache dump. s.mergeUpdates.Watch(c)
func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache { return c
var cache []TileCache }
s.st.View(ctx, func(tx *bbolt.Tx) error {
return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error { // GetAllTileCache returns all tiles for the initial SSE cache dump.
select { func (s *MapService) GetAllTileCache(ctx context.Context) []TileCache {
case <-ctx.Done(): var cache []TileCache
return ctx.Err() _ = s.st.View(ctx, func(tx *bbolt.Tx) error {
default: return s.st.ForEachTile(tx, func(mapK, zoomK, coordK, v []byte) error {
} select {
td := app.TileData{} case <-ctx.Done():
if err := json.Unmarshal(v, &td); err != nil { return ctx.Err()
return err default:
} }
cache = append(cache, TileCache{ td := app.TileData{}
M: td.MapID, if err := json.Unmarshal(v, &td); err != nil {
X: td.Coord.X, return err
Y: td.Coord.Y, }
Z: td.Zoom, cache = append(cache, TileCache{
T: int(td.Cache), M: td.MapID,
}) X: td.Coord.X,
return nil Y: td.Coord.Y,
}) Z: td.Zoom,
}) T: int(td.Cache),
return cache })
} return nil
})
// TileCache represents a minimal tile entry for SSE streaming. })
type TileCache struct { return cache
M, X, Y, Z, T int }
}
// TileCache represents a minimal tile entry for SSE streaming.
// ProcessZoomLevels processes zoom levels for a set of tile operations. type TileCache struct {
func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) { M, X, Y, Z, T int
needProcess := map[zoomproc]struct{}{} }
for _, op := range ops {
s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano()) // ProcessZoomLevels processes zoom levels for a set of tile operations.
needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{} func (s *MapService) ProcessZoomLevels(ctx context.Context, ops []TileOp) {
} needProcess := map[zoomproc]struct{}{}
for z := 1; z <= app.MaxZoomLevel; z++ { for _, op := range ops {
process := needProcess s.SaveTile(ctx, op.MapID, app.Coord{X: op.X, Y: op.Y}, 0, op.File, time.Now().UnixNano())
needProcess = map[zoomproc]struct{}{} needProcess[zoomproc{c: app.Coord{X: op.X, Y: op.Y}.Parent(), m: op.MapID}] = struct{}{}
for p := range process { }
s.UpdateZoomLevel(ctx, p.m, p.c, z) for z := 1; z <= app.MaxZoomLevel; z++ {
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{} process := needProcess
} needProcess = map[zoomproc]struct{}{}
} for p := range process {
} s.UpdateZoomLevel(ctx, p.m, p.c, z)
needProcess[zoomproc{p.c.Parent(), p.m}] = struct{}{}
// TileOp represents a tile save operation. }
type TileOp struct { }
MapID int }
X, Y int
File string // TileOp represents a tile save operation.
} type TileOp struct {
MapID int
X, Y int
File string
}

View File

@@ -57,9 +57,11 @@ func TestGetConfig(t *testing.T) {
svc, st := newTestMapService(t) svc, st := newTestMapService(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "title", []byte("Test Map")) return st.PutConfig(tx, "title", []byte("Test Map"))
}) }); err != nil {
t.Fatal(err)
}
config, err := svc.GetConfig(ctx, app.Auths{app.AUTH_MAP}) config, err := svc.GetConfig(ctx, app.Auths{app.AUTH_MAP})
if err != nil { if err != nil {
@@ -93,9 +95,11 @@ func TestGetConfig_Empty(t *testing.T) {
func TestGetPage(t *testing.T) { func TestGetPage(t *testing.T) {
svc, st := newTestMapService(t) svc, st := newTestMapService(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutConfig(tx, "title", []byte("Map Page")) return st.PutConfig(tx, "title", []byte("Map Page"))
}) }); err != nil {
t.Fatal(err)
}
page, err := svc.GetPage(ctx) page, err := svc.GetPage(ctx)
if err != nil { if err != nil {
@@ -112,9 +116,11 @@ func TestGetGrid(t *testing.T) {
gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 5, Y: 10}} gd := app.GridData{ID: "g1", Map: 1, Coord: app.Coord{X: 5, Y: 10}}
raw, _ := json.Marshal(gd) raw, _ := json.Marshal(gd)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutGrid(tx, "g1", raw) return st.PutGrid(tx, "g1", raw)
}) }); err != nil {
t.Fatal(err)
}
got, err := svc.GetGrid(ctx, "g1") got, err := svc.GetGrid(ctx, "g1")
if err != nil { if err != nil {
@@ -158,11 +164,17 @@ func TestGetMaps_HiddenFilter(t *testing.T) {
mi2 := app.MapInfo{ID: 2, Name: "hidden", Hidden: true} mi2 := app.MapInfo{ID: 2, Name: "hidden", Hidden: true}
raw1, _ := json.Marshal(mi1) raw1, _ := json.Marshal(mi1)
raw2, _ := json.Marshal(mi2) raw2, _ := json.Marshal(mi2)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutMap(tx, 1, raw1) if err := st.PutMap(tx, 1, raw1); err != nil {
st.PutMap(tx, 2, raw2) return err
}
if err := st.PutMap(tx, 2, raw2); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
maps, err := svc.GetMaps(ctx, false) maps, err := svc.GetMaps(ctx, false)
if err != nil { if err != nil {
@@ -201,14 +213,18 @@ func TestGetMarkers_WithData(t *testing.T) {
m := app.Marker{Name: "Tower", ID: 1, GridID: "g1", Position: app.Position{X: 10, Y: 20}, Image: "gfx/terobjs/mm/tower"} m := app.Marker{Name: "Tower", ID: 1, GridID: "g1", Position: app.Position{X: 10, Y: 20}, Image: "gfx/terobjs/mm/tower"}
mRaw, _ := json.Marshal(m) mRaw, _ := json.Marshal(m)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutGrid(tx, "g1", gdRaw) if err := st.PutGrid(tx, "g1", gdRaw); err != nil {
return err
}
grid, _, err := st.CreateMarkersBuckets(tx) grid, _, err := st.CreateMarkersBuckets(tx)
if err != nil { if err != nil {
return err return err
} }
return grid.Put([]byte("g1_10_20"), mRaw) return grid.Put([]byte("g1_10_20"), mRaw)
}) }); err != nil {
t.Fatal(err)
}
markers, err := svc.GetMarkers(ctx) markers, err := svc.GetMarkers(ctx)
if err != nil { if err != nil {
@@ -233,9 +249,11 @@ func TestGetTile(t *testing.T) {
td := app.TileData{MapID: 1, Coord: app.Coord{X: 0, Y: 0}, Zoom: 0, File: "grids/g1.png", Cache: 12345} td := app.TileData{MapID: 1, Coord: app.Coord{X: 0, Y: 0}, Zoom: 0, File: "grids/g1.png", Cache: 12345}
raw, _ := json.Marshal(td) raw, _ := json.Marshal(td)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutTile(tx, 1, 0, "0_0", raw) return st.PutTile(tx, 1, 0, "0_0", raw)
}) }); err != nil {
t.Fatal(err)
}
got := svc.GetTile(ctx, 1, app.Coord{X: 0, Y: 0}, 0) got := svc.GetTile(ctx, 1, app.Coord{X: 0, Y: 0}, 0)
if got == nil { if got == nil {
@@ -287,9 +305,11 @@ func TestGetAllTileCache_WithData(t *testing.T) {
td := app.TileData{MapID: 1, Coord: app.Coord{X: 1, Y: 2}, Zoom: 0, Cache: 999} td := app.TileData{MapID: 1, Coord: app.Coord{X: 1, Y: 2}, Zoom: 0, Cache: 999}
raw, _ := json.Marshal(td) raw, _ := json.Marshal(td)
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
return st.PutTile(tx, 1, 0, "1_2", raw) return st.PutTile(tx, 1, 0, "1_2", raw)
}) }); err != nil {
t.Fatal(err)
}
cache := svc.GetAllTileCache(ctx) cache := svc.GetAllTileCache(ctx)
if len(cache) != 1 { if len(cache) != 1 {

View File

@@ -25,12 +25,14 @@ func TestUserCRUD(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Verify user doesn't exist on empty DB. // Verify user doesn't exist on empty DB.
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetUser(tx, "alice"); got != nil { if got := st.GetUser(tx, "alice"); got != nil {
t.Fatal("expected nil user before creation") t.Fatal("expected nil user before creation")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
// Create user. // Create user.
if err := st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
@@ -40,7 +42,7 @@ func TestUserCRUD(t *testing.T) {
} }
// Verify user exists and count is correct (separate transaction for accurate Stats). // Verify user exists and count is correct (separate transaction for accurate Stats).
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
got := st.GetUser(tx, "alice") got := st.GetUser(tx, "alice")
if got == nil || string(got) != `{"pass":"hash"}` { if got == nil || string(got) != `{"pass":"hash"}` {
t.Fatalf("expected user data, got %s", got) t.Fatalf("expected user data, got %s", got)
@@ -49,7 +51,9 @@ func TestUserCRUD(t *testing.T) {
t.Fatalf("expected 1 user, got %d", c) t.Fatalf("expected 1 user, got %d", c)
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
// Delete user. // Delete user.
if err := st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
@@ -58,31 +62,41 @@ func TestUserCRUD(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetUser(tx, "alice"); got != nil { if got := st.GetUser(tx, "alice"); got != nil {
t.Fatal("expected nil user after deletion") t.Fatal("expected nil user after deletion")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestForEachUser(t *testing.T) { func TestForEachUser(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutUser(tx, "alice", []byte("1")) if err := st.PutUser(tx, "alice", []byte("1")); err != nil {
st.PutUser(tx, "bob", []byte("2")) return err
}
if err := st.PutUser(tx, "bob", []byte("2")); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
var names []string var names []string
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachUser(tx, func(k, _ []byte) error { return st.ForEachUser(tx, func(k, _ []byte) error {
names = append(names, string(k)) names = append(names, string(k))
return nil return nil
}) })
}) }); err != nil {
t.Fatal(err)
}
if len(names) != 2 { if len(names) != 2 {
t.Fatalf("expected 2 users, got %d", len(names)) t.Fatalf("expected 2 users, got %d", len(names))
} }
@@ -91,12 +105,14 @@ func TestForEachUser(t *testing.T) {
func TestUserCountEmptyBucket(t *testing.T) { func TestUserCountEmptyBucket(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if c := st.UserCount(tx); c != 0 { if c := st.UserCount(tx); c != 0 {
t.Fatalf("expected 0 users on empty db, got %d", c) t.Fatalf("expected 0 users on empty db, got %d", c)
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestSessionCRUD(t *testing.T) { func TestSessionCRUD(t *testing.T) {
@@ -211,19 +227,27 @@ func TestForEachMap(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutMap(tx, 1, []byte("a")) if err := st.PutMap(tx, 1, []byte("a")); err != nil {
st.PutMap(tx, 2, []byte("b")) return err
}
if err := st.PutMap(tx, 2, []byte("b")); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
var count int var count int
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachMap(tx, func(_, _ []byte) error { return st.ForEachMap(tx, func(_, _ []byte) error {
count++ count++
return nil return nil
}) })
}) }); err != nil {
t.Fatal(err)
}
if count != 2 { if count != 2 {
t.Fatalf("expected 2 maps, got %d", count) t.Fatalf("expected 2 maps, got %d", count)
} }
@@ -290,20 +314,30 @@ func TestForEachTile(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutTile(tx, 1, 0, "0_0", []byte("a")) if err := st.PutTile(tx, 1, 0, "0_0", []byte("a")); err != nil {
st.PutTile(tx, 1, 1, "0_0", []byte("b")) return err
st.PutTile(tx, 2, 0, "1_1", []byte("c")) }
if err := st.PutTile(tx, 1, 1, "0_0", []byte("b")); err != nil {
return err
}
if err := st.PutTile(tx, 2, 0, "1_1", []byte("c")); err != nil {
return err
}
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
var count int var count int
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
return st.ForEachTile(tx, func(_, _, _, _ []byte) error { return st.ForEachTile(tx, func(_, _, _, _ []byte) error {
count++ count++
return nil return nil
}) })
}) }); err != nil {
t.Fatal(err)
}
if count != 3 { if count != 3 {
t.Fatalf("expected 3 tiles, got %d", count) t.Fatalf("expected 3 tiles, got %d", count)
} }
@@ -313,7 +347,7 @@ func TestTilesMapBucket(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if b := st.GetTilesMapBucket(tx, 1); b != nil { if b := st.GetTilesMapBucket(tx, 1); b != nil {
t.Fatal("expected nil bucket before creation") t.Fatal("expected nil bucket before creation")
} }
@@ -328,31 +362,39 @@ func TestTilesMapBucket(t *testing.T) {
t.Fatal("expected non-nil after create") t.Fatal("expected non-nil after create")
} }
return st.DeleteTilesMapBucket(tx, 1) return st.DeleteTilesMapBucket(tx, 1)
}) }); err != nil {
t.Fatal(err)
}
} }
func TestDeleteTilesBucket(t *testing.T) { func TestDeleteTilesBucket(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
st.PutTile(tx, 1, 0, "0_0", []byte("a")) if err := st.PutTile(tx, 1, 0, "0_0", []byte("a")); err != nil {
return err
}
return st.DeleteTilesBucket(tx) return st.DeleteTilesBucket(tx)
}) }); err != nil {
t.Fatal(err)
}
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if got := st.GetTile(tx, 1, 0, "0_0"); got != nil { if got := st.GetTile(tx, 1, 0, "0_0"); got != nil {
t.Fatal("expected nil after bucket deletion") t.Fatal("expected nil after bucket deletion")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestMarkerBuckets(t *testing.T) { func TestMarkerBuckets(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if b := st.GetMarkersGridBucket(tx); b != nil { if b := st.GetMarkersGridBucket(tx); b != nil {
t.Fatal("expected nil grid bucket before creation") t.Fatal("expected nil grid bucket before creation")
} }
@@ -376,7 +418,9 @@ func TestMarkerBuckets(t *testing.T) {
t.Fatal("expected non-zero sequence") t.Fatal("expected non-zero sequence")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestOAuthStateCRUD(t *testing.T) { func TestOAuthStateCRUD(t *testing.T) {
@@ -408,23 +452,29 @@ func TestBucketExistsAndDelete(t *testing.T) {
st := newTestStore(t) st := newTestStore(t)
ctx := context.Background() ctx := context.Background()
st.Update(ctx, func(tx *bbolt.Tx) error { if err := st.Update(ctx, func(tx *bbolt.Tx) error {
if st.BucketExists(tx, store.BucketUsers) { if st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to not exist") t.Fatal("expected bucket to not exist")
} }
st.PutUser(tx, "alice", []byte("x")) if err := st.PutUser(tx, "alice", []byte("x")); err != nil {
return err
}
if !st.BucketExists(tx, store.BucketUsers) { if !st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to exist") t.Fatal("expected bucket to exist")
} }
return st.DeleteBucket(tx, store.BucketUsers) return st.DeleteBucket(tx, store.BucketUsers)
}) }); err != nil {
t.Fatal(err)
}
st.View(ctx, func(tx *bbolt.Tx) error { if err := st.View(ctx, func(tx *bbolt.Tx) error {
if st.BucketExists(tx, store.BucketUsers) { if st.BucketExists(tx, store.BucketUsers) {
t.Fatal("expected bucket to be deleted") t.Fatal("expected bucket to be deleted")
} }
return nil return nil
}) }); err != nil {
t.Fatal(err)
}
} }
func TestDeleteBucketNonExistent(t *testing.T) { func TestDeleteBucketNonExistent(t *testing.T) {

View File

@@ -15,7 +15,9 @@ func (t *Topic[T]) Watch(c chan *T) {
t.c = append(t.c, c) t.c = append(t.c, c)
} }
// Send broadcasts to all subscribers. // Send broadcasts to all subscribers. If a subscriber's channel is full,
// the message is dropped for that subscriber only; the subscriber is not
// removed, so the connection stays alive and later updates are still delivered.
func (t *Topic[T]) Send(b *T) { func (t *Topic[T]) Send(b *T) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@@ -23,9 +25,7 @@ func (t *Topic[T]) Send(b *T) {
select { select {
case t.c[i] <- b: case t.c[i] <- b:
default: default:
close(t.c[i]) // Channel full: drop this message for this subscriber, keep them subscribed
t.c[i] = t.c[len(t.c)-1]
t.c = t.c[:len(t.c)-1]
} }
} }
} }