Files
pichunt/app/pages/hunt/[huntSlug]/showcase.vue
T
Pascal 61cc516f47
Docker Image CI / test (push) Failing after 41s
Docker Image CI / deploy (push) Skipped
Tests / unit (push) Failing after 36s
Tests / integration (push) Successful in 1m34s
feat(hunt): track walking distance via GPS, show live locations on map
Add foreground-only GPS distance tracking for hunts
2026-09-27 20:55:58 +02:00

353 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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';
definePageMeta({
layout: 'full'
});
const { t, d } = useI18n();
const localePath = useLocalePath();
const { tKey, hasKey, tSluggy } = useI18nKey();
const { formatDistance } = useFormatDistance();
const app = useNuxtApp();
const { name, id } = app.$slugData.huntSlug!;
const { data, error } = await useFetch(`/api/hunt/${id}/showcase`);
useHead({
title: t('layouts.two_title', {
title: tKey(data.value ?? { name_de: name, name_en: name }, 'name').value,
subtitle: t('page.showcase.title')
})
});
const { toggle, isFullscreen, isSupported } = useFullscreen();
const leaderboardColumns: DataTableColumnSource[] = [
'place',
'name',
'description',
{
key: 'score',
displayFormatFn: (data: number) => Number(data).toLocaleString('de')
},
'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>
<template>
<div
v-if="isSupported"
class="fixed left-2 top-2 opacity-25 transition-opacity hover:opacity-100"
>
<VaButton
round
border-color="primary"
color="onPrimary"
:icon="isFullscreen ? 'fullscreen_exit' : 'fullscreen'"
@click="toggle"
/>
</div>
<!-- eslint-disable-next-line vue/no-multiple-template-root -->
<article
v-if="data"
class="h-dvh w-full snap-y snap-mandatory overflow-y-scroll scroll-smooth"
>
<section id="title">
<div
class="flex flex-col items-center justify-center gap-8 bg-green-300 text-center"
>
<h1 class="text-7xl">{{ t('page.showcase.showcase_for') }}</h1>
<h1 class="text-9xl font-bold">
{{ hasKey(data, 'name') ? tKey(data, 'name') : name }}
</h1>
<p v-if="data.start || data.end" class="flex flex-row gap-4 text-4xl">
<span v-if="data.start">{{ d(data.start, fullDate) }}</span
><span v-if="data.start && data.end"> – </span
><span v-if="data.end">{{ d(data.end, fullDate) }}</span>
</p>
</div>
</section>
<section id="numbers">
<div class="grid grid-cols-3 gap-4 bg-red-100 p-4">
<NumberCard
:title="t('page.showcase.distance')"
:value="formatDistance(totalDistance)"
icon="route"
/>
<NumberCard
:title="t('page.showcase.quests')"
:count="data._count.quests"
icon="home"
/>
<NumberCard
:title="t('page.showcase.users')"
:count="data.userCount"
icon="person"
/>
<NumberCard
:title="t('page.showcase.teams')"
:count="data._count.teams"
icon="groups"
/>
<NumberCard
:title="t('page.showcase.total_points')"
:count="data.totalScore"
icon="star"
/>
<NumberCard
:title="t('page.showcase.pictures')"
:count="data.pictureCount"
icon="photo"
/>
</div>
</section>
<section
v-for="quest in data.quests"
:id="tSluggy(quest).value"
:key="quest.id"
>
<div class="flex flex-col gap-4 bg-yellow-100 p-4">
<div class="my-8 flex w-full flex-row justify-around">
<h3 class="text-3xl">{{ tKey(quest, 'title') }}</h3>
<p v-if="hasKey(quest, 'question')" class="text-xl font-bold">
{{ tKey(quest, 'question') }}
</p>
<p v-if="quest.textSolution" class="text-3xl">
{{ quest.textSolution }}
</p>
</div>
<ClientOnly>
<VaCarousel
stateful
:items="quest.answers"
arrows
indicators
lazy
color="onPrimary"
indicator-trigger="hover"
class="w-full flex-grow"
style="--va-carousel-background: none"
>
<template #default="{ item }"
><VaImage :src="item.picture.url" fit="contain" lazy />
<p
class="absolute top-2 mx-auto rounded bg-gray-200/75 px-2 py-1 text-center text-xl"
>
{{ item.team.name }} — "{{ item.text }}" —
{{ item.picture.creator.name }}
</p></template
></VaCarousel
></ClientOnly
>
</div>
</section>
<section v-if="data.leaderboard.length > 3" id="leaderboard">
<div class="flex flex-col gap-4 bg-indigo-100">
<h1 class="mt-8 text-center text-6xl">
{{ t('page.showcase.leaderboard') }}
</h1>
<VaDataTable
class="h-full text-2xl"
virtual-scroller
striped
sticky-header
:items="data.leaderboard.slice(3)"
:columns="leaderboardColumns"
>
<template v-for="col in columns" #[col] :key="col"
><span>{{ t(`page.showcase.table.${col}`) }}</span></template
>
<template #cell(place)="{ rowIndex }">
<strong>{{ rowIndex + 4 }}</strong>
</template>
</VaDataTable>
</div>
</section>
<section v-if="data.leaderboard[2]" id="leaderboard-third">
<div class="place bg-[#AD8A56]">
<div class="place-left">
<VaIcon name="looks_3" size="10rem" />
<h2>{{ t('page.showcase.third_place') }}</h2>
</div>
<div class="place-right">
<h3>{{ data.leaderboard[2].name }}</h3>
<p>{{ data.leaderboard[2].description }}</p>
<NumberCard
:title="t('page.showcase.points')"
:count="data.leaderboard[2].score"
icon="star"
/>
<NumberCard
:title="t('page.showcase.answers')"
: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>
<section v-if="data.leaderboard[1]" id="leaderboard-second">
<div class="place bg-[#B4B4B4]">
<div class="place-left">
<VaIcon name="looks_two" size="10rem" />
<h2>{{ t('page.showcase.second_place') }}</h2>
</div>
<div class="place-right">
<h3>{{ data.leaderboard[1].name }}</h3>
<p>{{ data.leaderboard[1].description }}</p>
<NumberCard
:title="t('page.showcase.points')"
:count="data.leaderboard[1].score"
icon="star"
/>
<NumberCard
:title="t('page.showcase.answers')"
: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>
<section v-if="data.leaderboard[0]" id="leaderboard-winner-teaser">
<div class="place bg-[#C9B037]">
<div class="place-left">
<VaIcon name="looks_one" size="10rem" />
<h2>{{ t('page.showcase.first_place') }}</h2>
</div>
<div class="place-right">
<h3 class="bg-white text-transparent">
{{ data.leaderboard[0].name }}
</h3>
<p class="bg-white text-transparent">
{{ data.leaderboard[0].description }}
</p>
<NumberCard
:title="t('page.showcase.points')"
:count="data.leaderboard[0].score"
icon="star"
/>
<NumberCard
:title="t('page.showcase.answers')"
: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>
<section v-if="data.leaderboard[0]" id="leaderboard-winner">
<div class="place bg-[#C9B037]">
<div class="place-left">
<VaIcon name="workspace_premium" size="10rem" />
<h2>{{ t('page.showcase.first_place') }}</h2>
</div>
<div class="place-right">
<h3>{{ data.leaderboard[0].name }}</h3>
<p>{{ data.leaderboard[0].description }}</p>
<NumberCard
:title="t('page.showcase.points')"
:count="data.leaderboard[0].score"
icon="star"
/>
<NumberCard
:title="t('page.showcase.answers')"
: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>
</article>
<article
v-else-if="error"
class="flex h-dvh w-full flex-col items-center justify-center text-center text-2xl"
>
<VaAlert color="danger" class="w-[90%]">{{
t('page.common.not_revealed')
}}</VaAlert>
<VaAlert v-if="error.statusCode !== 403" color="danger" class="w-[90%]">{{
error
}}</VaAlert
><VaButton
icon="arrow_back"
:to="localePath(`/hunt/${sluggy($nuxt.$slugData.huntSlug!)}`)"
>{{ t('page.common.back_to_hunt') }}</VaButton
>
</article>
</template>
<style scoped>
section {
@apply mt-6 h-dvh snap-center p-6 first:mt-0;
}
section > div {
@apply h-full w-full rounded-lg;
}
.place {
@apply grid h-full w-full grid-cols-4 items-center p-4;
}
.place-left {
@apply flex h-full flex-col justify-center gap-4 text-center text-white;
}
.place-right {
@apply col-span-3 grid h-full grid-cols-2 gap-4 text-center text-white;
}
.place-right h3 {
@apply mt-auto text-6xl font-bold;
}
.place-right p {
@apply mt-auto text-3xl font-bold;
}
.place h2 {
@apply text-5xl font-bold;
}
</style>