Move review to separate model

This commit is contained in:
2025-08-13 23:53:36 +02:00
parent befc678bdb
commit 900414727f
17 changed files with 403 additions and 155 deletions
+3 -3
View File
@@ -8,7 +8,7 @@ const props = defineProps<{
points: number;
extraPoints: number | null;
answer?: {
score: number | null;
review?: { score: number | null } | null;
} | null;
};
}>();
@@ -18,8 +18,8 @@ const props = defineProps<{
<span>{{ quest.pictureRequired ? '📸' : '📷' }}</span>
<span title="aaa">{{ quest.textRequired ? '❓' : '❔' }}</span>
<span class="mx-2">{{ quest.title }}</span>
<VaChip v-if="quest.answer?.score" color="success" class="float-right"
>{{ quest.answer?.score || 0 }} / {{ quest.points
<VaChip v-if="quest.answer?.review?.score" color="success" class="float-right"
>{{ quest.answer?.review?.score || 0 }} / {{ quest.points
}}{{ quest.extraPoints ? `+${quest.extraPoints}` : '' }}</VaChip
>
<VaChip v-else class="float-right"
+22 -15
View File
@@ -6,13 +6,16 @@ const props = defineProps<{
text: string | null;
updatedAt: string;
team: { id: number; name: string };
score: number | null;
review: string | null;
reviewer: { name: string; id: number } | null;
final: boolean;
review: {
internalNote: string | null;
rankingValue: number | null;
showcase: boolean;
score: number | null;
review: string | null;
reviewer: { name: string; id: number } | null;
} | null;
member: { name: string; id: number } | null;
internalNote: string | null;
rankingValue: number | null;
showcase: boolean;
picture: {
url: string;
createdAt: string;
@@ -35,23 +38,26 @@ const header = computed(() => {
props.answer.picture?.url ? '📸✅' : '📷❌',
props.answer.text ? '📝✅' : '📝❌',
props.answer.review ? '🔍✅' : '🔍❌',
props.answer.score ?? 'No points',
props.answer.review?.score ?? 'No points',
d(props.answer.updatedAt, {
dateStyle: 'medium',
timeStyle: 'medium'
}),
props.answer.rankingValue,
props.answer.showcase ? '★' : null
props.answer.review?.rankingValue,
props.answer.review?.showcase ? '★' : null
];
return parts.filter((s) => !!s).join(' ');
});
const style = computed<[string, string]>(() => {
if (props.answer.review && props.answer.score !== null) {
if (props.answer.review?.review && props.answer.review?.score !== null) {
return ['success', 'check'];
}
if (props.answer.review || props.answer.score !== null) {
if (!props.answer.final) {
return ['', ''];
}
if (props.answer.review?.review || props.answer.review?.score !== null) {
return ['danger', 'priority_high'];
}
if (
@@ -71,6 +77,7 @@ const style = computed<[string, string]>(() => {
<div
class="mb-2 grid grid-cols-3 justify-between gap-8 rounded-lg bg-gray-200 p-4 font-bold md:grid-cols-6"
>
<p v-if="!answer.final">NOT FINAL</p>
<p>
{{
answer.picture?.url ? '📸✅ Picture done' : '📷❌ Picture missing'
@@ -80,14 +87,14 @@ const style = computed<[string, string]>(() => {
<p>
{{ answer.review ? '🔍✅ Review done' : '🔍❌ Review missing' }}
</p>
<p v-if="answer.rankingValue">
<p v-if="answer.review?.rankingValue">
<VaIcon name="leaderboard" color="primary" />
{{ answer.rankingValue }}
{{ answer.review?.rankingValue }}
</p>
<p v-if="answer.showcase">
<p v-if="answer.review?.showcase">
<VaIcon name="auto_awesome" color="warning" /> Showcase
</p>
<p>{{ answer.score ?? 'No' }} points</p>
<p>{{ answer.review?.score ?? 'No' }} points</p>
</div>
<ClickableImage
v-if="answer.picture?.url"
+1 -1
View File
@@ -21,7 +21,7 @@ const newUser = ref<User>();
watchDebounced(
search,
(newSearch) => {
if (newSearch && newSearch.length >= 3) {
if (newSearch) {
safeSearch.value = newSearch;
}
},
+78 -24
View File
@@ -33,6 +33,7 @@ useHead({
});
const answer = ref(data.value?.answer?.text);
const final = ref(data.value?.answer?.final || false);
const textType = {
TEXT: 'text',
@@ -44,8 +45,10 @@ const textType = {
const newImage = ref<File>();
const uploading = ref(false);
const submitError = ref('');
async function submit() {
uploading.value = true;
submitError.value = '';
const body = new FormData();
@@ -55,21 +58,38 @@ async function submit() {
if (answer.value) {
body.set('answer', answer.value);
}
if (final.value) {
body.set('final', 'true');
}
if (newImage.value) {
body.set('image', newImage.value);
}
const result = await $fetch(`/api/quest/${questSlug?.id}/submit`, {
method: 'POST',
body
});
try {
await $fetch(`/api/quest/${questSlug?.id}/submit`, {
method: 'POST',
body
});
console.log(result);
await refresh();
await refresh();
answer.value = data.value?.answer?.text;
newImage.value = undefined;
} catch (e) {
if (e instanceof Error && 'statusCode' in e && e.statusCode === 418) {
submitError.value = 'Answer is already finalized, no update allowed!';
await refresh();
} else if (
e instanceof Error &&
'statusCode' in e &&
e.statusCode === 409
) {
submitError.value = 'Conflict: Please refresh';
} else {
submitError.value = parseError(e);
}
}
uploading.value = false;
answer.value = data.value?.answer?.text;
newImage.value = undefined;
}
const createImageURL = (file: File) => URL.createObjectURL(file);
@@ -83,7 +103,10 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
>Back to Hunt</VaButton
>
<VaButton
@click="refresh"
@click="
submitError = '';
refresh();
"
icon="refresh"
:loading="pending"
color="success"
@@ -105,7 +128,9 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
<VaCard
:stripe="data.answer !== null"
:stripe-color="
data.answer?.score && data.answer?.score > 0 ? 'success' : 'primary'
data.answer?.review?.score && data.answer?.review?.score > 0
? 'success'
: 'primary'
"
>
<VaCardTitle><QuestTags :quest="data" /></VaCardTitle>
@@ -132,26 +157,34 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
<VaCard color="info" v-if="data.answer?.review">
<VaCardTitle>Review</VaCardTitle>
<VaCardContent>
<p v-if="data.answer.score" class="mb-2">
<VaChip color="success"
>Answer type: {{ data.answer.score }}</VaChip
>
<span v-if="data.answer.reviewer" class="text-sm">
by {{ data.answer.reviewer.name }}</span
<p v-if="data.answer.review?.score" class="mb-2">
Score:
<VaChip color="success">{{ data.answer.review.score }}</VaChip>
<span v-if="data.answer.review.reviewer" class="text-sm">
by {{ data.answer.review.reviewer.name }} at
{{
$d(data.answer.updatedAt, {
dateStyle: 'medium',
timeStyle: 'medium'
})
}}</span
>
</p>
<p>{{ data.answer.review }}</p>
<p class="italic">{{ data.answer.review.review }}</p>
</VaCardContent>
</VaCard>
<VaCard color="primary" v-if="user && user.role !== 'ADMIN'">
<VaCard
stripe
:stripe-color="data.answer?.final ? 'success' : 'primary'"
v-if="user && user.role !== 'ADMIN'"
>
<VaCardTitle>Submission</VaCardTitle>
<VaCardContent>
<div class="flex flex-col gap-4">
<VaInput
:label="data.question"
:disabled="uploading"
:disabled="uploading || data.answer?.final"
v-if="data.question"
color="onPrimary"
placeholder="Your answer"
:required-mark="data.textRequired"
v-model="answer"
@@ -187,8 +220,7 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
></VaImage>
<VaFileUpload
v-model="newImage"
:disabled="uploading"
color="onPrimary"
:disabled="uploading || data.answer?.final"
type="single"
file-types="jpg,png,jpeg"
/>
@@ -206,13 +238,35 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
}}
by {{ data.answer.member.name }}
</p>
<VaCheckbox
:disabled="data.answer?.final"
label="Finalize answer"
messages="This allows the admins to review your answer and give you points. You cannot change your answer afterwards!"
v-model="final"
/>
<VaAlert
v-if="submitError.length > 0"
color="danger"
class="w-full text-center"
>{{ $t(submitError) }}</VaAlert
>
<VaButton
:disabled="!(newImage || data.answer?.text !== answer)"
color="onPrimary"
v-if="!data.answer?.final"
:disabled="
!(
newImage ||
data.answer?.text !== answer ||
data.answer?.final !== final
)
"
:loading="uploading"
@click="submit"
>Update</VaButton
>
<p v-else class="font-bold">
Your final answer has already been submitted!
</p>
</div>
</VaCardContent>
</VaCard>
+1 -1
View File
@@ -55,7 +55,7 @@ const newUser = ref<User>();
watchDebounced(
search,
(newSearch) => {
if (newSearch && newSearch.length >= 3) {
if (newSearch) {
safeSearch.value = newSearch;
}
},
+8 -3
View File
@@ -143,9 +143,14 @@ async function invite() {
v-for="quest in data.quests"
:key="`q-${quest.id}`"
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}`)"
:stripe="quest.answer !== null"
stripe
:stripe-color="
quest.answer?.score && quest.answer?.score > 0 ? 'success' : 'primary'
(quest.answer?.review?.score && quest.answer?.review?.score > 0) ||
quest.answer?.final
? 'success'
: !!quest.answer
? 'warning'
: 'primary'
"
>
<VaCardTitle><QuestTags :quest="quest" /></VaCardTitle>
@@ -172,7 +177,7 @@ async function invite() {
<VaButton
color="danger"
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}/admin`)"
>Admin</VaButton
>Admin ({{ quest.unreviewed || 0 }} unreviewed)</VaButton
>
</VaCardActions>
</VaCard>