diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..eb241e2 --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM golang:1.21-alpine + +WORKDIR /hnh-map + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN go build -o hnh-map ./cmd/hnh-map + +EXPOSE 3080 + +CMD ["/hnh-map/hnh-map", "-grids=/map"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index f3da6f1..545e184 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,30 +1,31 @@ -# Development: backend (Go) on 8080, frontend (Nuxt dev) on 3000 with proxy to backend. -# Open http://localhost:3000/map/ — /map/api, /map/updates, /map/grids are proxied to backend. - -services: - backend: - build: - context: . - dockerfile: Dockerfile - ports: - - "3080:3080" - volumes: - - ./grids:/map - environment: - - HNHMAP_PORT=3080 - - HNHMAP_BOOTSTRAP_PASSWORD=admin - - frontend: - image: node:20-alpine - working_dir: /app - command: sh -c "npm ci && npm run dev" - ports: - - "3000:3000" - volumes: - - ./frontend-nuxt:/app - # Prevent overwriting node_modules from host - - /app/node_modules - environment: - - NUXT_PUBLIC_API_BASE=/map/api - depends_on: - - backend +# Development: backend API on 3080 + frontend Nuxt dev server on 3000. +# Open http://localhost:3000/ for app development with live-reload. + +services: + backend: + build: + context: . + dockerfile: Dockerfile.dev + ports: + - "3080:3080" + volumes: + - ./grids:/map + environment: + - HNHMAP_PORT=3080 + - HNHMAP_BOOTSTRAP_PASSWORD=admin + + frontend: + image: node:20-alpine + working_dir: /app + command: sh -c "npm ci && npm run dev" + ports: + - "3000:3000" + volumes: + - ./frontend-nuxt:/app + # Prevent overwriting node_modules from host + - /app/node_modules + environment: + - NUXT_PUBLIC_API_BASE=/map/api + - HOST=0.0.0.0 + depends_on: + - backend diff --git a/docs/development.md b/docs/development.md index 97e3b57..2e02fca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,50 +1,53 @@ -# Разработка - -## Локальный запуск - -### Бэкенд (Go) - -Из корня репозитория: - -```bash -go build -o hnh-map ./cmd/hnh-map -./hnh-map -grids=./grids -port=8080 -``` - -Или без сборки: - -```bash -go run ./cmd/hnh-map -grids=./grids -port=8080 -``` - -Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). - -### Фронтенд (Nuxt) - -```bash -cd frontend-nuxt -npm install -npm run dev -``` - -В dev-режиме приложение доступно по корню (например `http://localhost:3000/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`). - -### Docker Compose (разработка) - -```bash -docker compose -f docker-compose.dev.yml up -``` - -- Фронт: порт **3000** (Nuxt dev-сервер). -- Бэкенд: порт **3080** (чтобы не конфликтовать с другими сервисами на 8080). - -Откройте http://localhost:3000/. Запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд (host `backend`, порт 3080). - -### Сборка образа и prod-композ - -```bash -docker build -t hnh-map . -docker compose -f docker-compose.prod.yml up -d -``` - -В prod фронт собран в образ и отдаётся бэкендом из каталога `frontend/`; порт 8080. +# Разработка + +## Локальный запуск + +### Бэкенд (Go) + +Из корня репозитория: + +```bash +go build -o hnh-map ./cmd/hnh-map +./hnh-map -grids=./grids -port=8080 +``` + +Или без сборки: + +```bash +go run ./cmd/hnh-map -grids=./grids -port=8080 +``` + +Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). + +### Фронтенд (Nuxt) + +```bash +cd frontend-nuxt +npm install +npm run dev +``` + +В dev-режиме приложение доступно по корню (например `http://localhost:3000/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`). + +### Docker Compose (разработка) + +```bash +docker compose -f docker-compose.dev.yml up +``` + +Dev-композ поднимает два сервиса: + +- `backend` — Go API на порту `3080` (без сборки/раздачи фронтенд-статики в dev-режиме). +- `frontend` — Nuxt dev-сервер на порту `3000` с live-reload; запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд. + +Используйте [http://localhost:3000/](http://localhost:3000/) как основной URL для разработки интерфейса. +Порт `3080` предназначен для API и backend-эндпоинтов; корень `/` может возвращать `404` в dev-режиме — это ожидаемо. + +### Сборка образа и prod-композ + +```bash +docker build -t hnh-map . +docker compose -f docker-compose.prod.yml up -d +``` + +В prod фронт собран в образ и отдаётся бэкендом из каталога `frontend/`; порт 8080. diff --git a/frontend-nuxt/Dockerfile b/frontend-nuxt/Dockerfile deleted file mode 100644 index ea04678..0000000 --- a/frontend-nuxt/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -# Use Node 20+ for build (required by Tailwind/PostCSS toolchain) -FROM node:20-alpine AS builder - -WORKDIR /app - -COPY package.json package-lock.json* ./ -RUN npm ci - -COPY . . -RUN npm run generate - -# Output: .output/public is the static site root (for Go http.Dir("frontend")) -FROM alpine:3.19 -RUN apk add --no-cache bash -COPY --from=builder /app/.output/public /frontend -# Optional: when integrating with main Dockerfile, copy /frontend into the image diff --git a/frontend-nuxt/README.md b/frontend-nuxt/README.md index 65f8b31..3e0f4d7 100644 --- a/frontend-nuxt/README.md +++ b/frontend-nuxt/README.md @@ -1,66 +1,66 @@ -# HnH Map – Nuxt 3 frontend - -Nuxt 3 + Tailwind + DaisyUI frontend for the HnH map. Served by the Go backend under `/map/`. - -In dev mode the app is available at the path with baseURL **`/map/`** (e.g. `http://localhost:3000/map/`). The Go backend must be reachable (directly or via the dev proxy in `nuxt.config.ts`). - -## Project structure - -- **pages/** — route pages (e.g. map view, profile, login) -- **components/** — Vue components -- **composables/** — shared composition functions -- **layouts/** — layout components -- **server/** — Nitro server (if used) -- **plugins/** — Nuxt plugins -- **public/gfx/** — static assets (sprites, terrain, etc.) - -## Requirements - -- **Node.js 20+** (required for build; `engines` in package.json). Use `nvm use` if you have `.nvmrc`, or build via Docker (see below). -- npm - -## Setup - -```bash -npm install -``` - -## Development - -```bash -npm run dev -``` - -Then open the app at the path shown (e.g. `http://localhost:3000/map/`). Ensure the Go backend is running and proxying or serving this app if needed. - -## Build - -```bash -npm run build -``` - -Static export (for Go `http.Dir`): - -```bash -npm run generate -``` - -Output is in `.output/public`. To serve from the existing `frontend` directory, copy contents to `../frontend` after generate, or set `nitro.output.dir` in `nuxt.config.ts` and build from the repo root. - -## Build with Docker (Node 20) - -Build requires Node 20+. If your host has an older version (e.g. Node 18), build the frontend in Docker: - -```bash -docker build -t frontend-nuxt . -docker create --name fn frontend-nuxt -docker cp fn:/frontend ./output-public -docker rm fn -# Copy output-public/* into repo frontend/ and run Go server -``` - -## Cutover from Vue 2 frontend - -1. Build this app (`npm run generate`). -2. Copy `.output/public/*` into the repo’s `frontend` directory (or point Go at the Nuxt output directory). -3. Restart the Go server. The same `/map/` routes and API remain. +# HnH Map – Nuxt 3 frontend + +Nuxt 3 + Tailwind + DaisyUI frontend for the HnH map. Served by the Go backend under `/map/`. + +In dev mode the app is available at the path with baseURL **`/map/`** (e.g. `http://localhost:3000/map/`). The Go backend must be reachable (directly or via the dev proxy in `nuxt.config.ts`). + +## Project structure + +- **pages/** — route pages (e.g. map view, profile, login) +- **components/** — Vue components +- **composables/** — shared composition functions +- **layouts/** — layout components +- **server/** — Nitro server (if used) +- **plugins/** — Nuxt plugins +- **public/gfx/** — static assets (sprites, terrain, etc.) + +## Requirements + +- **Node.js 20+** (required for build; `engines` in package.json). Use `nvm use` if you have `.nvmrc`, or build via Docker (see below). +- npm + +## Setup + +```bash +npm install +``` + +## Development + +```bash +npm run dev +``` + +Then open the app at the path shown (e.g. `http://localhost:3000/map/`). Ensure the Go backend is running and proxying or serving this app if needed. + +## Build + +```bash +npm run build +``` + +Static export (for Go `http.Dir`): + +```bash +npm run generate +``` + +Output is in `.output/public`. To serve from the existing `frontend` directory, copy contents to `../frontend` after generate, or set `nitro.output.dir` in `nuxt.config.ts` and build from the repo root. + +## Build with Docker (Node 20) + +Build requires Node 20+. If your host has an older version (e.g. Node 18), build the frontend in Docker: + +```bash +docker build -t frontend-nuxt . +docker create --name fn frontend-nuxt +docker cp fn:/frontend ./output-public +docker rm fn +# Copy output-public/* into repo frontend/ and run Go server +``` + +## Cutover from Vue 2 frontend + +1. Build this app (`npm run generate`). +2. Copy `.output/public/*` into the repo’s `frontend` directory (or point Go at the Nuxt output directory). +3. Restart the Go server. The same `/map/` routes and API remain. diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index c614a88..321e83f 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -1,510 +1,530 @@ - - - - - + + + + + diff --git a/frontend-nuxt/components/map/MapContextMenu.vue b/frontend-nuxt/components/map/MapContextMenu.vue index 66146f4..366f3a3 100644 --- a/frontend-nuxt/components/map/MapContextMenu.vue +++ b/frontend-nuxt/components/map/MapContextMenu.vue @@ -1,63 +1,65 @@ - - - + + + diff --git a/frontend-nuxt/composables/useMapLogic.ts b/frontend-nuxt/composables/useMapLogic.ts index 1fdb4f5..999a7ac 100644 --- a/frontend-nuxt/composables/useMapLogic.ts +++ b/frontend-nuxt/composables/useMapLogic.ts @@ -101,6 +101,7 @@ export function useMapLogic() { } function openTileContextMenu(clientX: number, clientY: number, coords: { x: number; y: number }) { + closeContextMenus() contextMenu.tile.show = true contextMenu.tile.x = clientX contextMenu.tile.y = clientY @@ -108,6 +109,7 @@ export function useMapLogic() { } function openMarkerContextMenu(clientX: number, clientY: number, id: number, name: string) { + closeContextMenus() contextMenu.marker.show = true contextMenu.marker.x = clientX contextMenu.marker.y = clientY diff --git a/frontend-nuxt/layouts/default.vue b/frontend-nuxt/layouts/default.vue index 6f8e624..51cf622 100644 --- a/frontend-nuxt/layouts/default.vue +++ b/frontend-nuxt/layouts/default.vue @@ -1,128 +1,128 @@ - - - + + + diff --git a/frontend-nuxt/nuxt.config.ts b/frontend-nuxt/nuxt.config.ts index 2437c70..3ac0271 100644 --- a/frontend-nuxt/nuxt.config.ts +++ b/frontend-nuxt/nuxt.config.ts @@ -1,54 +1,54 @@ -// https://nuxt.com/docs/api/configuration/nuxt-config -import { viteUriGuard } from './vite/vite-uri-guard' - -export default defineNuxtConfig({ - compatibilityDate: '2024-11-01', - devtools: { enabled: true }, - - app: { - baseURL: '/', - pageTransition: { name: 'page', mode: 'out-in' }, - head: { - title: 'HnH Map', - meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }], - link: [ - { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, - { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, - { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap' }, - ], - }, - }, - - ssr: false, - - runtimeConfig: { - public: { - apiBase: '/map/api', - }, - }, - - modules: ['@nuxtjs/tailwindcss'], - tailwindcss: { - cssPath: '~/assets/css/app.css', - }, - css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'], - - vite: { - plugins: [viteUriGuard()], - optimizeDeps: { - include: ['leaflet'], - }, - }, - - // Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml) - nitro: { - devProxy: { - '/map/api': { target: 'http://backend:3080', changeOrigin: true }, - '/map/updates': { target: 'http://backend:3080', changeOrigin: true }, - '/map/grids': { target: 'http://backend:3080', changeOrigin: true }, - }, - }, - - // For cutover: set nitro.preset to 'static' and optionally copy .output/public to ../frontend - // nitro: { output: { dir: '../frontend' } }, -}) +// https://nuxt.com/docs/api/configuration/nuxt-config +import { viteUriGuard } from './vite/vite-uri-guard' + +export default defineNuxtConfig({ + compatibilityDate: '2024-11-01', + devtools: { enabled: true }, + + app: { + baseURL: '/', + pageTransition: { name: 'page', mode: 'out-in' }, + head: { + title: 'HnH Map', + meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }], + link: [ + { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, + { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, + { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap' }, + ], + }, + }, + + ssr: false, + + runtimeConfig: { + public: { + apiBase: '/map/api', + }, + }, + + modules: ['@nuxtjs/tailwindcss'], + tailwindcss: { + cssPath: '~/assets/css/app.css', + }, + css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'], + + vite: { + plugins: [viteUriGuard()], + optimizeDeps: { + include: ['leaflet'], + }, + }, + + // Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml) + nitro: { + devProxy: { + '/map/api': { target: 'http://backend:3080/map/api', changeOrigin: true }, + '/map/updates': { target: 'http://backend:3080/map/updates', changeOrigin: true }, + '/map/grids': { target: 'http://backend:3080/map/grids', changeOrigin: true }, + }, + }, + + // For cutover: set nitro.preset to 'static' and optionally copy .output/public to ../frontend + // nitro: { output: { dir: '../frontend' } }, +}) diff --git a/frontend-nuxt/public/index.html b/frontend-nuxt/public/index.html deleted file mode 100644 index 898093b..0000000 --- a/frontend-nuxt/public/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - - - - - - - -
- - - diff --git a/internal/app/app.go b/internal/app/app.go index 79d8afe..a16702f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,261 +1,261 @@ -package app - -import ( - "fmt" - "net/http" - "path/filepath" - "strings" - "sync" - "time" - - "go.etcd.io/bbolt" -) - -// App is the main application (map server) state. -type App struct { - gridStorage string - frontendRoot string - db *bbolt.DB - - characters map[string]Character - chmu sync.RWMutex - - gridUpdates Topic[TileData] - mergeUpdates Topic[Merge] -} - -// GridStorage returns the grid storage path. -func (a *App) GridStorage() string { - return a.gridStorage -} - -// GridUpdates returns the tile updates topic for MapService. -func (a *App) GridUpdates() *Topic[TileData] { - return &a.gridUpdates -} - -// MergeUpdates returns the merge updates topic for MapService. -func (a *App) MergeUpdates() *Topic[Merge] { - return &a.mergeUpdates -} - -// GetCharacters returns a copy of all characters (for MapService). -func (a *App) GetCharacters() []Character { - a.chmu.RLock() - defer a.chmu.RUnlock() - chars := make([]Character, 0, len(a.characters)) - for _, v := range a.characters { - chars = append(chars, v) - } - return chars -} - -// NewApp creates an App with the given storage paths and database. -// frontendRoot is the directory for the map SPA (e.g. "frontend"). -func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { - return &App{ - gridStorage: gridStorage, - frontendRoot: frontendRoot, - db: db, - characters: make(map[string]Character), - }, nil -} - -type Session struct { - ID string - Username string - Auths Auths `json:"-"` - TempAdmin bool -} - -type Character struct { - Name string `json:"name"` - ID int `json:"id"` - Map int `json:"map"` - Position Position `json:"position"` - Type string `json:"type"` - updated time.Time -} - -type Marker struct { - Name string `json:"name"` - ID int `json:"id"` - GridID string `json:"gridID"` - Position Position `json:"position"` - Image string `json:"image"` - Hidden bool `json:"hidden"` -} - -type FrontendMarker struct { - Name string `json:"name"` - ID int `json:"id"` - Map int `json:"map"` - Position Position `json:"position"` - Image string `json:"image"` - Hidden bool `json:"hidden"` -} - -type MapInfo struct { - ID int - Name string - Hidden bool - Priority bool -} - -type GridData struct { - ID string - Coord Coord - NextUpdate time.Time - Map int -} - -type Coord struct { - X int `json:"x"` - Y int `json:"y"` -} - -type Position struct { - X int `json:"x"` - Y int `json:"y"` -} - -func (c Coord) Name() string { - return fmt.Sprintf("%d_%d", c.X, c.Y) -} - -func (c Coord) Parent() Coord { - if c.X < 0 { - c.X-- - } - if c.Y < 0 { - c.Y-- - } - return Coord{ - X: c.X / 2, - Y: c.Y / 2, - } -} - -type Auths []string - -func (a Auths) Has(auth string) bool { - for _, v := range a { - if v == auth { - return true - } - } - return false -} - -const ( - AUTH_ADMIN = "admin" - AUTH_MAP = "map" - AUTH_MARKERS = "markers" - AUTH_UPLOAD = "upload" -) - -type User struct { - Pass []byte - Auths Auths - Tokens []string - // OAuth: provider -> subject (unique ID from provider) - OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789" -} - -type Page struct { - Title string `json:"title"` -} - -// serveSPARoot serves the map SPA from root: static files from frontend, fallback to index.html for client-side routes. -// Handles redirects from old /map/* URLs for backward compatibility. -func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) { - path := req.URL.Path - - // Redirect old /map/* URLs to flat routes - if path == "/map" || path == "/map/" { - http.Redirect(rw, req, "/", http.StatusFound) - return - } - if strings.HasPrefix(path, "/map/") { - rest := path[len("/map/"):] - switch { - case rest == "login": - http.Redirect(rw, req, "/login", http.StatusFound) - return - case rest == "profile": - http.Redirect(rw, req, "/profile", http.StatusFound) - return - case rest == "admin" || strings.HasPrefix(rest, "admin/"): - http.Redirect(rw, req, "/"+rest, http.StatusFound) - return - case rest == "setup": - http.Redirect(rw, req, "/setup", http.StatusFound) - return - case strings.HasPrefix(rest, "character/"): - http.Redirect(rw, req, "/"+rest, http.StatusFound) - return - case strings.HasPrefix(rest, "grid/"): - http.Redirect(rw, req, "/"+rest, http.StatusFound) - return - } - } - - // File serving: path relative to frontend root (with baseURL /, files are at root) - filePath := strings.TrimPrefix(path, "/") - if filePath == "" { - filePath = "index.html" - } - filePath = filepath.Clean(filePath) - if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") { - http.NotFound(rw, req) - return - } - // Try both root and map/ for backward compatibility with old builds - tryPaths := []string{filePath, filepath.Join("map", filePath)} - var f http.File - for _, p := range tryPaths { - var err error - f, err = http.Dir(a.frontendRoot).Open(p) - if err == nil { - filePath = p - break - } - } - if f == nil { - http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) - return - } - defer f.Close() - stat, err := f.Stat() - if err != nil || stat.IsDir() { - http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) - return - } - http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f) -} - -// CleanChars runs a background loop that removes stale character entries. Call once as a goroutine. -func (a *App) CleanChars() { - for range time.Tick(time.Second * 10) { - a.chmu.Lock() - for n, c := range a.characters { - if c.updated.Before(time.Now().Add(-10 * time.Second)) { - delete(a.characters, n) - } - } - a.chmu.Unlock() - } -} - -// RegisterRoutes registers all HTTP handlers for the app. -func (a *App) RegisterRoutes() { - http.HandleFunc("/client/", a.client) - http.HandleFunc("/logout", a.redirectLogout) - - http.HandleFunc("/map/api/", a.apiRouter) - http.HandleFunc("/map/updates", a.watchGridUpdates) - http.HandleFunc("/map/grids/", a.gridTile) - - // SPA catch-all: must be last - http.HandleFunc("/", a.serveSPARoot) -} +package app + +import ( + "fmt" + "net/http" + "path/filepath" + "strings" + "sync" + "time" + + "go.etcd.io/bbolt" +) + +// App is the main application (map server) state. +type App struct { + gridStorage string + frontendRoot string + db *bbolt.DB + + characters map[string]Character + chmu sync.RWMutex + + gridUpdates Topic[TileData] + mergeUpdates Topic[Merge] +} + +// GridStorage returns the grid storage path. +func (a *App) GridStorage() string { + return a.gridStorage +} + +// GridUpdates returns the tile updates topic for MapService. +func (a *App) GridUpdates() *Topic[TileData] { + return &a.gridUpdates +} + +// MergeUpdates returns the merge updates topic for MapService. +func (a *App) MergeUpdates() *Topic[Merge] { + return &a.mergeUpdates +} + +// GetCharacters returns a copy of all characters (for MapService). +func (a *App) GetCharacters() []Character { + a.chmu.RLock() + defer a.chmu.RUnlock() + chars := make([]Character, 0, len(a.characters)) + for _, v := range a.characters { + chars = append(chars, v) + } + return chars +} + +// NewApp creates an App with the given storage paths and database. +// frontendRoot is the directory for the map SPA (e.g. "frontend"). +func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { + return &App{ + gridStorage: gridStorage, + frontendRoot: frontendRoot, + db: db, + characters: make(map[string]Character), + }, nil +} + +type Session struct { + ID string + Username string + Auths Auths `json:"-"` + TempAdmin bool +} + +type Character struct { + Name string `json:"name"` + ID int `json:"id"` + Map int `json:"map"` + Position Position `json:"position"` + Type string `json:"type"` + updated time.Time +} + +type Marker struct { + Name string `json:"name"` + ID int `json:"id"` + GridID string `json:"gridID"` + Position Position `json:"position"` + Image string `json:"image"` + Hidden bool `json:"hidden"` +} + +type FrontendMarker struct { + Name string `json:"name"` + ID int `json:"id"` + Map int `json:"map"` + Position Position `json:"position"` + Image string `json:"image"` + Hidden bool `json:"hidden"` +} + +type MapInfo struct { + ID int + Name string + Hidden bool + Priority bool +} + +type GridData struct { + ID string + Coord Coord + NextUpdate time.Time + Map int +} + +type Coord struct { + X int `json:"x"` + Y int `json:"y"` +} + +type Position struct { + X int `json:"x"` + Y int `json:"y"` +} + +func (c Coord) Name() string { + return fmt.Sprintf("%d_%d", c.X, c.Y) +} + +func (c Coord) Parent() Coord { + if c.X < 0 { + c.X-- + } + if c.Y < 0 { + c.Y-- + } + return Coord{ + X: c.X / 2, + Y: c.Y / 2, + } +} + +type Auths []string + +func (a Auths) Has(auth string) bool { + for _, v := range a { + if v == auth { + return true + } + } + return false +} + +const ( + AUTH_ADMIN = "admin" + AUTH_MAP = "map" + AUTH_MARKERS = "markers" + AUTH_UPLOAD = "upload" +) + +type User struct { + Pass []byte + Auths Auths + Tokens []string + // OAuth: provider -> subject (unique ID from provider) + OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789" +} + +type Page struct { + Title string `json:"title"` +} + +// serveSPARoot serves the map SPA from root: static files from frontend, fallback to index.html for client-side routes. +// Handles redirects from old /map/* URLs for backward compatibility. +func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) { + path := req.URL.Path + + // Redirect old /map/* URLs to flat routes + if path == "/map" || path == "/map/" { + http.Redirect(rw, req, "/", http.StatusFound) + return + } + if strings.HasPrefix(path, "/map/") { + rest := path[len("/map/"):] + switch { + case rest == "login": + http.Redirect(rw, req, "/login", http.StatusFound) + return + case rest == "profile": + http.Redirect(rw, req, "/profile", http.StatusFound) + return + case rest == "admin" || strings.HasPrefix(rest, "admin/"): + http.Redirect(rw, req, "/"+rest, http.StatusFound) + return + case rest == "setup": + http.Redirect(rw, req, "/setup", http.StatusFound) + return + case strings.HasPrefix(rest, "character/"): + http.Redirect(rw, req, "/"+rest, http.StatusFound) + return + case strings.HasPrefix(rest, "grid/"): + http.Redirect(rw, req, "/"+rest, http.StatusFound) + return + } + } + + // File serving: path relative to frontend root (with baseURL /, files are at root) + filePath := strings.TrimPrefix(path, "/") + if filePath == "" { + filePath = "index.html" + } + filePath = filepath.Clean(filePath) + if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") { + http.NotFound(rw, req) + return + } + // Try both root and map/ for backward compatibility with old builds + tryPaths := []string{filePath, filepath.Join("map", filePath)} + var f http.File + for _, p := range tryPaths { + var err error + f, err = http.Dir(a.frontendRoot).Open(p) + if err == nil { + filePath = p + break + } + } + if f == nil { + http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) + return + } + defer f.Close() + stat, err := f.Stat() + if err != nil || stat.IsDir() { + http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) + return + } + http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f) +} + +// CleanChars runs a background loop that removes stale character entries. Call once as a goroutine. +func (a *App) CleanChars() { + for range time.Tick(time.Second * 10) { + a.chmu.Lock() + for n, c := range a.characters { + if c.updated.Before(time.Now().Add(-10 * time.Second)) { + delete(a.characters, n) + } + } + a.chmu.Unlock() + } +} + +// RegisterRoutes registers all HTTP handlers for the app. +func (a *App) RegisterRoutes() { + http.HandleFunc("/client/", a.client) + http.HandleFunc("/logout", a.redirectLogout) + + http.HandleFunc("/map/api/", a.apiRouter) + http.HandleFunc("/map/updates", a.watchGridUpdates) + http.HandleFunc("/map/grids/", a.gridTile) + + // SPA catch-all: must be last + http.HandleFunc("/", a.serveSPARoot) +}