Add writing reviews for answers
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user