feat(hunt): track walking distance via GPS, show live locations on map
Add foreground-only GPS distance tracking for hunts
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type TrackableHunt,
|
||||
useGeoTracking
|
||||
} from '~/composables/useGeoTracking';
|
||||
|
||||
const props = defineProps<{
|
||||
hunt: TrackableHunt;
|
||||
seedDistance?: number;
|
||||
}>();
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
|
||||
const {
|
||||
distance,
|
||||
lastFix,
|
||||
paused,
|
||||
consented,
|
||||
eligible,
|
||||
gate,
|
||||
isSupported,
|
||||
start,
|
||||
stop,
|
||||
toggleConsent
|
||||
} = useGeoTracking(props.hunt, props.seedDistance ?? 0);
|
||||
|
||||
// Ticking clock so the "last fix … ago" label stays live while the card is open.
|
||||
const nowTs = ref(Date.now());
|
||||
let clock: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
clock = setInterval(() => (nowTs.value = Date.now()), 1000);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
if (clock !== undefined) clearInterval(clock);
|
||||
});
|
||||
|
||||
const lastFixAgo = computed(() => {
|
||||
if (!lastFix.value) return '';
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.round((nowTs.value - lastFix.value.getTime()) / 1000)
|
||||
);
|
||||
return new Intl.RelativeTimeFormat(locale.value || 'en', {
|
||||
numeric: 'auto'
|
||||
}).format(-seconds, 'second');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VaCard
|
||||
v-if="eligible"
|
||||
stripe
|
||||
:stripe-color="gate && !paused ? 'success' : 'primary'"
|
||||
>
|
||||
<VaCardTitle>{{ t('page.hunt.tracking.title') }}</VaCardTitle>
|
||||
<VaCardContent>
|
||||
<div class="flex flex-col gap-3">
|
||||
<VaAlert v-if="!isSupported" color="warning">
|
||||
{{ t('page.hunt.tracking.unsupported') }}
|
||||
</VaAlert>
|
||||
<VaSwitch
|
||||
:model-value="consented"
|
||||
:disabled="!isSupported"
|
||||
:label="t('page.hunt.tracking.consent')"
|
||||
@update:model-value="toggleConsent"
|
||||
/>
|
||||
<p class="text-sm opacity-70">
|
||||
{{ t('page.hunt.tracking.consent_explain') }}
|
||||
</p>
|
||||
<template v-if="consented">
|
||||
<div class="grid grid-cols-2 gap-2 text-lg md:grid-cols-3">
|
||||
<VaChip :color="paused ? 'secondary' : 'success'">
|
||||
{{
|
||||
t(
|
||||
paused
|
||||
? 'page.hunt.tracking.status_paused'
|
||||
: 'page.hunt.tracking.status_tracking'
|
||||
)
|
||||
}}
|
||||
</VaChip>
|
||||
<span class="font-bold">{{ formatDistance(distance) }}</span>
|
||||
<span
|
||||
v-if="lastFixAgo"
|
||||
class="col-span-2 text-sm opacity-70 md:col-span-1"
|
||||
>
|
||||
{{ t('page.hunt.tracking.last_fix', { ago: lastFixAgo }) }}
|
||||
</span>
|
||||
</div>
|
||||
<VaButton
|
||||
:disabled="!isSupported"
|
||||
:icon="paused ? 'play_arrow' : 'pause'"
|
||||
@click="paused ? start() : stop()"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
paused
|
||||
? 'page.hunt.tracking.resume'
|
||||
: 'page.hunt.tracking.pause'
|
||||
)
|
||||
}}
|
||||
</VaButton>
|
||||
</template>
|
||||
</div>
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { fullDate } from '~~/server/utils/date';
|
||||
const { confirm } = useModal();
|
||||
const { init } = useToast();
|
||||
const { t, d } = useI18n();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
|
||||
const props = defineProps<{
|
||||
team: Team;
|
||||
@@ -151,7 +152,7 @@ async function deleteTeam() {
|
||||
<VaCollapse
|
||||
:key="team.id"
|
||||
color="info"
|
||||
:header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points`"
|
||||
:header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points — ${formatDistance(team.distance)}`"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-xl">
|
||||
@@ -171,6 +172,9 @@ async function deleteTeam() {
|
||||
by {{ team.owner.name }} ({{ team.owner.email }})
|
||||
</p>
|
||||
<p>{{ team._count.answers }} answers</p>
|
||||
<p v-if="team.distance > 0">
|
||||
{{ t('page.common.distance') }}: {{ formatDistance(team.distance) }}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-3 lg:grid-cols-5">
|
||||
<VaButton
|
||||
v-for="member in team.members"
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
import type { HuntMapData } from '#shared/types/map';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import { useI18nKey } from '~/composables/useI18nKey';
|
||||
import { darken } from '~/composables/useTeamColors';
|
||||
import { fullDate } from '~~/server/utils/date';
|
||||
|
||||
const props = defineProps<{
|
||||
huntId: number;
|
||||
huntSlug: string;
|
||||
data: HuntMapData;
|
||||
colors: Map<number, string>;
|
||||
showAnswers: boolean;
|
||||
showPositions: boolean;
|
||||
selectedUserId: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select-user', userId: number | null): void;
|
||||
}>();
|
||||
|
||||
const { t, d, locale } = useI18n();
|
||||
const { tSluggy } = useI18nKey();
|
||||
const localePath = useLocalePath();
|
||||
|
||||
const container = ref<HTMLDivElement | null>(null);
|
||||
let map: L.Map | null = null;
|
||||
let tile: L.TileLayer | null = null;
|
||||
let answerLayer: L.LayerGroup | null = null;
|
||||
let positionLayer: L.LayerGroup | null = null;
|
||||
let trailLayer: L.LayerGroup | null = null;
|
||||
let fitted = false;
|
||||
|
||||
// Lookup maps rebuilt whenever data changes.
|
||||
const teamName = computed(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const team of props.data.teams) m.set(team.id, team.name);
|
||||
return m;
|
||||
});
|
||||
const userName = computed(() => {
|
||||
const m = new Map<number, string>();
|
||||
for (const team of props.data.teams) {
|
||||
m.set(team.owner.id, team.owner.name);
|
||||
for (const mem of team.members) m.set(mem.id, mem.name);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
const userTeam = computed(() => {
|
||||
const m = new Map<number, number>();
|
||||
for (const team of props.data.teams) {
|
||||
m.set(team.owner.id, team.id);
|
||||
for (const mem of team.members) m.set(mem.id, team.id);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
function colorForTeam(teamId: number): string {
|
||||
return props.colors.get(teamId) ?? '#808080';
|
||||
}
|
||||
|
||||
function pinIcon(color: string): L.DivIcon {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: `<svg width="28" height="40" viewBox="0 0 28 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 0C6.3 0 0 6.3 0 14c0 10.5 14 26 14 26s14-15.5 14-26C28 6.3 21.7 0 14 0z" fill="${color}" stroke="#fff" stroke-width="1.5"/>
|
||||
<circle cx="14" cy="14" r="5" fill="#fff"/>
|
||||
</svg>`,
|
||||
iconSize: [28, 40],
|
||||
iconAnchor: [14, 40],
|
||||
popupAnchor: [0, -38]
|
||||
});
|
||||
}
|
||||
|
||||
function relTime(iso: string): string {
|
||||
const seconds = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - new Date(iso).getTime()) / 1000)
|
||||
);
|
||||
return new Intl.RelativeTimeFormat(locale.value || 'en', {
|
||||
numeric: 'auto'
|
||||
}).format(-seconds, 'second');
|
||||
}
|
||||
|
||||
function questAdminHref(quest: {
|
||||
id: number;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
}) {
|
||||
return localePath(`/hunt/${props.huntSlug}/${tSluggy(quest).value}/admin`);
|
||||
}
|
||||
|
||||
function answerPopup(a: HuntMapData['answers'][number]): string {
|
||||
const title =
|
||||
locale.value === 'en'
|
||||
? a.quest.title_en
|
||||
: (a.quest.title_de ?? a.quest.title_en);
|
||||
const team = teamName.value.get(a.teamId) ?? '';
|
||||
const member = userName.value.get(a.memberId) ?? '';
|
||||
const time = d(new Date(a.createdAt), fullDate);
|
||||
const thumb = a.pictureUrl
|
||||
? `<img src="${a.pictureUrl}" alt="" style="max-width:160px;max-height:120px;border-radius:4px;margin:4px 0;" />`
|
||||
: '';
|
||||
return `<div style="min-width:180px">
|
||||
<strong>${title}</strong><br/>
|
||||
<span>${t('page.map.team')}: ${team}</span><br/>
|
||||
<span>${t('page.map.member')}: ${member}</span><br/>
|
||||
<span>${t('page.map.time')}: ${time}</span>
|
||||
${thumb}
|
||||
<a href="${questAdminHref(a.quest)}" target="_blank">${t('page.map.open_quest_admin')}</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function positionPopup(p: HuntMapData['positions'][number]): string {
|
||||
const teamId = userTeam.value.get(p.userId);
|
||||
const team = teamId != null ? (teamName.value.get(teamId) ?? '') : '';
|
||||
const name = userName.value.get(p.userId) ?? '';
|
||||
const acc =
|
||||
p.accuracy != null
|
||||
? `<br/><span>${t('page.map.accuracy')}: ${Math.round(p.accuracy)} m</span>`
|
||||
: '';
|
||||
return `<div style="min-width:160px">
|
||||
<strong>${name}</strong><br/>
|
||||
<span>${t('page.map.team')}: ${team}</span><br/>
|
||||
<span>${t('page.map.last_fix', { ago: relTime(p.recordedAt) })}</span>${acc}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderAnswers() {
|
||||
if (!map || !answerLayer) return;
|
||||
answerLayer.clearLayers();
|
||||
if (!props.showAnswers) return;
|
||||
for (const a of props.data.answers) {
|
||||
const marker = L.marker([a.lat, a.lon], {
|
||||
icon: pinIcon(colorForTeam(a.teamId))
|
||||
}).bindPopup(answerPopup(a));
|
||||
answerLayer.addLayer(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPositions() {
|
||||
if (!map || !positionLayer) return;
|
||||
positionLayer.clearLayers();
|
||||
if (!props.showPositions) return;
|
||||
for (const p of props.data.positions) {
|
||||
const teamId = userTeam.value.get(p.userId);
|
||||
const color = teamId != null ? colorForTeam(teamId) : '#808080';
|
||||
const dot = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 7,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 1
|
||||
}).bindPopup(positionPopup(p));
|
||||
dot.on('click', () => {
|
||||
emit('select-user', props.selectedUserId === p.userId ? null : p.userId);
|
||||
});
|
||||
positionLayer.addLayer(dot);
|
||||
}
|
||||
}
|
||||
|
||||
function fitOnce() {
|
||||
if (!map || fitted) return;
|
||||
const pts: L.LatLngExpression[] = [
|
||||
...(props.showAnswers
|
||||
? props.data.answers.map((a) => [a.lat, a.lon] as L.LatLngExpression)
|
||||
: []),
|
||||
...(props.showPositions
|
||||
? props.data.positions.map((p) => [p.lat, p.lon] as L.LatLngExpression)
|
||||
: [])
|
||||
];
|
||||
if (pts.length === 0) return;
|
||||
const bounds = L.latLngBounds(pts);
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 17 });
|
||||
fitted = true;
|
||||
}
|
||||
|
||||
async function renderTrail() {
|
||||
if (!map || !trailLayer) return;
|
||||
trailLayer.clearLayers();
|
||||
const uid = props.selectedUserId;
|
||||
if (uid == null) return;
|
||||
|
||||
const { points } = await $fetch<{
|
||||
points: {
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number | null;
|
||||
recordedAt: string;
|
||||
}[];
|
||||
}>(`/api/admin/hunt/${props.huntId}/track/${uid}`);
|
||||
|
||||
if (points.length === 0) return;
|
||||
|
||||
const teamId = userTeam.value.get(uid);
|
||||
const base = teamId != null ? colorForTeam(teamId) : '#808080';
|
||||
const latlngs = points.map((p) => [p.lat, p.lon] as L.LatLngExpression);
|
||||
|
||||
const line = L.polyline(latlngs, {
|
||||
color: darken(base, 0.75),
|
||||
weight: 4,
|
||||
opacity: 0.9
|
||||
});
|
||||
trailLayer.addLayer(line);
|
||||
|
||||
// Start + end markers for clarity.
|
||||
const start = L.circleMarker(latlngs[0]!, {
|
||||
radius: 5,
|
||||
color: '#fff',
|
||||
weight: 2,
|
||||
fillColor: base,
|
||||
fillOpacity: 1
|
||||
});
|
||||
trailLayer.addLayer(start);
|
||||
|
||||
map.fitBounds(line.getBounds(), { padding: [50, 50], maxZoom: 18 });
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
if (!container.value || map) return;
|
||||
map = L.map(container.value, { zoomControl: true }).setView([51.0, 10.0], 6);
|
||||
tile = L.tileLayer('https://tile.openstreetmap.de/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap-Mitwirkende',
|
||||
maxZoom: 19
|
||||
});
|
||||
map.addLayer(tile);
|
||||
|
||||
trailLayer = L.layerGroup().addTo(map);
|
||||
answerLayer = L.layerGroup().addTo(map);
|
||||
positionLayer = L.layerGroup().addTo(map);
|
||||
|
||||
renderAnswers();
|
||||
renderPositions();
|
||||
fitOnce();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initMap();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Re-render markers on data/toggle changes (trail preserved separately).
|
||||
watch(
|
||||
() => props.data,
|
||||
() => {
|
||||
renderAnswers();
|
||||
renderPositions();
|
||||
fitOnce();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
watch(() => props.showAnswers, renderAnswers);
|
||||
watch(() => props.showPositions, renderPositions);
|
||||
watch(() => props.selectedUserId, renderTrail, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="livemap" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.livemap {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
z-index: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
members: string[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="m-4 flex flex-col justify-center gap-2 rounded-lg p-4 text-4xl shadow-lg"
|
||||
>
|
||||
<span v-for="(member, i) in members" :key="i">
|
||||
{{ member }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
count: number | string;
|
||||
value?: string;
|
||||
count?: number | string;
|
||||
title: string;
|
||||
icon: string;
|
||||
}>();
|
||||
@@ -8,7 +9,7 @@ defineProps<{
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h3>{{ Number(count).toLocaleString('de') }}</h3>
|
||||
<h3>{{ value ? value : Number(count).toLocaleString('de') }}</h3>
|
||||
<VaIcon :name="icon" size="10rem" />
|
||||
<p>{{ title }}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Locale-aware walking-distance formatting.
|
||||
*
|
||||
* Distances are stored as Int meters. Under 1000 m they render as whole
|
||||
* meters, above that as kilometers with one decimal (e.g. "1.2 km"). Reused by
|
||||
* the tracking card, the team leaderboards and the profile distances.
|
||||
*/
|
||||
export function useFormatDistance() {
|
||||
const { locale } = useI18n();
|
||||
|
||||
function formatDistance(meters: number | null | undefined): string {
|
||||
const value = meters ?? 0;
|
||||
const loc = locale.value || 'en';
|
||||
|
||||
if (value < 1000) {
|
||||
return new Intl.NumberFormat(loc, {
|
||||
style: 'unit',
|
||||
unit: 'meter',
|
||||
maximumFractionDigits: 0
|
||||
}).format(Math.round(value));
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(loc, {
|
||||
style: 'unit',
|
||||
unit: 'kilometer',
|
||||
maximumFractionDigits: 1
|
||||
}).format(value / 1000);
|
||||
}
|
||||
|
||||
return { formatDistance };
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import {
|
||||
computeTrackDistance,
|
||||
type GeoPoint,
|
||||
haversine
|
||||
} from '#shared/utils/geo';
|
||||
import { useSettingsStore } from '~/stores/settings';
|
||||
|
||||
export type TrackableHunt = {
|
||||
id: number;
|
||||
trackDistance: boolean;
|
||||
virtual: boolean;
|
||||
start?: Date | string | null;
|
||||
end?: Date | string | null;
|
||||
};
|
||||
|
||||
/** Flush cadence and the key prefix for the offline retry queue. */
|
||||
const FLUSH_INTERVAL_MS = 30_000;
|
||||
const QUEUE_PREFIX = 'pichunt:track:queue:';
|
||||
const MIN_DISTANCE = 0.9;
|
||||
|
||||
/**
|
||||
* Foreground GPS distance tracking for a single hunt.
|
||||
*
|
||||
* Independent paths by design:
|
||||
* - the watch loop buffers fixes and flushes them to `POST /api/hunt/:id/track`
|
||||
* via `navigator.sendBeacon` (tracking);
|
||||
* - {@link getPosition} is a one-shot read used by the answer submit form.
|
||||
*
|
||||
* Consent is client-only and persisted per hunt in the settings store; the
|
||||
* server enforces the hunt flag, window and membership, never consent.
|
||||
*/
|
||||
export function useGeoTracking(hunt: TrackableHunt, seedDistance = 0) {
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const consented = computed(() => settings.hasTrackConsent(hunt.id));
|
||||
|
||||
// Re-evaluated on the flush tick so the window stays fresh while the page
|
||||
// is open. Plain Date.now() keeps this SSR-safe (no timers on the server).
|
||||
const nowTs = ref(Date.now());
|
||||
|
||||
const withinWindow = computed(() => {
|
||||
const start = hunt.start ? new Date(hunt.start).getTime() : null;
|
||||
const end = hunt.end ? new Date(hunt.end).getTime() : null;
|
||||
if (start !== null && nowTs.value < start) return false;
|
||||
if (end !== null && nowTs.value > end) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
/** The tracking card may be shown (independent of consent). */
|
||||
const eligible = computed(
|
||||
() => hunt.trackDistance && !hunt.virtual && withinWindow.value
|
||||
);
|
||||
|
||||
/** Tracking is active only when eligible and the user consented. */
|
||||
const gate = computed(() => eligible.value && consented.value);
|
||||
|
||||
const { isSupported, coords, locatedAt, resume, pause } = useGeolocation({
|
||||
enableHighAccuracy: true,
|
||||
immediate: false
|
||||
});
|
||||
|
||||
const tracking = ref(false);
|
||||
const userPaused = ref(false);
|
||||
const hidden = ref(false);
|
||||
const paused = computed(() => userPaused.value || hidden.value);
|
||||
|
||||
const lastFix = ref<Date | null>(null);
|
||||
const baseDistance = ref(seedDistance);
|
||||
const sessionFixes = ref<GeoPoint[]>([]);
|
||||
|
||||
const distance = computed(
|
||||
() => baseDistance.value + computeTrackDistance(sessionFixes.value)
|
||||
);
|
||||
|
||||
// --- offline queue -------------------------------------------------------
|
||||
const queueKey = `${QUEUE_PREFIX}${hunt.id}`;
|
||||
let pending: GeoPoint[] = loadQueue();
|
||||
|
||||
function loadQueue(): GeoPoint[] {
|
||||
if (!import.meta.client) return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(queueKey);
|
||||
return raw ? (JSON.parse(raw) as GeoPoint[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistQueue() {
|
||||
if (!import.meta.client) return;
|
||||
try {
|
||||
if (pending.length > 0) {
|
||||
localStorage.setItem(queueKey, JSON.stringify(pending));
|
||||
} else {
|
||||
localStorage.removeItem(queueKey);
|
||||
}
|
||||
} catch {
|
||||
// Ignore quota/serialization errors; the in-memory buffer still flushes.
|
||||
}
|
||||
}
|
||||
|
||||
/** Send the buffered fixes; re-queue them if the beacon could not be sent. */
|
||||
function flush() {
|
||||
if (!import.meta.client || pending.length === 0) return;
|
||||
const batch = pending;
|
||||
pending = [];
|
||||
persistQueue();
|
||||
|
||||
let queued = false;
|
||||
try {
|
||||
queued =
|
||||
'sendBeacon' in navigator &&
|
||||
navigator.sendBeacon(
|
||||
`/api/hunt/${hunt.id}/track`,
|
||||
new Blob([JSON.stringify(batch)], { type: 'application/json' })
|
||||
);
|
||||
} catch {
|
||||
queued = false;
|
||||
}
|
||||
|
||||
if (!queued) {
|
||||
// Network/beacon failure: keep the batch for the next flush or 'online'.
|
||||
pending = [...batch, ...pending];
|
||||
persistQueue();
|
||||
}
|
||||
}
|
||||
|
||||
// --- watch loop ----------------------------------------------------------
|
||||
function onFix() {
|
||||
const at = locatedAt.value;
|
||||
const c = coords.value;
|
||||
if (at == null || !c) return;
|
||||
const recordedAt = new Date(at);
|
||||
const fix: GeoPoint = {
|
||||
lat: c.latitude,
|
||||
lon: c.longitude,
|
||||
accuracy: c.accuracy ?? null,
|
||||
recordedAt
|
||||
};
|
||||
const last = sessionFixes.value.at(-1);
|
||||
const distToLast = last ? haversine(last, fix) : Number.MAX_VALUE;
|
||||
if (distToLast < MIN_DISTANCE) {
|
||||
return;
|
||||
}
|
||||
sessionFixes.value = [...sessionFixes.value, fix];
|
||||
lastFix.value = recordedAt;
|
||||
pending.push(fix);
|
||||
}
|
||||
|
||||
function beginWatch() {
|
||||
if (!import.meta.client || !isSupported.value || tracking.value) return;
|
||||
resume();
|
||||
tracking.value = true;
|
||||
}
|
||||
|
||||
function endWatch() {
|
||||
if (!tracking.value) return;
|
||||
pause();
|
||||
tracking.value = false;
|
||||
}
|
||||
|
||||
function start() {
|
||||
userPaused.value = false;
|
||||
if (gate.value) beginWatch();
|
||||
}
|
||||
|
||||
function stop() {
|
||||
userPaused.value = true;
|
||||
endWatch();
|
||||
flush();
|
||||
}
|
||||
|
||||
function toggleConsent() {
|
||||
const next = !consented.value;
|
||||
settings.setTrackConsent(hunt.id, next);
|
||||
if (next) start();
|
||||
else stop();
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!import.meta.client) return;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
hidden.value = true;
|
||||
flush();
|
||||
endWatch();
|
||||
} else {
|
||||
hidden.value = false;
|
||||
if (gate.value && !userPaused.value) beginWatch();
|
||||
}
|
||||
}
|
||||
|
||||
/** One-shot position read for the submit form; independent of the watch. */
|
||||
function getPosition(timeoutMs = 10_000): Promise<{
|
||||
lat: number;
|
||||
lon: number;
|
||||
accuracy: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!import.meta.client || !('geolocation' in navigator)) {
|
||||
reject(new Error('geolocation unsupported'));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) =>
|
||||
resolve({
|
||||
lat: pos.coords.latitude,
|
||||
lon: pos.coords.longitude,
|
||||
accuracy: pos.coords.accuracy ?? null
|
||||
}),
|
||||
(err) => reject(err),
|
||||
{ enableHighAccuracy: true, timeout: timeoutMs, maximumAge: 0 }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
watch(locatedAt, onFix);
|
||||
watch(gate, (open) => {
|
||||
if (open && !userPaused.value && !hidden.value) beginWatch();
|
||||
else if (!open) endWatch();
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return;
|
||||
window.addEventListener('online', flush);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
window.addEventListener('pagehide', flush);
|
||||
timer = setInterval(() => {
|
||||
nowTs.value = Date.now();
|
||||
flush();
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
if (gate.value && !userPaused.value) beginWatch();
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
flush();
|
||||
endWatch();
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
if (import.meta.client) {
|
||||
window.removeEventListener('online', flush);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
window.removeEventListener('pagehide', flush);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
distance,
|
||||
lastFix,
|
||||
tracking,
|
||||
paused,
|
||||
consented,
|
||||
eligible,
|
||||
gate,
|
||||
isSupported,
|
||||
start,
|
||||
stop,
|
||||
toggleConsent,
|
||||
getPosition
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Client-only team color assignment for the live map.
|
||||
*
|
||||
* A fixed 20-color high-contrast palette is shuffled once per page-load and
|
||||
* assigned to teams by their stable order (the order returned by the map
|
||||
* endpoint). Colors are team-shared but NOT persisted, so a refresh may
|
||||
* reassign them. Every member of a team resolves to the same color.
|
||||
*/
|
||||
|
||||
const PALETTE = [
|
||||
'#e6194b',
|
||||
'#3cb44b',
|
||||
'#4363d8',
|
||||
'#f58231',
|
||||
'#911eb4',
|
||||
'#46f0f0',
|
||||
'#f032e6',
|
||||
'#bcf60c',
|
||||
'#fabebe',
|
||||
'#008080',
|
||||
'#e6beff',
|
||||
'#9a6324',
|
||||
'#fffac8',
|
||||
'#800000',
|
||||
'#aaffc3',
|
||||
'#808000',
|
||||
'#ffd8b1',
|
||||
'#000075',
|
||||
'#808080',
|
||||
'#ff4d00'
|
||||
] as const;
|
||||
|
||||
function shuffled<T>(input: readonly T[]): T[] {
|
||||
const arr = [...input];
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
/** Darken a hex color by a factor (0..1) for trail lines. */
|
||||
export function darken(hex: string, factor = 0.75): string {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const r = Math.round(((n >> 16) & 255) * factor);
|
||||
const g = Math.round(((n >> 8) & 255) * factor);
|
||||
const b = Math.round((n & 255) * factor);
|
||||
return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
export function useTeamColors() {
|
||||
// Shuffled once per composable instance (i.e. per page-load).
|
||||
const colors = shuffled(PALETTE);
|
||||
|
||||
/** Build a teamId -> color map from an ordered list of team ids. */
|
||||
function assign(orderedTeamIds: number[]): Map<number, string> {
|
||||
const map = new Map<number, string>();
|
||||
orderedTeamIds.forEach((id, i) => {
|
||||
map.set(id, colors[i % colors.length]!);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
return { assign, colors, darken };
|
||||
}
|
||||
@@ -90,6 +90,7 @@
|
||||
"back_home": "Zurück zur Startseite",
|
||||
"back_to_hunt": "Zurück zum Hunt",
|
||||
"cancel": "Abbrechen",
|
||||
"distance": "Strecke",
|
||||
"join": "Beitreten",
|
||||
"login_first": "Erst anmelden",
|
||||
"no_pictures": "Es wurden noch keine Bilder abgeschickt :(",
|
||||
@@ -118,6 +119,7 @@
|
||||
"start": "Start",
|
||||
"success": "Hunt erfolgreich erstellt!",
|
||||
"title": "Neuen Hunt erstellen",
|
||||
"track_distance": "Gelaufene Strecke aufzeichnen",
|
||||
"virtual": "Virtueller Hunt"
|
||||
},
|
||||
"diashow": {
|
||||
@@ -166,6 +168,17 @@
|
||||
"teams": "Teams",
|
||||
"title": "Hunts",
|
||||
"total_points": "Punktestand: {score}",
|
||||
"tracking": {
|
||||
"consent": "Meine gelaufene Strecke aufzeichnen",
|
||||
"consent_explain": "Solange dies aktiv ist, wird dein Standort im Hintergrund aufgezeichnet, während diese Hunt-Seite geöffnet ist, und daraus die gelaufene Strecke berechnet. Wenn der Tab ausgeblendet ist oder du pausierst, wird nichts aufgezeichnet. Dies ermöglicht uns zu sehen, wo alle Teams unterwegs sind.",
|
||||
"last_fix": "letzter Punkt {ago}",
|
||||
"pause": "Pause",
|
||||
"resume": "Fortsetzen",
|
||||
"status_paused": "Pausiert",
|
||||
"status_tracking": "Aufzeichnung läuft",
|
||||
"title": "Gelaufene Strecke",
|
||||
"unsupported": "Die Standortbestimmung wird von deinem Browser nicht unterstützt."
|
||||
},
|
||||
"your_team": "Dein Team:"
|
||||
},
|
||||
"imprint": {
|
||||
@@ -188,6 +201,21 @@
|
||||
"step_win_text": "Sammle Punkte, klettere die Rangliste hinauf und feiere in der Showcase.",
|
||||
"step_win_title": "Punkte gewinnen"
|
||||
},
|
||||
"map": {
|
||||
"accuracy": "Genauigkeit",
|
||||
"last_fix": "letzte Position {ago}",
|
||||
"last_updated": "Zuletzt aktualisiert: {time}",
|
||||
"layer_answers": "Antworten",
|
||||
"layer_positions": "Live-Positionen",
|
||||
"member": "Mitglied",
|
||||
"nav": "Live-Karte",
|
||||
"no_teams": "Noch keine Teams.",
|
||||
"open_quest_admin": "Quest-Administration öffnen",
|
||||
"roster": "Teams & Spieler",
|
||||
"team": "Team",
|
||||
"time": "Zeit",
|
||||
"title": "Live-Karte"
|
||||
},
|
||||
"pictures": {
|
||||
"filter_quest": "Filtere Bilder nach Quests",
|
||||
"filter_team": "Filtere Bilder nach Teams",
|
||||
@@ -197,6 +225,11 @@
|
||||
},
|
||||
"profile": {
|
||||
"admins_and_creators": "Admins & Ersteller",
|
||||
"distances": {
|
||||
"no_distances": "Noch keine Strecken aufgezeichnet.",
|
||||
"title": "Deine Strecken",
|
||||
"total": "Gesamt: {distance}"
|
||||
},
|
||||
"new_password": "Neues Passwort",
|
||||
"no_pictures": "Noch keine Bilder hochgeladen.",
|
||||
"old_password": "Altes Passwort",
|
||||
@@ -220,12 +253,15 @@
|
||||
"finalize": "Antwort finalisieren",
|
||||
"finalize_explain": "Dies gibt den Admins die Möglichkeit deine Antwort zu bewerten und dir Punkte zu geben. Du kannst deine Antwort anschließend nicht mehr ändern!",
|
||||
"last_update": "Letztes Update: {date} von {name}",
|
||||
"location_unavailable": "Dein Standort konnte nicht ermittelt werden, die Antwort wird ohne ihn übermittelt.",
|
||||
"private_image": "Privates Bild",
|
||||
"private_image_explain": "Private Bilder werden in der öffentlichen Galerie, Diashow und im Showcase ausgeblendet. Admins können sie weiterhin zur Bewertung sehen.",
|
||||
"required_answer": "Erforderliche Antwort",
|
||||
"review": "Bewertung",
|
||||
"reviewed_by": "von {name} am {date}",
|
||||
"score": "Punkte:",
|
||||
"send_location": "Aktuellen Standort senden",
|
||||
"send_location_explain": "Hängt deine aktuelle GPS-Position an diese Antwort an.",
|
||||
"submission": "Einreichung",
|
||||
"upload_compress": "Bild wird komprimiert...",
|
||||
"upload_title": "Antwort wird hochgeladen...",
|
||||
@@ -270,6 +306,7 @@
|
||||
"showcase": {
|
||||
"answers": "Antworten",
|
||||
"crew": "Crew",
|
||||
"distance": "Strecke",
|
||||
"first_place": "Erster Platz",
|
||||
"leaderboard": "Rangliste",
|
||||
"pictures": "Bilder",
|
||||
@@ -280,6 +317,7 @@
|
||||
"table": {
|
||||
"answers": "Antworten",
|
||||
"description": "Beschreibung",
|
||||
"distance": "Strecke",
|
||||
"name": "Name",
|
||||
"place": "Platz",
|
||||
"score": "Punkte"
|
||||
|
||||
@@ -90,6 +90,7 @@
|
||||
"back_home": "Back home",
|
||||
"back_to_hunt": "Back to Hunt",
|
||||
"cancel": "Cancel",
|
||||
"distance": "Distance",
|
||||
"join": "Join",
|
||||
"login_first": "Login first",
|
||||
"no_pictures": "No pictures has been submitted yet :(",
|
||||
@@ -118,6 +119,7 @@
|
||||
"start": "Start",
|
||||
"success": "Hunt created successfully!",
|
||||
"title": "Create a new Hunt",
|
||||
"track_distance": "Track walking distance",
|
||||
"virtual": "Virtual hunt"
|
||||
},
|
||||
"diashow": {
|
||||
@@ -166,6 +168,17 @@
|
||||
"teams": "Teams",
|
||||
"title": "Hunts",
|
||||
"total_points": "Total points: {score}",
|
||||
"tracking": {
|
||||
"consent": "Record my walking distance",
|
||||
"consent_explain": "While this is on, your location is recorded in the background while this hunt page is open and used to calculate the distance you walked. Nothing is recorded when the tab is hidden or you pause. This allows us to monitor where the teams are.",
|
||||
"last_fix": "last fix {ago}",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"status_paused": "Paused",
|
||||
"status_tracking": "Tracking",
|
||||
"title": "Walking distance",
|
||||
"unsupported": "Geolocation is not supported by your browser."
|
||||
},
|
||||
"your_team": "Your team:"
|
||||
},
|
||||
"imprint": {
|
||||
@@ -188,6 +201,21 @@
|
||||
"step_win_text": "Collect points, climb the leaderboard and celebrate in the showcase.",
|
||||
"step_win_title": "Win points"
|
||||
},
|
||||
"map": {
|
||||
"accuracy": "Accuracy",
|
||||
"last_fix": "last fix {ago}",
|
||||
"last_updated": "Last updated: {time}",
|
||||
"layer_answers": "Answers",
|
||||
"layer_positions": "Live positions",
|
||||
"member": "Member",
|
||||
"nav": "Live Map",
|
||||
"no_teams": "No teams yet.",
|
||||
"open_quest_admin": "Open quest admin",
|
||||
"roster": "Teams & users",
|
||||
"team": "Team",
|
||||
"time": "Time",
|
||||
"title": "Live Map"
|
||||
},
|
||||
"pictures": {
|
||||
"filter_quest": "Filter pictures by quests",
|
||||
"filter_team": "Filter pictures by teams",
|
||||
@@ -197,6 +225,11 @@
|
||||
},
|
||||
"profile": {
|
||||
"admins_and_creators": "Admins & Creators",
|
||||
"distances": {
|
||||
"no_distances": "No distances recorded yet.",
|
||||
"title": "Your distances",
|
||||
"total": "Total: {distance}"
|
||||
},
|
||||
"new_password": "New password",
|
||||
"no_pictures": "No pictures uploaded yet.",
|
||||
"old_password": "Old password",
|
||||
@@ -220,12 +253,15 @@
|
||||
"finalize": "Finalize answer",
|
||||
"finalize_explain": "This allows the admins to review your answer and give you points. You cannot change your answer afterwards!",
|
||||
"last_update": "Last update: {date} by {name}",
|
||||
"location_unavailable": "Your location could not be determined, the answer is submitted without it.",
|
||||
"private_image": "Private image",
|
||||
"private_image_explain": "Private images are hidden from the public gallery, diashow and showcase. Admins can still see them for review.",
|
||||
"required_answer": "Required Answer",
|
||||
"review": "Review",
|
||||
"reviewed_by": "by {name} at {date}",
|
||||
"score": "Score:",
|
||||
"send_location": "Send current location",
|
||||
"send_location_explain": "Attaches your current GPS position to this answer.",
|
||||
"submission": "Submission",
|
||||
"upload_compress": "Compressing image...",
|
||||
"upload_title": "Uploading answer...",
|
||||
@@ -270,6 +306,7 @@
|
||||
"showcase": {
|
||||
"answers": "Answers",
|
||||
"crew": "Crew",
|
||||
"distance": "Distance",
|
||||
"first_place": "First place",
|
||||
"leaderboard": "Leaderboard",
|
||||
"pictures": "Pictures",
|
||||
@@ -280,6 +317,7 @@
|
||||
"table": {
|
||||
"answers": "Answers",
|
||||
"description": "Description",
|
||||
"distance": "Distance",
|
||||
"name": "Name",
|
||||
"place": "Place",
|
||||
"score": "Score"
|
||||
|
||||
@@ -127,6 +127,22 @@ const isHuntAdmin = computed(
|
||||
<VaSidebarItemTitle>Hunt Teams</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && isHuntAdmin && huntStore.trackDistance"
|
||||
text-color="danger"
|
||||
hover-color="danger"
|
||||
active-color="onDanger"
|
||||
:to="localePath(`/hunt/${sluggy(slugs.huntSlug)}/live-map`)"
|
||||
:active="
|
||||
localePath(`/hunt/${sluggy(slugs.huntSlug)}/live-map`) ===
|
||||
route.path
|
||||
"
|
||||
>
|
||||
<VaSidebarItemContent>
|
||||
<VaIcon name="map" />
|
||||
<VaSidebarItemTitle>{{ t('page.map.nav') }}</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && slugs?.questSlug"
|
||||
:to="
|
||||
|
||||
@@ -22,6 +22,7 @@ const form = reactive({
|
||||
description_de: '',
|
||||
description_en: '',
|
||||
virtual: false,
|
||||
trackDistance: false,
|
||||
start: null as Date | null,
|
||||
end: null as Date | null,
|
||||
allowJoin: false,
|
||||
@@ -114,6 +115,10 @@ async function submit() {
|
||||
v-model="form.virtual"
|
||||
:label="t('page.create_hunt.virtual')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.trackDistance"
|
||||
:label="t('page.create_hunt.track_distance')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="form.public"
|
||||
:label="t('page.create_hunt.public')"
|
||||
|
||||
@@ -74,6 +74,14 @@ const answer = ref(data.value?.answer?.text);
|
||||
const final = ref(data.value?.answer?.final || false);
|
||||
const privateImage = ref(data.value?.answer?.picture?.private ?? false);
|
||||
|
||||
const trackableHunt = data.value?.hunt ?? null;
|
||||
const geo = trackableHunt ? useGeoTracking(trackableHunt) : null;
|
||||
|
||||
// Submit-location slice: independent of the tracking watch loop. The checkbox
|
||||
// is offered only when the user consented and the hunt is tracking-eligible.
|
||||
const sendLocation = ref(unref(geo?.consented?.value ?? false));
|
||||
const locationNotice = ref('');
|
||||
|
||||
const privateChanged = computed(
|
||||
() => (data.value?.answer?.picture?.private ?? false) !== privateImage.value
|
||||
);
|
||||
@@ -105,6 +113,7 @@ useEventListener(window, 'beforeunload', (ev) => {
|
||||
async function submit() {
|
||||
uploading.value = true;
|
||||
submitError.value = '';
|
||||
locationNotice.value = '';
|
||||
uploadProgress.value = 0;
|
||||
|
||||
const body = new FormData();
|
||||
@@ -127,6 +136,16 @@ async function submit() {
|
||||
compressing.value = false;
|
||||
body.set('image', compressedImage);
|
||||
}
|
||||
if (sendLocation.value && geo) {
|
||||
try {
|
||||
const pos = await geo.getPosition();
|
||||
body.set('lat', String(pos.lat));
|
||||
body.set('lon', String(pos.lon));
|
||||
} catch {
|
||||
// Non-blocking: submit without the location and tell the user.
|
||||
locationNotice.value = t('page.quest.location_unavailable');
|
||||
}
|
||||
}
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
try {
|
||||
@@ -149,6 +168,7 @@ async function submit() {
|
||||
answer.value = data.value?.answer?.text;
|
||||
privateImage.value = data.value?.answer?.picture?.private ?? false;
|
||||
newImage.value = undefined;
|
||||
sendLocation.value = false;
|
||||
} catch (e) {
|
||||
if (xhr.status === 418) {
|
||||
submitError.value = t('page.quest.already_finalized');
|
||||
@@ -353,7 +373,20 @@ async function submit() {
|
||||
:label="t('page.quest.finalize')"
|
||||
:messages="t('page.quest.finalize_explain')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-if="geo?.gate"
|
||||
v-model="sendLocation"
|
||||
:disabled="uploading || data.answer?.final"
|
||||
:label="t('page.quest.send_location')"
|
||||
:messages="t('page.quest.send_location_explain')"
|
||||
/>
|
||||
|
||||
<VaAlert
|
||||
v-if="locationNotice.length > 0"
|
||||
color="warning"
|
||||
class="w-full text-center"
|
||||
>{{ locationNotice }}</VaAlert
|
||||
>
|
||||
<VaAlert
|
||||
v-if="submitError.length > 0"
|
||||
color="danger"
|
||||
@@ -367,7 +400,8 @@ async function submit() {
|
||||
newImage ||
|
||||
data.answer?.text !== answer ||
|
||||
data.answer?.final !== final ||
|
||||
privateChanged
|
||||
privateChanged ||
|
||||
sendLocation
|
||||
) || uploading
|
||||
"
|
||||
:loading="uploading"
|
||||
|
||||
@@ -286,6 +286,10 @@ async function deleteImage() {
|
||||
v-model="formData.cloned.value.virtual"
|
||||
:label="t('page.create_hunt.virtual')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="formData.cloned.value.trackDistance"
|
||||
:label="t('page.create_hunt.track_distance')"
|
||||
/>
|
||||
<VaCheckbox
|
||||
v-model="formData.cloned.value.public"
|
||||
:label="t('page.create_hunt.public')"
|
||||
|
||||
@@ -25,6 +25,7 @@ const teamSettingsOpen = ref(false);
|
||||
const { data, pending, refresh, error } = await useFetch(`/api/hunt/${id}`);
|
||||
|
||||
huntStore.setMembership(id, data.value?.isMember ?? false);
|
||||
huntStore.setTrackDistance(id, data.value?.trackDistance ?? false);
|
||||
|
||||
const hasBingo = computed(
|
||||
() => data.value?.quests?.some((q) => q.isBingo) ?? false
|
||||
@@ -282,6 +283,13 @@ ${document.location.href}`
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
</div>
|
||||
<ClientOnly v-if="data.ownTeam !== null">
|
||||
<TrackDistanceCard
|
||||
:hunt="data"
|
||||
:seed-distance="data.ownDistance ?? 0"
|
||||
class="my-4"
|
||||
/>
|
||||
</ClientOnly>
|
||||
<VaDivider />
|
||||
<VaCard
|
||||
v-if="data.ownTeam === null && !data.isMember"
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { parseError, sluggy } from '#imports';
|
||||
import type { HuntMapData } from '#shared/types/map';
|
||||
import { useTeamColors } from '~/composables/useTeamColors';
|
||||
import { useHuntStore } from '~/stores/hunt';
|
||||
import { useTitleStore } from '~/stores/title';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['auth']
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
const { loggedIn } = useUserSession();
|
||||
const { t } = useI18n();
|
||||
const localePath = useLocalePath();
|
||||
const router = useRouter();
|
||||
|
||||
const { id } = app.$slugData.huntSlug!;
|
||||
const huntSlug = sluggy(app.$slugData.huntSlug!);
|
||||
|
||||
const titleStore = useTitleStore();
|
||||
const huntStore = useHuntStore();
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
const { data, error, refresh } = await useFetch<HuntMapData>(
|
||||
`/api/admin/hunt/${id}/map`
|
||||
);
|
||||
|
||||
// The map endpoint 404s for non-members / tracking-off hunts; reflect that in
|
||||
// the store so the layout nav stays consistent.
|
||||
huntStore.setMembership(id, !error.value);
|
||||
huntStore.setTrackDistance(id, !error.value);
|
||||
|
||||
titleStore.title = t('page.map.title');
|
||||
|
||||
watch(loggedIn, (isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
router.push(localePath('/login'));
|
||||
}
|
||||
});
|
||||
|
||||
// Team colors: shuffled once per page-load, assigned by stable team order.
|
||||
const { assign } = useTeamColors();
|
||||
const teamColors = computed(() =>
|
||||
assign((data.value?.teams ?? []).map((team) => team.id))
|
||||
);
|
||||
|
||||
// Layer toggles + trail selection.
|
||||
const showAnswers = ref(true);
|
||||
const showPositions = ref(true);
|
||||
const selectedUserId = ref<number | null>(null);
|
||||
|
||||
function toggleUser(userId: number) {
|
||||
selectedUserId.value = selectedUserId.value === userId ? null : userId;
|
||||
}
|
||||
|
||||
// "Last updated" timestamp for silent-failure polling feedback.
|
||||
const lastUpdated = ref<Date | null>(null);
|
||||
watch(
|
||||
data,
|
||||
() => {
|
||||
if (data.value) lastUpdated.value = new Date();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 15s polling, paused while the tab is hidden, silent on failure (refresh()
|
||||
// keeps the previous data on error).
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function startPolling() {
|
||||
if (timer !== undefined) return;
|
||||
timer = setInterval(() => {
|
||||
if (import.meta.client && document.visibilityState === 'hidden') return;
|
||||
void refresh();
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function onVisibilityChange() {
|
||||
if (!import.meta.client) return;
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refresh();
|
||||
startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startPolling();
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
stopPolling();
|
||||
if (import.meta.client) {
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
});
|
||||
|
||||
// Roster: teams with owner + members flattened for display.
|
||||
const roster = computed(() =>
|
||||
(data.value?.teams ?? []).map((team) => ({
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
color: teamColors.value.get(team.id) ?? '#808080',
|
||||
users: [team.owner, ...team.members.filter((m) => m.id !== team.owner.id)]
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="error">
|
||||
<VaAlert color="danger" class="my-4">
|
||||
{{ t(parseError(error)) }}
|
||||
</VaAlert>
|
||||
<VaButton icon="arrow_back" :to="localePath(`/hunt/${huntSlug}`)">
|
||||
{{ t('page.common.back_to_hunt') }}
|
||||
</VaButton>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="mb-2 flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<h1 class="text-2xl">{{ t('page.map.title') }}</h1>
|
||||
<VaSpacer />
|
||||
<VaSwitch v-model="showAnswers" :label="t('page.map.layer_answers')" />
|
||||
<VaSwitch
|
||||
v-model="showPositions"
|
||||
:label="t('page.map.layer_positions')"
|
||||
/>
|
||||
<VaButton icon="replay" :disabled="!data" @click="refresh">
|
||||
{{ t('page.common.refresh') }}
|
||||
</VaButton>
|
||||
</div>
|
||||
<p v-if="lastUpdated" class="mb-2 text-sm opacity-70">
|
||||
{{
|
||||
t('page.map.last_updated', {
|
||||
time: lastUpdated.toLocaleTimeString()
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4 md:flex-row">
|
||||
<div class="h-[80vh] min-h-[400px] flex-grow">
|
||||
<LazyMapLiveMap
|
||||
v-if="data"
|
||||
:hunt-id="id"
|
||||
:hunt-slug="huntSlug"
|
||||
:data="data"
|
||||
:colors="teamColors"
|
||||
:show-answers="showAnswers"
|
||||
:show-positions="showPositions"
|
||||
:selected-user-id="selectedUserId"
|
||||
@select-user="selectedUserId = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<VaCard class="w-full md:max-w-xs">
|
||||
<VaCardTitle>{{ t('page.map.roster') }}</VaCardTitle>
|
||||
<VaCardContent>
|
||||
<p v-if="roster.length === 0" class="text-sm opacity-70">
|
||||
{{ t('page.map.no_teams') }}
|
||||
</p>
|
||||
<ul v-else class="flex flex-col gap-3">
|
||||
<li v-for="team in roster" :key="team.id">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<span
|
||||
class="inline-block h-4 w-4 rounded-full"
|
||||
:style="{ backgroundColor: team.color }"
|
||||
/>
|
||||
{{ team.name }}
|
||||
</div>
|
||||
<ul class="ml-6 mt-1 flex flex-col gap-1">
|
||||
<li v-for="u in team.users" :key="u.id">
|
||||
<VaButton
|
||||
size="small"
|
||||
preset="secondary"
|
||||
:color="selectedUserId === u.id ? 'primary' : 'secondary'"
|
||||
@click="toggleUser(u.id)"
|
||||
>
|
||||
{{ u.name }}
|
||||
</VaButton>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { sluggy } from '#imports';
|
||||
import type { DataTableColumnSource } from 'vuestic-ui';
|
||||
import MemberCard from '~/components/showcase/MemberCard.vue';
|
||||
import NumberCard from '~/components/showcase/NumberCard.vue';
|
||||
import { fullDate } from '~~/server/utils/date';
|
||||
|
||||
@@ -10,6 +11,7 @@ definePageMeta({
|
||||
const { t, d } = useI18n();
|
||||
const localePath = useLocalePath();
|
||||
const { tKey, hasKey, tSluggy } = useI18nKey();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
|
||||
const app = useNuxtApp();
|
||||
const { name, id } = app.$slugData.huntSlug!;
|
||||
@@ -32,11 +34,23 @@ const leaderboardColumns: DataTableColumnSource[] = [
|
||||
key: 'score',
|
||||
displayFormatFn: (data: number) => Number(data).toLocaleString('de')
|
||||
},
|
||||
'answers'
|
||||
'answers',
|
||||
...(data.value?.trackDistance
|
||||
? ([
|
||||
{
|
||||
key: 'distance',
|
||||
displayFormatFn: (value: number) => formatDistance(value)
|
||||
}
|
||||
] as DataTableColumnSource[])
|
||||
: [])
|
||||
];
|
||||
const columns = leaderboardColumns.map((c) =>
|
||||
typeof c === 'string' ? c : c.key
|
||||
);
|
||||
|
||||
const totalDistance = computed(
|
||||
() => data.value?.leaderboard?.reduce((ac, l) => ac + l.distance, 0) ?? 0
|
||||
);
|
||||
// TODO: colors
|
||||
</script>
|
||||
|
||||
@@ -76,9 +90,9 @@ const columns = leaderboardColumns.map((c) =>
|
||||
<section id="numbers">
|
||||
<div class="grid grid-cols-3 gap-4 bg-red-100 p-4">
|
||||
<NumberCard
|
||||
:title="t('page.showcase.crew')"
|
||||
:count="data._count.members + 1"
|
||||
icon="support_agent"
|
||||
:title="t('page.showcase.distance')"
|
||||
:value="formatDistance(totalDistance)"
|
||||
icon="route"
|
||||
/>
|
||||
<NumberCard
|
||||
:title="t('page.showcase.quests')"
|
||||
@@ -188,6 +202,12 @@ const columns = leaderboardColumns.map((c) =>
|
||||
:count="data.leaderboard[2].answers"
|
||||
icon="question_mark"
|
||||
/>
|
||||
<NumberCard
|
||||
:title="t('page.showcase.distance')"
|
||||
:value="formatDistance(data.leaderboard[2].distance)"
|
||||
icon="route"
|
||||
/>
|
||||
<MemberCard :members="data.leaderboard[2].members" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -210,6 +230,12 @@ const columns = leaderboardColumns.map((c) =>
|
||||
:count="data.leaderboard[1].answers"
|
||||
icon="question_mark"
|
||||
/>
|
||||
<NumberCard
|
||||
:title="t('page.showcase.distance')"
|
||||
:value="formatDistance(data.leaderboard[1].distance)"
|
||||
icon="route"
|
||||
/>
|
||||
<MemberCard :members="data.leaderboard[1].members" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -236,6 +262,11 @@ const columns = leaderboardColumns.map((c) =>
|
||||
:count="data.leaderboard[0].answers"
|
||||
icon="question_mark"
|
||||
/>
|
||||
<NumberCard
|
||||
:title="t('page.showcase.distance')"
|
||||
:value="formatDistance(data.leaderboard[0].distance)"
|
||||
icon="route"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -258,6 +289,12 @@ const columns = leaderboardColumns.map((c) =>
|
||||
:count="data.leaderboard[0].answers"
|
||||
icon="question_mark"
|
||||
/>
|
||||
<NumberCard
|
||||
:title="t('page.showcase.distance')"
|
||||
:value="formatDistance(data.leaderboard[0].distance)"
|
||||
icon="route"
|
||||
/>
|
||||
<MemberCard :members="data.leaderboard[0].members" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -11,6 +11,7 @@ const { loggedIn, fetch } = useUserSession();
|
||||
const localePath = useLocalePath();
|
||||
const validation = useValidation();
|
||||
const { tKey, tSluggy } = useI18nKey();
|
||||
const { formatDistance } = useFormatDistance();
|
||||
watch(loggedIn, (isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
router.push(localePath('/login'));
|
||||
@@ -18,6 +19,15 @@ watch(loggedIn, (isLoggedIn) => {
|
||||
});
|
||||
const { data, refresh, pending } = await useFetch('/api/auth/profile');
|
||||
|
||||
const distanceEntries = computed(() =>
|
||||
(data.value?.distances ?? []).map((entry) => ({
|
||||
id: entry.huntId,
|
||||
name_de: entry.huntName_de,
|
||||
name_en: entry.huntName_en,
|
||||
distanceMeters: entry.distanceMeters
|
||||
}))
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: t('layouts.title', {
|
||||
title: t('page.profile.title')
|
||||
@@ -271,6 +281,38 @@ async function submitPassword() {
|
||||
</VaCardContent>
|
||||
<VaCardContent v-else>{{ t('page.profile.no_pictures') }}</VaCardContent>
|
||||
</VaCard>
|
||||
<VaCard stripe stripe-color="info">
|
||||
<VaCardTitle>{{ t('page.profile.distances.title') }}</VaCardTitle>
|
||||
<VaCardContent v-if="data && data.distances.length > 0">
|
||||
<p class="mb-4 text-xl font-bold">
|
||||
{{
|
||||
t('page.profile.distances.total', {
|
||||
distance: formatDistance(data.totalDistance)
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="entry in distanceEntries"
|
||||
:key="entry.id"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="localePath(`/hunt/${tSluggy(entry).value}`)"
|
||||
class="hover:underline"
|
||||
>
|
||||
{{ tKey(entry, 'name') }}
|
||||
</NuxtLink>
|
||||
<span class="font-mono">{{
|
||||
formatDistance(entry.distanceMeters)
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</VaCardContent>
|
||||
<VaCardContent v-else>{{
|
||||
t('page.profile.distances.no_distances')
|
||||
}}</VaCardContent>
|
||||
</VaCard>
|
||||
<AdminUserRoles />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+7
-1
@@ -1,13 +1,19 @@
|
||||
export const useHuntStore = defineStore('hunt', () => {
|
||||
const huntId = ref<number>();
|
||||
const isMember = ref(false);
|
||||
const trackDistance = ref(false);
|
||||
|
||||
function setMembership(id: number, member: boolean) {
|
||||
huntId.value = id;
|
||||
isMember.value = member;
|
||||
}
|
||||
|
||||
return { huntId, isMember, setMembership };
|
||||
function setTrackDistance(id: number, value: boolean) {
|
||||
huntId.value = id;
|
||||
trackDistance.value = value;
|
||||
}
|
||||
|
||||
return { huntId, isMember, trackDistance, setMembership, setTrackDistance };
|
||||
});
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.accept(acceptHMRUpdate(useHuntStore, import.meta.hot));
|
||||
|
||||
+18
-1
@@ -3,8 +3,25 @@ export const useSettingsStore = defineStore(
|
||||
() => {
|
||||
const hideAnswers = ref(true);
|
||||
const isSidebarVisible = ref(false);
|
||||
// Per-hunt, client-only consent for GPS distance tracking. Off by default;
|
||||
// the server cannot prove consent, so this lives only in the client store.
|
||||
const trackConsent = ref<Record<number, boolean>>({});
|
||||
|
||||
return { hideAnswers, isSidebarVisible };
|
||||
function hasTrackConsent(huntId: number): boolean {
|
||||
return trackConsent.value[huntId] ?? false;
|
||||
}
|
||||
|
||||
function setTrackConsent(huntId: number, consent: boolean) {
|
||||
trackConsent.value[huntId] = consent;
|
||||
}
|
||||
|
||||
return {
|
||||
hideAnswers,
|
||||
isSidebarVisible,
|
||||
trackConsent,
|
||||
hasTrackConsent,
|
||||
setTrackConsent
|
||||
};
|
||||
},
|
||||
{ persist: true }
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user