feat: review backend and frontend

- update to the latest version of Java/SpringBoot
- update to the latest version NuxtJS
- add account/password update
- add account creation
- add account password reset
- add bundle to regroup questions and add default questions on user creation
- add bundle creation
This commit is contained in:
2024-07-03 15:55:34 +02:00
parent f86d794239
commit 11c3a5d8db
202 changed files with 5018 additions and 40354 deletions

View File

@@ -1,14 +0,0 @@
import { Context } from '@nuxt/types'
export default function (context: Context) {
context.redirect(301, '/redirect');
}
// export default function ({ $auth, redirect }) {
// if (process.client) {
// if (!$auth.loggedIn || !$auth.user.role.includes('admin')) {
// redirect('/admin/login')
// }
// }
// }
// https://stackoverflow.com/questions/64433369/how-to-auto-redirect-to-login-after-an-unauthorized-api-request-ex-when-token

View File

@@ -1,4 +1,4 @@
FROM node:16 as builder
FROM node:20 as builder
ARG BACKEND_BASE_URL
RUN npm install -g npm
@@ -6,19 +6,18 @@ RUN npm install -g npm
USER node
COPY --chown=1000:1000 ./package.json /application/package.json
COPY --chown=1000:1000 ./package-lock.json /application/package-lock.json
WORKDIR /application
RUN npm install
RUN yarn install
COPY --chown=1000:1000 . /application
RUN npm run generate
RUN yarn generate
FROM nginx
COPY --from=builder --chown=1000:1000 /application/dist /usr/share/nginx/html
COPY --from=builder --chown=1000:1000 /application/.output/public/ /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

View File

@@ -9,4 +9,11 @@ $orange_hover: #DC6A00;
$gray_1: #F6F6F6;
$gray_2: #E8E8E8;
$gray_3: #9B9B9B;
$gray_4: #666666;
$gray_4: #666666;
$info_text: $gray_4;
$info_background: $gray_1;
$warn_text: #FFFFFF;
$warn_background: #f44336;
$success_text: #eafbf6;
$success_background: #2ed6a0;

View File

@@ -1,3 +1,8 @@
@import "_color";
@import "_font";
@import "_spacing";
@mixin border-shadow() {
box-shadow: inset 0 0 0.5px 1px hsla(0, 0%,
100%, 0.075),
0 0 0 1px hsla(0, 0%, 0%, 0.05),
0 0.3px 0.4px hsla(0, 0%, 0%, 0.02),
0 0.9px 1.5px hsla(0, 0%, 0%, 0.045),
0 3.5px 6px hsla(0, 0%, 0%, 0.09);
}

View File

@@ -0,0 +1,86 @@
@import "main";
.modal {
position: fixed;
display: flex;
justify-content: center;
align-items: end;
visibility: hidden;
inset: 0;
z-index: 1040;
@media only screen and (min-width: $breakpoint) {
align-items: center;
}
&.visible {
visibility: visible;
}
&-content {
display: flex;
flex-direction: column;
gap: 24px;
background: $white;
position: relative;
margin: 0;
padding: 64px 24px;
height: 90%;
width: 100%;
@media only screen and (min-width: $breakpoint) {
border-radius: 8px;
padding: 64px;
margin: 32px;
max-width: $breakpoint;
height: 80%;
}
&-header {
margin-block-end: 8px;
h1 {
margin: 0 0 $xx_small 0;
}
.close_modal {
position: absolute;
right: 0;
top: 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
border: 0;
cursor: pointer;
background: none;
font-size: 1rem;
padding: 20px;
}
}
&-body {
display: flex;
flex-direction: column;
gap: $xxx_small;
overflow: auto;
overflow-x: hidden;
}
&-footer {
display: flex;
gap: $xxx_small;
.button:not(.button-back) {
flex: 1;
}
}
}
&-overlay {
position: absolute;
inset: 0;
background-color: rgba($color: #000000, $alpha: 0.25);
z-index: -1;
}
}

View File

@@ -1,62 +0,0 @@
@import "_font";
%flex-center {
display: flex;
justify-content: center;
align-items: center;
}
%main-text {
font-size: $main-font-size;
font-style: normal;
font-weight: 400;
line-height: 2.1rem;
letter-spacing: 0;
}
%main-text-2 {
font-size: $main-font-size;
font-style: normal;
font-weight: 400;
line-height: 1.8rem;
letter-spacing: 0;
}
%secondary-text {
font-size: $secondary-font-size;
font-style: normal;
font-weight: 400;
line-height: 1.625rem;
letter-spacing: 0;
}
%secondary-text-bold {
@extend %secondary-text;
font-weight: 700;
}
%tertiary-text {
font-size: $tertiary-font-size;
font-style: normal;
font-weight: 400;
line-height: 1.625rem;
letter-spacing: 0;
}
%tertiary-text-bold {
@extend %tertiary-text;
font-weight: bold;
}
%main-text-bold {
@extend %main-text;
font-weight: 700;
}
%main-text-small {
font-size: $small-font-size;
font-style: normal;
font-weight: 400;
line-height: 1.425rem;
letter-spacing: 0;
}

View File

@@ -1,16 +1,18 @@
$page_width: 1200px;
$content_width: 920px;
$header_height: 78px;
$footer_height: 200px;
$max_z_index: 999;
$xxx_small: .5rem;
$xx_small: .75rem;
$x_small: 1.5rem;
$small: 2rem;
$medium: 3rem;
$x_medium: 3.75rem;
$xx_medium: 5rem;
$large: 7.5rem;
$x_large: 11.875rem;
$xx_large: 16.25rem;
$xxxx_small: 4px;
$xxx_small: 8px;
$xx_small: 12px;
$x_small: 24px;
$small: 32px;
$medium: 48px;
$x_medium: 60px;
$xx_medium: 80px;
$large: 120px;
$x_large: 190px;
$xx_large: 260px;

View File

@@ -1,45 +1,59 @@
@import "_color";
@import "_font";
@import "_placeholders";
@import "_mixin";
@import "_spacing";
$breakpoint: 1024px;
* {
*,
::after,
::before {
box-sizing: border-box;
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
font-family: $font-primary;
background: $white;
color: $gray_4;
body {
scroll-behavior: smooth;
overflow-x: hidden;
color: $gray_4;
font-family: $font-primary;
background: $white; // #ededf0
}
a {
color: $light_blue;
text-decoration: none;
&:hover {
text-decoration: underline;
.main-content {
min-height: calc(100vh - $footer_height - $header_height);
max-width: $content_width;
margin: 0 auto;
@media only screen and (max-width: $breakpoint) {
margin: 0 $x_small;
}
}
a, .button-link {
color: $gray_4;
font-size: 1rem;
font-weight: 500;
text-decoration: underline;
&:hover {
text-decoration: none;
}
}
.button-link {
border: none;
background: none;
cursor: pointer;
}
ul, ol {
margin: $xx_small $small;
margin: 0;
}
h1 {
font-size: 1.75rem;
line-height: 2.125rem;
margin: $medium 0 $small 0;
margin: $small 0;
color: $black;
text-align: center;
@media only screen and (max-width: $breakpoint) {
margin: $xx_small;
margin: $xx_small 0;
}
}
@@ -70,6 +84,17 @@ hr {
margin: $xxx_small 0;
}
.form {
display: flex;
flex-direction: column;
gap: $xxx_small;
margin-block: $small;
&__help {
margin-block: $small $x_small;
}
}
.bold {
font-weight: bold;
}
@@ -94,7 +119,18 @@ hr {
}
.button-container {
display: flex;
justify-content: center;
gap: $xx_small;
margin: $medium 0;
.button {
flex: 1;
}
.button-back {
flex: 0;
}
}
.button.orange:hover:not(:disabled) {
@@ -126,7 +162,23 @@ hr {
cursor: default;
}
.button-icon {
border: none;
background: none;
cursor: pointer;
padding: $xxx_small;
font-size: 1.5rem;
line-height: 24px;
border-radius: $xxx_small;
&:hover, &:active {
background: $gray_1;
}
}
input[type=text],
input[type=email],
input[type=password] {
background: $gray_1;
border: 1px solid $gray_2;
@@ -136,6 +188,7 @@ input[type=password] {
}
input[type=text]::placeholder,
input[type=email]::placeholder,
input[type=password]::placeholder {
color: $gray_3;
}
@@ -150,41 +203,3 @@ input[type=password]::placeholder {
.text-center {
text-align: center;
}
.content {
max-width: none;
display: grid;
grid-template-columns: [fullWidth-start] 1rem
[left-start] 1fr
[article-start right-start] minmax(20ch, $content_width)
[article-end left-end] 1fr
[right-end] 1rem [fullWidth-end];
}
.content > * {
grid-column: article;
}
.full-width {
grid-template-columns: minmax(20ch, $content_width);
justify-content: center;
grid-column: fullWidth;
display: grid;
padding: $small;
}
@supports (grid-template-columns: subgrid) {
.full-width {
grid-template-columns: subgrid;
padding: 0;
}
.full-width-center {
grid-column: article;
}
.full-width-right {
grid-column: right;
text-align: right;
}
.full-width-left {
grid-column: left;
}
}

View File

@@ -0,0 +1,243 @@
<script lang="ts" setup>
import type {PropType} from "vue";
import type {Axe} from "~/store/axe";
import type {Question} from "~/store/question";
const questions = ref<Question[]>([]);
const newQuestionMode = ref(false);
const newQuestionLabel = ref("");
const newQuestionDescription = ref("");
const emit = defineEmits(["changed", "close"]);
const props = defineProps({
axe: {
type: Object as PropType<Axe>,
},
questions: {
type: Object as PropType<Question[]>,
},
questionsExample: {
type: Object as PropType<Question[]>,
default: [{
label: "",
description: ""
}]
},
visible: Boolean
});
onMounted(() => {
questions.value = props.questions ? props.questions.map(q => {
return {
label: q.label,
description: q.description
}
}) : [];
});
function removeQuestion(index: number) {
questions.value.splice(index, 1);
}
function moveQuestion(fromIndex, toIndex) {
if (toIndex > questions.value.length || toIndex < 0) {
return;
}
const element = questions.value[fromIndex];
questions.value.splice(fromIndex, 1);
questions.value.splice(toIndex, 0, element);
}
function questionChanged(event, {index, newQuestion}) {
questions.value[index] = newQuestion;
}
function addNewQuestion() {
questions.value.push({label: newQuestionLabel.value, description: newQuestionDescription.value});
newQuestionMode.value = false;
}
function changeToNewQuestionMode() {
newQuestionLabel.value = "";
newQuestionDescription.value = "";
newQuestionMode.value = true;
}
function validate() {
emit("changed", {axeId: props.axe.id, newQuestions: questions.value});
emit("close");
}
</script>
<template>
<div :class="`modal${visible ? ' visible' : ''}`" v-if="axe">
<section class="modal-content">
<header class="modal-content-header">
<h1>{{ axe.identifier }} - {{ axe.shortTitle }}</h1>
<p>{{ axe.title }}</p>
<p v-if="axe.description">{{ axe.description }}</p>
<button class="close_modal" @click="$emit('close')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.6 16L0 14.4L6.4 8L0 1.6L1.6 0L8 6.4L14.4 0L16 1.6L9.6 8L16 14.4L14.4 16L8 9.6L1.6 16Z"
fill="#1C1B1F"/>
</svg>
<span>Fermer</span>
</button>
</header>
<main class="modal-content-body">
<div v-if="newQuestionMode" class="new-question">
<div class="new-question__inputs">
<label for="label-new-question">Libellé *</label>
<input id="label-new-question" type="text" list="question-labels" v-model="newQuestionLabel" maxlength="200"/>
<label for="description-new-question">Description</label>
<input id="description-new-question" type="text" list="question-labels" v-model="newQuestionDescription" maxlength="500"/>
</div>
<h2>Historique des questions</h2>
<ul class="new-question__example-list">
<li v-for="(question, index) in questionsExample">
<input :id="'example-'+index" type="radio" name="example-radio" @input="() => {newQuestionLabel = question.label;newQuestionDescription = question.description;}">
<label :for="'example-'+index">
<span class="new-question__example-list__item-label">{{ question.label }}</span>
<span class="new-question__example-list__item-description" v-if="question.description">{{ question.description }}</span>
</label>
</li>
</ul>
</div>
<div v-else>
<ul class="question-list">
<li v-for="(question, index) in questions" class="question-list__item">
<div class="question-list__item__inputs">
<span class="question-list__item__inputs__title">Question {{index+1}}</span>
<label :for="'label-'+index">Libellé *</label>
<input :id="'label-'+index" type="text" list="question-labels" :value="question.label" maxlength="200"
@change="event => questionChanged(event, {index, newQuestion: {label: event.target.value, description: question.description}})"/>
<label :for="'description-'+index">Description</label>
<input :id="'description-'+index" type="text" list="question-descriptions" :value="question.description" maxlength="500"
@change="event => questionChanged(event, {index, newQuestion: {label: question.label, description: event.target.value}})"/>
</div>
<div class="question-list__item__buttons">
<button class="button-icon button-up" aria-label="Déplacer vers le haut"
@click="moveQuestion(index, index-1)" :disabled="index === 0">
</button>
<button class="button-icon button-down" aria-label="Déplacer vers le bas"
@click="moveQuestion(index, index+1)" :disabled="index === questions.length -1">
</button>
<button class="button-icon" @click="removeQuestion(index)">
🗑
</button>
</div>
</li>
</ul>
</div>
</main>
<footer class="modal-content-footer">
<button v-if="newQuestionMode" class="button gray button-back"
@click="newQuestionMode = false">
</button>
<button v-if="newQuestionMode" class="button orange" :disabled="!newQuestionLabel"
@click="addNewQuestion()">Ajouter
</button>
<button v-if="!newQuestionMode" class="button blue" @click="changeToNewQuestionMode()">Ajouter une question
</button>
<button v-if="!newQuestionMode" class="button orange" @click="validate()">Valider</button>
</footer>
</section>
<div class="modal-overlay" @click="$emit('close')"></div>
</div>
</template>
<style scoped lang="scss">
@import "assets/css/modal";
.modal-content-body {
//overflow: unset;
}
.question-list,
.new-question__example-list {
//overflow-y: scroll;
//overflow-x: hidden;
//height: 30%;
}
.new-question {
&__inputs {
display: flex;
flex-direction: column;
gap: $xx_small;
}
h2 {
margin: $xx_small 0;
font-size: $secondary-font-size;
}
&__example-list {
display: flex;
flex-direction: column;
gap: $xx_small;
&__item-description {
font-style: italic;
}
input {
//align-content: start;
}
li {
display: flex;
gap: $xxx_small;
//align-items: start;
}
label {
display: flex;
flex-direction: column;
gap: $xxxx_small;
}
}
}
.question-list {
list-style: none;
display: flex;
flex-direction: column;
gap: $x_small;
&__item {
display: flex;
gap: $xxx_small;
&__inputs {
display: flex;
flex-direction: column;
flex: 1;
gap: $xxx_small;
&__title {
font-weight: bold;
}
}
&__buttons {
//align-self: center;
display: flex;
flex-direction: column;
align-self: end;
//gap: $xxx_small;
}
}
}
.button-up {
rotate: -90deg;
}
.button-down {
rotate: 90deg;
}
</style>

View File

@@ -0,0 +1,37 @@
<script lang="ts" setup>
const emit = defineEmits(["close", "validate"]);
defineProps({
visible: Boolean
});
</script>
<template>
<div :class="`modal${visible ? ' visible' : ''}`">
<section class="modal-content">
<header class="modal-content-header">
<h1>CGU</h1>
<button class="close_modal" @click="$emit('close')">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.6 16L0 14.4L6.4 8L0 1.6L1.6 0L8 6.4L14.4 0L16 1.6L9.6 8L16 14.4L14.4 16L8 9.6L1.6 16Z"
fill="#1C1B1F"/>
</svg>
<span>Fermer</span>
</button>
</header>
<main class="modal-content-body">
<p>
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto consequatur, consequuntur et expedita libero
non soluta sunt ullam vel velit voluptatibus voluptatum? Accusamus blanditiis est obcaecati temporibus velit. Dicta
doloribus eveniet id incidunt suscipit. Accusamus ad aspernatur at aut, beatae laboriosam modi natus nemo, officia
perspiciatis porro quisquam, totam vel.
</p>
</main>
<footer class="modal-content-footer">
<button class="button orange" @click="emit('validate')">Accepter</button>
</footer>
</section>
<div class="modal-overlay" @click="$emit('close')"></div>
</div>
</template>
<style scoped lang="scss">
@import "assets/css/modal";
</style>

View File

@@ -1,42 +0,0 @@
<template>
<div class="header">
<nuxt-link to="/">
<div class="secondary-logo">
<img src="/logo/logo_apes.svg" height="50px" alt="Logo APES"/>
</div>
<div class="main-logo">
<img src="/logo/main_logo.svg" width="245px" alt="Boussole PLUSS"/>
</div>
</nuxt-link>
</div>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
@import "assets/css/color";
.header {
width: 100%;
height: 115px;
margin-bottom: $xx_medium;
background-repeat: no-repeat;
background-image: url("/decoration/background.svg");
background-position: bottom center, 50%;
background-size: cover;
.secondary-logo img {
padding: 12px 0 0 12px;
}
.main-logo {
position: absolute;
margin-left: auto;
margin-right: auto;
top: 40px;
left: 0;
right: 0;
width: 225px;
}
}
</style>

View File

@@ -1,29 +1,45 @@
<template>
<div>
<Header/>
<div class="content">
<section>
<h1>La démarche</h1>
<p>
Les crises successives que nous subissons révèlent encore plus la fragilité d'une économie productive
prioritairement mondialisée. S'il est impératif que les producteurs locaux reprennent la main sur certaines
filières, il faut aussi, pour favoriser la transition écologique et sociale, que la production locale soit
avant tout utile, solidaire et soutenable.
</p>
</section>
<section>
<h1>La boussole comme auto-évaluation</h1>
<p>
Nous proposons dix points de repères pour permettre aux différents écosystèmes de production locale sur un
territoire d'agir conjointement et collectivement dans ce sens et faire en sorte que les acteurs locaux
puissent innover, expérimenter et renforcer la production locale quantitativement et qualitativement.
</p>
</section>
<div class="button-container">
<nuxt-link class="button orange" to="/login">Démarrer</nuxt-link>
</div>
</div>
<section>
<h1>La Boussole PLUSS, cest quoi ?</h1>
<p>
« Une application de valorisation des ressources mobilisées dans les projets locaux. Le petit plus cest
lidentification collective des axes à améliorer. »
</p>
<p>
Vous souhaitez engager votre action ou votre entreprise dans une démarche responsable, utile, solidaire ? 10
balises comme 10 repères pour vous aider à identifier les points à améliorer et visualiser concrètement vous en
êtes !
Le principe : il sagit dauto-évaluer une dynamique territoriale ou un projet en répondant à des questions
pré-formatées portant sur chacune des 10 balises du référentiel <nuxt-link to="https://apes-hdf.org/_docs/Fichier/2022/21-220610094246.pdf" target="_blank">« Agir pour une production locale » (1,24 Mo)</nuxt-link>. Les
participants choisissent 3 questions par balise.
Cette application fait partie dune boîte à outils plus vaste, initiée par l<nuxt-link to="https://www.apes-hdf.org/page-0-0-0.html" target="_blank">Apes</nuxt-link> et ses adhérents. En savoir
<nuxt-link to="https://pluss-hdf.org/" target="_blank">PLUSS</nuxt-link> ?
</p>
</section>
<section>
<h1>Comment ça marche ?</h1>
<p>
Après avoir créé un compte pour votre équipe, vous pouvez commencer votre évaluation sur un premier lot de
questions disponible. Vous pouvez également décider de configurer vous même vos questions, et même de piocher
parmi celles imaginées par les autres utilisateurs !
Vous pouvez créer autant de boussoles que vous le désirez.<br/>
Une fois la boussole configurée, vous pouvez réaliser autant dévaluations que vous le souhaiter, afin de mesurer
votre progression sur chacune des balises. Les évaluations passées restent disponibles.
</p>
</section>
<section>
<h1>Contribuer à la Boussole</h1>
<p>
Le projet actuel est prévu pour travailler sur le thème de la production locale.<br/>
Cet outil peut servir à évaluer des collectifs sur dautres thématiques. Vous êtes encouragé à y réfléchir et à y
travailler. La licence AGPL version 3 prévoit un partage des améliorations que vous porterez à loutil. Si vous
désirez faire un fork de loutil, contactez-nous : <nuxt-link to="mailto:contact@apes-hdf.org">contact@apes-hdf.org</nuxt-link>
</p>
</section>
<div class="button-container">
<nuxt-link class="button orange" to="/login">Démarrer</nuxt-link>
</div>
</template>
<script setup lang="ts">
</script>

View File

@@ -19,9 +19,6 @@
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
ol {
margin: 0;
position: relative;

View File

@@ -0,0 +1,26 @@
<template>
<footer class="footer">
<nuxt-link to="https://www.apes-hdf.org" target="_blank">
<img src="/images/logo/logo_apes.png" height="125" width="200" alt="Logo APES"/>
</nuxt-link>
</footer>
</template>
<style lang="scss" scoped>
.footer {
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
background: #ededf0;
margin-top: auto;
clear: both;
height: $footer_height;
background: $white url(/images/decoration/cube.png) repeat left top;;
}
</style>
<script setup lang="ts">
</script>

View File

@@ -0,0 +1,40 @@
<template>
<header class="header">
<div class="secondary-logo">
<nuxt-link to="https://www.apes-hdf.org" target="_blank">
<img src="/images/logo/logo_apes.svg" height="50px" alt="Logo APES"/>
</nuxt-link>
</div>
<div class="main-logo">
<nuxt-link :to="useRoute().name === 'index' ? '' : '/'">
<img src="/images/logo/main_logo.svg" width="245px" alt="Boussole PLUSS"/>
</nuxt-link>
</div>
</header>
</template>
<style lang="scss" scoped>
.header {
width: 100%;
height: 115px;
margin-bottom: $xx_medium;
background-repeat: no-repeat;
background-image: url("/images/decoration/background.svg");
background-position: bottom center, 50%;
background-size: cover;
.secondary-logo img {
padding: 12px 0 0 12px;
}
.main-logo {
position: absolute;
margin-left: auto;
margin-right: auto;
top: 40px;
left: 0;
right: 0;
width: 225px;
}
}
</style>

View File

@@ -1,80 +1,15 @@
<template>
<PolarArea
:chart-options="chartOptions"
:chart-data="chartData"
:chart-id="chartId"
:dataset-id-key="datasetIdKey"
:plugins="plugins"
:css-classes="cssClasses"
:styles="styles"
:width="width"
:height="height"
/>
</template>
<script lang="ts" setup>
import {PolarArea} from 'vue-chartjs';
import type {PropType} from "vue";
<script lang="ts">
import {PolarArea} from 'vue-chartjs/legacy'
import {ArcElement, Chart as ChartJS, Legend, RadialLinearScale, Title, Tooltip} from 'chart.js'
import {Component, Prop, Vue, Watch} from "nuxt-property-decorator";
ChartJS.register(Title, Tooltip, Legend, ArcElement, RadialLinearScale);
@Component({
components: {
PolarArea
const props = defineProps({
data: {
type: Object as PropType<number[]>
}
})
export default class PolarAreaChart extends Vue {
});
@Prop({
default: 'polar-chart',
type: String
})
public chartId!: string;
@Prop({
default: 'label',
type: String
})
public datasetIdKey!: string;
@Prop({
default: 400,
type: Number
})
public width!: number;
@Prop({
default: 400,
type: Number
})
public height!: number;
@Prop({
default: '',
type: String
})
public cssClasses!: string;
@Prop({
type: Object
})
public styles!: Object;
@Prop({
default: () => [],
type: Array
})
public plugins!: [];
@Prop({
default: () => [],
type: Array<number>
})
public data!: number[];
readonly chartData = {
const chartData = computed(() => {
return {
labels: [
"1. Pouvoir d'agir",
"2. Multi-secteur",
@@ -101,36 +36,45 @@ export default class PolarAreaChart extends Vue {
"#E9D280",
"#7BD1F5"
],
data: this.data,
data: props.data,
borderWidth: 1,
borderColor: "#666666"
}
]
};
});
readonly chartOptions = {
responsive: true,
maintainAspectRatio: false,
// animation: false,
scales: {
r: {
suggestedMin: 0,
suggestedMax: 10,
ticks: {
display: false
}
}
},
plugins: {
legend: {
const chartOptions = ref({
responsive: true,
maintainAspectRatio: true,
scales: {
r: {
suggestedMin: 0,
suggestedMax: 10,
ticks: {
display: false
}
}
};
@Watch("data", {})
private renderChart() {
this.chartData.datasets[0].data = this.data;
},
plugins: {
legend: {
display: false
}
}
}
})
</script>
<template>
<div class="chart-container">
<polar-area
:options="chartOptions"
:data="chartData"
/>
</div>
</template>
<style lang="scss" scoped>
.chart-container {
position: relative;
max-width: 60vh;
}
</style>

View File

@@ -1,10 +1,34 @@
<script lang="ts" setup>
import type {PropType} from "vue";
import type {QuizResponse} from "~/store/quiz";
import type {Axe} from "~/store/axe";
const props = defineProps({
axe: {
type: Object as PropType<Axe>,
required: true
},
average: Number,
responses: {
type: Object as PropType<QuizResponse[]>,
required: true
}
});
const cssVars = computed(() => {
return {
'--color': props.axe ? props.axe.color : "#ffffff"
}
});
</script>
<template>
<section :style="cssVars" class="axe-details">
<h2 class="title">
<span><span class="upper">{{ axe.identifier }}</span><span>{{ axe.shortTitle }}</span></span>
<span class="upper">{{ score.scoreAvg | formatRate }} / 10</span>
<span class="upper">{{ Number((average).toFixed(1)) }} / 10</span>
</h2>
<div v-for="response in responses" :key="response._links.self.href">
<div v-for="response in responses">
<p class="question">
<span>
{{ response.question }}
@@ -16,38 +40,6 @@
</section>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "nuxt-property-decorator";
import {Axe} from "~/repositories/models/axe.model";
import {ResponseWithQuestion, Score} from "~/repositories/models/quiz.model";
@Component
export default class QuizAxeDetails extends Vue {
@Prop({
type: Object,
required: true
})
public axe!: Axe;
@Prop({
type: Object,
required: true
})
public score!: Score;
@Prop({
type: Array
})
public responses!: ResponseWithQuestion[];
get cssVars() {
return {
'--color': this.axe ? this.axe.color : "#ffffff"
}
}
}
</script>
<style lang="scss" scoped>
@import "assets/css/font";

View File

@@ -1,3 +1,62 @@
<script lang="ts" setup>
import type {PropType} from "vue";
import {useQuizStore} from "~/store/quiz";
import type {Question} from "~/store/question";
const props = defineProps({
title: String,
description: {
type: String,
required: false,
},
axeNumber: Number,
totalAxes: Number,
icon: String,
color: String,
questions: {
type: Object as PropType<Question[]>,
required: true
}
});
const emit = defineEmits(["rate"]);
const store = useQuizStore();
const cssVars = computed(() => {
return {
'--color': props.color
}
});
function onRate(score: number, question: Question) {
store.updateScoreResponse({axeId: props.axeNumber, questionId: question.id, score});
this.emitRatingState();
}
function onComment(comment: string, question: Question) {
store.updateCommentResponse({axeId: props.axeNumber, questionId: question.id, comment});
}
function getCurrentScore(question: Question): number | undefined {
const rate = store.responses.get(question.id) as QuizRate;
return rate ? rate.score : undefined;
}
function getCurrentComment(question: Question): string | undefined {
const rate = store.responses.get(question.id) as QuizRate;
return rate ? rate.comment : undefined;
}
function emitRatingState() {
const questions = store.questionsRatedPerAxe.get(props.axeNumber);
const unratedQuestions = questions ? questions.filter(value => !value.rated) : [];
emit('rate', {
isFullRated: unratedQuestions.length === 0
});
}
</script>
<template>
<article :style="cssVars">
<header>
@@ -11,102 +70,23 @@
<img :src="icon" width="200px" alt="" aria-hidden="true"/>
</div>
</header>
<section v-for="question in questions" :key="question._links.self.href" class="question">
<section v-if="questions" v-for="question in questions" :key="question.id" class="question">
<div class="title">
{{ question.label }}
</div>
<details v-if="question.description">
<summary> {{ question.label }}</summary>
<summary>Description</summary>
<p>{{ question.description }}</p>
</details>
<div v-else class="title">
{{ question.label }}
</div>
<div class="rating">
<rating :color="color" :initial-value="getCurrentScore(question)" @rate="rate => onRate(rate, question)" />
<rating :color="color" :initial-value="getCurrentScore(question)" @rate="rate => onRate(rate, question)"/>
</div>
<input :value="getCurrentComment(question)" type="text" placeholder="Commentaire" maxlength="500" @input="event => onComment(event.target.value, question)"/>
<input :value="getCurrentComment(question)" type="text" placeholder="Commentaire" maxlength="500"
@input="event => onComment(event.target.value, question)"/>
</section>
</article>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "nuxt-property-decorator";
import {Question} from "~/repositories/models/question.model";
import {quizStore} from "~/utils/store-accessor";
import {QuizRate} from "~/repositories/models/quiz.model";
@Component
export default class Quiz extends Vue {
@Prop({
required: true
})
private title!: string;
@Prop({
required: false
})
private description!: string;
@Prop({
required: true
})
private axeNumber!: number;
@Prop({
required: true
})
private totalAxes!: number;
@Prop({
required: true,
type: String,
})
public icon !: string;
@Prop({
required: true,
type: Array<Question>
})
private questions!: Array<Question>;
@Prop({
required: true,
type: String,
})
public color !: string;
get cssVars() {
return {
'--color': this.color
}
}
onRate(score: number, question: Question) {
quizStore.updateScoreResponse({axeId: this.axeNumber, questionId: question.id, score});
this.emitRatingState();
}
onComment(comment: string, question: Question) {
quizStore.updateCommentResponse({axeId: this.axeNumber, questionId: question.id, comment});
}
getCurrentScore(question: Question): number | undefined {
const rate = quizStore.responses.get(question.id) as QuizRate;
return rate ? rate.score : undefined;
}
getCurrentComment(question: Question): string | undefined {
const rate = quizStore.responses.get(question.id) as QuizRate;
return rate ? rate.comment : undefined;
}
emitRatingState() {
const questions = quizStore.questionsRatedPerAxe.get(this.axeNumber);
const unratedQuestions = questions ? questions.filter(value => !value.rated) : [];
this.$emit('rate', {
isFullRated: unratedQuestions.length === 0
})
}
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@@ -131,6 +111,7 @@ $size: 31px;
display: flex;
flex-direction: row;
text-align: left;
> span {
padding: 0 $x_small;
}
@@ -149,40 +130,35 @@ $size: 31px;
.question {
width: 100%;
}
.question details,
.question .title {
margin-bottom: $x_small;
}
.question details summary,
.question .title {
font-weight: 700;
font-size: $title-font-size;
}
.question details summary {
margin-bottom: $xxx_small;
}
details,
.title {
margin-bottom: $x_small;
}
.title {
font-weight: 700;
font-size: $title-font-size;
}
.question details p {
font-size: $secondary-font-size;
color: $gray_4;
margin-left: $x_small;
}
details summary {
list-style-position: inside;
margin-bottom: $xxx_small;
}
.question .rating {
margin: 0 auto;
max-width: 401px;
}
.rating {
margin: 0 auto;
max-width: 401px;
}
.question input[type="text"] {
width: 100%;
border: 1px solid $gray_2;
border-radius: 8px;
background: none;
margin: $small 0;
font-weight: 400;
font-size: $tertiary-font-size;
input[type="text"] {
width: 100%;
border: 1px solid $gray_2;
border-radius: 8px;
background: none;
margin: $small 0;
font-weight: 400;
font-size: $tertiary-font-size;
}
}
</style>

View File

@@ -1,38 +1,24 @@
<template>
<div :style="cssVars" class="progress">
<span v-for="step in numberSteps" :key="step" class="step" :class="{ active: step <= currentStep }"></span>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue} from "nuxt-property-decorator";
<script lang="ts" setup>
@Component
export default class QuizProgress extends Vue {
const props = defineProps({
numberSteps: Number,
currentStep: Number,
color: String
});
@Prop({
type: Number
})
private numberSteps!: number;
@Prop({
required: true,
type: Number
})
private currentStep!: number;
@Prop({
required: true,
type: String
})
private color!: string;
get cssVars() {
return {
'--color': this.color
}
const cssVars = computed(() => {
return {
'--color': props.color
}
}
});
</script>
<template>
<div :style="cssVars" class="progress">
<span v-for="step in numberSteps" :key="step" class="step" :class="{ active: step <= currentStep }"></span>
</div>
</template>
<style lang="scss" scoped>
@import "assets/css/color";
@@ -42,6 +28,7 @@ export default class QuizProgress extends Vue {
display: flex;
justify-content: center;
}
.step {
border-radius: 2px;
border: 6px solid $gray_2;

View File

@@ -1,3 +1,76 @@
<script lang="ts" setup>
import {randomUUID} from "uncrypto";
const MAX_VALUE = 10;
const props = defineProps({
initialValue: Number,
color: {
type: String,
required: false,
default: "#DC6A00"
},
});
const emit = defineEmits(["rate"]);
const checkedValue = ref(0);
const componentId = ref();
onMounted(() => {
componentId.value = randomUUID();
checkedValue.value = props.initialValue;
});
const index = computed(() =>
Array.apply(0, Array(MAX_VALUE)).map((_, b) => {
return b + 1;
}).reverse()
);
const cssVars = computed(() => {
return {
'--color': props.color
}
});
function setChecked(index: number) {
if (index < 1) {
index = 1;
} else if (index > MAX_VALUE) {
index = MAX_VALUE;
}
checkedValue.value = index;
emit('rate', index);
}
function increment() {
setChecked(checkedValue.value + 1);
}
function decrement() {
setChecked(checkedValue.value - 1);
}
function isChecked(index: number) {
return checkedValue.value >= index;
}
function handleKeyUp(event: KeyboardEvent) {
switch (event.key) {
case "ArrowLeft":
decrement();
break;
case "ArrowRight":
increment();
break;
}
event.preventDefault();
return false;
}
</script>
<template>
<div :style="cssVars">
<div class="rating" tabindex="0" @keyup="handleKeyUp">
@@ -17,91 +90,9 @@
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "nuxt-property-decorator";
@Component
export default class Rating extends Vue {
@Prop({
type: String,
default: "#DC6A00"
})
readonly color !: string;
@Prop({
type: Number,
required: false
})
readonly initialValue !: number | undefined;
readonly MAX_VALUE = 10;
created() {
if (this.initialValue) {
this.checkedValue = this.initialValue;
}
}
readonly index = Array.apply(0, Array(this.MAX_VALUE)).map((_, b) => {
return b + 1;
}).reverse();
public checkedValue = 0;
private _uid: any;
public setChecked(index: number) {
if (index < 1) {
index = 1;
} else if (index > this.MAX_VALUE) {
index = this.MAX_VALUE;
}
this.checkedValue = index;
this.$emit('rate', index);
}
public increment() {
this.setChecked(this.checkedValue + 1);
}
public decrement() {
this.setChecked(this.checkedValue - 1);
}
public isChecked(index: number) {
return this.checkedValue >= index;
}
get componentId() {
return this._uid;
}
handleKeyUp(event: KeyboardEvent) {
switch (event.key) {
case "ArrowLeft":
this.decrement();
break;
case "ArrowRight":
this.increment();
break;
}
event.preventDefault();
return false;
}
get cssVars() {
return {
'--color': this.color
}
}
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
@import "assets/css/font";

View File

@@ -1,56 +1,105 @@
<script lang="ts" setup>
import {useAuthStore} from "~/store/auth";
function logout() {
useAuthStore().logout();
navigateTo("/");
}
</script>
<template>
<header>
<nuxt-link to="/" class="title">
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<nuxt-link to="/" class="title">
<svg width="26" height="32" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0 7.07348C0 6.81265 0.101912 6.56214 0.284 6.37538L5.784 0.73436C6.17647 0.331827 6.82353 0.331826 7.216 0.734359L12.716 6.37538C12.8981 6.56214 13 6.81265 13 7.07348V15C13 15.5523 12.5523 16 12 16H1C0.447715 16 0 15.5523 0 15V7.07348Z"
fill="#8BCDCD"/>
</svg>
Boussole <span class="bold">PLUSS</span>
</nuxt-link>
<div class="menu-container">
<button class="button-icon">
<svg class="svg-icon" width="32" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path
d="M0 7.07348C0 6.81265 0.101912 6.56214 0.284 6.37538L5.784 0.73436C6.17647 0.331827 6.82353 0.331826 7.216 0.734359L12.716 6.37538C12.8981 6.56214 13 6.81265 13 7.07348V15C13 15.5523 12.5523 16 12 16H1C0.447715 16 0 15.5523 0 15V7.07348Z"
d="M723.43 508.6c-54.123 47.75-125.977 77.056-205.163 77.056-80.364 0-153.4-30.259-207.765-79.421C184.05 539.325 105.81 652.308 105.81 787.277v68.782c0 160.968 812.39 160.968 812.39 0v-68.782c-0.005-131.415-74.22-242.509-194.77-278.677z m-205.163 28.13c140.165 0 254.095-109.44 254.095-244.64S658.668 47.218 518.267 47.218c-139.93 0-253.855 109.675-253.855 244.874 0 135.204 113.925 244.639 253.855 244.639z m0 0"
fill="#8BCDCD"/>
</svg>
Boussole <span class="bold">PLUSS</span>
</nuxt-link>
<span class="team">
Équipe : {{ team }}
</span>
</svg>
</button>
<ul class="menu-container__content">
<li>
{{ useAuthStore().user.username }}
</li>
<li>
<nuxt-link to="/account">Mon compte</nuxt-link>
</li>
<li>
<button class="button-link" @click="logout">Me déconnecter</button>
</li>
</ul>
</div>
</header>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
<style lang="scss" scoped>
@Component
export default class TeamHeader extends Vue {
header {
padding: 0 $xx_small;
color: $black;
display: flex;
justify-content: space-between;
align-items: center;
//position: absolute;
//height: $header_height;
border-bottom: 2px solid $gray_3;
}
get team() {
return this.$auth.user ? this.$auth.user.username : "Non connecté";
.title {
display: flex;
align-items: center;
gap: $xxx_small;
text-transform: uppercase;
color: $black;
text-decoration: none;
}
.title svg {
margin-right: $xxx_small;
}
.team {
font-size: $small-font-size;
}
.menu-container {
position: relative;
& > .menu-container__content {
display: none;
position: absolute;
right: -16px;
top: 47px;
background: $white;
}
&:focus-within > .menu-container__content {
display: flex;
flex-direction: column;
}
.menu-container__content {
gap: $xx_small;
align-items: flex-end;
list-style: none;
min-width: 150px;
padding: $xx_small $xx_small;
border: 2px solid $gray_3;
& li {
//margin: $xxx_small;
}
}
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
@import "assets/css/font";
header {
margin-top: $xx_small;
color: $black;
display: flex;
justify-content: space-between;
}
.title {
text-transform: uppercase;
color: $black;
text-decoration: none;
}
.title svg {
margin-right: $xxx_small;
}
.team {
font-size: $small-font-size;
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts" setup>
import type {PropType} from "vue";
defineProps({
"title": {
type: String,
required: true,
},
"type": {
type: String as PropType<'warn' | 'info' | 'success'>,
default: 'info'
}
});
</script>
<template>
<aside :class="'toaster ' + type">
<div class="toaster-content">
<div class="toaster-content-title">{{ title }}</div>
<div class="toaster-content-body">
<slot/>
</div>
</div>
</aside>
</template>
<style scoped lang="scss">
.toaster {
display: flex;
position: fixed;
top: 0;
left: 50%;
translate: -50% 0;
padding: $small $small $xx_small;
gap: $xxx_small;
border-radius: 0 0 16px 16px;
animation-name: fadeInRight;
animation-duration: 300ms;
animation-fill-mode: both;
@include border-shadow();
&.info {
color: $info_text;
background: $info_background;
}
&.warn {
color: $warn_text;
background: $warn_background;
}
&.success {
color: $success_text;
background: $success_background;
}
}
@keyframes fadeInRight {
0% {
opacity: 0;
transform: translate3d(0, -100%, 0);
}
100% {
opacity: 1;
transform: none;
}
}
.toaster-content {
display: flex;
flex-direction: column;
gap: $xxx_small;
font-size: $small-font-size;
font-style: normal;
font-weight: 400;
}
.toaster-content-title {
font-weight: bold;
line-height: 120%;
font-size: $tertiary-font-size;
}
.toaster-content-body {
line-height: 150%;
}
</style>

View File

@@ -0,0 +1,52 @@
import type {UseFetchOptions} from "#app";
import {useAuthStore} from "~/store/auth";
export interface Page<T> {
content: T[];
size: number;
totalElements: number;
totalPages: number;
number: number;
}
export interface FieldError {
detail: string;
fields?: string[];
}
export interface ApiError {
message: string;
statusCode?: number;
fieldErrors?: FieldError[]
}
export const useApi = async (url: string, options?: UseFetchOptions<any>, auth = true) => {
const config = useRuntimeConfig();
let headers = {};
if (useAuthStore().authenticated && auth) {
let token = useAuthStore().auth.token || null;
if (!token || new Date(token.expireAt).getTime() < new Date().getTime()) {
console.info("Refresh the session");
await useAuthStore().refreshSession();
}
token = useAuthStore().auth.token
headers = {'Authorization': `${token.type} ${token.value}`};
}
return useFetch(url, {
headers: headers,
baseURL: config.public.baseURL,
...options
}).then(res => {
const error = res.error.value;
if (error) {
if (error.statusCode === 401) {
useAuthStore().authenticated = false;
}
return Promise.reject(error.data);
}
return Promise.resolve(res.data.value);
}, error => {
return Promise.reject(error);
})
}

14
frontend/error.vue Normal file
View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>
<template>
<nuxt-layout name="main-header">
<h1 class="text-center">{{ error.statusCode }}</h1>
<nuxt-link to="/">Retourner à l'accueil</nuxt-link>
</nuxt-layout>
</template>

View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
import {useNotificationStore} from "~/store/notification";
const notification = computed(() => useNotificationStore().notification);
const hasNotification = computed(() => useNotificationStore().hasNotification);
const type = computed(() => useNotificationStore().type);
</script>
<template>
<team-header/>
<main class="main-content">
<slot/>
<toaster v-if="hasNotification" :title="notification.message" :type="type">
<ul v-if="notification.details">
<li v-for="detail in notification.details">{{ detail }}</li>
</ul>
</toaster>
</main>
<main-footer/>
</template>

View File

@@ -0,0 +1,23 @@
<script lang="ts" setup>
import {useNotificationStore} from "~/store/notification";
const notification = computed(() => useNotificationStore().notification);
const hasNotification = computed(() => useNotificationStore().hasNotification);
const type = computed(() => useNotificationStore().type);
</script>
<template>
<main-header/>
<main class="main-content">
<slot/>
<toaster v-if="hasNotification" :title="notification.message" :type="type">
<ul v-if="notification.details && Array.isArray(notification.details)">
<li v-for="error in notification.details">{{ error }}</li>
</ul>
<p v-else-if="notification.details">
{{ notification.details}}
</p>
</toaster>
</main>
<main-footer/>
</template>

View File

@@ -0,0 +1,21 @@
import {useAuthStore} from "~/store/auth";
const publicUrl = [
"index",
"login",
"account-password-reset",
"account-password-confirm-reset",
"account-create",
]
export default defineNuxtRouteMiddleware((to) => {
const store = useAuthStore();
if (store.authenticated && to?.name === 'login') {
return navigateTo('/bundle');
}
// if token doesn't exist redirect to log in if not in public URL
if (!store.authenticated && !publicUrl.includes(to?.name)) {
abortNavigation();
return navigateTo('/login');
}
});

View File

@@ -1,151 +1,50 @@
export default {
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
ssr: false,
// Learn more about it on https://go.nuxtjs.dev/static-target
target: "static",
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
router: {
middleware:['auth']
},
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: 'Boussole PLUSS',
htmlAttrs: {
lang: 'fr',
},
meta: [
{charset: 'utf-8'},
{name: 'viewport', content: 'width=device-width, initial-scale=1'},
{hid: 'description', name: 'description', content: ''},
{name: 'format-detection', content: 'telephone=no'},
],
link: [{rel: 'icon', href: '/favicon.svg'}],
},
// Global CSS: https://go.nuxtjs.dev/config-css
css: [
// SCSS file in the project
'~/assets/css/main.scss',
'~/assets/css/_color.scss',
'~/assets/css/_font.scss',
'~/assets/css/_spacing.scss'
],
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
{ src: '~/plugins/axios-accessor.ts' },
{ src: '~/plugins/filters.ts' }
],
// Auto import components: https://go.nuxtjs.dev/config-components
components: true,
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
buildModules: [
// https://go.nuxtjs.dev/typescript
'@nuxt/typescript-build',
],
// Modules: https://go.nuxtjs.dev/config-modules
modules: [
// https://go.nuxtjs.dev/axios
'@nuxtjs/axios',
'@nuxtjs/auth-next',
'@nuxtjs/eslint-module',
"@nuxtjs/device",
"@pinia/nuxt"
],
// Axios module configuration: https://go.nuxtjs.dev/config-axios
// axios: {
// // Workaround to avoid enforcing hard-coded localhost:3000: https://github.com/nuxt-community/axios-module/issues/308
// baseURL: '/',
// },
axios: {
baseURL: process.env.BACKEND_BASE_URL || 'http://localhost:8080/'
css: ["~/assets/css/main.scss"],
ssr: false,
devServer: {
port: Number(process.env.PORT) || 3000
},
// https://www.wolfpack-digital.com/blogposts/nuxt-auth-refresh-token-authentication-in-your-nuxt-app
// auth: {
// redirect: {
// login: '/login',
// logout: '/',
// home: "/login"
// },
// // localStorage: true,
// // cookie: {
// // prefix: 'auth',
// // options: {
// // path: "/home",
// // maxAge: 1000
// // }
// // },
// strategies: {
// local: {
// token: {
// property: 'token',
// maxAge: 1800,
// global: true
// },
// user: {
// property: false,
// autoFetch: false
// },
// refreshToken: {
// property: 'refreshToken',
// data: 'refresh_token',
// maxAge: 60 * 60 * 24 * 30
// },
// endpoints: {
// login: {url: 'auth/signin', method: 'post' },
// refresh: {url: 'auth/refreshtoken', method: 'post'},
// user: {url: 'auth/me', method: 'get'},
// logout: {url: 'auth/logout', method: 'post'}
// }
// }
// }
// },
auth:{
redirect:{
login:'/',
logout:'/',
home:"/redirect"
},
localStorage: true,
cookie: {
prefix:'auth',
options:{
path:"/redirect",
maxAge:1000
}
},
strategies:{
local:{
token:{
property:'token',
global:true
},
user:{
property: ''
},
endpoints:{
login: {url: 'auth/signin', method: 'post' },
refresh: {url: 'auth/refreshtoken', method: 'post'},
user: {url: 'auth/me', method: 'get' },
logout: {url: 'auth/logout', method: 'post'}
runtimeConfig: {
public: {
baseURL: process.env.BACKEND_BASE_URL || "http://localhost:8080"
}
},
components: [
{
path: "~/components",
pathPrefix: false
}
],
pinia: {
autoImports: [
// automatically imports `defineStore`
"defineStore", // import { defineStore } from 'pinia'
["defineStore", "definePiniaStore"] // import { defineStore as definePiniaStore } from 'pinia'
]
},
vite: {
css: {
preprocessorOptions: {
scss: {
additionalData:
'@use "@/assets/css/_color.scss" as *;@use "@/assets/css/_mixin.scss" as *;@use "@/assets/css/_font.scss" as *;@use "@/assets/css/_spacing.scss" as *;'
}
}
}
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {},
typescript: {
typeCheck: {
eslint: {
files: './**/*.{ts,vue}'
}
}
}
}
compatibilityDate: "2024-07-06"
});

37801
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,26 @@
{
"name": "boussole-pluss-frontend",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"dev": "nuxt dev",
"generate": "nuxt generate",
"lint:js": "eslint --ext .js,.ts,.vue .",
"lint:prettier": "prettier --check .",
"lint": "npm run lint:js && npm run lint:prettier",
"lintfix": "prettier --write --list-different . && npm run lint:js -- --fix",
"test": "jest"
},
"dependencies": {
"@nuxtjs/auth-next": "5.0.0-1648802546.c9880dc",
"@nuxtjs/axios": "^5.13.6",
"chart.js": "^3.9.1",
"core-js": "^3.19.3",
"nuxt": "^2.15.8",
"nuxt-property-decorator": "^2.9.1",
"vue": "^2.6.14",
"vue-chartjs": "^4.1.1",
"vue-server-renderer": "^2.6.14",
"vue-template-compiler": "^2.6.14",
"webpack": "^4.46.0"
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"devDependencies": {
"@babel/eslint-parser": "^7.16.3",
"@nuxt/types": "^2.15.8",
"@nuxt/typescript-build": "^2.1.0",
"@nuxtjs/eslint-config-typescript": "^8.0.0",
"@nuxtjs/eslint-module": "^3.0.2",
"@vue/test-utils": "^1.3.0",
"babel-core": "7.0.0-bridge.0",
"babel-jest": "^27.4.4",
"eslint": "^8.0.1",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-nuxt": "^3.1.0",
"eslint-plugin-vue": "^8.2.0",
"jest": "^27.4.4",
"prettier": "^2.5.1",
"sass": "^1.54.5",
"sass-loader": "^10.3.1",
"ts-jest": "^27.1.1",
"vue-jest": "^3.0.4"
"@nuxt/devtools": "^1.3.9",
"@nuxtjs/device": "^3.1.1",
"nuxt": "^3.12.3",
"sass": "^1.77.6",
"vue": "^3.4.31",
"vue-router": "^4.4.0"
},
"dependencies": {
"@pinia/nuxt": "^0.5.1",
"chart.js": "^4.4.3",
"pinia": "^2.1.7",
"vue-chartjs": "^5.3.1"
}
}

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
import {useNotificationStore} from "~/store/notification";
import {useAccountStore} from "~/store/account";
import type {ApiError} from "~/composables/fetch-api";
definePageMeta({
layout: 'main-header'
});
const email = ref();
const username = ref();
const emailConfirmation = ref();
const password = ref();
const confirmationPassword = ref();
const conditionChecked = ref(false);
const cguModalVisible = ref(false);
function createAccount() {
if (emailConfirmation.value !== email.value) {
useNotificationStore().pushNotification("warn", {message: "Saisir le même e-mail dans les champs 'E-mail' et 'Confirmation de l'e-mail'."});
} else if (password.value !== confirmationPassword.value) {
useNotificationStore().pushNotification("warn", {message: "Saisir le même mot de passe dans les champs 'Mot de passe' et 'Confirmation du mot de passe'."});
} else {
useAccountStore().create(username.value, email.value, password.value)
.then(() => {
useNotificationStore().pushNotification("success", {
message: "Votre compte a bien été créé.",
details: "Vous allez recevoir un e-mail."
});
navigateTo("/login");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
});
}
}
</script>
<template>
<div>
<section>
<h1>Créer un compte</h1>
<form class="form" @submit.prevent="createAccount">
<label for="username">Nom de l'équipe *</label>
<input id="username" v-model="username" type="text" autocomplete="username" required>
<label for="email">E-mail *</label>
<input id="email" v-model="email" type="email" autocomplete="email" required>
<label for="emailConfirmation">Confirmation de l'e-mail *</label>
<input id="emailConfirmation" v-model="emailConfirmation" type="email" required>
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une
majuscule, une minuscule,
un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:</p>
<label for="newPassword">Mot de passe *</label>
<input id="newPassword" v-model="password" type="password" required>
<label for="confirmationPassword">Confirmation du mot de passe *</label>
<input id="confirmationPassword" v-model="confirmationPassword" type="password" required>
<label>
<input v-model="conditionChecked" type="checkbox" required/>
En continuant, jaccepte
<button class="button-link" @click="cguModalVisible = true" type="button">les conditions d'utilisation de Boussole PLUSS et jai lu la politique de
confidentialité
</button>
</label>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/login" aria-label="Retour à la page précédente"></nuxt-link>
<button class="button orange" type="submit" :disabled="!conditionChecked">Enregistrer</button>
</div>
</form>
</section>
</div>
<cgu-modal :visible="cguModalVisible" @close="cguModalVisible = false; conditionChecked = false;" @validate="conditionChecked = true; cguModalVisible = false"/>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
.form {
input[type="checkbox"] {
grid-column: span 2 / 3;
margin-top: $small;
}
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import {useNotificationStore} from "~/store/notification";
import {useAuthStore} from "~/store/auth";
import type {ApiError} from "~/composables/fetch-api";
const email = ref(useAuthStore().user.email);
const username = ref(useAuthStore().user.username);
const emailConfirmation = ref();
function updateAccount() {
if (emailConfirmation.value !== email.value) {
useNotificationStore().pushNotification("warn", {message: "Saisir le même e-mail dans les champs 'E-mail' et 'Confirmation de l'e-mail'."});
} else {
useAccountStore().update(username.value, email.value)
.then(async () => {
if (useAuthStore().user.email !== email.value) {
await useAuthStore().refreshSession();
}
useNotificationStore().pushNotification("success", {message: "Votre compte a bien été mis à jour."});
useAuthStore().user.username = username.value;
navigateTo("/bundle");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
});
}
}
</script>
<template>
<div>
<section>
<h1>Mon compte</h1>
<form class="form" @submit.prevent="updateAccount">
<label for="username">Nom de l'équipe *</label>
<input id="username" v-model="username" type="text" autocomplete="username" required>
<label for="email">E-mail *</label>
<input id="email" v-model="email" type="email" autocomplete="email" required>
<label for="emailConfirmation">Confirmation de l'e-mail *</label>
<input id="emailConfirmation" v-model="emailConfirmation" type="email" required>
<button class="button orange" type="submit">Enregistrer</button>
</form>
<div class="button-container">
<button class="button gray button-back" @click="useRouter().back()" aria-label="Retour à la page précédente">
</button>
<nuxt-link to="/account/password" class="button blue" type="submit">Modifier mon mot de passe</nuxt-link>
</div>
</section>
</div>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
.form {
& [type="submit"] {
grid-column: span 2 / 3;
margin-top: $x_small;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import type {ApiError} from "~/composables/fetch-api";
import {useNotificationStore} from "~/store/notification";
definePageMeta({
layout: 'main-header'
});
const email = ref();
const newPassword = ref();
const confirmationPassword = ref();
onMounted(() => {
if (!useRoute().query.token) {
navigateTo("/");
}
});
function resetPassword() {
const token = useRoute().query.token;
useAccountStore().resetPassword(token, email.value, newPassword.value, confirmationPassword.value)
.then(() => {
useNotificationStore().pushNotification("success", {message: "Votre mot de passe a bien été ré-initialisé."});
navigateTo("/login");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
});
}
</script>
<template>
<section>
<h1>-initialisé mon mot de passe</h1>
<form class="form" @submit.prevent="resetPassword">
<label for="email">Mon e-mail</label>
<input id="email" v-model="email" type="email" autocomplete="username" required>
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une
majuscule, une minuscule, un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:</p>
<label for="newPassword">Nouveau mot de passe</label>
<input id="newPassword" v-model="newPassword" type="password" required>
<label for="confirmationPassword">Confirmation du mot de passe</label>
<input id="confirmationPassword" v-model="confirmationPassword" type="password" required>
<div class="button-container">
<button class="button orange" type="submit">Enregistrer</button>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,46 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import type {ApiError} from "~/composables/fetch-api";
import {useNotificationStore} from "~/store/notification";
const currentPassword = ref();
const newPassword = ref();
const confirmationPassword = ref();
function updatePassword() {
useAccountStore().updatePassword(currentPassword.value, newPassword.value, confirmationPassword.value)
.then(() => {
useNotificationStore().pushNotification("success",{message: "Votre mot de passe a bien été modifié."});
navigateTo("/account");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
});
}
</script>
<template>
<section>
<h1>Modifier mon mot de passe</h1>
<form class="form" @submit.prevent="updatePassword">
<label for="currentPassword">Mot de passe actuel</label>
<input id="currentPassword" v-model="currentPassword" type="password" autocomplete="currentPassword" required>
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé dau moins une
majuscule, une minuscule,
un chiffre de 0 à 9, et un caractère spécial parmi @?!#$&;,:</p>
<label for="newPassword">Nouveau mot de passe</label>
<input id="newPassword" v-model="newPassword" type="password" required>
<label for="confirmationPassword">Confirmation du mot de passe</label>
<input id="confirmationPassword" v-model="confirmationPassword" type="password" required>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/account" aria-label="Retour à la page précédente"></nuxt-link>
<button class="button orange" type="submit">Enregistrer</button>
</div>
</form>
</section>
</template>

View File

@@ -0,0 +1,58 @@
<script lang="ts" setup>
import {useAccountStore} from "~/store/account";
import {useNotificationStore} from "~/store/notification";
import type {ApiError} from "~/composables/fetch-api";
definePageMeta({
layout: 'main-header'
});
const email = ref();
const loading = ref(false);
function sendEmail() {
loading.value = true;
useAccountStore()
.requestPasswordReset(email.value)
.then(() => {
useNotificationStore().pushNotification("success",{message: "Consultez vos emails pour réinitialiser votre mot de passe."})
navigateTo("login");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
})
.finally(() => {
loading.value = false;
});
}
</script>
<template>
<section>
<h1>Mot de passe oublié</h1>
<form class="form" @submit.prevent="sendEmail">
<p>Entrez votre email pour recevoir un lien permettant de réinitialiser le mot de passe associé à votre
compte.</p>
<input v-model="email" type="email" placeholder="E-mail" required/>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/login" aria-label="Retour à la page de login"></nuxt-link>
<button class="button orange" type="submit">Envoyer l'e-mail</button>
</div>
<loader v-if="loading"/>
</form>
</section>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
.form {
display: flex;
flex-direction: column;
gap: $x_small;
}
</style>

View File

@@ -0,0 +1,175 @@
<script lang="ts" setup>
import {type QuestionCreation, useBundleStore} from "~/store/bundle";
import {useNotificationStore} from "~/store/notification";
import type {ApiError} from "~/composables/fetch-api";
import {type Axe, useAxeStore} from "~/store/axe";
import {type Question, useQuestionStore} from "~/store/question";
const axes = ref<Axe[]>();
const label = ref();
const questions = ref<Map<number, QuestionCreation[]>>(new Map());
const questionsExample = ref<Map<number, QuestionCreation[]>>(new Map());
const modalVisible = ref(false);
const currentAxe = ref<Axe>();
const currentQuestions = ref<Question[]>();
onMounted(() => {
useAxeStore().findAxes().then(response => {
response.forEach(axe => {
useQuestionStore().findDefaults(axe.id).then(response => {
questions.value.set(axe.id, response);
});
useQuestionStore().findAll(axe.id).then(response => {
questionsExample.value.set(axe.id, response);
});
});
axes.value = response;
});
});
function createBundle() {
const newQuestions = [];
let errors = [];
questions.value.forEach((value, axeId) => {
if (value.length === 0) {
const axeNumber = axes.value.filter(a => a.id === axeId)[0].identifier;
errors.push(`L'axe ${axeNumber} n'a pas de question.`);
}
value.forEach((q: Question, index)=> {
newQuestions.push({
axeId: axeId,
label: q.label,
description: q.description,
index: index+1
});
if (!q.label.trim()) {
const axeNumber = axes.value.filter(a => a.id === axeId)[0].identifier;
errors.push(`L'une des question de l'axe ${axeNumber} contient un libellé non rempli.`);
}
});
});
if (errors.length > 0) {
useNotificationStore().pushNotification("warn",
{message: `La boussole contient des erreurs !`, details: errors});
}else {
useBundleStore()
.create({
label: label.value,
questions: newQuestions
})
.then(() => {
useNotificationStore().pushNotification("success",{message: "La boussole a bien été créée."});
navigateTo("/bundle");
})
.catch((apiError: ApiError) => {
let details;
if (apiError.fieldErrors) {
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
}
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
});
}
}
// const questionsInput = computed<boolean>(() => {
// let emptyAxe = 0;
// for (const [key, value] of questions.value.entries()) {
// if (value.length === 0) {
// emptyAxe += 1;
// }
// console.log(`${key}: ${value.length}`);
// }
// return emptyAxe === questions.value.keys().length;
// });
function css(axe: Axe) {
return {
'--color': axe.color
}
}
function showAxeModal(axe: Axe) {
currentAxe.value = axe;
modalVisible.value = true;
currentQuestions.value = questions.value.get(axe.id);
document.body.style.overflowY = "hidden";
}
function hideAxeModal() {
currentAxe.value = undefined;
currentQuestions.value = undefined;
modalVisible.value = false;
document.body.style.overflowY = "auto";
}
function onQuestionsChange({axeId, newQuestions}) {
questions.value.set(axeId, newQuestions);
}
</script>
<template>
<h1>Créer une nouvelle boussole</h1>
<section>
<form class="form" @submit.prevent="createBundle">
<label for="label">Nom de la boussole *</label>
<input id="label" v-model="label" type="text" required maxlength="50">
<ul class="axe-list">
<li class="axe-list__item" v-for="axe in axes" :style="css(axe)">
<h2>{{ axe.identifier }} - {{ axe.shortTitle }}</h2>
<div class="axe-list__item__content">
<p>{{ axe.title }}</p>
<button class="button blue" type="button" @click="showAxeModal(axe)">Configurer les
questions ({{ questions.get(axe.id) ? questions.get(axe.id).length: 0}})
</button>
</div>
</li>
</ul>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/bundle" aria-label="Précédent"></nuxt-link>
<button class="button orange">Valider</button>
</div>
</form>
</section>
<bundle-axe-modal v-if="currentAxe" :visible="modalVisible"
:axe="currentAxe"
:questions="questions.get(currentAxe.id)"
:questions-example="questionsExample.get(currentAxe.id)"
@close="hideAxeModal()"
@changed="(axeId, newQuestions) => onQuestionsChange(axeId, newQuestions)"/>
</template>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
@import "assets/css/font";
.axe-list {
display: flex;
flex-direction: column;
list-style: none;
gap: $xxx_small;
margin: 0;
&__item {
padding: $xx_small 0;
border-bottom: 2px solid var(--color);
h2 {
font-size: $secondary-font-size;
margin: 0;
}
&__content {
display: flex;
align-items: center;
justify-content: space-between;
gap: $xx_small;
}
}
}
</style>

View File

@@ -0,0 +1,95 @@
<script lang="ts" setup>
import {type Bundle, useBundleStore} from "~/store/bundle";
const bundles = ref<Bundle[]>([]);
const loading = ref(false);
onMounted(() => {
loading.value = true;
useBundleStore().findAll().then((response: Quiz[]) => {
bundles.value = response;
}).finally(() => {
loading.value = false;
});
});
function selectBundle(bundle: Bundle) {
useBundleStore().setCurrentBundle(bundle);
navigateTo("/dashboard");
}
</script>
<template>
<section v-if="!loading">
<ul class="bundle-list">
<li v-for="bundle in bundles">
<article class="bundle-list__item">
<main>
<h1>{{ bundle.label }}</h1>
<dl class="bundle-list__item__attribute">
<dt>Dernière auto-évaluation réalisée</dt>
<dd>{{bundle.lastQuizzDate ? formatDate(bundle.lastQuizzDate): 'NA' }}</dd>
</dl>
<dl class="bundle-list__item__attribute">
<dt>Nombre d'auto-évaluation</dt>
<dd>{{ bundle.numberOfQuizzes || 0 }}</dd>
</dl>
</main>
<footer class="bundle-list__item__button-container">
<button class="button blue" @click="selectBundle(bundle)">Voir</button>
</footer>
</article>
</li>
</ul>
<div class="button-container">
<nuxt-link to="/bundle/create">Nouvelle boussole</nuxt-link>
</div>
</section>
<loader v-else/>
</template>
<style lang="scss" scoped>
.bundle-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));
grid-gap: $x_small;
list-style: none;
margin-top: $medium;
&__item {
display: flex;
flex-direction: column;
gap: $x_small;
padding: $x_small;
border-radius: 20px;
@include border-shadow();
h1 {
margin: 0 0 $x_small 0;
}
&__attribute {
display: flex;
gap: $xxx_small;
dt {
font-weight: bold;
&:after {
content: ' :';
}
}
}
&__button-container {
display: flex;
justify-content: center;
}
}
}
</style>

View File

@@ -1,74 +1,61 @@
<template>
<div class="content">
<team-header/>
<hr/>
<div v-if="loading" class="center" >
<loader/>
</div>
<section v-if="currentResult" class="last-quiz">
<div class="last-quiz-header">
<span class="date"> {{ currentResult.createdDate | formatDate }}</span>
<nuxt-link
class="link" :to="{ path: '/details', query: { quiz: currentResult.id }}">
+ Voir le détail
</nuxt-link>
</div>
<div v-if="currentResult && currentResult.scores" class="chart-area">
<polar-area-chart :data="currentResult.scores.map(value => value.scoreAvg)"/>
<Legend/>
</div>
</section>
<section v-if="quizzes.length > 0" class="history" >
<div v-for="q in quizzes" :key="q.id">
<button @click="setCurrent(q)"><span>Boussole - {{ q.createdDate | formatDate }}</span><span></span></button>
</div>
</section>
<section v-else-if="!loading" class="center">
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
</section>
<div class="button-container">
<nuxt-link class="button orange" to="/quiz">Nouveau</nuxt-link>
</div>
</div>
</template>
<script lang="ts" setup>
<script lang="ts">
import {Component, Vue} from "vue-property-decorator";
import {AxiosResponse} from "axios";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import {RestResponse} from "~/repositories/models/rest-response.model";
import {Quiz} from "~/repositories/models/quiz.model";
import {type Quiz, useQuizStore} from "~/store/quiz";
import {useBundleStore} from "~/store/bundle";
@Component
export default class History extends Vue {
const quizzes = ref<Quiz[]>([]);
const currentResult = ref<Quiz>();
readonly quizRepository = RepositoryFactory.get('quiz');
onMounted(() => {
useQuizStore().findQuizzes(useBundleStore().selectedBundle).then((response: Page<Quiz>) => {
quizzes.value = response.content;
currentResult.value = quizzes.value.length > 0 ? quizzes.value[0] : null;
});
});
private quizzes: Quiz[] = [];
private currentResult: Quiz | null = null;
private loading = true;
async mounted() {
await this.quizRepository.findMine().then((response: AxiosResponse<RestResponse<Quiz>>) => {
this.quizzes = response.data._embedded.quizzes;
});
this.currentResult = this.quizzes.length > 0 ? this.quizzes[0] : null;
this.loading = false;
}
private setCurrent(quiz: Quiz) {
this.currentResult = quiz;
}
function setCurrent(quiz: Quiz) {
currentResult.value = quiz;
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/spacing";
@import "assets/css/font";
<template>
<section v-if="currentResult" class="last-quiz">
<div class="last-quiz-header">
<span class="date"> {{ formatDateTime(currentResult.createdDate) }}</span>
<nuxt-link
class="link" :to="{ path: '/details', query: { quiz: currentResult.id }}">
+ Voir le détail
</nuxt-link>
</div>
<div v-if="currentResult && currentResult.axes" class="chart-area">
<polar-area-chart :data="currentResult.axes.map(value => value.average)"/>
<Legend/>
</div>
</section>
<section v-if="quizzes.length > 0">
<ul class="history">
<li class="history__item" v-for="q in quizzes">
<input :id="q.id" type="radio" @change="setCurrent(q)" :checked="q === currentResult" name="quiz"/>
<label :for="q.id">
<span>Boussole - {{ formatDateTime(q.createdDate) }}</span><span></span>
</label>
</li>
</ul>
</section>
<section v-else class="center">
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
</section>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/bundle" aria-label="Retour à la page précédente"></nuxt-link>
<nuxt-link class="button orange" to="/quiz">Nouveau</nuxt-link>
</div>
</template>
<style lang="scss" scoped>
.last-quiz {
margin-bottom: $x_small 0;
margin: $x_small 0;
}
.last-quiz-header {
@@ -80,47 +67,51 @@ export default class History extends Vue {
color: $gray_4;
font-weight: 700;
}
.link {
color: $blue;
}
}
.chart-area {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: $x_small 0;
gap: $x_small;
}
.history {
margin-top: $x_small;
}
.history button {
display: flex;
width: 100%;
justify-content: space-between;
padding: 16px;
margin: $xxx_small 0;
flex-direction: column;
gap: $xxx_small;
list-style: none;
background: $gray_1;
border-radius: 8px;
border: none;
color: $gray_4;
font-weight: 700;
font-size: $tertiary-font-size;
.history__item {
&:hover {
text-decoration: none;
cursor: pointer;
background: $gray_3;
color: $gray_1;
input {
position: absolute;
opacity: 0;
&:checked + label {
outline: 3px solid $gray_4;
}
}
label {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: $gray_1;
border-radius: 8px;
color: $gray_4;
font-weight: 700;
font-size: $tertiary-font-size;
&:hover {
text-decoration: none;
cursor: pointer;
background: $gray_3;
color: $gray_1;
}
}
}
}
.button-container {
margin-bottom: $x_small;
}
</style>

View File

@@ -1,91 +1,76 @@
<script lang="ts" setup>
import {type AxeResponses, type QuizResponse, useQuizStore} from "~/store/quiz";
import Quiz from "~/pages/quiz.vue";
import {type Axe, useAxeStore} from "~/store/axe";
const loading = ref(true);
const quiz = ref<Quiz>();
const axes = ref<Axe[]>();
onMounted(() => {
if (!useRoute().query.quiz) {
navigateTo("/dashboard");
}
loading.value = true;
const quizId = Number.parseInt(useRoute().query.quiz as string);
useQuizStore().findById(quizId)
.then((result: Quiz) => {
quiz.value = result;
})
.then(() => {
useAxeStore().findAxes().then(result => {
axes.value = result.filter(axe => {
return quiz.value.axes.filter(axeResponse => axeResponse.axeIdentifier === axe.identifier).length > 0;
});
});
})
.finally(() => {
loading.value = false;
});
});
function getAverage(axe: Axe): number {
const axeResponses = quiz.value.axes.filter((response: AxeResponses) => response.axeIdentifier === axe.identifier);
if (axeResponses.length === 1) {
return axeResponses[0].average;
}
return 0;
}
function getResponses(axe: Axe): QuizResponse[] {
const axeResponses = quiz.value.axes.filter((response: AxeResponses) => response.axeIdentifier === axe.identifier);
if (axeResponses.length === 1) {
return axeResponses[0].responses;
}
return [];
}
function print() {
window.print();
}
</script>
<template>
<div class="content">
<team-header/>
<hr/>
<div v-if="!loading">
<span class="date">{{ quiz.createdDate | formatDate }}</span>
<quiz-axe-details
v-for="axe in axes"
:key="axe.identifier"
:axe="axe"
:score="getScore(axe)"
:responses="getResponses(axe)"/>
</div>
<loader v-else class="center"/>
<div class="button-container">
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
</div>
<section v-if="!loading">
<span class="date">{{ formatDateTime(quiz.createdDate) }}</span>
<quiz-axe-details
v-for="axe in axes"
:axe="axe"
:average="getAverage(axe)"
:responses="getResponses(axe)"/>
</section>
<loader v-else class="center"/>
<div class="button-container">
<nuxt-link class="button gray button-back" to="/dashboard" aria-label="Retour à l'accueil"></nuxt-link>
<button class="button orange button-print" @click="print">Imprimer</button>
</div>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
import {AxiosResponse} from "axios";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import {Quiz, ResponseWithQuestion, Score} from "~/repositories/models/quiz.model";
import QuizAxeDetails from "~/components/QuizAxeDetails.vue";
import {Axe} from "~/repositories/models/axe.model";
import {RestResponse} from "~/repositories/models/rest-response.model";
@Component({
components: {QuizAxeDetails}
})
export default class Result extends Vue {
readonly axeRepository = RepositoryFactory.get('axe');
readonly quizRepository = RepositoryFactory.get('quiz');
private axes: Axe[] = [];
private quiz: Quiz | null = null;
private responses: ResponseWithQuestion[] = [];
private loading = false;
created() {
if (!this.$route.query.quiz) {
this.$router.push("/dashboard");
}
try {
this.loading = true;
const quizId = Number.parseInt(this.$route.query.quiz as string);
this.quizRepository.findById(quizId)
.then((response: AxiosResponse<Quiz>) => {
this.quiz = response.data;
this.responses = response.data._embedded.responses;
return response;
})
.then(() => {
return this.axeRepository.findAll()
.then((response: AxiosResponse<RestResponse<Axe>>) => {
this.axes = response.data._embedded.axes;
return response;
});
})
.finally(() => {
this.loading = false;
});
} catch (e: any) {
console.info("error", e);
this.loading = false;
}
}
getScore(axe: Axe): Score {
const responses = this.getResponses(axe);
return {
axeIdentifier: axe.identifier,
scoreAvg: responses.reduce((total, response) => total + response.score, 0) / responses.length
};
}
getResponses(axe: Axe) {
return this.responses.filter((response: ResponseWithQuestion) => response.axeIdentifier === axe.identifier);
}
}
</script>
<style lang="scss" scoped>
@import "assets/css/color";
@import "assets/css/font";
section {
margin: $x_small 0;
}
.date {
font-weight: 700;
@@ -93,4 +78,10 @@ export default class Result extends Vue {
color: $gray_4;
}
@media print {
.button-container {
display: none;
}
}
</style>

14
frontend/pages/error.vue Normal file
View File

@@ -0,0 +1,14 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps({
error: Object as () => NuxtError
})
</script>
<template>
<div>
<h1>{{ error.statusCode }}</h1>
<nuxt-link to="/">Go back home</nuxt-link>
</div>
</template>

View File

@@ -1,3 +1,9 @@
<script setup lang="ts">
definePageMeta({
layout: 'main-header'
});
</script>
<template>
<Home />
<home />
</template>

View File

@@ -1,80 +1,60 @@
<template>
<div>
<Header/>
<form id="login_form" class="content login" @submit.prevent="authenticate">
<input v-model="username" type="text" required autocomplete="username" placeholder="Nom de l'équipe" aria-label="Nom de l'équipe"/>
<input id="code" v-model="password" type="password" required autocomplete="current-password" placeholder="Code" aria-label="Code"/>
<div>
<label>
<input v-model="conditionChecked" type="checkbox" required/>
En continuant, jaccepte les conditions d'utilisation de Boussole PLUSS et jai lu la politique de
confidentialité
</label>
</div>
<button class="button blue" type="submit" :disabled="!conditionChecked">
Continuer
</button>
<div class="error-container">{{ error }}</div>
</form>
</div>
</template>
<script lang="ts" setup>
import {useAuthStore} from "~/store/auth";
import {useNotificationStore} from "~/store/notification";
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
import {HTTPResponse} from "@nuxtjs/auth-next/dist";
definePageMeta({
layout: 'main-header'
});
@Component
export default class Login extends Vue {
private username = "";
private password = "";
private conditionChecked: boolean = false;
private error = "";
const email = ref("");
const password = ref("");
async authenticate() {
try {
this.error = "";
const response = await this.$auth.loginWith('local', {
data: {
username: this.username,
password: this.password
}
}) as HTTPResponse;
if (response && response.data) {
const { token } = response.data;
this.$axios.defaults.headers.common = { Authorization: `Bearer ${token}` };
} else {
console.error("Unable to login, no data in HTTP response")
function authenticate() {
useAuthStore()
.login(email.value, password.value)
.then(() => {
if (useAuthStore().authenticated) {
navigateTo("/bundle");
}
} catch (e: any) {
this.error = e;
console.info("error", e);
}
}
})
.catch(e => {
useNotificationStore().pushNotification("warn", e);
});
}
</script>
<template>
<form class="login" @submit.prevent="authenticate">
<input v-model="email" type="email" required autocomplete="username" placeholder="E-mail" aria-label="E-mail"/>
<input id="code" v-model="password" type="password" required autocomplete="current-password"
placeholder="Mot de passe" aria-label="Mot de passe"/>
<button class="button orange" type="submit">
Continuer
</button>
<nuxt-link class="link-forget-password" to="/account/password/reset">J'ai oublié mon mot de passe</nuxt-link>
<nuxt-link class="button blue" to="/account/create">Créer un compte</nuxt-link>
</form>
</template>
<style lang="scss" scoped>
@import "assets/css/spacing";
form.login {
input[type="text"] {
margin: $x_small 0;
}
input[type="password"] {
margin-bottom: $small;
}
display: flex;
flex-direction: column;
gap: $xxx_small;
padding: $medium 0;
button {
margin: $medium 0;
margin-block: $medium 0;
}
}
.error-container {
display: flex;
justify-content: center;
color: red;
.link-forget-password {
align-self: center;
margin-block: $xx_small $x_medium;
}
}
</style>

View File

@@ -1,9 +1,78 @@
<template>
<div class="content">
<team-header/>
<hr/>
<div v-if="!loading">
<script lang="ts" setup>
import type {Axe} from "~/store/axe";
const currentAxe = ref<Axe>();
const currentAxeIdentifier = ref(1);
const questions = computed(() => useQuizStore().questions);
const axes = computed(() => useQuizStore().axes);
const loading = ref(true);
const saving = ref(false);
const isFullRated = ref(false);
import {useQuizStore} from "~/store/quiz";
const store = useQuizStore();
onMounted(() => {
loading.value = true;
store.initialize().finally(()=> {
store.resetResponses();
initializeState();
loading.value = false;
});
});
function showPrevious() {
if (currentAxeIdentifier.value > 1) {
currentAxeIdentifier.value--;
initializeState();
}
}
function showNext() {
if (currentAxeIdentifier.value < axes.value.length) {
currentAxeIdentifier.value++;
initializeState();
setTimeout(() => {
scrollTop();
}, 50)
}
}
function initializeState() {
currentAxe.value = axes.value.find(value => value.identifier === currentAxeIdentifier.value);
const questions = store.questionsRatedPerAxe.get(currentAxeIdentifier.value);
if (questions) {
isFullRated.value = questions.filter(value => !value.rated).length == 0;
} else {
isFullRated.value = false;
}
}
function scrollTop() {
window.scrollTo({
top: 60,
behavior: "smooth",
});
}
function saveResult() {
store.save().then((response) => {
navigateTo({path: "result", query: {quiz: response.id + ""}});
});
}
function onRate(event: { isFullRated: boolean }) {
console.dir(event.isFullRated);
isFullRated.value = event.isFullRated;
}
</script>
<template>
<div v-if="!loading && questions && questions.get(currentAxe.identifier)">
<quiz-part
:key="currentAxe.identifier" :axe-number="currentAxe.identifier" :total-axes="axes.length"
:title="currentAxe.title"
@@ -17,18 +86,18 @@
<div class="button-container">
<nuxt-link
v-if="currentAxeIdentifier <= 1"
class="button gray"
class="button gray button-back"
to="/dashboard" aria-label="Précédent">
</nuxt-link>
<button v-if="currentAxeIdentifier > 1" class="button gray" @click="showPrevious"></button>
<button
v-if="currentAxeIdentifier < axes.length" class="button blue" :disabled="!isFilled"
v-if="currentAxeIdentifier < axes.length" class="button blue" :disabled="!isFullRated"
@click="showNext"
>Suivant
</button>
<button
v-if="currentAxeIdentifier >= axes.length" class="button orange"
:disabled="!isFilled || saving" @click="saveResult()"
:disabled="!isFullRated || saving" @click="saveResult()"
>Valider
</button>
</div>
@@ -36,137 +105,4 @@
<div v-else class="center">
<loader/>
</div>
</div>
</template>
<script lang="ts">
import {AxiosResponse} from "axios";
import {Component, Vue} from "nuxt-property-decorator";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import {Axe} from "~/repositories/models/axe.model";
import {RestResponse} from "~/repositories/models/rest-response.model";
import {Question} from "~/repositories/models/question.model";
import {Quiz} from "~/repositories/models/quiz.model";
import {quizStore} from "~/utils/store-accessor";
@Component
export default class Login extends Vue {
readonly axeRepository = RepositoryFactory.get("axe");
readonly questionRepository = RepositoryFactory.get("question");
private axes: Axe[] = [];
private currentAxe?: Axe | undefined;
private currentAxeIdentifier = 1;
private questions: Map<number, Question[]> = new Map<number, []>();
private loading = true;
private saving = false;
private isFullRated = false;
mounted() {
this.loading = true;
this.axeRepository
.findAll()
.then((response: AxiosResponse<RestResponse<Axe>>) => {
this.axes = response.data._embedded.axes;
const promises: any[] = [];
this.axes.forEach(axe => {
promises.push(
this.questionRepository
.findAllByAxeId(axe.identifier)
.then((response: AxiosResponse<RestResponse<Question>>) => {
return {
axeId: axe.identifier,
questions: response.data._embedded.questions
};
}));
});
Promise.all(promises).then((axeQuestions) => {
axeQuestions.forEach(axeQuestion => {
this.questions.set(axeQuestion.axeId, axeQuestion.questions)
});
quizStore.initialize(this.questions);
this.initializeState();
this.loading = false;
});
});
}
showPrevious() {
if (this.currentAxeIdentifier > 1) {
this.currentAxeIdentifier--;
this.initializeState();
}
}
showNext() {
if (this.currentAxeIdentifier < this.axes.length) {
this.currentAxeIdentifier++;
this.initializeState();
setTimeout(() => {
this.scrollTop();
}, 50)
}
}
initializeState() {
this.currentAxe = this.axes.find(value => value.identifier === this.currentAxeIdentifier);
const questions = quizStore.questionsRatedPerAxe.get(this.currentAxeIdentifier);
const unratedQuestions = questions ? questions.filter(value => !value.rated) : [];
this.isFullRated = unratedQuestions.length === 0;
}
scrollTop() {
window.scrollTo({
top: 60,
behavior: "smooth",
});
}
saveResult() {
const responsesFormatted: { score: number; comment?: string; questionId: number }[] = [];
quizStore.responses.forEach((value, key) => {
responsesFormatted.push({
score: value.score ? value.score : 0,
comment: value.comment,
questionId: key
});
});
this.saving = true;
RepositoryFactory.get('quiz').save(responsesFormatted).then((response: AxiosResponse<Quiz>) => {
this.saving = false;
quizStore.reset();
this.$router.push({path: "/result", query: {quiz: response.data.id + ""}});
});
}
onRate(event: { isFullRated: boolean }) {
this.isFullRated = event.isFullRated;
}
get isFilled() {
return this.isFullRated;
}
}
</script>
<style lang="scss" scoped>
.button-container {
display: flex;
flex-direction: row;
justify-content: center;
> a {
margin-right: 5px;
}
> button {
margin-left: 5px;
}
}
</style>

View File

@@ -1,20 +0,0 @@
<template>
<div>
Redirect
</div>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
@Component
export default class Redirect extends Vue {
created(){
if (this.$auth.user) {
this.$router.push(`/dashboard`);
} else {
window.location.reload()
}
}
}
</script>

View File

@@ -1,48 +1,32 @@
<template>
<div class="content">
<team-header/>
<hr/>
<section>
<h1>Bravo !</h1>
<p class="text-center">Merci pour votre contribution à la production locale et bravo pour votre implication.</p>
<div v-if="!loading" class="chart-area">
<polar-area-chart :data="scores"/>
<Legend/>
</div>
<loader v-else class="center"/>
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
<script lang="ts" setup>
</section>
</div>
import {type Quiz, useQuizStore} from "~/store/quiz";
const scores = ref<number[]>();
const loading = ref(true);
onMounted(() => {
loading.value = true;
useQuizStore().findById(useRoute().query.quiz).then((quiz: Quiz) => {
scores.value = quiz.axes.map(value => value.average);
})
.finally(() => loading.value = false);
});
</script>
<template>
<section>
<h1>Bravo !</h1>
<p class="text-center">Merci pour votre contribution à la production locale et bravo pour votre implication.</p>
<div v-if="!loading" class="chart-area">
<polar-area-chart :data="scores"/>
<Legend/>
</div>
<loader v-else class="center"/>
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
</section>
</template>
<script lang="ts">
import {Component, Vue} from "nuxt-property-decorator";
import {AxiosResponse} from "axios";
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
import { Score} from "~/repositories/models/quiz.model";
@Component
export default class Result extends Vue {
readonly quizRepository = RepositoryFactory.get('quiz');
private scores: number[] = [];
private loading = false;
mounted() {
this.loading = true;
try {
this.quizRepository.findScores(this.$route.query.quiz).then((response: AxiosResponse<Score[]>) => {
this.scores = response.data.map(value => value.scoreAvg);
});
} catch (e: any) {
console.info("error", e);
} finally {
this.loading = false;
}
}
}
</script>
<style lang="scss" scoped>

View File

@@ -1,7 +0,0 @@
import { initializeAxios } from '~/utils/api'
const accessor: ({$axios}: { $axios: any }) => void = ({ $axios }) => {
initializeAxios($axios)
}
export default accessor

View File

@@ -0,0 +1,5 @@
import {ArcElement, Chart, Legend, RadialLinearScale, Title, Tooltip} from 'chart.js'
export default defineNuxtPlugin(() => {
Chart.register(Title, Tooltip, Legend, ArcElement, RadialLinearScale)
})

View File

@@ -1,13 +0,0 @@
import Vue from 'vue'
Vue.filter('formatDate', (value: string) => {
const date = new Date(value);
return `${(date.getDate() > 9 ? '' : '0')
+ date.getDate()}/${((date.getMonth() + 1) > 9 ? "" : "0")
+ (date.getMonth() + 1)}/${date.getFullYear()}`;
})
Vue.filter('formatRate', (value: number) => {
return value.toFixed(1);
})

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View File

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View File

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 184 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 374 B

After

Width:  |  Height:  |  Size: 374 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,15 +0,0 @@
import AxeRepository from "./axeRepository";
import QuizRepository from "~/repositories/quizRepository";
import QuestionRepository from "~/repositories/questionRepository";
const repositories = {
axe: AxeRepository,
quiz: QuizRepository,
question: QuestionRepository,
// other repositories ...
};
export const RepositoryFactory = {
// @ts-ignore
get: (name: string) => repositories[name]
};

View File

@@ -1,9 +0,0 @@
import {RestResponse} from "~/repositories/models/rest-response.model";
import {$axios} from "~/utils/api";
import {Axe} from "~/repositories/models/axe.model";
export default {
findAll() {
return $axios.get<RestResponse<Axe>>("/axes");
}
}

View File

@@ -1,19 +0,0 @@
import {RestLinks} from "~/repositories/models/rest-response.model";
import {Question} from "~/repositories/models/question.model";
export interface Axe extends RestLinks {
identifier: number;
shortTitle: string;
description: string;
title: string;
color: string;
}
export interface AxeWithQuestions extends RestLinks {
identifier: number;
shortTitle: string;
description: string;
title: string;
color: string;
questions: Question[];
}

View File

@@ -1,7 +0,0 @@
import {RestLinks} from "~/repositories/models/rest-response.model";
export interface Question extends RestLinks {
id: number;
label: string;
description: string;
}

View File

@@ -1,34 +0,0 @@
import {RestLinks} from "~/repositories/models/rest-response.model";
export interface Score {
scoreAvg: number;
axeIdentifier: number;
}
export interface ResponseWithQuestion extends RestLinks {
axeIdentifier: number;
question: string;
score: number;
comment: string;
}
export interface Quiz extends RestLinks {
id: number;
createdDate: string;
scores: Score[];
_embedded: {
responses: ResponseWithQuestion[]
};
}
export interface Response {
axeId: number;
questionId: number;
score?: number;
comment?: string;
}
export interface QuizRate {
score?: number;
comment?: string;
}

View File

@@ -1,20 +0,0 @@
export interface RestLinks {
"_links": {
"self": {
"href": string;
}
}
}
export interface Page {
"page": {
"size": number;
"totalElements": number;
"totalPages": number;
"number": number;
}
}
export interface RestResponse<T> extends RestLinks, Page {
_embedded: { [key: string]: T[] };
}

View File

@@ -1,4 +0,0 @@
export interface Token {
"name": string,
"password": "password"
}

View File

@@ -1,21 +0,0 @@
import {$axios} from "~/utils/api";
import {RestResponse} from "~/repositories/models/rest-response.model";
import {Question} from "~/repositories/models/question.model";
export default {
findAllByAxeId(axeId: number) {
return $axios
.get<RestResponse<Question>>("/questions/search/byAxeId", {
params: {
id: axeId
}
})
.then((response) => {
response.data._embedded.questions.forEach(question => {
question.id = Number(question._links.self.href.split("/").reverse()[0]);
return question;
});
return response;
});
}
}

View File

@@ -1,39 +0,0 @@
import {$axios} from "~/utils/api";
import {RestResponse} from "~/repositories/models/rest-response.model";
import {Quiz, Response, ResponseWithQuestion, Score} from "~/repositories/models/quiz.model";
export default {
findMine() {
return $axios.get<RestResponse<Quiz>>("/quizzes/search/me", {
params: {
sort: "createdDate,desc"
}
})
.then((response) => {
response.data._embedded.quizzes.map(quiz => {
quiz.id = Number(quiz._links.self.href.split("/").reverse()[0]);
return quiz;
});
return response;
});
},
findScores(quizId: number) {
return $axios.get<RestResponse<Score[]>>("/quizzes/" + quizId + "/scores", {});
},
findById(quizId: number) {
return $axios.get<RestResponse<Quiz>>("/quizzes/" + quizId, {});
},
findResponses(quizId: number) {
return $axios.get<RestResponse<ResponseWithQuestion>>("/quizzes/" + quizId + "/responses", {});
},
save(responses: Response[]) {
return $axios.post<RestResponse<Quiz>>("/quizzes/batch/", {
responses
}, {});
}
}

47
frontend/store/account.ts Normal file
View File

@@ -0,0 +1,47 @@
import {defineStore} from 'pinia';
export const useAccountStore = defineStore('account', {
state: () => ({}),
actions: {
update(username: string, email: string) {
return useApi("/account", {
method: "PUT",
body: {
email, username
}
});
},
updatePassword(currentPassword: string, newPassword: string, confirmationPassword: string) {
return useApi("/account/password", {
method: "PUT",
body: {
currentPassword, newPassword, confirmationPassword
}
});
},
requestPasswordReset(email: string) {
return useApi("/account/password/notify-reset-request", {
method: "POST",
body: {
email
}
}, false);
},
resetPassword(token: string, email: string, newPassword: string, confirmationPassword: string) {
return useApi("/account/password/reset", {
method: "POST",
body: {
token, email, newPassword, confirmationPassword
}
}, false);
},
create(username: string, email: string, password: string) {
return useApi("/auth/register", {
method: "POST",
body: {
username, email, password
}
}, false);
}
},
});

76
frontend/store/auth.ts Normal file
View File

@@ -0,0 +1,76 @@
import {defineStore} from 'pinia';
import {useApi} from "~/composables/fetch-api";
export interface Auth {
token: {
type: string;
value: string;
expireAt: Date;
},
refreshToken: string;
}
export interface User {
id: number
email: string;
username: string;
}
export const useAuthStore = defineStore('auth', {
state: () => ({
authenticated: ref<boolean>(useCookie("auth").value !== undefined),
auth: ref<Auth>(useCookie("auth").value),
user: ref<User>(useCookie("user").value)
}),
getters: {
},
actions: {
async login(email: string, password: string) {
return useApi('auth/login', {
method: 'post',
body: {
email,
password,
},
}, false).then(data => {
useCookie('auth').value = JSON.stringify(data);
this.authenticated = true;
this.auth = data;
useApi('auth/me').then(data => {
this.user = data;
useCookie("user").value = JSON.stringify(data)
});
});
},
logout() {
useApi('auth/logout', {
method: 'post',
body: {
userId: this.user.id,
},
}).finally(() => {
this.authenticated = false;
useCookie('auth').value = undefined;
useCookie("user").value = undefined;
});
},
refreshSession() {
// Use useFetch to not call
return useFetch('auth/refresh-token', {
baseURL: useRuntimeConfig().public.baseURL,
method: 'post',
body: {
refreshToken: this.auth.refreshToken,
},
}, false).then((response) => {
this.authenticated = true;
this.auth.token = response.data;
useCookie('auth').value = JSON.stringify(this.auth);
}).catch(() => {
this.authenticated = false;
useCookie('auth').value = undefined;
useCookie("user").value = undefined;
});
}
},
});

19
frontend/store/axe.ts Normal file
View File

@@ -0,0 +1,19 @@
import {defineStore} from 'pinia';
export interface Axe {
id: number;
identifier: number;
shortTitle: string;
description: string;
title: string;
color: string;
}
export const useAxeStore = defineStore('axe', {
state: () => ({}),
actions: {
findAxes() {
return useApi("axes");
}
}
});

44
frontend/store/bundle.ts Normal file
View File

@@ -0,0 +1,44 @@
import {defineStore} from 'pinia';
export interface Bundle {
id: number;
label: string;
lastQuizzDate: string;
numberOfQuizzes: number;
}
export interface QuestionCreation {
label: string;
description: string;
axeId: number;
index: number;
}
export interface BundleCreationRequest {
label: string;
questions: QuestionCreation[];
}
export const useBundleStore = defineStore('bundle', {
state: () => ({
selectedBundle: ref<number>(useCookie('bundleId').value),
}),
actions: {
findAll(): Bundle[] {
return useApi("bundles");
},
setCurrentBundle(bundle: Bundle) {
this.selectedBundle = bundle.id;
useCookie('bundleId').value = bundle.id;
},
create(request: BundleCreationRequest) {
return useApi("bundles", {
method: "POST",
body: request
});
}
}
});

View File

@@ -1,5 +0,0 @@
import { Store } from 'vuex'
import { initialiseStores } from '~/utils/store-accessor'
const initializer = (store: Store<any>) => initialiseStores(store)
export const plugins = [initializer]
export * from '~/utils/store-accessor'

View File

@@ -0,0 +1,29 @@
import {defineStore} from 'pinia';
interface Notification {
message?: string;
details?: string[] | string;
}
export const useNotificationStore = defineStore('notification', {
state: () => ({
hasNotification: false,
type: "info",
notification: {},
}),
actions: {
pushNotification(type: 'warn' | 'info' | 'success', notification: Notification) {
this.notification = notification;
this.hasNotification = true;
this.type = type;
setTimeout(() => {
this.clearNotification();
}, 5000);
},
clearNotification() {
this.notification = {};
this.type = 'info';
this.hasNotification = false;
}
}
});

View File

@@ -0,0 +1,29 @@
import {defineStore} from 'pinia';
export interface Question {
id: number;
label: string;
description: string;
}
export const useQuestionStore = defineStore('question', {
state: () => ({}),
actions: {
findDefaults(axeId: number): Promise<Question> {
return useApi("/questions/search/defaults", {
params: {
axeId
}
});
},
findAll(axeId: number): Promise<Question[]> {
return useApi("/questions/search", {
params: {
axeId,
}
});
}
}
});

View File

@@ -1,76 +1,155 @@
import {Module, VuexModule, Mutation} from 'vuex-module-decorators'
import {Question} from "~/repositories/models/question.model";
import {QuizRate, Response} from "~/repositories/models/quiz.model";
import {defineStore} from 'pinia';
import {useBundleStore} from "~/store/bundle";
import {Axe, useAxeStore} from "~/store/axe";
import type {Question} from "~/store/question";
@Module({
name: 'quiz',
stateFactory: true,
namespaced: true,
})
export default class Quiz extends VuexModule {
responses = new Map<number, QuizRate>;
questionsRatedPerAxe = new Map<number, { questionId: number; rated: boolean }[]>;
@Mutation
initialize(questions: Map<number, Question[]>) {
questions.forEach((questions, axeId) => this.questionsRatedPerAxe.set(axeId, questions.map(value => {
return {
questionId: value.id,
rated: this.responses.has(value.id)
}
})));
}
@Mutation
reset() {
this.responses.clear();
this.questionsRatedPerAxe.forEach((questions) => {
questions
.map(value => {
value.rated = false;
return value;
});
});
}
@Mutation
updateScoreResponse(response: Response) {
const previous = this.responses.get(response.questionId);
if (previous) {
this.responses.set(response.questionId, {
comment: previous.comment,
score: response.score
});
} else {
this.responses.set(response.questionId, {
score: response.score
});
}
const questionsRated = this.questionsRatedPerAxe.get(response.axeId);
if (questionsRated) {
questionsRated
.filter(value => value.questionId === response.questionId)
.map(value => {
value.rated = true;
return value;
});
}
// else should not happen
}
@Mutation
updateCommentResponse(response: Response) {
const previous = this.responses.get(response.questionId);
if (previous) {
this.responses.set(response.questionId, {
score: previous.score,
comment: response.comment
});
} else {
this.responses.set(response.questionId, {
comment: response.comment
});
}
}
export interface QuizResponse {
question: string;
score: number;
comment: string;
}
export interface AxeResponses {
axeIdentifier: number;
average: number;
responses: QuizResponse[];
}
export interface Quiz {
id: number;
createdDate: string;
axes: AxeResponses[]
}
export interface Response {
axeId: number;
questionId: number;
score?: number;
comment?: string;
}
export interface QuizRate { // ?
score?: number;
comment?: string;
}
export const useQuizStore = defineStore('quiz', {
state: () => ({
axes: ref<Axe[]>([]),
questions: ref<Map<number, Question[]>>(new Map()),
responses: ref<Map<number, QuizRate>>(new Map()),
questionsRatedPerAxe: ref<Map<number, Array<{ questionId: number; rated: boolean }>>>(new Map())
}),
actions: {
initialize() {
const bundleId = useBundleStore().selectedBundle;
return useAxeStore().findAxes().then(axes => {
const promises: any[] = [];
this.axes = axes;
axes.forEach(axe => {
promises.push(
useApi(`/bundles/${bundleId}/questions/search`, {
params: {
axeId: axe.id
}
})
.then((questions: Question[]) => {
return {
axeId: axe.identifier,
questions: questions
};
}));
});
Promise.all(promises).then((axeQuestions) => {
axeQuestions.forEach(axeQuestion => {
this.questions.set(axeQuestion.axeId, axeQuestion.questions)
});
this.questions.forEach((questions, axeId) => this.questionsRatedPerAxe.set(axeId, questions.map(value => {
return {
questionId: value.id,
rated: this.responses.has(value.id)
}
})));
});
});
},
resetResponses() {
this.responses.clear();
this.questionsRatedPerAxe.forEach((questions) => {
questions
.map(value => {
value.rated = false;
return value;
});
});
},
updateScoreResponse(response: Response) {
const previous = this.responses.get(response.questionId);
if (previous) {
this.responses.set(response.questionId, {
comment: previous.comment,
score: response.score
});
} else {
this.responses.set(response.questionId, {
score: response.score
});
}
const questionsRated = this.questionsRatedPerAxe.get(response.axeId);
if (questionsRated) {
questionsRated
.filter(value => value.questionId === response.questionId)
.map(value => {
value.rated = true;
return value;
});
}
// else should not happen
},
updateCommentResponse(response: Response) {
const previous = this.responses.get(response.questionId);
if (previous) {
this.responses.set(response.questionId, {
score: previous.score,
comment: response.comment
});
} else {
this.responses.set(response.questionId, {
comment: response.comment
});
}
},
findQuizzes(bundleId: number) {
return useApi("/quizzes/search", {
params: {
bundleId: bundleId,
sort: "createdDate,desc"
}
});
},
findById(quizId: number) {
return useApi(`/quizzes/${quizId}`);
},
save() {
const responses = [];
this.responses.forEach((value, key) => {
responses.push({
score: value.score ? value.score : 0,
comment: value.comment,
questionId: key
});
});
return useApi("/quizzes", {
method: "POST",
body: {responses, bundleId: useBundleStore().selectedBundle}
}).finally(() => this.resetResponses());
}
}
});

View File

@@ -1,21 +1,22 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "ESNext",
"moduleResolution": "Node",
"lib": ["ESNext", "ESNext.AsyncIterable", "DOM"],
"esModuleInterop": true,
"allowJs": true,
"sourceMap": true,
"strict": true,
"noEmit": true,
"experimentalDecorators": true,
"baseUrl": ".",
"paths": {
"~/*": ["./*"],
"@/*": ["./*"]
},
"types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/auth-next"]
},
"exclude": ["node_modules", ".nuxt", "dist"]
// "compilerOptions": {
// "target": "ES2018",
// "module": "ESNext",
// "moduleResolution": "Node",
// "lib": ["ESNext", "ESNext.AsyncIterable", "DOM"],
// "esModuleInterop": true,
// "allowJs": true,
// "sourceMap": true,
// "strict": true,
// "noEmit": true,
// "experimentalDecorators": true,
// "baseUrl": ".",
// "paths": {
// "~/*": ["./*"],
// "@/*": ["./*"]
// },
// "types": ["@nuxt/types", "@nuxtjs/axios", "@types/node", "@nuxtjs/auth-next"]
// },
"extends": "./.nuxt/tsconfig.json",
// "exclude": ["node_modules", ".nuxt", "dist"]
}

View File

@@ -1,10 +0,0 @@
import type { NuxtAxiosInstance } from '@nuxtjs/axios'
/* eslint import/no-mutable-exports: 0 */
let $axios: NuxtAxiosInstance;
export function initializeAxios(axiosInstance: NuxtAxiosInstance) {
$axios = axiosInstance
}
export { $axios }

View File

@@ -0,0 +1,4 @@
export default (date: string) => {
const dateObject = new Date(date);
return dateObject.toLocaleDateString();
}

View File

@@ -0,0 +1,4 @@
export default (date: string) => {
const dateObject = new Date(date);
return dateObject.toLocaleDateString() + ' ' + dateObject.toLocaleTimeString();
}

View File

@@ -1,12 +0,0 @@
import {Store} from 'vuex'
import {getModule} from 'vuex-module-decorators'
import Quiz from "~/store/quiz";
/* eslint import/no-mutable-exports: 0 */
let quizStore: Quiz;
function initialiseStores(store: Store<any>): void {
quizStore = getModule(Quiz, store);
}
export {initialiseStores, quizStore}