Add writing reviews for answers

This commit is contained in:
2025-08-14 20:33:39 +02:00
parent 5146e8e490
commit 9cb405be22
7 changed files with 116 additions and 13 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
- [ ] Change display name - [ ] Change display name
- **Hunt Admin** - **Hunt Admin**
- [x] Start/Stop - [x] Start/Stop
- [ ] Add Reviews/Score - [x] Add Reviews/Score
- [x] View best Answers and Pictures - [x] View best Answers and Pictures
- [x] All/Best Images gallery - [x] All/Best Images gallery
- [x] Manage teams (remove, kick, add) - [x] Manage teams (remove, kick, add)
+82 -7
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
const { d } = useI18n(); import { fullDate } from '~~/server/utils/date';
const { d, t } = useI18n();
const props = defineProps<{ const props = defineProps<{
answer: { answer: {
id: number; id: number;
@@ -13,6 +15,7 @@ const props = defineProps<{
showcase: boolean; showcase: boolean;
score: number | null; score: number | null;
review: string | null; review: string | null;
updatedAt: string;
reviewer: { name: string; id: number } | null; reviewer: { name: string; id: number } | null;
} | null; } | null;
member: { name: string; id: number } | null; member: { name: string; id: number } | null;
@@ -32,6 +35,21 @@ const props = defineProps<{
}; };
}>(); }>();
const emits = defineEmits<{
update: [];
}>();
const formData = useCloned(
() =>
props.answer.review ?? {
internalNote: '',
rankingValue: 0,
showcase: false,
score: props.quest.points,
review: ''
}
);
const header = computed(() => { const header = computed(() => {
const parts = [ const parts = [
props.answer.team.name, props.answer.team.name,
@@ -39,10 +57,7 @@ const header = computed(() => {
props.answer.text ? '📝✅' : '📝❌', props.answer.text ? '📝✅' : '📝❌',
props.answer.review ? '🔍✅' : '🔍❌', props.answer.review ? '🔍✅' : '🔍❌',
props.answer.review?.score ?? 'No points', props.answer.review?.score ?? 'No points',
d(props.answer.updatedAt, { d(props.answer.updatedAt, fullDate),
dateStyle: 'medium',
timeStyle: 'medium'
}),
props.answer.review?.rankingValue, props.answer.review?.rankingValue,
props.answer.review?.showcase ? '★' : null props.answer.review?.showcase ? '★' : null
]; ];
@@ -68,6 +83,31 @@ const style = computed<[string, string]>(() => {
} }
return ['', '']; return ['', ''];
}); });
const pending = ref(false);
const submitError = ref('');
async function submit() {
pending.value = true;
submitError.value = '';
try {
await $fetch(`/api/admin/answer/${props.answer.id}/review`, {
method: 'POST',
body: formData.cloned.value
});
emits('update');
formData.sync();
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 409) {
submitError.value = 'Conflict: Please refresh';
} else {
submitError.value = parseError(e);
}
}
pending.value = false;
}
</script> </script>
<template> <template>
@@ -113,29 +153,64 @@ const style = computed<[string, string]>(() => {
class="flex flex-col justify-between gap-4 md:flex-row md:items-center" class="flex flex-col justify-between gap-4 md:flex-row md:items-center"
> >
<VaInput <VaInput
v-model="formData.cloned.value.score"
label="Score" label="Score"
type="number" type="number"
:min="0" :min="0"
messages="Including extra points" messages="Including extra points"
/> />
<VaInput <VaInput
v-model="formData.cloned.value.rankingValue"
label="Ranking value" label="Ranking value"
type="number" type="number"
:min="0" :min="0"
messages="Useful for sorting metric-based answers (e.g. distance, time), sorted descending" messages="Useful for sorting metric-based answers (e.g. distance, time), sorted descending"
/> />
<VaCheckbox <VaCheckbox
v-model="formData.cloned.value.showcase"
label="Showcase" label="Showcase"
messages="Special showcase at the end, before the final leaderboard" messages="Special showcase at the end, before the final leaderboard"
/> />
</div> </div>
<VaTextarea label="Review" messages="Will be shown publicly!" /> <VaTextarea
v-model="formData.cloned.value.review"
label="Review"
messages="Will be shown publicly!"
/>
<VaDivider /> <VaDivider />
<VaTextarea <VaTextarea
v-model="formData.cloned.value.internalNote"
label="Internal note" label="Internal note"
messages="Only internal, the team won't see this!" messages="Only internal, the team won't see this!"
/> />
<VaButton>Save</VaButton> <p v-if="answer.review?.reviewer">
Reviewed by {{ answer.review?.reviewer?.name }} at
{{ d(answer.review.updatedAt, fullDate) }}
</p>
<VaAlert
v-if="submitError.length > 0"
color="danger"
class="w-full text-center"
>{{ t(submitError) }}</VaAlert
>
<VaButtonGroup :loading="pending" :disabled="pending">
<VaButton
icon="replay"
:disabled="!formData.isModified.value"
@click.prevent="formData.sync()"
>Reset all values</VaButton
>
<VaButton
:disabled="formData.isModified.value"
@click.prevent="emits('update')"
>Refresh</VaButton
>
<VaButton
:disabled="!formData.isModified.value"
@click.prevent="submit"
>Save</VaButton
>
</VaButtonGroup>
</div> </div>
</template> </template>
</VaCollapse> </VaCollapse>
+1 -1
View File
@@ -147,7 +147,7 @@ async function deleteTeam() {
<template> <template>
<VaCollapse <VaCollapse
:key="team.id" :key="team.id"
:header="`${team.name} — ${team.members.length} members`" :header="`${team.name} — ${team.members.length} members — ${team._count.answers} answers — ${team.score} points`"
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<h2 class="text-xl"> <h2 class="text-xl">
@@ -63,6 +63,7 @@ titleStore.title = data.value?.title;
:key="answer.id" :key="answer.id"
:answer="answer" :answer="answer"
:quest="data" :quest="data"
@update="refresh"
/> />
</VaAccordion> </VaAccordion>
</div> </div>
@@ -66,7 +66,8 @@ export default defineEventHandler(async (event) => {
}, },
create: { create: {
...data, ...data,
reviewerId: userId reviewerId: userId,
answerId
} }
}); });
+28 -3
View File
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
}) })
); );
const hunt = await prisma.hunt.findUnique({ const _hunt = await prisma.hunt.findUnique({
where: { where: {
id: huntId, id: huntId,
OR: [ OR: [
@@ -58,6 +58,22 @@ export default defineEventHandler(async (event) => {
answers: true answers: true
} }
}, },
answers: {
select: {
review: {
select: {
score: true
}
}
},
where: {
review: {
score: {
gt: 0
}
}
}
},
members: { members: {
select: { select: {
createdAt: true, createdAt: true,
@@ -75,9 +91,18 @@ export default defineEventHandler(async (event) => {
} }
}); });
if (!hunt) { if (!_hunt) {
throw createError({ status: 404, statusText: 'Not found!' }); throw createError({ status: 404, statusText: 'Not found!' });
} }
const { teams, ...hunt } = _hunt;
return hunt; return {
teams: teams
.map(({ answers, ...t }) => ({
...t,
score: answers.reduce((sum, a) => sum + (a.review?.score || 0), 0)
}))
.toSorted((a, b) => b.score - a.score),
...hunt
};
}); });
@@ -66,6 +66,7 @@ export default defineEventHandler(async (event) => {
internalNote: true, internalNote: true,
rankingValue: true, rankingValue: true,
showcase: true, showcase: true,
updatedAt: true,
reviewer: { reviewer: {
select: { select: {
name: true, name: true,