mirror of
https://gitlab.com/PronounsPage/PronounsPage.git
synced 2025-09-24 05:05:20 -04:00
444 lines
18 KiB
Vue
444 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { DateTime } from 'luxon';
|
|
|
|
import { buildDict, shuffle, PermissionAreas } from '#shared/helpers.ts';
|
|
import useConfig from '~/composables/useConfig.ts';
|
|
import useDialogue from '~/composables/useDialogue.ts';
|
|
import { useMainStore } from '~/store/index.ts';
|
|
import type { CensusNumberQuestion, CensusOptionsQuestion, CensusTextQuestion } from '~~/locale/config.ts';
|
|
|
|
definePageMeta({
|
|
translatedPaths: (config) => translatedPathByConfigModule(config.census),
|
|
});
|
|
const { $translator: translator } = useNuxtApp();
|
|
const config = useConfig();
|
|
const dialogue = useDialogue();
|
|
|
|
useSimpleHead({
|
|
title: translator.translate('census.headerLong'),
|
|
description: translator.translate('census.description')[0],
|
|
banner: `img/${config.locale}/census/census-banner.png`,
|
|
}, translator);
|
|
|
|
if (!config.census.enabled) {
|
|
throw new Error('config.census is disabled');
|
|
}
|
|
|
|
const finishedAsyncData = useFetch('/api/census/finished');
|
|
const countResponsesAsyncData = useFetch('/api/census/count');
|
|
await Promise.all([finishedAsyncData, countResponsesAsyncData]);
|
|
|
|
const finished = finishedAsyncData.data;
|
|
const countResponses = countResponsesAsyncData.data;
|
|
|
|
onBeforeRouteLeave(async (to, from, next) => {
|
|
if (q.value !== null && q.value < questions.length) {
|
|
try {
|
|
await dialogue.confirm(translator.translate('census.leave'));
|
|
} catch {
|
|
next(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
next();
|
|
});
|
|
|
|
const store = useMainStore();
|
|
const user = store.user;
|
|
|
|
type SortedCensusQuestion =
|
|
CensusOptionsQuestion & { optionsSorted: string[][] } | CensusTextQuestion | CensusNumberQuestion;
|
|
|
|
const questions = config.census.questions.map((question): SortedCensusQuestion => {
|
|
switch (question.type) {
|
|
case 'radio':
|
|
case 'checkbox':
|
|
return {
|
|
...question,
|
|
optionsSorted: question.randomise
|
|
? [
|
|
...question.optionsFirst || [],
|
|
...shuffle(question.options),
|
|
...question.optionsLast || [],
|
|
]
|
|
: question.options,
|
|
};
|
|
default:
|
|
return question;
|
|
}
|
|
});
|
|
|
|
const agreement = ref(false);
|
|
const q = ref<number | null>(null);
|
|
const answers = ref<Record<number, string | string[] | null>>(buildDict(function* () {
|
|
let i = 0;
|
|
for (const question of questions) {
|
|
yield [i, question.type === 'checkbox' ? [] : null];
|
|
i++;
|
|
}
|
|
}));
|
|
const writins = ref<Record<number, string>>(buildDict(function* () {
|
|
let i = 0;
|
|
for (const _question of questions) {
|
|
yield [i, ''];
|
|
i++;
|
|
}
|
|
}));
|
|
const cw = ref(false);
|
|
|
|
const progress = computed(() => {
|
|
return Math.round(100 * (q.value || 0) / questions.length);
|
|
});
|
|
const question = computed(() => {
|
|
return q.value !== null ? questions[q.value] : null;
|
|
});
|
|
const stepValid = computed((): boolean => {
|
|
if (q.value === null || question.value === null) {
|
|
return false;
|
|
}
|
|
if (writins.value[q.value] !== '') {
|
|
return true;
|
|
}
|
|
if (question.value.optional) {
|
|
return true;
|
|
}
|
|
const answer = answers.value[q.value];
|
|
if (question.value.type === 'radio') {
|
|
return answer !== undefined && answer !== null;
|
|
}
|
|
if (question.value.type === 'checkbox') {
|
|
return Array.isArray(answer) && answer.length > 0;
|
|
}
|
|
if (question.value.type === 'number') {
|
|
const v = parseInt(answer as string);
|
|
return answer !== '' && v >= question.value.min && v <= question.value.max;
|
|
}
|
|
if (question.value.type === 'text' || question.value.type === 'textarea') {
|
|
return answer !== '';
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const start = DateTime.fromISO(config.census.start).toLocal();
|
|
const end = DateTime.fromISO(config.census.end).toLocal();
|
|
|
|
const open = computed(() => {
|
|
const now = DateTime.utc();
|
|
if (config.format) {
|
|
now.setZone(config.format.timezone);
|
|
}
|
|
return now >= start && now <= end;
|
|
});
|
|
|
|
const finishedEditionKey = `census-${config.census.edition}-finished`;
|
|
|
|
const questionform = useTemplateRef('questionform');
|
|
watch(q, async (newValue, oldValue) => {
|
|
if (q.value !== null && newValue !== null && oldValue !== null && question.value && question.value.conditionalOn) {
|
|
const conditionAnswer = answers.value[question.value.conditionalOn];
|
|
const conditionFullfilled = Array.isArray(conditionAnswer)
|
|
? conditionAnswer.filter((a) => question.value?.conditionalValue?.includes(a)).length > 0
|
|
: conditionAnswer === question.value.conditionalValue;
|
|
|
|
if (!conditionFullfilled) {
|
|
if (newValue > oldValue) {
|
|
q.value++;
|
|
} else {
|
|
q.value--;
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (q.value === questions.length) {
|
|
await dialogue.postWithAlertOnError('/api/census/submit', {
|
|
answers: JSON.stringify(answers.value),
|
|
writins: JSON.stringify(writins.value),
|
|
});
|
|
finished.value = true;
|
|
window.localStorage.setItem(finishedEditionKey, `${config.census.edition}`);
|
|
}
|
|
await nextTick();
|
|
if (questionform.value) {
|
|
questionform.value.querySelector<HTMLElement>('input,textarea')?.focus();
|
|
}
|
|
});
|
|
|
|
onMounted(async () => {
|
|
if (import.meta.client && !user) {
|
|
const finishedEdition = window.localStorage.getItem(finishedEditionKey) ?? '0';
|
|
finished.value = config.census.edition && parseInt(finishedEdition) === parseInt(config.census.edition);
|
|
if (!finished.value) {
|
|
finished.value = await $fetch('/api/census/finished');
|
|
}
|
|
}
|
|
});
|
|
const startSurvey = () => {
|
|
q.value = 0;
|
|
};
|
|
</script>
|
|
|
|
<template>
|
|
<Page v-if="config.census.enabled">
|
|
<CommunityNav />
|
|
|
|
<h2>
|
|
<Icon v="user-chart" />
|
|
<T>census.headerLong</T>
|
|
</h2>
|
|
|
|
<template v-if="q === null || question === null">
|
|
<section v-if="$isGranted(PermissionAreas.Census)">
|
|
<div class="alert alert-info">
|
|
<a href="/api/census/export" class="btn btn-outline-secondary btn-sm float-end">
|
|
<Icon v="download" />
|
|
</a>
|
|
|
|
<p>{{ countResponses.all }} <T>census.replies</T></p>
|
|
<ul>
|
|
<li>{{ countResponses.nonbinary }} <T>census.repliesNonbinary</T></li>
|
|
<li>{{ countResponses.usable }} <T>census.repliesUsable</T></li>
|
|
<li>
|
|
<nuxt-link :to="`/${config.census.route}/admin`">
|
|
{{ countResponses.awaiting }} <T>census.repliesAwaiting</T>
|
|
</nuxt-link>
|
|
</li>
|
|
</ul>
|
|
<ChartSet name="useful responses" :data="countResponses.graphs" init="cumulative" class="mb-3" />
|
|
</div>
|
|
</section>
|
|
|
|
<section class="row">
|
|
<div class="col-12 col-lg-6">
|
|
<T>census.description</T>
|
|
<T
|
|
v-if="open"
|
|
:params="{
|
|
questions: questions.length,
|
|
start: start.setLocale(config.locale).toLocaleString(DateTime.DATE_SHORT),
|
|
end: end.setLocale(config.locale).toLocaleString(DateTime.DATE_SHORT),
|
|
}"
|
|
>
|
|
census.descriptionOpen
|
|
</T>
|
|
<T v-else>census.descriptionClosed</T>
|
|
</div>
|
|
<div class="col-12 col-lg-6">
|
|
<div v-if="Object.keys(config.census.results).length > 0" class="alert alert-info">
|
|
<ul class="list-unstyled mb-0">
|
|
<li v-for="(text, link) in config.census.results" class="h5 m-3">
|
|
<router-link v-if="typeof text === 'string'" :to="`/blog/${link}`">
|
|
<Icon v="file-chart-line" />
|
|
{{ text }}
|
|
</router-link>
|
|
<div v-else>
|
|
<router-link :to="`/blog/${link}`">
|
|
<Icon v="file-chart-line" />
|
|
{{ text._ }}
|
|
</router-link>
|
|
<ul class="list-unstyled small">
|
|
<template v-for="(subText, subLink) in text">
|
|
<li v-if="subLink !== '_'">
|
|
<span style="display: inline-block; width: 1.5em"></span>
|
|
<router-link :to="`/blog/${subLink}`">
|
|
{{ subText }}
|
|
</router-link>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<CensusShare :text="$t('census.headerLong')" />
|
|
<CensusSubscribe />
|
|
</div>
|
|
</section>
|
|
|
|
<section v-if="open">
|
|
<div v-if="finished" class="alert alert-success">
|
|
<Icon v="badge-check" :size="2" class="float-start me-2 mt-2" />
|
|
<T>census.finished</T>
|
|
</div>
|
|
<template v-else>
|
|
<div class="form-group">
|
|
<div class="form-check">
|
|
<label class="form-check-label small">
|
|
<input v-model="agreement" type="checkbox" class="form-check-input">
|
|
<T>census.agree</T>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="form-group">
|
|
<button
|
|
type="button"
|
|
class="btn btn-primary btn-lg"
|
|
:disabled="!agreement"
|
|
@click="startSurvey"
|
|
>
|
|
<T>census.start</T>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</section>
|
|
</template>
|
|
|
|
<template v-else-if="q < questions.length">
|
|
<div class="progress my-3">
|
|
<div
|
|
class="progress-bar"
|
|
role="progressbar"
|
|
:style="`width: ${progress}%`"
|
|
:aria-valuenow="q"
|
|
aria-valuemin="0"
|
|
:aria-valuemax="questions.length"
|
|
>
|
|
{{ q }}/{{ questions.length }}
|
|
</div>
|
|
</div>
|
|
<p class="h4 mt-5 mb-3">
|
|
{{ q + 1 }}. {{ question.question }}
|
|
</p>
|
|
<div v-if="question.instruction" class="alert alert-info small">
|
|
<p v-for="(line, i) in question.instruction" :class="i === question.instruction.length - 1 ? 'mb-0' : ''">
|
|
<LinkedText :text="line" />
|
|
</p>
|
|
</div>
|
|
<div v-if="question.cw" class="form-check form-switch my-2">
|
|
<label>
|
|
<input v-model="cw" class="form-check-input" type="checkbox" role="switch">
|
|
<Icon v="engine-warning" />
|
|
<T>census.cw.switch</T>
|
|
</label>
|
|
</div>
|
|
<form ref="questionform" @submit.prevent="q++">
|
|
<div v-if="question.type === 'radio'" :class="['form-group', question.optionsSorted.length > 10 ? 'multi-column' : '']">
|
|
<div v-for="[option, help] in question.optionsSorted" class="form-check mb-2">
|
|
<label class="form-check-label small">
|
|
<input
|
|
v-model="answers[q]"
|
|
type="radio"
|
|
class="form-check-input"
|
|
:name="`question${q}`"
|
|
:value="option"
|
|
required
|
|
>
|
|
<span v-if="question.cw && question.cw.includes(option) && !cw" class="badge text-bg-light"><T>census.cw.label</T></span>
|
|
<span v-else><LinkedText :text="option" /></span>
|
|
<template v-if="help">
|
|
<template v-if="question.expandedHelp">
|
|
<br>
|
|
<span class="text-muted"><LinkedText :text="help" /></span>
|
|
</template>
|
|
<span v-else class="text-muted">(<LinkedText :text="help" />)</span>
|
|
</template>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="question.type === 'checkbox'" :class="['form-group', question.optionsSorted.length > 10 ? 'multi-column' : '']">
|
|
<div v-for="[option, help] in question.optionsSorted" class="form-check mb-2">
|
|
<label class="form-check-label small">
|
|
<input v-model="answers[q]" type="checkbox" class="form-check-input" :value="option">
|
|
<span v-if="question.cw && question.cw.includes(option) && !cw" class="badge text-bg-light"><T>census.cw.label</T></span>
|
|
<span v-else><LinkedText :text="option" /></span>
|
|
<template v-if="help">
|
|
<template v-if="question.expandedHelp">
|
|
<br>
|
|
<span class="text-muted"><LinkedText :text="option" /></span>
|
|
</template>
|
|
<span v-else class="text-muted">(<LinkedText :text="help" />)</span>
|
|
</template>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div v-else-if="question.type === 'text'" class="form-group">
|
|
<input v-model="answers[q]" type="text" class="form-control" required>
|
|
</div>
|
|
<div v-else-if="question.type === 'number'" class="form-group">
|
|
<input
|
|
v-model="answers[q]"
|
|
type="number"
|
|
class="form-control"
|
|
:min="question.min"
|
|
:max="question.max"
|
|
required
|
|
>
|
|
</div>
|
|
<div v-else-if="question.type === 'textarea'" class="form-group">
|
|
<textarea v-model="answers[q]" class="form-control"></textarea>
|
|
</div>
|
|
|
|
<div v-if="question.writein" class="form-group">
|
|
<input v-model="writins[q]" type="text" class="form-control form-control-sm" :placeholder="$t('census.writein')">
|
|
</div>
|
|
</form>
|
|
|
|
<div class="btn-group w-100">
|
|
<button type="button" class="btn btn-outline-primary" :disabled="q === 0" @click="q--">
|
|
<Icon v="arrow-alt-left" />
|
|
<T>census.prev</T>
|
|
</button>
|
|
<button type="button" class="btn btn-primary" :disabled="!stepValid" @click="q++">
|
|
<T>census.next</T>
|
|
<Icon v="arrow-alt-right" />
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="user?.username === 'andrea'" class="mt-4 btn-group w-100">
|
|
<button
|
|
v-for="(question, i) in questions"
|
|
type="button"
|
|
:class="['btn', q === i ? 'btn-primary' : 'btn-outline-primary']"
|
|
:disabled="q === i"
|
|
@click="q = i"
|
|
>
|
|
{{ i }}
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="progress my-3">
|
|
<div
|
|
class="progress-bar"
|
|
role="progressbar"
|
|
:style="`width: ${progress}%`"
|
|
:aria-valuenow="q"
|
|
aria-valuemin="0"
|
|
:aria-valuemax="questions.length"
|
|
>
|
|
{{ q }}/{{ questions.length }}
|
|
</div>
|
|
</div>
|
|
<section>
|
|
<div class="alert alert-success">
|
|
<Icon v="badge-check" :size="2" class="float-start me-2 mt-2" />
|
|
<T>census.finished</T>
|
|
</div>
|
|
</section>
|
|
<section class="row">
|
|
<div class="col-12 col-lg-6">
|
|
<CensusShare :text="$t('census.shareButton')" />
|
|
</div>
|
|
<div class="col-12 col-lg-6">
|
|
<CensusSubscribe />
|
|
</div>
|
|
</section>
|
|
</template>
|
|
</Page>
|
|
</template>
|
|
|
|
<style lang="scss">
|
|
@import "assets/variables";
|
|
|
|
.multi-column {
|
|
columns: 2;
|
|
}
|
|
@include media-breakpoint-up('md', $grid-breakpoints) {
|
|
.multi-column {
|
|
columns: 3;
|
|
}
|
|
}
|
|
</style>
|