Add end showcase

This commit is contained in:
2025-08-09 19:14:21 +02:00
parent 4189c95d38
commit dc3c21f885
7 changed files with 547 additions and 1 deletions
+28
View File
@@ -0,0 +1,28 @@
<script setup lang="ts">
const props = defineProps<{
count: number | string;
title: string;
icon: string;
}>();
</script>
<template>
<div class="card">
<h3>{{ Number(count).toLocaleString('de') }}</h3>
<VaIcon :name="icon" size="10rem" />
<p>{{ title }}</p>
</div>
</template>
<style scoped>
.card {
@apply m-4 grid grid-cols-2 items-center justify-items-center gap-8 rounded-lg p-4 text-3xl shadow-lg;
}
.card > *:nth-child(1) {
@apply col-span-2 mt-auto text-9xl font-bold;
}
.card > *:nth-child(3) {
@apply mr-auto text-left text-6xl;
}
</style>
+22
View File
@@ -53,6 +53,28 @@
"no_hunt_joined": "Noch keinem Hunt beigetreten!",
"open_to_join": "Bereit zum Beitreten:",
"start": "Start: {start}"
},
"showcase": {
"showcase_for": "Abschluss für",
"crew": "Crew",
"quests": "Quests",
"users": "Spieler",
"teams": "Teams",
"total_points": "Punkte erzielt",
"pictures": "Bilder",
"leaderboard": "Rangliste",
"third_place": "Dritter Platz",
"second_place": "Zweiter Platz",
"first_place": "Erster Platz",
"points": "Punkte",
"answers": "Antworten",
"table": {
"answers": "Antworten",
"place": "Platz",
"name": "Name",
"description": "Beschreibung",
"score": "Punkte"
}
}
},
"validation": {
+22
View File
@@ -53,6 +53,28 @@
"no_hunt_joined": "No hunt joined yet!",
"open_to_join": "Open to join hunts:",
"start": "Start: {start}"
},
"showcase": {
"showcase_for": "Showcase for",
"crew": "Crew",
"quests": "Quests",
"users": "Users",
"teams": "Teams",
"total_points": "Total points earned",
"pictures": "Pictures",
"leaderboard": "Leaderboard",
"third_place": "Third place",
"second_place": "Second place",
"first_place": "First place",
"points": "Points",
"answers": "Answers",
"table": {
"answers": "Answers",
"place": "Place",
"name": "Name",
"description": "Description",
"score": "Score"
}
}
},
"validation": {
+5
View File
@@ -0,0 +1,5 @@
<template>
<Suspense>
<slot />
</Suspense>
</template>
+288
View File
@@ -0,0 +1,288 @@
<script setup lang="ts">
import type { DataTableColumnSource } from 'vuestic-ui';
import NumberCard from '~/components/showcase/NumberCard.vue';
definePageMeta({
// TODO: re-enable perms
// middleware: ['admin'],
layout: 'full'
});
const app = useNuxtApp();
const { name, id } = app.$slugData.huntSlug!;
const { data } = await useFetch(`/api/admin/hunt/${id}/showcase`);
const { toggle, isFullscreen, isSupported } = useFullscreen();
const leaderboardColumns: DataTableColumnSource[] = [
'place',
'name',
'description',
{
key: 'score',
displayFormatFn: (data: number) => Number(data).toLocaleString('de')
},
'answers'
];
const columns = leaderboardColumns.map((c) =>
typeof c === 'string' ? c : c.key
);
// 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>
<article
class="h-dvh w-full snap-y snap-mandatory overflow-y-scroll scroll-smooth"
v-if="data"
>
<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">{{ 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, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}</span
><span v-if="data.start && data.end"> </span
><span v-if="data.end">{{
$d(data.end, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}</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.crew')"
:count="data._count.members + 1"
icon="support_agent"
/>
<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" :key="quest.id" :id="sluggy(quest)">
<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">{{ quest.title }}</h3>
<p class="text-xl font-bold">{{ 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 id="leaderboard" v-if="data.leaderboard.length > 3">
<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]
><span>{{ $t(`page.showcase.table.${col}`) }}</span></template
>
<template #cell(place)="{ rowIndex }">
<strong>{{ rowIndex + 4 }}</strong>
</template>
</VaDataTable>
</div>
</section>
<section id="leaderboard-third" v-if="data.leaderboard[2]">
<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"
/>
</div>
</div>
</section>
<section id="leaderboard-second" v-if="data.leaderboard[1]">
<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"
/>
</div>
</div>
</section>
<section id="leaderboard-winner-teaser" v-if="data.leaderboard[0]">
<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-white">{{ data.leaderboard[0].name }}</h3>
<p class="bg-white text-white">
{{ 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"
/>
</div>
</div>
</section>
<section id="leaderboard-winner" v-if="data.leaderboard[0]">
<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"
/>
</div>
</div>
</section>
</article>
<article v-else>pending...</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>
@@ -48,7 +48,7 @@ export default defineEventHandler(async (event) => {
const { score, review } = await useValidatedBody(
event,
z.object({
score: z.number().min(0).nullable(),
score: z.int().min(0).max(32767).nullable(),
review: z.string().max(256).nullable()
})
);
@@ -0,0 +1,181 @@
import { useValidatedParams, z, zh } from 'h3-zod';
import prisma from '~~/lib/prisma';
export default defineEventHandler(async (event) => {
// TODO: re-enable perms
// const user = await requireUserSession(event);
//
// if (user.user.role !== 'ADMIN') {
// throw createError({
// status: 403,
// statusText: 'Not allowed!'
// });
// }
// const userId = user.user.id;
const { huntId } = await useValidatedParams(
event,
z.object({
huntId: zh.numAsString
})
);
const _hunt = await prisma.hunt.findUnique({
where: {
id: huntId
// OR: [
// {
// creatorId: userId
// },
// {
// members: {
// some: {
// memberId: userId
// }
// }
// }
// ]
},
select: {
id: true,
name: true,
start: true,
end: true,
_count: {
select: {
teams: true,
members: true,
quests: true
}
},
teams: {
select: {
_count: {
select: {
members: true,
answers: {
where: {
score: { gt: 0 }
}
}
}
},
id: true,
name: true,
description: true,
answers: {
select: {
score: true
},
where: {
score: { not: null }
}
}
}
},
quests: {
select: {
id: true,
title: true,
question: true,
textSolution: true,
points: true,
extraPoints: true,
answers: {
select: {
id: true,
text: true,
score: true,
picture: {
select: {
url: true,
createdAt: true,
creator: {
select: {
id: true,
name: true
}
}
}
},
team: {
select: {
id: true,
name: true
}
}
},
where: {
showcase: true
},
orderBy: [
{ rankingValue: { sort: 'asc', nulls: 'last' } },
{ score: { sort: 'asc', nulls: 'last' } },
{ createdAt: 'asc' }
]
}
},
where: {
OR: [
{
answers: {
some: {
showcase: true
}
}
}
// TODO: showcase solutions to quests
// {
// textSolution: { not: null }
// }
]
}
}
}
});
if (!_hunt) {
throw createError({ status: 404, statusText: 'Not found!' });
}
const { teams, ...hunt } = _hunt;
const userCount = teams.reduce(
(sum, team) => sum + team._count.members + 1,
0
);
const leaderboard = teams
.map(({ name, id, description, answers, _count }) => ({
name,
id,
description,
answers: _count.answers,
score: answers.reduce((sum, a) => sum + (a.score || 0), 0)
}))
.toSorted((a, b) => b.score - a.score);
const totalScore = leaderboard.reduce((sum, team) => sum + team.score, 0);
const pictureCount = await prisma.file.count({
where: {
answer: {
quest: {
huntId
}
}
}
});
return {
userCount,
pictureCount,
totalScore,
leaderboard,
...hunt
};
});