Refactor Docker setup and enhance development environment

- Updated docker-compose.dev.yml to use Dockerfile.dev for backend builds and added HOST environment variable for frontend.
- Introduced Dockerfile.dev for streamlined backend development with Go.
- Enhanced development documentation to reflect changes in local setup and API proxying.
- Removed outdated frontend Dockerfile and adjusted frontend configuration for improved development experience.
This commit is contained in:
2026-02-28 23:53:20 +03:00
parent 5ffa10f8b7
commit 0466ff3087
12 changed files with 1203 additions and 1198 deletions

13
Dockerfile.dev Normal file
View File

@@ -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"]

View File

@@ -1,30 +1,31 @@
# Development: backend (Go) on 8080, frontend (Nuxt dev) on 3000 with proxy to backend. # Development: backend API on 3080 + frontend Nuxt dev server on 3000.
# Open http://localhost:3000/map/ — /map/api, /map/updates, /map/grids are proxied to backend. # Open http://localhost:3000/ for app development with live-reload.
services: services:
backend: backend:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile.dev
ports: ports:
- "3080:3080" - "3080:3080"
volumes: volumes:
- ./grids:/map - ./grids:/map
environment: environment:
- HNHMAP_PORT=3080 - HNHMAP_PORT=3080
- HNHMAP_BOOTSTRAP_PASSWORD=admin - HNHMAP_BOOTSTRAP_PASSWORD=admin
frontend: frontend:
image: node:20-alpine image: node:20-alpine
working_dir: /app working_dir: /app
command: sh -c "npm ci && npm run dev" command: sh -c "npm ci && npm run dev"
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
- ./frontend-nuxt:/app - ./frontend-nuxt:/app
# Prevent overwriting node_modules from host # Prevent overwriting node_modules from host
- /app/node_modules - /app/node_modules
environment: environment:
- NUXT_PUBLIC_API_BASE=/map/api - NUXT_PUBLIC_API_BASE=/map/api
depends_on: - HOST=0.0.0.0
- backend depends_on:
- backend

View File

@@ -1,50 +1,53 @@
# Разработка # Разработка
## Локальный запуск ## Локальный запуск
### Бэкенд (Go) ### Бэкенд (Go)
Из корня репозитория: Из корня репозитория:
```bash ```bash
go build -o hnh-map ./cmd/hnh-map go build -o hnh-map ./cmd/hnh-map
./hnh-map -grids=./grids -port=8080 ./hnh-map -grids=./grids -port=8080
``` ```
Или без сборки: Или без сборки:
```bash ```bash
go run ./cmd/hnh-map -grids=./grids -port=8080 go run ./cmd/hnh-map -grids=./grids -port=8080
``` ```
Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже). Сервер будет отдавать статику из каталога `frontend/` (нужно предварительно собрать фронт, см. ниже).
### Фронтенд (Nuxt) ### Фронтенд (Nuxt)
```bash ```bash
cd frontend-nuxt cd frontend-nuxt
npm install npm install
npm run dev npm run dev
``` ```
В dev-режиме приложение доступно по корню (например `http://localhost:3000/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`). В dev-режиме приложение доступно по корню (например `http://localhost:3000/`). Бэкенд должен быть доступен; при необходимости настройте прокси в `nuxt.config.ts` (например на `http://localhost:8080`).
### Docker Compose (разработка) ### Docker Compose (разработка)
```bash ```bash
docker compose -f docker-compose.dev.yml up docker compose -f docker-compose.dev.yml up
``` ```
- Фронт: порт **3000** (Nuxt dev-сервер). Dev-композ поднимает два сервиса:
- Бэкенд: порт **3080** (чтобы не конфликтовать с другими сервисами на 8080).
- `backend` — Go API на порту `3080` (без сборки/раздачи фронтенд-статики в dev-режиме).
Откройте http://localhost:3000/. Запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд (host `backend`, порт 3080). - `frontend` — Nuxt dev-сервер на порту `3000` с live-reload; запросы к `/map/api`, `/map/updates`, `/map/grids` проксируются на бэкенд.
### Сборка образа и prod-композ Используйте [http://localhost:3000/](http://localhost:3000/) как основной URL для разработки интерфейса.
Порт `3080` предназначен для API и backend-эндпоинтов; корень `/` может возвращать `404` в dev-режиме — это ожидаемо.
```bash
docker build -t hnh-map . ### Сборка образа и prod-композ
docker compose -f docker-compose.prod.yml up -d
``` ```bash
docker build -t hnh-map .
В prod фронт собран в образ и отдаётся бэкендом из каталога `frontend/`; порт 8080. docker compose -f docker-compose.prod.yml up -d
```
В prod фронт собран в образ и отдаётся бэкендом из каталога `frontend/`; порт 8080.

View File

@@ -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

View File

@@ -1,66 +1,66 @@
# HnH Map Nuxt 3 frontend # HnH Map Nuxt 3 frontend
Nuxt 3 + Tailwind + DaisyUI frontend for the HnH map. Served by the Go backend under `/map/`. 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`). 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 ## Project structure
- **pages/** — route pages (e.g. map view, profile, login) - **pages/** — route pages (e.g. map view, profile, login)
- **components/** — Vue components - **components/** — Vue components
- **composables/** — shared composition functions - **composables/** — shared composition functions
- **layouts/** — layout components - **layouts/** — layout components
- **server/** — Nitro server (if used) - **server/** — Nitro server (if used)
- **plugins/** — Nuxt plugins - **plugins/** — Nuxt plugins
- **public/gfx/** — static assets (sprites, terrain, etc.) - **public/gfx/** — static assets (sprites, terrain, etc.)
## Requirements ## Requirements
- **Node.js 20+** (required for build; `engines` in package.json). Use `nvm use` if you have `.nvmrc`, or build via Docker (see below). - **Node.js 20+** (required for build; `engines` in package.json). Use `nvm use` if you have `.nvmrc`, or build via Docker (see below).
- npm - npm
## Setup ## Setup
```bash ```bash
npm install npm install
``` ```
## Development ## Development
```bash ```bash
npm run dev 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. 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 ## Build
```bash ```bash
npm run build npm run build
``` ```
Static export (for Go `http.Dir`): Static export (for Go `http.Dir`):
```bash ```bash
npm run generate 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. 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 with Docker (Node 20)
Build requires Node 20+. If your host has an older version (e.g. Node 18), build the frontend in Docker: Build requires Node 20+. If your host has an older version (e.g. Node 18), build the frontend in Docker:
```bash ```bash
docker build -t frontend-nuxt . docker build -t frontend-nuxt .
docker create --name fn frontend-nuxt docker create --name fn frontend-nuxt
docker cp fn:/frontend ./output-public docker cp fn:/frontend ./output-public
docker rm fn docker rm fn
# Copy output-public/* into repo frontend/ and run Go server # Copy output-public/* into repo frontend/ and run Go server
``` ```
## Cutover from Vue 2 frontend ## Cutover from Vue 2 frontend
1. Build this app (`npm run generate`). 1. Build this app (`npm run generate`).
2. Copy `.output/public/*` into the repos `frontend` directory (or point Go at the Nuxt output directory). 2. Copy `.output/public/*` into the repos `frontend` directory (or point Go at the Nuxt output directory).
3. Restart the Go server. The same `/map/` routes and API remain. 3. Restart the Go server. The same `/map/` routes and API remain.

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +1,65 @@
<template> <template>
<!-- Context menu (tile) --> <!-- Context menu (tile) Teleport so it is not clipped by map/overflow -->
<div <Teleport to="body">
v-show="contextMenu.tile.show" <div
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150" v-show="contextMenu.tile.show"
:style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }" class="fixed z-[9999] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
> :style="{ left: contextMenu.tile.x + 'px', top: contextMenu.tile.y + 'px' }"
<button >
type="button" <button
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" type="button"
@click="onWipeTile(contextMenu.tile.data?.coords)" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
> @click="onWipeTile(contextMenu.tile.data?.coords)"
Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }} >
</button> Wipe tile {{ contextMenu.tile.data?.coords?.x }}, {{ contextMenu.tile.data?.coords?.y }}
<button </button>
type="button" <button
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" type="button"
@click="onRewriteCoords(contextMenu.tile.data?.coords)" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
> @click="onRewriteCoords(contextMenu.tile.data?.coords)"
Rewrite tile coords >
</button> Rewrite tile coords
</div> </button>
<!-- Context menu (marker) --> </div>
<div <!-- Context menu (marker) -->
v-show="contextMenu.marker.show" <div
class="fixed z-[1000] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150" v-show="contextMenu.marker.show"
:style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }" class="fixed z-[9999] bg-base-100/95 backdrop-blur-xl shadow-xl rounded-lg border border-base-300 py-1 min-w-[180px] transition-opacity duration-150"
> :style="{ left: contextMenu.marker.x + 'px', top: contextMenu.marker.y + 'px' }"
<button >
type="button" <button
class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors" type="button"
@click="onHideMarker(contextMenu.marker.data?.id)" class="btn btn-ghost btn-sm w-full justify-start hover:bg-base-200 transition-colors"
> @click="onHideMarker(contextMenu.marker.data?.id)"
Hide marker {{ contextMenu.marker.data?.name }} >
</button> Hide marker {{ contextMenu.marker.data?.name }}
</div> </button>
</template> </div>
</Teleport>
<script setup lang="ts"> </template>
import type { ContextMenuState } from '~/composables/useMapLogic'
<script setup lang="ts">
defineProps<{ import type { ContextMenuState } from '~/composables/useMapLogic'
contextMenu: ContextMenuState
}>() defineProps<{
contextMenu: ContextMenuState
const emit = defineEmits<{ }>()
wipeTile: [coords: { x: number; y: number } | undefined]
rewriteCoords: [coords: { x: number; y: number } | undefined] const emit = defineEmits<{
hideMarker: [id: number | undefined] wipeTile: [coords: { x: number; y: number } | undefined]
}>() rewriteCoords: [coords: { x: number; y: number } | undefined]
hideMarker: [id: number | undefined]
function onWipeTile(coords: { x: number; y: number } | undefined) { }>()
if (coords) emit('wipeTile', coords)
} function onWipeTile(coords: { x: number; y: number } | undefined) {
if (coords) emit('wipeTile', coords)
function onRewriteCoords(coords: { x: number; y: number } | undefined) { }
if (coords) emit('rewriteCoords', coords)
} function onRewriteCoords(coords: { x: number; y: number } | undefined) {
if (coords) emit('rewriteCoords', coords)
function onHideMarker(id: number | undefined) { }
if (id != null) emit('hideMarker', id)
} function onHideMarker(id: number | undefined) {
</script> if (id != null) emit('hideMarker', id)
}
</script>

View File

@@ -101,6 +101,7 @@ export function useMapLogic() {
} }
function openTileContextMenu(clientX: number, clientY: number, coords: { x: number; y: number }) { function openTileContextMenu(clientX: number, clientY: number, coords: { x: number; y: number }) {
closeContextMenus()
contextMenu.tile.show = true contextMenu.tile.show = true
contextMenu.tile.x = clientX contextMenu.tile.x = clientX
contextMenu.tile.y = clientY contextMenu.tile.y = clientY
@@ -108,6 +109,7 @@ export function useMapLogic() {
} }
function openMarkerContextMenu(clientX: number, clientY: number, id: number, name: string) { function openMarkerContextMenu(clientX: number, clientY: number, id: number, name: string) {
closeContextMenus()
contextMenu.marker.show = true contextMenu.marker.show = true
contextMenu.marker.x = clientX contextMenu.marker.x = clientX
contextMenu.marker.y = clientY contextMenu.marker.y = clientY

View File

@@ -1,128 +1,128 @@
<template> <template>
<div class="h-screen flex flex-col bg-base-100 overflow-hidden"> <div class="h-screen flex flex-col bg-base-100 overflow-hidden">
<header class="navbar bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0"> <header class="navbar bg-base-100/80 backdrop-blur-xl border-b border-base-300/50 px-4 gap-2 shrink-0">
<NuxtLink to="/" class="text-lg font-semibold hover:opacity-80 transition-all duration-200">{{ title }}</NuxtLink> <NuxtLink to="/" class="text-lg font-semibold hover:opacity-80 transition-all duration-200">{{ title }}</NuxtLink>
<div class="flex-1" /> <div class="flex-1" />
<NuxtLink <NuxtLink
v-if="!isLogin" v-if="!isLogin"
to="/" to="/"
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105" class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
:class="route.path === '/' ? 'btn-primary' : ''" :class="route.path === '/' ? 'btn-primary' : ''"
> >
<icons-icon-map /> <icons-icon-map />
Map Map
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
v-if="!isLogin" v-if="!isLogin"
to="/profile" to="/profile"
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105" class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
:class="route.path === '/profile' ? 'btn-primary' : ''" :class="route.path === '/profile' ? 'btn-primary' : ''"
> >
<icons-icon-user /> <icons-icon-user />
Profile Profile
</NuxtLink> </NuxtLink>
<NuxtLink <NuxtLink
v-if="!isLogin && isAdmin" v-if="!isLogin && isAdmin"
to="/admin" to="/admin"
class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105" class="btn btn-ghost btn-sm gap-1.5 transition-all duration-200 hover:scale-105"
:class="route.path.startsWith('/admin') ? 'btn-primary' : ''" :class="route.path.startsWith('/admin') ? 'btn-primary' : ''"
> >
<icons-icon-shield /> <icons-icon-shield />
Admin Admin
</NuxtLink> </NuxtLink>
<button <button
v-if="!isLogin && me" v-if="!isLogin && me"
type="button" type="button"
class="btn btn-ghost btn-sm btn-error btn-outline gap-1.5 transition-all duration-200 hover:scale-105" class="btn btn-ghost btn-sm btn-error btn-outline gap-1.5 transition-all duration-200 hover:scale-105"
@click="doLogout" @click="doLogout"
> >
<icons-icon-logout /> <icons-icon-logout />
Logout Logout
</button> </button>
<label class="swap swap-rotate btn btn-ghost btn-sm transition-all duration-200 hover:scale-105"> <label class="swap swap-rotate btn btn-ghost btn-sm transition-all duration-200 hover:scale-105">
<input type="checkbox" v-model="dark" @change="toggleTheme" /> <input type="checkbox" v-model="dark" @change="toggleTheme" />
<span class="swap-off"><icons-icon-sun /></span> <span class="swap-off"><icons-icon-sun /></span>
<span class="swap-on"><icons-icon-moon /></span> <span class="swap-on"><icons-icon-moon /></span>
</label> </label>
<span v-if="live" class="badge badge-success badge-sm">Live</span> <span v-if="live" class="badge badge-success badge-sm">Live</span>
</header> </header>
<main class="flex-1 min-h-0 overflow-y-auto relative"> <main class="flex-1 min-h-0 overflow-y-auto relative">
<slot /> <slot />
</main> </main>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const THEME_KEY = 'hnh-map-theme' const THEME_KEY = 'hnh-map-theme'
function getInitialDark(): boolean { function getInitialDark(): boolean {
if (import.meta.client) { if (import.meta.client) {
const stored = localStorage.getItem(THEME_KEY) const stored = localStorage.getItem(THEME_KEY)
if (stored === 'dark') return true if (stored === 'dark') return true
if (stored === 'light') return false if (stored === 'light') return false
return window.matchMedia('(prefers-color-scheme: dark)').matches return window.matchMedia('(prefers-color-scheme: dark)').matches
} }
return false return false
} }
const title = ref('HnH Map') const title = ref('HnH Map')
const dark = ref(false) const dark = ref(false)
const live = ref(false) const live = ref(false)
const me = ref<{ username?: string; auths?: string[] } | null>(null) const me = ref<{ username?: string; auths?: string[] } | null>(null)
const { isLoginPath } = useAppPaths() const { isLoginPath } = useAppPaths()
const isLogin = computed(() => isLoginPath(route.path)) const isLogin = computed(() => isLoginPath(route.path))
const isAdmin = computed(() => !!me.value?.auths?.includes('admin')) const isAdmin = computed(() => !!me.value?.auths?.includes('admin'))
async function loadMe() { async function loadMe() {
if (isLogin.value) return if (isLogin.value) return
try { try {
me.value = await useMapApi().me() me.value = await useMapApi().me()
} catch { } catch {
me.value = null me.value = null
} }
} }
async function loadConfig() { async function loadConfig() {
if (isLogin.value) return if (isLogin.value) return
try { try {
const config = await useMapApi().getConfig() const config = await useMapApi().getConfig()
if (config?.title) title.value = config.title if (config?.title) title.value = config.title
} catch (_) {} } catch (_) {}
} }
onMounted(() => { onMounted(() => {
dark.value = getInitialDark() dark.value = getInitialDark()
const html = document.documentElement const html = document.documentElement
html.setAttribute('data-theme', dark.value ? 'dark' : 'light') html.setAttribute('data-theme', dark.value ? 'dark' : 'light')
}) })
watch( watch(
() => route.path, () => route.path,
(path) => { (path) => {
if (!isLoginPath(path)) loadMe().then(loadConfig) if (!isLoginPath(path)) loadMe().then(loadConfig)
}, },
{ immediate: true } { immediate: true }
) )
function toggleTheme() { function toggleTheme() {
const html = document.documentElement const html = document.documentElement
if (dark.value) { if (dark.value) {
html.setAttribute('data-theme', 'dark') html.setAttribute('data-theme', 'dark')
localStorage.setItem(THEME_KEY, 'dark') localStorage.setItem(THEME_KEY, 'dark')
} else { } else {
html.setAttribute('data-theme', 'light') html.setAttribute('data-theme', 'light')
localStorage.setItem(THEME_KEY, 'light') localStorage.setItem(THEME_KEY, 'light')
} }
} }
async function doLogout() { async function doLogout() {
await useMapApi().logout() await useMapApi().logout()
await router.push('/login') await router.push('/login')
me.value = null me.value = null
} }
defineExpose({ setLive: (v: boolean) => { live.value = v } }) defineExpose({ setLive: (v: boolean) => { live.value = v } })
</script> </script>

View File

@@ -1,54 +1,54 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
import { viteUriGuard } from './vite/vite-uri-guard' import { viteUriGuard } from './vite/vite-uri-guard'
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: '2024-11-01', compatibilityDate: '2024-11-01',
devtools: { enabled: true }, devtools: { enabled: true },
app: { app: {
baseURL: '/', baseURL: '/',
pageTransition: { name: 'page', mode: 'out-in' }, pageTransition: { name: 'page', mode: 'out-in' },
head: { head: {
title: 'HnH Map', title: 'HnH Map',
meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }], meta: [{ charset: 'utf-8' }, { name: 'viewport', content: 'width=device-width, initial-scale=1' }],
link: [ link: [
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, { 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' }, { 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, ssr: false,
runtimeConfig: { runtimeConfig: {
public: { public: {
apiBase: '/map/api', apiBase: '/map/api',
}, },
}, },
modules: ['@nuxtjs/tailwindcss'], modules: ['@nuxtjs/tailwindcss'],
tailwindcss: { tailwindcss: {
cssPath: '~/assets/css/app.css', cssPath: '~/assets/css/app.css',
}, },
css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'], css: ['~/assets/css/app.css', 'leaflet/dist/leaflet.css', '~/assets/css/leaflet-overrides.css'],
vite: { vite: {
plugins: [viteUriGuard()], plugins: [viteUriGuard()],
optimizeDeps: { optimizeDeps: {
include: ['leaflet'], include: ['leaflet'],
}, },
}, },
// Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml) // Dev: proxy /map API, SSE and grids to Go backend (e.g. docker compose -f docker-compose.dev.yml)
nitro: { nitro: {
devProxy: { devProxy: {
'/map/api': { target: 'http://backend:3080', changeOrigin: true }, '/map/api': { target: 'http://backend:3080/map/api', changeOrigin: true },
'/map/updates': { target: 'http://backend:3080', changeOrigin: true }, '/map/updates': { target: 'http://backend:3080/map/updates', changeOrigin: true },
'/map/grids': { target: 'http://backend:3080', 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 // For cutover: set nitro.preset to 'static' and optionally copy .output/public to ../frontend
// nitro: { output: { dir: '../frontend' } }, // nitro: { output: { dir: '../frontend' } },
}) })

View File

@@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.4.0/dist/leaflet.css"
integrity="sha512-puBpdR0798OZvTTbP4A8Ix/l+A4dHDD0DGqYW6RQ+9jxkRFclaxxQb/SJAWZfWAkuyeQUytO7+7N4QKrDh+drA=="
crossorigin=""/>
<title></title>
</head>
<body>
<noscript>
<strong>We're sorry but map-frontend doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

@@ -1,261 +1,261 @@
package app package app
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
) )
// App is the main application (map server) state. // App is the main application (map server) state.
type App struct { type App struct {
gridStorage string gridStorage string
frontendRoot string frontendRoot string
db *bbolt.DB db *bbolt.DB
characters map[string]Character characters map[string]Character
chmu sync.RWMutex chmu sync.RWMutex
gridUpdates Topic[TileData] gridUpdates Topic[TileData]
mergeUpdates Topic[Merge] mergeUpdates Topic[Merge]
} }
// GridStorage returns the grid storage path. // GridStorage returns the grid storage path.
func (a *App) GridStorage() string { func (a *App) GridStorage() string {
return a.gridStorage return a.gridStorage
} }
// GridUpdates returns the tile updates topic for MapService. // GridUpdates returns the tile updates topic for MapService.
func (a *App) GridUpdates() *Topic[TileData] { func (a *App) GridUpdates() *Topic[TileData] {
return &a.gridUpdates return &a.gridUpdates
} }
// MergeUpdates returns the merge updates topic for MapService. // MergeUpdates returns the merge updates topic for MapService.
func (a *App) MergeUpdates() *Topic[Merge] { func (a *App) MergeUpdates() *Topic[Merge] {
return &a.mergeUpdates return &a.mergeUpdates
} }
// GetCharacters returns a copy of all characters (for MapService). // GetCharacters returns a copy of all characters (for MapService).
func (a *App) GetCharacters() []Character { func (a *App) GetCharacters() []Character {
a.chmu.RLock() a.chmu.RLock()
defer a.chmu.RUnlock() defer a.chmu.RUnlock()
chars := make([]Character, 0, len(a.characters)) chars := make([]Character, 0, len(a.characters))
for _, v := range a.characters { for _, v := range a.characters {
chars = append(chars, v) chars = append(chars, v)
} }
return chars return chars
} }
// NewApp creates an App with the given storage paths and database. // NewApp creates an App with the given storage paths and database.
// frontendRoot is the directory for the map SPA (e.g. "frontend"). // frontendRoot is the directory for the map SPA (e.g. "frontend").
func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) { func NewApp(gridStorage, frontendRoot string, db *bbolt.DB) (*App, error) {
return &App{ return &App{
gridStorage: gridStorage, gridStorage: gridStorage,
frontendRoot: frontendRoot, frontendRoot: frontendRoot,
db: db, db: db,
characters: make(map[string]Character), characters: make(map[string]Character),
}, nil }, nil
} }
type Session struct { type Session struct {
ID string ID string
Username string Username string
Auths Auths `json:"-"` Auths Auths `json:"-"`
TempAdmin bool TempAdmin bool
} }
type Character struct { type Character struct {
Name string `json:"name"` Name string `json:"name"`
ID int `json:"id"` ID int `json:"id"`
Map int `json:"map"` Map int `json:"map"`
Position Position `json:"position"` Position Position `json:"position"`
Type string `json:"type"` Type string `json:"type"`
updated time.Time updated time.Time
} }
type Marker struct { type Marker struct {
Name string `json:"name"` Name string `json:"name"`
ID int `json:"id"` ID int `json:"id"`
GridID string `json:"gridID"` GridID string `json:"gridID"`
Position Position `json:"position"` Position Position `json:"position"`
Image string `json:"image"` Image string `json:"image"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
} }
type FrontendMarker struct { type FrontendMarker struct {
Name string `json:"name"` Name string `json:"name"`
ID int `json:"id"` ID int `json:"id"`
Map int `json:"map"` Map int `json:"map"`
Position Position `json:"position"` Position Position `json:"position"`
Image string `json:"image"` Image string `json:"image"`
Hidden bool `json:"hidden"` Hidden bool `json:"hidden"`
} }
type MapInfo struct { type MapInfo struct {
ID int ID int
Name string Name string
Hidden bool Hidden bool
Priority bool Priority bool
} }
type GridData struct { type GridData struct {
ID string ID string
Coord Coord Coord Coord
NextUpdate time.Time NextUpdate time.Time
Map int Map int
} }
type Coord struct { type Coord struct {
X int `json:"x"` X int `json:"x"`
Y int `json:"y"` Y int `json:"y"`
} }
type Position struct { type Position struct {
X int `json:"x"` X int `json:"x"`
Y int `json:"y"` Y int `json:"y"`
} }
func (c Coord) Name() string { func (c Coord) Name() string {
return fmt.Sprintf("%d_%d", c.X, c.Y) return fmt.Sprintf("%d_%d", c.X, c.Y)
} }
func (c Coord) Parent() Coord { func (c Coord) Parent() Coord {
if c.X < 0 { if c.X < 0 {
c.X-- c.X--
} }
if c.Y < 0 { if c.Y < 0 {
c.Y-- c.Y--
} }
return Coord{ return Coord{
X: c.X / 2, X: c.X / 2,
Y: c.Y / 2, Y: c.Y / 2,
} }
} }
type Auths []string type Auths []string
func (a Auths) Has(auth string) bool { func (a Auths) Has(auth string) bool {
for _, v := range a { for _, v := range a {
if v == auth { if v == auth {
return true return true
} }
} }
return false return false
} }
const ( const (
AUTH_ADMIN = "admin" AUTH_ADMIN = "admin"
AUTH_MAP = "map" AUTH_MAP = "map"
AUTH_MARKERS = "markers" AUTH_MARKERS = "markers"
AUTH_UPLOAD = "upload" AUTH_UPLOAD = "upload"
) )
type User struct { type User struct {
Pass []byte Pass []byte
Auths Auths Auths Auths
Tokens []string Tokens []string
// OAuth: provider -> subject (unique ID from provider) // OAuth: provider -> subject (unique ID from provider)
OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789" OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789"
} }
type Page struct { type Page struct {
Title string `json:"title"` Title string `json:"title"`
} }
// serveSPARoot serves the map SPA from root: static files from frontend, fallback to index.html for client-side routes. // 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. // Handles redirects from old /map/* URLs for backward compatibility.
func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) { func (a *App) serveSPARoot(rw http.ResponseWriter, req *http.Request) {
path := req.URL.Path path := req.URL.Path
// Redirect old /map/* URLs to flat routes // Redirect old /map/* URLs to flat routes
if path == "/map" || path == "/map/" { if path == "/map" || path == "/map/" {
http.Redirect(rw, req, "/", http.StatusFound) http.Redirect(rw, req, "/", http.StatusFound)
return return
} }
if strings.HasPrefix(path, "/map/") { if strings.HasPrefix(path, "/map/") {
rest := path[len("/map/"):] rest := path[len("/map/"):]
switch { switch {
case rest == "login": case rest == "login":
http.Redirect(rw, req, "/login", http.StatusFound) http.Redirect(rw, req, "/login", http.StatusFound)
return return
case rest == "profile": case rest == "profile":
http.Redirect(rw, req, "/profile", http.StatusFound) http.Redirect(rw, req, "/profile", http.StatusFound)
return return
case rest == "admin" || strings.HasPrefix(rest, "admin/"): case rest == "admin" || strings.HasPrefix(rest, "admin/"):
http.Redirect(rw, req, "/"+rest, http.StatusFound) http.Redirect(rw, req, "/"+rest, http.StatusFound)
return return
case rest == "setup": case rest == "setup":
http.Redirect(rw, req, "/setup", http.StatusFound) http.Redirect(rw, req, "/setup", http.StatusFound)
return return
case strings.HasPrefix(rest, "character/"): case strings.HasPrefix(rest, "character/"):
http.Redirect(rw, req, "/"+rest, http.StatusFound) http.Redirect(rw, req, "/"+rest, http.StatusFound)
return return
case strings.HasPrefix(rest, "grid/"): case strings.HasPrefix(rest, "grid/"):
http.Redirect(rw, req, "/"+rest, http.StatusFound) http.Redirect(rw, req, "/"+rest, http.StatusFound)
return return
} }
} }
// File serving: path relative to frontend root (with baseURL /, files are at root) // File serving: path relative to frontend root (with baseURL /, files are at root)
filePath := strings.TrimPrefix(path, "/") filePath := strings.TrimPrefix(path, "/")
if filePath == "" { if filePath == "" {
filePath = "index.html" filePath = "index.html"
} }
filePath = filepath.Clean(filePath) filePath = filepath.Clean(filePath)
if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") { if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "..") {
http.NotFound(rw, req) http.NotFound(rw, req)
return return
} }
// Try both root and map/ for backward compatibility with old builds // Try both root and map/ for backward compatibility with old builds
tryPaths := []string{filePath, filepath.Join("map", filePath)} tryPaths := []string{filePath, filepath.Join("map", filePath)}
var f http.File var f http.File
for _, p := range tryPaths { for _, p := range tryPaths {
var err error var err error
f, err = http.Dir(a.frontendRoot).Open(p) f, err = http.Dir(a.frontendRoot).Open(p)
if err == nil { if err == nil {
filePath = p filePath = p
break break
} }
} }
if f == nil { if f == nil {
http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html"))
return return
} }
defer f.Close() defer f.Close()
stat, err := f.Stat() stat, err := f.Stat()
if err != nil || stat.IsDir() { if err != nil || stat.IsDir() {
http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html")) http.ServeFile(rw, req, filepath.Join(a.frontendRoot, "index.html"))
return return
} }
http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f) http.ServeContent(rw, req, stat.Name(), stat.ModTime(), f)
} }
// CleanChars runs a background loop that removes stale character entries. Call once as a goroutine. // CleanChars runs a background loop that removes stale character entries. Call once as a goroutine.
func (a *App) CleanChars() { func (a *App) CleanChars() {
for range time.Tick(time.Second * 10) { for range time.Tick(time.Second * 10) {
a.chmu.Lock() a.chmu.Lock()
for n, c := range a.characters { for n, c := range a.characters {
if c.updated.Before(time.Now().Add(-10 * time.Second)) { if c.updated.Before(time.Now().Add(-10 * time.Second)) {
delete(a.characters, n) delete(a.characters, n)
} }
} }
a.chmu.Unlock() a.chmu.Unlock()
} }
} }
// RegisterRoutes registers all HTTP handlers for the app. // RegisterRoutes registers all HTTP handlers for the app.
func (a *App) RegisterRoutes() { func (a *App) RegisterRoutes() {
http.HandleFunc("/client/", a.client) http.HandleFunc("/client/", a.client)
http.HandleFunc("/logout", a.redirectLogout) http.HandleFunc("/logout", a.redirectLogout)
http.HandleFunc("/map/api/", a.apiRouter) http.HandleFunc("/map/api/", a.apiRouter)
http.HandleFunc("/map/updates", a.watchGridUpdates) http.HandleFunc("/map/updates", a.watchGridUpdates)
http.HandleFunc("/map/grids/", a.gridTile) http.HandleFunc("/map/grids/", a.gridTile)
// SPA catch-all: must be last // SPA catch-all: must be last
http.HandleFunc("/", a.serveSPARoot) http.HandleFunc("/", a.serveSPARoot)
} }