Start admin pages
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
const { d } = useI18n();
|
||||
const props = defineProps<{
|
||||
answer: {
|
||||
id: number;
|
||||
text: string | null;
|
||||
updatedAt: string;
|
||||
team: { id: number; name: string };
|
||||
score: number | null;
|
||||
review: string | null;
|
||||
reviewer: { name: string; id: number } | null;
|
||||
member: { name: string; id: number } | null;
|
||||
internalNote: string | null;
|
||||
rankingValue: number | null;
|
||||
showcase: boolean;
|
||||
picture: {
|
||||
url: string;
|
||||
createdAt: string;
|
||||
} | null;
|
||||
};
|
||||
quest: {
|
||||
title: string;
|
||||
pictureRequired: boolean;
|
||||
textRequired: boolean;
|
||||
points: number;
|
||||
extraPoints: number | null;
|
||||
extraText: string | null;
|
||||
textSolution: string | null;
|
||||
};
|
||||
}>();
|
||||
|
||||
const header = computed(() => {
|
||||
const parts = [
|
||||
props.answer.team.name,
|
||||
props.answer.picture?.url ? '📸✅' : '📷❌',
|
||||
props.answer.text ? '📝✅' : '📝❌',
|
||||
props.answer.review ? '🔍✅' : '🔍❌',
|
||||
props.answer.score ?? 'No points',
|
||||
d(props.answer.updatedAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium'
|
||||
}),
|
||||
props.answer.rankingValue,
|
||||
props.answer.showcase ? '★' : null
|
||||
];
|
||||
|
||||
return parts.filter((s) => !!s).join(' – ');
|
||||
});
|
||||
|
||||
const style = computed<[string, string]>(() => {
|
||||
if (props.answer.review && props.answer.score !== null) {
|
||||
return ['success', 'check'];
|
||||
}
|
||||
if (props.answer.review || props.answer.score !== null) {
|
||||
return ['danger', 'priority_high'];
|
||||
}
|
||||
if (
|
||||
(props.quest.pictureRequired ? !!props.answer.picture?.url : true) &&
|
||||
(props.quest.textRequired ? !!props.answer.text : true)
|
||||
) {
|
||||
return ['primary', 'info'];
|
||||
}
|
||||
return ['', ''];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VaCollapse :header="header" :icon="style[1]" :color="style[0]">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-2">
|
||||
<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>
|
||||
{{
|
||||
answer.picture?.url ? '📸✅ Picture done' : '📷❌ Picture missing'
|
||||
}}
|
||||
</p>
|
||||
<p>{{ answer.text ? '📝✅ Answer done' : '📝❌ Answer missing' }}</p>
|
||||
<p>
|
||||
{{ answer.review ? '🔍✅ Review done' : '🔍❌ Review missing' }}
|
||||
</p>
|
||||
<p v-if="answer.rankingValue">
|
||||
<VaIcon name="leaderboard" color="primary" />
|
||||
{{ answer.rankingValue }}
|
||||
</p>
|
||||
<p v-if="answer.showcase">
|
||||
<VaIcon name="auto_awesome" color="warning" /> Showcase
|
||||
</p>
|
||||
<p>{{ answer.score ?? 'No' }} points</p>
|
||||
</div>
|
||||
<ClickableImage
|
||||
v-if="answer.picture?.url"
|
||||
:src="answer.picture.url"
|
||||
:title="`${quest.title} – ${answer.team.name}`"
|
||||
class="h-48"
|
||||
/>
|
||||
<h3 v-if="quest.textSolution">Solution:</h3>
|
||||
<p v-if="quest.textSolution" class="bg-green-300 p-2 font-mono text-xl">
|
||||
{{ quest.textSolution }}
|
||||
</p>
|
||||
<h3>Teams Answer:</h3>
|
||||
<p class="bg-gray-300 p-2 font-mono text-xl">{{ answer.text }}</p>
|
||||
<VaDivider />
|
||||
<div
|
||||
class="flex flex-col justify-between gap-4 md:flex-row md:items-center"
|
||||
>
|
||||
<VaInput
|
||||
label="Score"
|
||||
type="number"
|
||||
:min="0"
|
||||
messages="Including extra points"
|
||||
/>
|
||||
<VaInput
|
||||
label="Ranking value"
|
||||
type="number"
|
||||
:min="0"
|
||||
messages="Useful for sorting metric-based answers (e.g. distance, time), sorted descending"
|
||||
/>
|
||||
<VaCheckbox
|
||||
label="Showcase"
|
||||
messages="Special showcase at the end, before the final leaderboard"
|
||||
/>
|
||||
</div>
|
||||
<VaTextarea label="Review" messages="Will be shown publicly!" />
|
||||
<VaDivider />
|
||||
<VaTextarea
|
||||
label="Internal note"
|
||||
messages="Only internal, the team won't see this!"
|
||||
/>
|
||||
<VaButton>Save</VaButton>
|
||||
</div>
|
||||
</template>
|
||||
</VaCollapse>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
+37
-5
@@ -9,10 +9,6 @@ const titleStore = useTitleStore();
|
||||
|
||||
const isSidebarVisible = ref(breakpoints.mdUp);
|
||||
|
||||
watchEffect(() => {
|
||||
isSidebarVisible.value = breakpoints.smUp;
|
||||
});
|
||||
|
||||
const slugs = computed(() => checkSlug(route.params));
|
||||
</script>
|
||||
|
||||
@@ -67,6 +63,21 @@ const slugs = computed(() => checkSlug(route.params));
|
||||
}}</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && user?.role === 'ADMIN'"
|
||||
text-color="danger"
|
||||
hover-color="danger"
|
||||
active-color="onDanger"
|
||||
:to="$localePath(`/hunt/${sluggy(slugs.huntSlug)}/admin`)"
|
||||
:active="
|
||||
$localePath(`/hunt/${sluggy(slugs.huntSlug)}/admin`) === route.path
|
||||
"
|
||||
>
|
||||
<VaSidebarItemContent>
|
||||
<VaIcon name="admin_panel_settings" />
|
||||
<VaSidebarItemTitle>Hunt Admin</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && slugs?.questSlug"
|
||||
:to="
|
||||
@@ -87,6 +98,27 @@ const slugs = computed(() => checkSlug(route.params));
|
||||
}}</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem
|
||||
v-if="slugs?.huntSlug && slugs?.questSlug && user?.role === 'ADMIN'"
|
||||
text-color="danger"
|
||||
hover-color="danger"
|
||||
active-color="onDanger"
|
||||
:to="
|
||||
$localePath(
|
||||
`/hunt/${sluggy(slugs.huntSlug)}/${sluggy(slugs.questSlug)}/admin`
|
||||
)
|
||||
"
|
||||
:active="
|
||||
$localePath(
|
||||
`/hunt/${sluggy(slugs.huntSlug)}/${sluggy(slugs.questSlug)}/admin`
|
||||
) === route.path
|
||||
"
|
||||
>
|
||||
<VaSidebarItemContent>
|
||||
<VaIcon name="question_mark" />
|
||||
<VaSidebarItemTitle>Quest Admin</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSpacer />
|
||||
<VaSidebarItem
|
||||
v-if="user"
|
||||
@@ -116,7 +148,7 @@ const slugs = computed(() => checkSlug(route.params));
|
||||
</VaSidebarItemTitle>
|
||||
</VaSidebarItemContent>
|
||||
</VaSidebarItem>
|
||||
<VaSidebarItem v-else :to="$localePath('/login')">
|
||||
<VaSidebarItem v-else :to="$localePath(`/login?to=${route.path}`)">
|
||||
<VaSidebarItemContent>
|
||||
<VaIcon name="login" />
|
||||
<VaSidebarItemTitle>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const { loggedIn, user } = useUserSession();
|
||||
|
||||
if (!loggedIn.value || !user.value || user.value?.role !== 'ADMIN') {
|
||||
const split = to.path.endsWith('/')
|
||||
? to.path.substring(0, to.path.length - 1).split('/')
|
||||
: to.path.split('/');
|
||||
|
||||
const localePath = useLocalePath();
|
||||
return navigateTo(
|
||||
!user.value
|
||||
? localePath(`/login?to=${to.path}`)
|
||||
: localePath(
|
||||
split.length > 1 ? split.slice(0, split.length - 1).join('/') : '/'
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { useTitleStore } from '~/stores/title';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['admin']
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
const { loggedIn } = useUserSession();
|
||||
const localePath = useLocalePath();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const { huntSlug } = app.$slugData;
|
||||
const { name, id } = app.$slugData.questSlug!;
|
||||
const titleStore = useTitleStore();
|
||||
|
||||
const { data, pending, refresh, error } = await useFetch(
|
||||
`/api/admin/quest/${id}`
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: t('layouts.title', {
|
||||
title: data.value?.title || name
|
||||
})
|
||||
});
|
||||
|
||||
watch(loggedIn, (isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
router.push(localePath('/login'));
|
||||
}
|
||||
});
|
||||
|
||||
titleStore.title = data.value?.title;
|
||||
// TODO: handle data!
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>quest admin</h1>
|
||||
<VaButtonGroup>
|
||||
<VaButton
|
||||
icon="arrow_back"
|
||||
:to="$localePath(`/hunt/${sluggy(data?.hunt || huntSlug!)}`)"
|
||||
>Back to Hunt</VaButton
|
||||
>
|
||||
<VaButton icon="replay" color="success" @click="refresh">Refresh</VaButton>
|
||||
</VaButtonGroup>
|
||||
<div class="grid grid-cols-2 gap-2 p-2 md:flex md:flex-row">
|
||||
<VaChip outline>In progress</VaChip>
|
||||
<VaChip color="primary">Reviewable</VaChip>
|
||||
<VaChip color="danger">Review or Score missing</VaChip>
|
||||
<VaChip color="success">Reviewed</VaChip>
|
||||
</div>
|
||||
<div v-if="data">
|
||||
<p>{{ data.points }} Points</p>
|
||||
<p v-if="data.extraPoints">{{ data.extraPoints }} Extra points</p>
|
||||
<p v-if="data.extraText">Extra: {{ data.extraText }}</p>
|
||||
</div>
|
||||
<div v-if="data && data.answers.length > 0">
|
||||
<VaAccordion multiple stateful>
|
||||
<AdminAnswer
|
||||
v-for="answer in data.answers"
|
||||
:key="answer.id"
|
||||
:answer="answer"
|
||||
:quest="data"
|
||||
/>
|
||||
</VaAccordion>
|
||||
</div>
|
||||
<p v-else>No answers yet</p>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -7,6 +7,8 @@ definePageMeta({
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
|
||||
const { user } = useUserSession();
|
||||
const { huntSlug, questSlug } = app.$slugData;
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
@@ -86,6 +88,16 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
|
||||
:loading="pending"
|
||||
color="success"
|
||||
>{{ $t('layouts.refresh') }}</VaButton
|
||||
>
|
||||
<VaButton
|
||||
v-if="user && user.role === 'ADMIN'"
|
||||
color="danger"
|
||||
:to="
|
||||
$localePath(
|
||||
`/hunt/${sluggy(data?.hunt || huntSlug!)}/${sluggy(data || questSlug)}/admin`
|
||||
)
|
||||
"
|
||||
>Admin</VaButton
|
||||
></VaButtonGroup
|
||||
>
|
||||
<div v-if="data">
|
||||
@@ -131,7 +143,7 @@ const createImageURL = (file: File) => URL.createObjectURL(file);
|
||||
<p>{{ data.answer.review }}</p>
|
||||
</VaCardContent>
|
||||
</VaCard>
|
||||
<VaCard color="primary">
|
||||
<VaCard color="primary" v-if="user && user.role !== 'ADMIN'">
|
||||
<VaCardTitle>Submission</VaCardTitle>
|
||||
<VaCardContent>
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import superjson, { type SuperJSONResult } from 'superjson';
|
||||
import { useTitleStore } from '~/stores/title';
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['admin']
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
const { loggedIn } = useUserSession();
|
||||
const localePath = useLocalePath();
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const { name, id } = app.$slugData.huntSlug!;
|
||||
const titleStore = useTitleStore();
|
||||
|
||||
const { data, pending, refresh, error } = await useFetch(
|
||||
`/api/admin/hunt/${id}`,
|
||||
{
|
||||
transform: (value) => {
|
||||
return superjson.deserialize(
|
||||
value as unknown as SuperJSONResult
|
||||
) as unknown as typeof value;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const formData = useCloned(data, {
|
||||
clone: (source) => superjson.deserialize(superjson.serialize(source))
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: t('layouts.title', {
|
||||
title: data.value?.name || name
|
||||
})
|
||||
});
|
||||
|
||||
watch(loggedIn, (isLoggedIn) => {
|
||||
if (!isLoggedIn) {
|
||||
router.push(localePath('/login'));
|
||||
}
|
||||
});
|
||||
|
||||
titleStore.title = data.value?.name;
|
||||
|
||||
async function submit() {
|
||||
await $fetch(`/api/admin/hunt/${data.value?.id || id}`, {
|
||||
method: 'POST',
|
||||
body: formData.cloned.value
|
||||
});
|
||||
await refresh();
|
||||
formData.sync();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>admin</h1>
|
||||
<VaForm v-if="formData.cloned.value && data" class="flex flex-col gap-4">
|
||||
<VaInput
|
||||
label="Name"
|
||||
v-model="formData.cloned.value.name"
|
||||
clearable
|
||||
:clear-value="data.name"
|
||||
clearable-icon="replay"
|
||||
/>
|
||||
<VaTextarea
|
||||
label="Description"
|
||||
v-model="formData.cloned.value.description"
|
||||
clearable
|
||||
:clear-value="data.description"
|
||||
clearable-icon="replay"
|
||||
/>
|
||||
<VaCheckbox label="Virtual" v-model="formData.cloned.value.virtual" />
|
||||
<VaCheckbox label="Allow Join" v-model="formData.cloned.value.allowJoin" />
|
||||
<VaCheckbox
|
||||
label="Reveal Quests"
|
||||
v-model="formData.cloned.value.revealQuests"
|
||||
/>
|
||||
<VaCheckbox
|
||||
label="Reveal Answers"
|
||||
v-model="formData.cloned.value.revealAnswers"
|
||||
/>
|
||||
<div class="flex flex-row gap-2">
|
||||
<VaCheckbox
|
||||
label="Start?"
|
||||
:model-value="!!formData.cloned.value.start"
|
||||
@update:model-value="
|
||||
(newVal) =>
|
||||
(formData.cloned.value!.start = newVal ? new Date() : null)
|
||||
"
|
||||
/>
|
||||
<VaDateInput
|
||||
v-if="formData.cloned.value.start"
|
||||
label="Start"
|
||||
v-model="formData.cloned.value.start"
|
||||
manual-input
|
||||
/>
|
||||
<VaTimeInput
|
||||
v-if="formData.cloned.value.start"
|
||||
label="Start"
|
||||
v-model="formData.cloned.value.start"
|
||||
view="seconds"
|
||||
manual-input
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2">
|
||||
<VaCheckbox
|
||||
label="End?"
|
||||
:model-value="!!formData.cloned.value.end"
|
||||
@update:model-value="
|
||||
(newVal) => (formData.cloned.value!.end = newVal ? new Date() : null)
|
||||
"
|
||||
/>
|
||||
<VaDateInput
|
||||
v-if="formData.cloned.value.end"
|
||||
label="End"
|
||||
v-model="formData.cloned.value.end"
|
||||
manual-input
|
||||
/>
|
||||
<VaTimeInput
|
||||
v-if="formData.cloned.value.end"
|
||||
label="End"
|
||||
v-model="formData.cloned.value.end"
|
||||
view="seconds"
|
||||
manual-input
|
||||
/>
|
||||
</div>
|
||||
<p>
|
||||
Last update:
|
||||
{{
|
||||
$d(data.updatedAt, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium'
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<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="refresh()"
|
||||
>Refresh</VaButton
|
||||
>
|
||||
<VaButton :disabled="!formData.isModified.value" @click.prevent="submit"
|
||||
>Save</VaButton
|
||||
>
|
||||
</VaButtonGroup>
|
||||
</VaForm>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -43,7 +43,12 @@ async function invite() {
|
||||
<template>
|
||||
<h1 class="text-2xl">
|
||||
{{ data?.name || name }}
|
||||
<VaChip color="danger" v-if="data?.isMember">Admin</VaChip>
|
||||
<VaButton
|
||||
color="danger"
|
||||
v-if="data?.isMember"
|
||||
:to="$localePath(`/hunt/${sluggy(data)}/admin`)"
|
||||
>Admin</VaButton
|
||||
>
|
||||
<VaChip v-if="data?.totalScore" class="float-right" color="success"
|
||||
>Total points: {{ data!!.totalScore }}</VaChip
|
||||
>
|
||||
@@ -112,12 +117,12 @@ async function invite() {
|
||||
>
|
||||
</VaCardActions>
|
||||
</VaCard>
|
||||
<div class="flex flex-row gap-2" v-else>
|
||||
<div class="flex flex-row gap-2" v-else-if="!data.isMember">
|
||||
<VaSwitch v-model="hideAnswers" icon="image">Hide answers</VaSwitch>
|
||||
</div>
|
||||
<VaDivider />
|
||||
<div
|
||||
v-if="data.revealQuests"
|
||||
v-if="data.revealQuests || data.isMember"
|
||||
class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<VaCard
|
||||
@@ -141,6 +146,7 @@ async function invite() {
|
||||
></VaImage>
|
||||
<VaCardContent>
|
||||
<p class="line-clamp-1">{{ quest.description }}</p>
|
||||
|
||||
<div v-if="!hideAnswers && quest.answer?.text">
|
||||
<VaDivider />
|
||||
<p class="line-clamp-1">
|
||||
@@ -148,6 +154,13 @@ async function invite() {
|
||||
</p>
|
||||
</div>
|
||||
</VaCardContent>
|
||||
<VaCardActions v-if="data.isMember">
|
||||
<VaButton
|
||||
color="danger"
|
||||
:to="$localePath(`/hunt/${sluggy(data)}/${sluggy(quest)}/admin`)"
|
||||
>Admin</VaButton
|
||||
>
|
||||
</VaCardActions>
|
||||
</VaCard>
|
||||
</div>
|
||||
<VaCard v-else color="warning" class="mt-4"
|
||||
|
||||
Reference in New Issue
Block a user