Compare commits
10 Commits
dda35baeca
...
dc53b79d84
| Author | SHA1 | Date | |
|---|---|---|---|
| dc53b79d84 | |||
| 179357bc93 | |||
| 3968bdc76f | |||
| 40945c818b | |||
| 7fcdde3657 | |||
| 1a0db9baf0 | |||
| 337386caa8 | |||
| fd624c2357 | |||
| 761fbaed55 | |||
| fc42d86ca0 |
@@ -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).
|
||||||
|
|||||||
29
.cursor/skills/run-lint/SKILL.md
Normal file
29
.cursor/skills/run-lint/SKILL.md
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
16
Makefile
16
Makefile
@@ -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:
|
||||||
|
|||||||
@@ -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", "./..."]
|
||||||
|
|
||||||
|
|||||||
158
docs/api.md
158
docs/api.md
@@ -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"}`.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (1–6) 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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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] }>()
|
||||||
|
|||||||
@@ -6,5 +6,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ inheritAttrs: false })
|
defineOptions({ name: 'AppSkeleton', inheritAttrs: false })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
6
frontend-nuxt/components/icons/IconCopy.vue
Normal file
6
frontend-nuxt/components/icons/IconCopy.vue
Normal 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>
|
||||||
7
frontend-nuxt/components/icons/IconInfo.vue
Normal file
7
frontend-nuxt/components/icons/IconInfo.vue
Normal 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>
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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: '/' } })
|
||||||
|
|||||||
@@ -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' }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'] = '[]'
|
||||||
|
|||||||
@@ -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 })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
BIN
frontend-nuxt/public/gfx/terobjs/mm/cave.png
Normal file
BIN
frontend-nuxt/public/gfx/terobjs/mm/cave.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -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'],
|
||||||
|
|||||||
27
frontend-nuxt/vitest.setup.ts
Normal file
27
frontend-nuxt/vitest.setup.ts
Normal 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,
|
||||||
|
})
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user