201 lines
5.7 KiB
Vue
201 lines
5.7 KiB
Vue
<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>
|