Enhance API and frontend components for character management and map visibility
- Updated API documentation to include `ownedByMe` field in character responses, indicating if a character was last updated by the current user's tokens. - Modified MapView component to track and display the live status based on user-owned characters. - Enhanced map data handling to exclude hidden maps for non-admin users and improved character icon representation on the map. - Refactored character data structures to support new properties and ensure accurate rendering in the frontend.
This commit is contained in:
@@ -24,9 +24,9 @@ The API is available under the `/map/api/` prefix. Requests requiring authentica
|
|||||||
## 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).
|
- **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).
|
- **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)
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
<span class="text-base-content/80">{{ measurePointA == null ? 'Click first point on map' : 'Click second point' }}</span>
|
<span class="text-base-content/80">{{ measurePointA == null ? 'Click first point on map' : 'Click second point' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="font-mono">Distance: {{ measureDistance.toFixed(2) }} units</span>
|
<span class="font-mono">Distance: {{ measureDistance.toFixed(3) }} units</span>
|
||||||
<button type="button" class="btn btn-ghost btn-xs ml-2" @click="clearMeasure">Clear</button>
|
<button type="button" class="btn btn-ghost btn-xs ml-2" @click="clearMeasure">Clear</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,6 +146,8 @@ const props = withDefaults(
|
|||||||
const mapContainerRef = ref<HTMLElement | null>(null)
|
const mapContainerRef = ref<HTMLElement | null>(null)
|
||||||
const mapRef = ref<HTMLElement | null>(null)
|
const mapRef = ref<HTMLElement | null>(null)
|
||||||
const api = useMapApi()
|
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<boolean>('mapLive', () => false)
|
||||||
const mapLogic = useMapLogic()
|
const mapLogic = useMapLogic()
|
||||||
const { resolvePath } = useAppPaths()
|
const { resolvePath } = useAppPaths()
|
||||||
const mapNavigate = useMapNavigate()
|
const mapNavigate = useMapNavigate()
|
||||||
@@ -275,13 +277,18 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
const mapsList: MapInfo[] = []
|
const mapsList: MapInfo[] = []
|
||||||
const raw = mapsData as Record<string, { ID?: number; Name?: string; id?: number; name?: string; size?: number }>
|
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) {
|
for (const id in raw) {
|
||||||
const m = raw[id]
|
const m = raw[id]
|
||||||
if (!m || typeof m !== 'object') continue
|
if (!m || typeof m !== 'object') continue
|
||||||
const idVal = m.ID ?? m.id
|
const idVal = m.ID ?? m.id
|
||||||
const nameVal = m.Name ?? m.name
|
const nameVal = m.Name ?? m.name
|
||||||
if (idVal == null || nameVal == null) continue
|
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.push({ ID: Number(idVal), Name: String(nameVal), size: m.size })
|
||||||
}
|
}
|
||||||
mapsList.sort((a, b) => (b.size ?? 0) - (a.size ?? 0))
|
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()
|
players.value = layersManager.getPlayers()
|
||||||
|
mapLive.value = charsList.some((c) => c.ownedByMe)
|
||||||
|
|
||||||
if (props.characterId !== undefined && props.characterId >= 0) {
|
if (props.characterId !== undefined && props.characterId >= 0) {
|
||||||
mapLogic.state.trackingCharacterId.value = props.characterId
|
mapLogic.state.trackingCharacterId.value = props.characterId
|
||||||
@@ -386,8 +395,10 @@ onMounted(async () => {
|
|||||||
api
|
api
|
||||||
.getCharacters()
|
.getCharacters()
|
||||||
.then((body) => {
|
.then((body) => {
|
||||||
layersManager!.updateCharacters(Array.isArray(body) ? body : [])
|
const list = Array.isArray(body) ? body : []
|
||||||
|
layersManager!.updateCharacters(list)
|
||||||
players.value = layersManager!.getPlayers()
|
players.value = layersManager!.getPlayers()
|
||||||
|
mapLive.value = list.some((c) => c.ownedByMe)
|
||||||
})
|
})
|
||||||
.catch(() => clearInterval(intervalId!))
|
.catch(() => clearInterval(intervalId!))
|
||||||
}, 2000)
|
}, 2000)
|
||||||
@@ -482,8 +493,8 @@ onMounted(async () => {
|
|||||||
if (!measureMode.value) return
|
if (!measureMode.value) return
|
||||||
e.originalEvent.stopPropagation()
|
e.originalEvent.stopPropagation()
|
||||||
const point = leafletMap!.project(e.latlng, HnHMaxZoom)
|
const point = leafletMap!.project(e.latlng, HnHMaxZoom)
|
||||||
const x = Math.floor(point.x / TileSize)
|
const x = point.x / TileSize
|
||||||
const y = Math.floor(point.y / TileSize)
|
const y = point.y / TileSize
|
||||||
if (measurePointA.value == null) {
|
if (measurePointA.value == null) {
|
||||||
measurePointA.value = { x, y }
|
measurePointA.value = { x, y }
|
||||||
} else if (measurePointB.value == null) {
|
} else if (measurePointB.value == null) {
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ function getInitialDark(): boolean {
|
|||||||
|
|
||||||
const title = ref('HnH Map')
|
const title = ref('HnH Map')
|
||||||
const dark = ref(false)
|
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<boolean>('mapLive', () => false)
|
||||||
const me = useState<MeResponse | null>('me', () => null)
|
const me = useState<MeResponse | null>('me', () => null)
|
||||||
const gravatarErrorDesktop = ref(false)
|
const gravatarErrorDesktop = ref(false)
|
||||||
const gravatarErrorDrawer = ref(false)
|
const gravatarErrorDrawer = ref(false)
|
||||||
@@ -300,6 +301,4 @@ async function doLogout() {
|
|||||||
await router.push('/login')
|
await router.push('/login')
|
||||||
me.value = null
|
me.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ setLive: (v: boolean) => { live.value = v } })
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
import { HnHMaxZoom } from '~/lib/LeafletCustomTypes'
|
||||||
import * as L from 'leaflet'
|
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(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 32" width="24" height="32">' +
|
||||||
|
'<path fill="#0d9488" stroke="#0f766e" 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"/>' +
|
||||||
|
'</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
const CHARACTER_ICON = new L.Icon({
|
||||||
|
iconUrl: CHARACTER_ICON_URL,
|
||||||
|
iconSize: [24, 32],
|
||||||
|
iconAnchor: [12, 32],
|
||||||
|
popupAnchor: [0, -32],
|
||||||
|
})
|
||||||
|
|
||||||
export interface CharacterData {
|
export interface CharacterData {
|
||||||
name: string
|
name: string
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
@@ -58,7 +75,7 @@ export function createCharacter(data: CharacterData): MapCharacter {
|
|||||||
add(mapview: CharacterMapViewRef): void {
|
add(mapview: CharacterMapViewRef): void {
|
||||||
if (character.map === mapview.mapid) {
|
if (character.map === mapview.mapid) {
|
||||||
const position = mapview.map.unproject([character.position.x, character.position.y], HnHMaxZoom)
|
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) => {
|
leafletMarker.on('click', (e: L.LeafletMouseEvent) => {
|
||||||
if (onClick) onClick(e)
|
if (onClick) onClick(e)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface MapInfo {
|
|||||||
ID: number
|
ID: number
|
||||||
Name: string
|
Name: string
|
||||||
size?: number
|
size?: number
|
||||||
|
/** Present when returned by API (e.g. for admins); client should exclude Hidden maps from selectors. */
|
||||||
|
Hidden?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
@@ -36,6 +38,8 @@ export interface Character {
|
|||||||
map: number
|
map: number
|
||||||
position: { x: number; y: number }
|
position: { x: number; y: number }
|
||||||
type: string
|
type: string
|
||||||
|
/** True when this character was last updated by one of the current user's tokens. */
|
||||||
|
ownedByMe?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Marker {
|
export interface Marker {
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ type Session struct {
|
|||||||
TempAdmin bool
|
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.
|
// Character represents a game character on the map.
|
||||||
type Character struct {
|
type Character struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -178,6 +181,7 @@ type Character struct {
|
|||||||
Position Position `json:"position"`
|
Position Position `json:"position"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Updated time.Time
|
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.
|
// Marker represents a map marker stored per grid.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -26,7 +27,8 @@ func (h *Handlers) ClientRouter(rw http.ResponseWriter, req *http.Request) {
|
|||||||
rw.WriteHeader(http.StatusUnauthorized)
|
rw.WriteHeader(http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = username
|
ctx = context.WithValue(ctx, app.ClientUsernameKey, username)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
|
||||||
switch matches[2] {
|
switch matches[2] {
|
||||||
case "locate":
|
case "locate":
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/andyleap/hnh-map/internal/app"
|
"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)
|
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.
|
// APIGetChars handles GET /map/api/v1/characters.
|
||||||
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
||||||
ctx := req.Context()
|
ctx := req.Context()
|
||||||
@@ -35,7 +47,19 @@ func (h *Handlers) APIGetChars(rw http.ResponseWriter, req *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
chars := h.Map.GetCharacters()
|
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.
|
// APIGetMarkers handles GET /map/api/v1/markers.
|
||||||
|
|||||||
@@ -374,6 +374,8 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
username, _ := ctx.Value(app.ClientUsernameKey).(string)
|
||||||
|
|
||||||
s.withChars(func(chars map[string]app.Character) {
|
s.withChars(func(chars map[string]app.Character) {
|
||||||
for id, craw := range craws {
|
for id, craw := range craws {
|
||||||
gd, ok := gridDataByID[craw.GridID]
|
gd, ok := gridDataByID[craw.GridID]
|
||||||
@@ -391,6 +393,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
|
|||||||
},
|
},
|
||||||
Type: craw.Type,
|
Type: craw.Type,
|
||||||
Updated: time.Now(),
|
Updated: time.Now(),
|
||||||
|
Username: username,
|
||||||
}
|
}
|
||||||
old, ok := chars[id]
|
old, ok := chars[id]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -401,6 +404,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
|
|||||||
chars[id] = c
|
chars[id] = c
|
||||||
} else {
|
} else {
|
||||||
old.Position = c.Position
|
old.Position = c.Position
|
||||||
|
old.Username = username
|
||||||
chars[id] = old
|
chars[id] = old
|
||||||
}
|
}
|
||||||
} else if old.Type != "unknown" {
|
} else if old.Type != "unknown" {
|
||||||
@@ -408,6 +412,7 @@ func (s *ClientService) UpdatePositions(ctx context.Context, data []byte) error
|
|||||||
chars[id] = c
|
chars[id] = c
|
||||||
} else {
|
} else {
|
||||||
old.Position = c.Position
|
old.Position = c.Position
|
||||||
|
old.Username = username
|
||||||
chars[id] = old
|
chars[id] = old
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user