diff --git a/docs/api.md b/docs/api.md index f0abe6c..1e9fc5f 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,9 +24,9 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica ## Map data - **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). +- **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/maps** — list of maps (filtered by permissions and hidden status). +- **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) diff --git a/frontend-nuxt/components/MapView.vue b/frontend-nuxt/components/MapView.vue index 3398d09..6f40a1a 100644 --- a/frontend-nuxt/components/MapView.vue +++ b/frontend-nuxt/components/MapView.vue @@ -73,7 +73,7 @@ {{ measurePointA == null ? 'Click first point on map' : 'Click second point' }} @@ -146,6 +146,8 @@ const props = withDefaults( const mapContainerRef = ref(null) const mapRef = ref(null) const api = useMapApi() +/** Global state for navbar Live/Offline: true when at least one character is owned by current user's tokens. */ +const mapLive = useState('mapLive', () => false) const mapLogic = useMapLogic() const { resolvePath } = useAppPaths() const mapNavigate = useMapNavigate() @@ -275,13 +277,18 @@ onMounted(async () => { ]) const mapsList: MapInfo[] = [] - const raw = mapsData as Record + const raw = mapsData as Record< + string, + { ID?: number; Name?: string; id?: number; name?: string; size?: number; Hidden?: boolean; hidden?: boolean } + > for (const id in raw) { const m = raw[id] if (!m || typeof m !== 'object') continue const idVal = m.ID ?? m.id const nameVal = m.Name ?? m.name if (idVal == null || nameVal == null) continue + const hidden = !!(m.Hidden ?? m.hidden) + if (hidden) continue mapsList.push({ ID: Number(idVal), Name: String(nameVal), size: m.size }) } mapsList.sort((a, b) => (b.size ?? 0) - (a.size ?? 0)) @@ -354,8 +361,10 @@ onMounted(async () => { }, }) - layersManager.updateCharacters(Array.isArray(charactersData) ? charactersData : []) + const charsList = Array.isArray(charactersData) ? charactersData : [] + layersManager.updateCharacters(charsList) players.value = layersManager.getPlayers() + mapLive.value = charsList.some((c) => c.ownedByMe) if (props.characterId !== undefined && props.characterId >= 0) { mapLogic.state.trackingCharacterId.value = props.characterId @@ -386,8 +395,10 @@ onMounted(async () => { api .getCharacters() .then((body) => { - layersManager!.updateCharacters(Array.isArray(body) ? body : []) + const list = Array.isArray(body) ? body : [] + layersManager!.updateCharacters(list) players.value = layersManager!.getPlayers() + mapLive.value = list.some((c) => c.ownedByMe) }) .catch(() => clearInterval(intervalId!)) }, 2000) @@ -482,8 +493,8 @@ onMounted(async () => { if (!measureMode.value) return e.originalEvent.stopPropagation() const point = leafletMap!.project(e.latlng, HnHMaxZoom) - const x = Math.floor(point.x / TileSize) - const y = Math.floor(point.y / TileSize) + const x = point.x / TileSize + const y = point.y / TileSize if (measurePointA.value == null) { measurePointA.value = { x, y } } else if (measurePointB.value == null) { diff --git a/frontend-nuxt/layouts/default.vue b/frontend-nuxt/layouts/default.vue index 508c992..6b72ae5 100644 --- a/frontend-nuxt/layouts/default.vue +++ b/frontend-nuxt/layouts/default.vue @@ -224,7 +224,8 @@ function getInitialDark(): boolean { const title = ref('HnH Map') const dark = ref(false) -const live = ref(false) +/** Live when at least one of current user's characters is on the map (set by MapView). */ +const live = useState('mapLive', () => false) const me = useState('me', () => null) const gravatarErrorDesktop = ref(false) const gravatarErrorDrawer = ref(false) @@ -300,6 +301,4 @@ async function doLogout() { await router.push('/login') me.value = null } - -defineExpose({ setLive: (v: boolean) => { live.value = v } }) diff --git a/frontend-nuxt/lib/Character.ts b/frontend-nuxt/lib/Character.ts index 3a29e49..00fba4c 100644 --- a/frontend-nuxt/lib/Character.ts +++ b/frontend-nuxt/lib/Character.ts @@ -1,6 +1,23 @@ import { HnHMaxZoom } from '~/lib/LeafletCustomTypes' import * as L from 'leaflet' +/** SVG data URL for character marker icon (teal pin, bottom-center anchor). */ +const CHARACTER_ICON_URL = + 'data:image/svg+xml,' + + encodeURIComponent( + '' + + '' + + '' + + '' + ) + +const CHARACTER_ICON = new L.Icon({ + iconUrl: CHARACTER_ICON_URL, + iconSize: [24, 32], + iconAnchor: [12, 32], + popupAnchor: [0, -32], +}) + export interface CharacterData { name: string position: { x: number; y: number } @@ -58,7 +75,7 @@ export function createCharacter(data: CharacterData): MapCharacter { add(mapview: CharacterMapViewRef): void { if (character.map === mapview.mapid) { const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom) - leafletMarker = L.marker(position, { title: character.name }) + leafletMarker = L.marker(position, { icon: CHARACTER_ICON, title: character.name }) leafletMarker.on('click', (e: L.LeafletMouseEvent) => { if (onClick) onClick(e) }) diff --git a/frontend-nuxt/types/api.ts b/frontend-nuxt/types/api.ts index 7d5d02b..456b817 100644 --- a/frontend-nuxt/types/api.ts +++ b/frontend-nuxt/types/api.ts @@ -28,6 +28,8 @@ export interface MapInfo { ID: number Name: string size?: number + /** Present when returned by API (e.g. for admins); client should exclude Hidden maps from selectors. */ + Hidden?: boolean } export interface Character { @@ -36,6 +38,8 @@ export interface Character { map: number position: { x: number; y: number } type: string + /** True when this character was last updated by one of the current user's tokens. */ + ownedByMe?: boolean } export interface Marker { diff --git a/internal/app/app.go b/internal/app/app.go index 3b4f6d7..998c06c 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -170,6 +170,9 @@ type Session struct { TempAdmin bool } +// ClientUsernameKey is the context key for the username that owns a client token (set by client handlers). +var ClientUsernameKey = &struct{ key string }{key: "clientUsername"} + // Character represents a game character on the map. type Character struct { Name string `json:"name"` @@ -178,6 +181,7 @@ type Character struct { Position Position `json:"position"` Type string `json:"type"` Updated time.Time + Username string `json:"-"` // owner of the token that last updated this character; not sent to API } // Marker represents a map marker stored per grid. diff --git a/internal/app/handlers/client.go b/internal/app/handlers/client.go index 75ba29c..1dbfdc9 100644 --- a/internal/app/handlers/client.go +++ b/internal/app/handlers/client.go @@ -1,6 +1,7 @@ package handlers import ( + "context" "encoding/json" "io" "log/slog" @@ -26,7 +27,8 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusUnauthorized) return } - _ = username + ctx = context.WithValue(ctx, app.ClientUsernameKey, username) + req = req.WithContext(ctx) switch matches[2] { case "locate": diff --git a/internal/app/handlers/map.go b/internal/app/handlers/map.go index 1a022ab..f232e2e 100644 --- a/internal/app/handlers/map.go +++ b/internal/app/handlers/map.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "time" "github.com/andyleap/hnh-map/internal/app" ) @@ -22,6 +23,17 @@ func (h *Handlers) APIConfig(rw http.ResponseWriter, req *http.Request) { JSON(rw, http.StatusOK, config) } +// CharacterResponse is the API shape for a character, including ownedByMe for the current session. +type CharacterResponse struct { + Name string `json:"name"` + ID int `json:"id"` + Map int `json:"map"` + Position app.Position `json:"position"` + Type string `json:"type"` + Updated time.Time `json:"updated,omitempty"` + OwnedByMe bool `json:"ownedByMe"` +} + // APIGetChars handles GET /map/api/v1/characters. func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) { ctx := req.Context() @@ -35,7 +47,19 @@ func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) { return } chars := h.Map.GetCharacters() - JSON(rw, http.StatusOK, chars) + out := make([]CharacterResponse, 0, len(chars)) + for _, c := range chars { + out = append(out, CharacterResponse{ + Name: c.Name, + ID: c.ID, + Map: c.Map, + Position: c.Position, + Type: c.Type, + Updated: c.Updated.UTC(), + OwnedByMe: c.Username == s.Username, + }) + } + JSON(rw, http.StatusOK, out) } // APIGetMarkers handles GET /map/api/v1/markers. diff --git a/internal/app/services/client.go b/internal/app/services/client.go index 64cfcc9..c73fc18 100644 --- a/internal/app/services/client.go +++ b/internal/app/services/client.go @@ -374,6 +374,8 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error return nil }) + username, _ := ctx.Value(app.ClientUsernameKey).(string) + s.withChars(func(chars map[string]app.Character) { for id, craw := range craws { gd, ok := gridDataByID[craw.GridID] @@ -389,8 +391,9 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error X: craw.Coords.X + (gd.Coord.X * app.GridSize), Y: craw.Coords.Y + (gd.Coord.Y * app.GridSize), }, - Type: craw.Type, - Updated: time.Now(), + Type: craw.Type, + Updated: time.Now(), + Username: username, } old, ok := chars[id] if !ok { @@ -401,6 +404,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error chars[id] = c } else { old.Position = c.Position + old.Username = username chars[id] = old } } else if old.Type != "unknown" { @@ -408,6 +412,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error chars[id] = c } else { old.Position = c.Position + old.Username = username chars[id] = old } } else {