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 b6e86f0641
207 changed files with 5570 additions and 40453 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,19 @@
$page_width: 1200px;
$content_width: 920px;
$header_height: 78px;
$footer_height: 220px;
$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: 16px;
$small: 24px;
$medium: 32px;
$x_medium: 48px;
$xx_medium: 60px;
$xxx_medium: 80px;
$large: 120px;
$x_large: 190px;
$xx_large: 260px;

View File

@@ -1,45 +1,63 @@
@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 $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;
}
a, p, .button-link {
font-family: $font-primary;
}
ul, ol {
margin: $xx_small $small;
margin: 0;
}
h1 {
font-size: 1.75rem;
line-height: 2.125rem;
margin: $medium 0 $small 0;
margin: $medium 0;
color: $black;
text-align: center;
@media only screen and (max-width: $breakpoint) {
margin: $xx_small;
margin: $xx_small 0;
}
}
@@ -48,7 +66,7 @@ h2 {
font-size: 2rem;
line-height: 2.75rem;
letter-spacing: -0.02em;
margin: $x-medium 0 $medium 0;
margin: $xx_medium 0 $x_medium 0;
@media only screen and (max-width: $breakpoint) {
margin: $xx_small;
@@ -60,7 +78,7 @@ h3 {
font-weight: bold;
font-size: 1.75rem;
line-height: 2rem;
margin: $small 0;
margin: $medium 0;
}
p + p { margin-top: 1.5rem }
@@ -70,17 +88,27 @@ hr {
margin: $xxx_small 0;
}
.form {
display: flex;
flex-direction: column;
gap: $xxx_small;
margin-block: $medium;
&__help {
margin-block: $medium $small;
}
}
.bold {
font-weight: bold;
}
.button {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-decoration: none;
padding: 0 32px;
line-height: 60px;
padding: $x_small $small;
color: $white;
border-radius: 100px;
border: none;
@@ -94,7 +122,18 @@ hr {
}
.button-container {
margin: $medium 0;
display: flex;
justify-content: center;
gap: $xx_small;
margin: $x_medium 0;
.button {
flex: 1;
}
.button-back {
flex: 0;
}
}
.button.orange:hover:not(:disabled) {
@@ -126,16 +165,33 @@ 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;
border-radius: 100px;
line-height: 50px;
padding: 0 $x_small;
padding: 0 $small;
}
input[type=text]::placeholder,
input[type=email]::placeholder,
input[type=password]::placeholder {
color: $gray_3;
}
@@ -150,41 +206,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,213 @@
<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>Pour une production locale utile, solidaire et soutenable</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">
<section>
<p>La crise sanitaire de la COVID-19 a amené un groupe dadministrateurs et de salariés de lApes baptisé
Veille/Plaidoyer à chercher à répondre à cette préoccupation : « comment agir pour que le monde daprès soit
meilleur que celui davant ? ». Dans le prolongement des ConstructivESS que lApes a animées en novembre
2019, nous voulions surtout être force de propositions, pour agir concrètement aux côtés de ceux qui veulent
que le monde change. Nous souhaitons stimuler dautres façons de faire de léconomie. Nous avons choisi de
commencer par le sujet de la production locale. Car cest à léchelle locale quon innove, quon
expérimente,
quon donne de la place aux initiatives citoyennes, et que lon ouvre la voie aux transformations sociétales
auxquelles on aspire.</p>
<p>La crise sanitaire que nous subissons, et la crise sociale et économique qui se profile, révèlent encore
plus
la fragilité dune économie productive prioritairement mondialisée. Pour une meilleure autonomie face aux
marchés internationaux et pour favoriser la transition écologique et sociale, il est impératif que les
producteurs locaux reprennent la main sur certaines filières.</p>
<p>Mais les représentations courantes se complaisent didées reçues sur ce que la production locale est ou
devrait être et sur la manière de la stimuler.</p>
<ul>
<li>Il ne suffira pas dinterventions ou daides publiques pour relocaliser, y compris dans des secteurs
stratégiques pour conforter notre sécurité alimentaire, sanitaire et énergétique.
</li>
<li>Il ne suffira pas dorganiser ou de réguler la connexion entre loffre et la demande.</li>
<li>Il ne suffira pas de sensibiliser et de motiver les consommateurs à modifier leurs habitudes dachats
pour
en faire des locavores convaincus.
</li>
</ul>
<p>Pour renforcer la production locale quantitativement et qualitativement, il faut agir conjointement et
collectivement à léchelle des différents écosystèmes de production locale solidaire sur un territoire. Et
pour cela, il nous paraît indispensable de nous référer à plusieurs balises, au nombre de 10.</p>
<h2>1. agir en (re)donnant de lautonomie et du pouvoir dagir à chaque territoire et à ses habitants</h2>
<p>En redonnant du sens à l'acte de produire et de consommer, on développe ou conforte des activités, du
travail
et des emplois locaux, et on renforce la cohérence, la vitalité, la durabilité des territoires.</p>
<p>En produisant et en consommant localement, on réintègre les externalités négatives dans le périmètre de
l'écosystème productif local, on contribue mieux aux enjeux environnementaux (diminution de la production de
gaz à effets de serre, protection de la biodiversité) et au bien-être des populations locales.
</p>
<p>En donnant à chacun les moyens de son émancipation personnelle, sociale, professionnelle, relationnelle,
créative, on lui permet de participer activement à laffirmation dun bien-vivre partagé et dun
prendre-soin
universel.</p>
<p>« Si nous voulons que nos résistances soient créatrices et porteuses de la démonstration quun autre monde
est non seulement possible mais nécessaire, il nous faut dautant plus placer le bien-vivre en actes au cœur
de nos projets » (Patrick Viveret)</p>
<h2>2. agir sur tous les secteurs de léconomie et lensemble des chaînes de valeur</h2>
<p>La relocalisation des productions nécessite de revisiter lensemble des chaînes de valeur aboutissant à la
commercialisation et la distribution des biens et des services.</p>
<p>La (re)localisation des productions ne peut se réduire à quelques filières ou secteurs dactivités,
industrielles ou agricoles. Cest loccasion de procéder à une réévaluation des modes dorganisation des
activités selon des logiques fonctionnelles : salimenter, se déplacer, se loger, se soigner, ...</p>
<p>Intelligence collective, projets collaboratifs, initiatives solidaires, la relocalisation dépendra de notre
capacité dinnovation afin de réduire nos vulnérabilités et les coûts sociaux et environnementaux du
commerce
mondial et de revenir à un mode déchanges plus responsable et plus soutenable.</p>
<h2>3. agir localement en pensant globalement</h2>
<p>Ne pas confondre autonomie avec autarcie. Il est illusoire d'imaginer que l'on puisse produire sur place
tout
ce quon consomme ou utilise. Production et consommation locales doivent aussi se faire dans une
complémentarité avec dautres territoires.
</p>
<p>Des partenariats contractualisés avec dautres écosystèmes, parfois éloignés, instaurent des modalités
déchanges solidaires villes-campagnes, ou des échanges équilibrés entre le Nord et le Sud, également
nécessaires.
</p>
<h2>4. agir de manière utile et sobre, socialement et écologiquement</h2>
<p>La satisfaction de nos besoins par la consommation interroge la question de lutilité sociale et écologique
des biens et des services produits et consommés. Il ne peut y avoir de dogme universel en la matière et
chaque
territoire doit garder le droit de définir ce qui lui paraît utile.
</p>
<p>La recherche de sobriété en premier lieu, puis celle du recours à des systèmes coopératifs déconomie
circulaire sont à examiner avant de chercher à créer toute nouvelle condition de production locale.
</p>
<p>Une commande publique responsable est un moteur exemplaire dune consommation utile et raisonnable. La
co-définition des besoins et des solutions à mettre en œuvre, en mobilisant les différentes parties
prenantes,
permet de sassurer de lutilité et des impacts sociaux et environnementaux des achats publics.
</p>
<h2>5. agir pour préserver et se réapproprier les communs</h2>
<p>La gestion des communs, cest-à-dire des ressources partagées et maintenues collectivement par une
communauté, propres à chaque territoire est au centre des questionnements dune consommation responsable.
</p>
<p>L'accès aux services et aux ressources que la communauté locale souhaite préserver et gérer, ainsi que
l'usage de certains produits, doivent être réexaminés pour savoir sil est nécessaire et légitime den
disposer en pleine propriété.
</p>
<h2>6. agir démocratiquement</h2>
<p>Le territoire, lieu de production, de vie, didentité et dinteractions humaines, sociales et économiques,
doit saffirmer également comme lieu dexpression et de débat dune démocratie au quotidien.
</p>
<p>La création de lieux déchanges et de débats démocratiques entre citoyens, représentants élus et
producteurs,
permet de revitaliser les solidarités entre les groupes de population et la concertation entre ces groupes.
</p>
<p>Pour chaque projet de développement, une gouvernance ouverte et participative donne lopportunité aux
représentants de ces groupes dexprimer leur point de vue, de participer à la définition des priorités de
développement, de prendre part aux décisions et aux processus de suivi et dévaluation.
</p>
<h2>7. agir en encourageant la coopération à tous les niveaux</h2>
<p>Lensemble de ces procédés est un levier précieux pour le développement dindispensables coopérations.
Celles-ci favorisent la coconstruction des solutions répondant aux besoins du territoire, grâce à la
mobilisation dune diversité de compétences et de ressources matérielles, immatérielles, monétaires et non
monétaires.
</p>
<p>La création dalliances entre les acteurs locaux sur les territoires est à promouvoir, et on pourra pour
cela
mobiliser les outils de dynamiques de coopérations territoriales qui ont fait leur preuve (pôles
territoriaux
de coopération économique, SCIC territoriales, pôles économiques, grappes dentreprises).
</p>
<h2> 8. agir pour maîtriser les outils de production</h2>
<p>Les choix stratégiques dinvestisseurs fondés sur la seule maximisation du profit, qui plus est à court
terme, ont entraîné progressivement un éloignement des centres de décisions des territoires de production de
biens comme de services, avec des conséquences dramatiques sur lemploi local (course à la productivité et
au
rendement, délocalisations).
</p>
<p>La propriété collective de moyens de production, telle quelle se pratique dans les sociétés coopératives,
notamment les SCOP et les SCIC, est un exemple à suivre.
</p>
<h2> 9. agir sur les instruments financiers</h2>
<p>Le recours aux outils de financements solidaires et participatifs est essentiel pour maîtriser le pilotage
dune économie de proximité : mobilisation de lépargne solidaire, doutils de financement participatif
comme
moyens de soutenir financièrement les initiatives au niveau local.
</p>
<p>En complément, la mise en place de monnaies locales citoyennes, de fondations territoriales et de systèmes
déchanges locaux (SEL) favorisent une consommation responsable en fléchant les flux de consommation vers un
réseau dacteurs engagés dans le développement de leur territoire.
</p>
<h2>10. agir pour redonner au travail sa valeur véritable</h2>
<p>Tout ceci nécessite de remettre le travail au centre de la production de richesses et de leur
redistribution.
En reconnaissant lutilité sociale et écologique du travail, on contribue à la démocratisation de
lentreprise, et à la -marchandisation du travail.
</p>
<p>Cest par ce biais que les politiques de lutte contre les exclusions et dinsertion par lactivité
économique
pourront prendre leurs pleines mesures, et offrir à chaque citoyen de nos territoires la possibilité
dexercer
son droit fondamental au travail.
</p>
<p>
Cela passe aussi par la reconnaissance de l'importance du travail bénévole, en complémentarité avec le
travail
rémunéré, et de leur contribution au bien-être de chacun.
</p>
</section>
<section>
<hr/>
<p>Innovations, expérimentations, évaluations et amélioration continue des pratiques, les acteurs de lESS
nont
pas attendu dêtre confrontés à la pandémie pour ouvrir dautres voies et contribuer à la transformation
dune
société centrée sur le profit immédiat.
</p>
<p>En mettant lhumain au cœur de leurs projets, en répondant dabord
aux besoins fondamentaux des personnes par des solutions ancrées sur les territoires, ils ont démontré
limportance de la proximité, de la coopération et de la participation des citoyens aux décisions qui les
concernent
</p>
<p>
Le moment na sans doute jamais été aussi propice pour sinspirer
de ces pratiques et coopérer avec les entreprises de léconomie ordinaire au profit dune production locale
qui soit utile, solidaire et soutenable. Les balises ici proposées sont autant de repères pour y parvenir.
</p>
</section>
</main>
</section>
<div class="modal-overlay" @click="$emit('close')"></div>
</div>
</template>
<style scoped lang="scss">
@import "assets/css/modal";
h2 {
font-size: $title-font-size;
line-height: 150%;
margin: $x-small 0;
}
ul {
list-style: inside;
}
hr {
margin: $small $large;
}
</style>

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: $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>

148
frontend/components/Cgu.vue Normal file
View File

@@ -0,0 +1,148 @@
<template>
<h2>Bienvenue sur la Boussole PLUSS</h2>
<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 <a href="https://apes-hdf.org/_docs/Fichier/2022/21-220610094246.pdf">« Agir pour une production locale »</a>. Les participants choisissent au moins 3 questions par balise.
Cette application fait partie dune boîte à outils plus vaste, initiée par l<a href="https://www.apes-hdf.org/page-0-0-0.html">Apes</a> et ses adhérents. En savoir <a href="https://pluss-hdf.org/">PLUSS.</a></p>
<p>
Toute utilisation effectuée a quel que titre que ce soit de la plateforme implique obligatoirement l'acceptation sans réserve des présentes conditions générales dutilisation (CGU). </p>
<h2>1. Définitions</h2>
<p>Les termes mentionnés ci-dessous ont, dans les présentes Conditions Générales dUtilisation, la signification suivante, quils soient utilisés au singulier ou au pluriel :</p>
<ul>
<li>Balise : il sagit dun axe du référentiel PLUSS accessible ici : <a href="https://apes-hdf.org/_docs/Fichier/2022/21-220610094246.pdf">https://cd -hdf.org/_docs/Fichier/2022/21-220610094246.pdf</a></li>
<li>Boussole : le présent outil dévaluation accessible sur le site éponyme</li>
<li>Boussole de référence : une boussole déjà configurée avec des questions qui ont été écrites par lApes</li>
<li>Compte : désigne lespace personnel de lUtilisateur sur le Site. Laccès au Compte se fait grâce aux Identifiants.</li>
<li>Équipe : il sagit du collectif qui va utiliser la Boussole pour sévaluer</li>
<li>Identifiants : désigne ladresse courriel de lUtilisateur et le mot de passe associé à son Compte, nécessaires pour y accéder.</li>
<li>Site : le présent site internet Boussole PLUSS</li>
<li>Tableau de Bord : la page daccueil accessible après identification sur laquelle lUtilisateur retrouve toutes ses évaluations et peut en réaliser de nouvelles</li>
<li>Utilisateur : désigne toute personne qui accède et navigue sur le Site</li>
</ul>
<h2>2. Objet</h2>
Les présentes Conditions Générales dUtilisation ont pour objet de définir les conditions et les
modalités daccès à la Boussole PLUSS
<h2>3. Acceptation des Conditions Générales dUtilisation</h2>
Lutilisation des fonctionnalités de la Plateforme implique lacceptation des présentes CGU.
LUtilisateur sengage à lire attentivement les présentes Conditions Générales dUtilisation lors de
laccès au site et est invité à les télécharger ou les imprimer et à en conserver une copie.
Les présentes CGU sont référencées sur la page daccueil au moyen dun lien
hypertexte et peuvent ainsi être consultées à tout moment
<h3>3. 1. Durée</h3>
<p>Les Conditions Générales d'Utilisation entrent en vigueur à compter de leur publication sur le site et sappliquent pendant toute la durée de lutilisation des fonctionnalités du site (cest-à-dire, notamment, tant que lUtilisateur est titulaire dun Compte sur le site).</p>
<p>L'Utilisateur peut mettre fin à l'utilisation de son Compte à tout moment, sans frais et sans devoir
donner de notice préalable, en demandant la suppression de son Compte à ladresse suivante :
contact@apes-hdf.org</p>
<p>Les présentes CGU sont susceptibles dévoluer dans le temps. Les Utilisateurs en seront informés par mail.</p>
<h2>4. Fonctionnalités de la Boussole</h2>
<p>La Boussole est un outil dauto-évaluation qui offre aux Utilisateurs les fonctionnalités suivantes :</p>
<ul>
<li>Créer un compte et pouvoir ensuite en modifier les accès et le nom dUtilisateur</li>
<li>Générer une évaluation à partir dune série de questions programmée dans le Site, autant de fois que nécessaire</li>
<li>Créer de nouveaux supports dévaluation en créant de nouvelles questions et/ ou en modifiant et/ou sélectionnant des questions existantes</li>
<li>Générer une évaluation à partir des nouveaux supports dévaluations créés</li>
<li>Suivre les évaluations faites sur tous les supports utilisés pour sévaluer</li>
</ul>
<h3>4.1 Création dun Compte, gestion et accès à lespace « Mon Compte »</h3>
<h4>4.1.1 Création dun Compte</h4>
<p>Sur la page daccueil de la Boussole PLUSS il est possible de se créer un Compte en cliquant sur « créer un compte ». LUtilisateur est alors invité à remplir les champs du formulaire : nom de léquipe, adresse e-mail et mot de passe.
Un mail automatique est envoyé à lUtilisateur pour confirmer son inscription.
LUtilisateur est seul responsable de lutilisation de ses Identifiants. Il doit veiller à conserver secret
son mot de passe et à ne pas le divulguer.
Il sera responsable de lutilisation de ses Identifiants par des tiers, quelle soit frauduleuse ou non.</p>
<h4>4.1.2 Connexion à lespace personnel si le Compte est déjà créé</h4>
<p>Sur la page daccueil de la Boussole PLUSS, lUtilisateur peut se connecter si son Compte est déjà créé.
</p>
<h4>4.1.3 Gestion du Compte</h4>
<p>Dans le menu « Mon compte » en haut à droite, lutilisateur peut modifier le nom de son équipe, son adresse e-mail de connexion et son mot de passe.
</p>
<h4>4.1.4 Mot de passe oublié</h4>
<p>Sur la page daccueil de la Boussole PLUSS un lien « jai oublié mon mot de passe » permet de réinitialiser le mot de passe daccès au Compte. Le-mail utilisé lors de la création du Compte est demandé afin quun e-mail permettant la réinitialisation mot de passe y sera envoyé.
</p>
<h4>4.1.5 Suppression du Compte</h4>
<p>Pour supprimer votre compte, merci de contacter ladministrateur à contact@apes-hdf.org</p>
<h3>4.2 Réalisation dune évaluation</h3>
<h4 id="section-421">4.2.1 Réalisation dune évaluation sur la Boussole de référence</h4>
<p>Dès la première connexion lUtilisateur peut réaliser une première évaluation en utilisant le lot de question existant. Pour y accéder il suffit de cliquer sur la tuile « Boussole de référence ». LUtilisateur est alors invité à répondre aux questions. A la fin de lévaluation, un radar lui permettra de mesurer sa position sur les 10 balises de la PLUSS. Les réponses aux questions sont accessibles en cliquant sur la date de lévaluation.</p>
<p>Les évaluations passées restent disponibles. Les nouvelles évaluations sur la même Boussole viennent sajouter sous forme de liste en-dessous de la première, permettant de mesurer son évolution. Pour réaliser une nouvelle évaluation sur la même Boussole cliquez sur le bouton « nouveau ».</p>
<p>Vous pouvez sauvegarder en pdf ou imprimer les résultats de votre évaluation en cliquant sur « imprimer ».</p>
<h4>4.2.2 Création dune nouvelle Boussole</h4>
<p>La création dune nouvelle Boussole suit les étapes suivantes :</p>
<ul>
<li>cliquez sur « Nouvelle Boussole » sur votre tableau de bord</li>
<li>pour chaque Balise, cliquez sur « configurez les questions » afin daccéder aux questions existantes, les modifier, ou en écrire de nouvelles. Pour chaque question vous pouvez écrire une description.</li>
<li>le nombre maximale de questions par Balise est de 10 questions</li>
<li>les Balises ne sont pas modifiables</li>
<li>une fois toutes les Balises configurées cliquez sur valider</li>
<li>la nouvelle Boussole est maintenant accessible sur votre tableau de bord</li>
</ul>
<p>Vous pouvez réaliser autant de nouvelles Boussoles que vous le désirez. Les Boussoles ne sont pas supprimables une fois configurées.
</p>
<h4>4.2.3 Réalisation dune évaluation sur une nouvelle Boussole</h4>
<p>
Le mode opératoire est le même que pour la Boussole de référence (voir <nuxt-link to="#section-421">4.2.1</nuxt-link>).
</p>
<h2>5. Mentions légales</h2>
<p>Ce site a été produit et est administré par APES Hauts-de-France, association 1901, située au 235 boulevard Paul Painlevé, 59000 LILLE
pour nous contacter : contact@apes-hdf.org</p>
<p>Rédaction du site : Marie-Charlotte WOETS pour APES HDF</p>
<p>Réalisation du site : Its on us</p>
<p>Hébergement : Cliss XXI</p>
<p>Ces site est développé et maintenu sous licence AGPL 3. Il est disponible sur un dépôt public : <a href="https://git.itsonus.fr/client_projects/boussole-pluss">https://git.itsonus.fr/client_projects/boussole-pluss</a></p>
<h2>6. Protection des données personnelles</h2>
<p>Les données personnelles collectées se limitent à ladresse mail de lUtilisateur, pour la création du Compte. Nous nous inscrivons dans le respect de la RGPD en vigueur.</p>
<p>Les données produites lors de la création de Compte et de la réalisation dévaluation sont hébergées par Cliss XXI sur un serveur interne au 23 avenue Jean Jaurès 62800 LIEVIN</p>
<p>Ce site ne contient pas doutil analytique ou de mesure daudience.</p>
<h2>7. Accessibilité du site</h2>
<p>LAdministration fait de son mieux pour sassurer du bon fonctionnement du Site et des services y figurant, dans les limites de responsabilité des présentes conditions générales</p>
<p>Le Site est en principe accessible 24 heures sur 24 et 7 jours sur 7, cependant, le Site décline toute responsabilité, dans les cas suivants, sans que cette liste soit limitative :</p>
<ul>
<li>interruption du Site pour des opérations de maintenance techniques ou dactualisation des informations publiées.</li>
<li>impossibilité momentanée daccès au Site (et/ou aux sites internet et applications lui étant liés) en raison de problèmes techniques et ce quelles quen soient lorigine et la provenance.</li>
<li>indisponibilité ou de surcharge ou toute autre cause empêchant le fonctionnement normal du réseau de téléphonie mobile utilisé pour accéder au Site</li>
<li>contamination par des éventuels virus informatiques circulant sur le réseau.</li>
<li>dommages directs ou indirects causés à lUtilisateur, quelle quen soit la nature, résultant de laccès, ou de lutilisation du Site (et/ou des sites ou applications qui lui sont liés)</li>
<li>utilisation anormale ou dune exploitation illicite du Site</li>
</ul>
</template>
<style lang="scss" scoped>
h2 {
margin: $small 0;
}
h3 {
font-size: $secondary-font-size;
margin: $small 0;
}
h4 {
margin: $xx_small 0;
}
p, li {
line-height: 150%;
}
ul {
list-style-position: inside;
margin: $xxx_small 0;
li {
padding: $xxxx_small 0;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<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>Conditions Générales dUtilisation de la Boussole PLUSS</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">
<cgu />
</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,36 @@
<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>
<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,31 @@
<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>
<nuxt-link to="/cgu">Conditions Générales dUtilisation de la Boussole PLUSS</nuxt-link>
<nuxt-link to="mailto:contact@apes.org">Contactez-nous !</nuxt-link>
</footer>
</template>
<style lang="scss" scoped>
.footer {
width: 100vw;
display: flex;
flex-direction: column;
gap: $xxx_small;
padding-bottom: $xx_small;
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,31 @@
<template>
<header class="header">
<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: $xxx_medium;
background-repeat: no-repeat;
background-image: url("/images/decoration/background.svg");
background-position: bottom center, 50%;
background-size: cover;
.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";
@@ -55,7 +47,7 @@ export default class QuizAxeDetails extends Vue {
@import "assets/css/spacing";
.axe-details {
margin-top: $x_small;
margin-top: $small;
}
.title {

View File

@@ -1,3 +1,58 @@
<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});
const questions = store.questionsRatedPerAxe.get(props.axeNumber);
const unratedQuestions = questions ? questions.filter(value => !value.rated) : [];
emit('rate', {
isFullRated: unratedQuestions.length === 0
});
}
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;
}
</script>
<template>
<article :style="cssVars">
<header>
@@ -11,102 +66,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,14 +107,15 @@ $size: 31px;
display: flex;
flex-direction: row;
text-align: left;
> span {
padding: 0 $x_small;
padding: 0 $small;
}
}
.icon {
text-align: center;
margin: $small;
margin: $medium;
}
.description {
@@ -149,40 +126,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: $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: $medium 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,10 +28,11 @@ export default class QuizProgress extends Vue {
display: flex;
justify-content: center;
}
.step {
border-radius: 2px;
border: 6px solid $gray_2;
margin: $x_small $xxx_small;
margin: $small $xxx_small;
width: 30px;
}

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,101 @@
<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="/bundle" 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 v-if="useAuthStore().user">
{{ 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: 0;
top: 52px;
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;
}
}
</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: $medium $medium $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,22 @@
import {useAuthStore} from "~/store/auth";
const publicUrl = [
"index",
"login",
"account-password-reset",
"account-password-confirm-reset",
"account-create",
"cgu"
]
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,56 @@
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']
app: {
header: {
link: [{ rel: 'icon', type: 'image/svg', href: '/images/favicon.svg' }]
}
},
// 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,128 @@
<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 type="checkbox" v-model="conditionChecked" 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 {
//.checkbox {
// input {
// position: absolute;
// opacity: 0;
// cursor: pointer;
// height: 0;
// width: 0;
// }
// input:checked ~ &-checkmark:after {
// display: block;
// }
//
// &-checkmark {
// position: absolute;
// top: 0;
// left: 0;
// height: 25px;
// width: 25px;
// background-color: #eee;
//
// &:after {
// left: 9px;
// top: 5px;
// width: 5px;
// height: 10px;
// border: solid white;
// border-width: 0 3px 3px 0;
// rotate: 45deg;
// }
// }
// &-checkmark:after {
// content: "";
// position: absolute;
// display: none;
// }
// &:hover input ~ &-checkmark {
// background-color: #ccc;
// }
//}
input[type="checkbox"] {
grid-column: span 2 / 3;
margin-top: $medium;
}
}
</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">
<nuxt-link to="/bundle" class="button gray button-back" aria-label="Retour à la page principale">
</nuxt-link>
<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: $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,61 @@
<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 class="loader" v-if="loading"/>
</form>
</section>
</template>
<style lang="scss" scoped>
.form {
display: flex;
flex-direction: column;
gap: $small;
}
.loader {
align-self: center;
}
</style>

View File

@@ -0,0 +1,182 @@
<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 presentation = 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,
presentation: presentation.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});
});
}
}
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);
}
function numberOfQuestions(axe: Axe) {
const q = questions.value.get(axe.id)
return q ? q.length: 0;
}
</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">
<label for="label">Présentation</label>
<input id="label" v-model="presentation" type="text" maxlength="100">
<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">
<div class="axe-list__item__content-text">
<p>{{ axe.title }}</p>
<p>{{ numberOfQuestions(axe) }} question{{numberOfQuestions(axe) > 1? 's': ''}} configurée{{numberOfQuestions(axe) > 1? 's': ''}}</p>
</div>
<button class="button blue" type="button" @click="showAxeModal(axe)">
Configurer
</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;
&-text p + p {
margin: $xxx_small 0;
}
&-text p:last-child {
font-style: italic;
}
}
}
}
</style>

View File

@@ -0,0 +1,126 @@
<script lang="ts" setup>
import {type Bundle, useBundleStore} from "~/store/bundle";
const showModal = ref(false);
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 navigateToDashboard(bundle: Bundle) {
useBundleStore().setCurrentBundle(bundle);
navigateTo("/dashboard");
}
function navigateToQuiz(bundle: Bundle) {
useBundleStore().setCurrentBundle(bundle);
navigateTo("/quiz");
}
</script>
<template>
<section v-if="!loading" class="section">
<h1>Bienvenue sur votre espace dauto-évaluation Boussole&nbsp;!</h1>
<p>Vous allez pouvoir dès maintenant évaluer votre projet de Production Locale Utile Solidaire et Soutenable au
regard des <button class="button-link" @click="showModal = true">10 balises du référentiel</button>.
</p>
<p>Cliquez ci-dessous sur la <strong>Boussole de référence</strong>, pour évaluer votre projet sur des questions
déjà configurées. Pour configurez vous-même vos questions, cliquez sur <strong>Personnaliser votre Boussole</strong>.</p>
<p>Ce tableau de bord vous permet de suivre vos différentes auto-évaluations sur tous vos projets&nbsp;!</p>
<ul class="bundle-list">
<li v-for="bundle in bundles">
<article class="bundle-list__item">
<main>
<h1>{{ bundle.label }}</h1>
<p>{{ bundle.presentation }}</p>
<dl class="bundle-list__item__attribute">
<dt>Dernière auto-évaluation réalisée</dt>
<dd>{{ bundle.lastQuizzDate ? formatDate(bundle.lastQuizzDate) : 'NA' }}</dd>
<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="navigateToDashboard(bundle)">Voir mes évaluations</button>
<button class="button orange" @click="navigateToQuiz(bundle)">S'évaluer</button>
</footer>
</article>
</li>
</ul>
<div class="button-container">
<nuxt-link to="/bundle/create">Personnaliser votre Boussole</nuxt-link>
</div>
</section>
<loader v-else/>
<axe-definition-modal :visible="showModal" @close="showModal=false" />
</template>
<style lang="scss" scoped>
.section {
margin-block: $medium;
}
.bundle-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));
grid-gap: $small;
list-style: none;
margin-top: $x_medium;
&__item {
display: flex;
flex-direction: column;
height: 100%;
gap: $small;
padding: $small;
border-radius: 20px;
@include border-shadow();
h1 {
font-size: $title-font-size;
margin: 0 0 $xxx_small 0;
}
p {
margin: 0 0 $xxx_small 0;
}
&__attribute {
dt {
font-weight: bold;
float: left;
clear: left;
&:after {
content: ' :';
margin-right: $xxxx_small;
}
}
dd {
padding-bottom: $xxxx_small;
}
}
&__button-container {
display: flex;
justify-content: center;
gap: $xxx_small;
margin-top: auto;
}
}
}
</style>

20
frontend/pages/cgu.vue Normal file
View File

@@ -0,0 +1,20 @@
<script lang="ts" setup>
definePageMeta({
layout: 'main-header'
});
</script>
<template>
<section>
<h1>Conditions Générales dUtilisation de la Boussole PLUSS</h1>
<cgu />
<nuxt-link class="button gray button-link" to="/">Retour à l'accueil</nuxt-link>
</section>
</template>
<style lang="scss" scoped>
.button-link {
margin: $medium 0;
}
</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.id).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="button blue" :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>{{useBundleStore().selectedBundle.label }} - {{ formatDateTime(q.createdDate) }}</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">S'évaluer</nuxt-link>
</div>
</template>
<style lang="scss" scoped>
.last-quiz {
margin-bottom: $x_small 0;
margin: $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: $small;
}
.history {
margin-top: $x_small;
}
.history button {
margin-top: $small;
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,84 @@
<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 notFound = ref(false);
const quiz = ref<Quiz>();
const axes = ref<Axe[]>();
onMounted(() => {
if (!useRoute().query.quiz) {
navigateTo("/dashboard");
}
loading.value = true;
notFound.value = false;
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;
});
});
})
.catch(() => {
notFound.value = true;
})
.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>
<section v-else-if="notFound">
<p class="center">Le quizz demandé n'existe pas !</p>
</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: $small 0;
}
.date {
font-weight: 700;
@@ -93,4 +86,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: $x_medium 0;
button {
margin: $medium 0;
margin-block: $x_medium 0;
}
}
.error-container {
display: flex;
justify-content: center;
color: red;
.link-forget-password {
align-self: center;
margin-block: $xx_small $xx_medium;
}
}
</style>

View File

@@ -1,172 +1,105 @@
<script lang="ts" setup>
import type {Axe} from "~/store/axe";
import {useQuizStore} from "~/store/quiz";
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);
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 }) {
isFullRated.value = event.isFullRated;
}
</script>
<template>
<div class="content">
<team-header/>
<hr/>
<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"
:description="currentAxe.description"
:color="currentAxe.color"
:icon="'balise_' + currentAxe.identifier + '.svg'"
:questions="questions.get(currentAxe.identifier)"
@rate="onRate"
/>
<div v-if="!loading">
<quiz-part
:key="currentAxe.identifier" :axe-number="currentAxe.identifier" :total-axes="axes.length"
:title="currentAxe.title"
:description="currentAxe.description"
:color="currentAxe.color"
:icon="'balise_' + currentAxe.identifier + '.svg'"
:questions="questions.get(currentAxe.identifier)"
@rate="onRate"
/>
<div class="button-container">
<nuxt-link
v-if="currentAxeIdentifier <= 1"
class="button gray"
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"
@click="showNext"
>Suivant
</button>
<button
v-if="currentAxeIdentifier >= axes.length" class="button orange"
:disabled="!isFilled || saving" @click="saveResult()"
>Valider
</button>
</div>
</div>
<div v-else class="center">
<loader/>
<div class="button-container">
<nuxt-link
v-if="currentAxeIdentifier <= 1"
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="!isFullRated"
@click="showNext"
>Suivant
</button>
<button
v-if="currentAxeIdentifier >= axes.length" class="button orange"
:disabled="!isFullRated || saving" @click="saveResult()"
>Valider
</button>
</div>
</div>
<div v-else class="center">
<loader/>
</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>
@@ -52,7 +36,7 @@ export default class Result extends Vue {
display: flex;
flex-wrap: wrap;
justify-content: space-around;
margin: $x_small 0;
margin: $small 0;
}
</style>

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: 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
}, {});
}
}

View File

@@ -1 +0,0 @@
<svg width="49" height="30" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M23.366 16.044V22.8H21.65V20.53c-1.395 1.61-3.102 2.386-5.126 2.324-1.769-.06-3.275-.751-4.519-2.072-1.243-1.321-1.867-2.894-1.867-4.717 0-1.897.67-3.526 2.01-4.888 1.339-1.358 2.94-2 4.808-1.925 1.805.063 3.339.757 4.604 2.084 1.266 1.334 1.867 2.9 1.806 4.708zm-1.716-.125c-.031-1.37-.512-2.542-1.448-3.517-.935-.975-2.058-1.461-3.37-1.461-1.373 0-2.543.486-3.506 1.46-.966.976-1.456 2.154-1.469 3.54-.03 1.37.42 2.57 1.346 3.598.929 1.028 2.046 1.57 3.36 1.632 1.417.063 2.627-.436 3.63-1.495 1.003-1.06 1.488-2.311 1.457-3.757z" fill="#fff"/><path d="M49 23.583H24.977l-.003-.004c-1.454 1.935-3.96 3.181-6.383 3.181-2.537 0-5.194-1.022-6.833-3.187 2.991-.01 5.608-.47 7.772-1.377l-.213-.517c-2.117.885-4.688 1.334-7.642 1.334-.102 0-.204-.004-.305-.004-.756-1.224-1.216-2.76-1.216-4.626 0-3.274.855-5.445 2.61-6.635 1.47-.997 3.35-1.131 4.994-1.131v-.56c-1.728 0-3.71.146-5.305 1.226-1.895 1.287-2.858 3.677-2.858 7.103 0 1.66.34 3.075.923 4.256-2.645-1.452-4.253-4.1-4.253-7.103 0-4.604 3.713-8.352 8.274-8.352 4.55 0 8.253 3.729 8.271 8.318v.071c-.012 1.056-.558 2.985-.564 3.007l.533.155c.022-.078.525-1.85.58-2.997h.007v-.162-.04-.084l-.012-3.667C23.354 5.287 18.116 0 11.678 0 5.238 0 0 5.287 0 11.788c0 6.287 4.9 11.44 11.049 11.77 1.716 2.56 4.7 3.766 7.542 3.766 2.537 0 5.059-1.212 6.651-3.18H49v-.561zM.556 11.788C.556 5.598 5.546.561 11.678.561c6.133 0 11.12 5.034 11.12 11.227l.003.601c-1.262-3.367-4.487-5.766-8.262-5.766-4.87 0-8.83 3.997-8.83 8.913 0 3.028 1.525 5.713 4.065 7.311C4.546 21.935.556 17.321.556 11.788z" fill="#fff"/><path d="M31.992 11.604c-.815-.741-1.843-1.115-3.056-1.115h-4.002v12.324h1.675V18.09h2.695c1.04 0 1.956-.352 2.719-1.046.768-.698 1.163-1.577 1.175-2.605.019-1.134-.385-2.087-1.206-2.835zm-1.16 4.3c-.482.439-1.056.65-1.76.65h-2.466v-4.498h2.39a2.73 2.73 0 011.761.608c.497.398.75.89.778 1.504v.003c.04.714-.191 1.28-.704 1.732zM36.158 12.361c.2-.202.58-.302 1.13-.302h3.614v-1.567h-3.605c-.938 0-1.725.274-2.336.813-.62.549-.932 1.33-.932 2.334v6.034c0 1 .315 1.785.932 2.33.61.539 1.395.813 2.336.813h3.605v-1.57h-3.614c-.55 0-.93-.103-1.123-.299-.346-.364-.522-.726-.522-1.072v-2.507h3.944v-1.599h-3.944v-2.333c0-.352.173-.713.515-1.075zM48.72 19.617l.002-.16c-.021-1.747-1-2.94-2.91-3.547l-1.42-.449c-.845-.268-1.043-1.165-1.058-1.872 0-.246.061-.723.466-1.153.2-.202.58-.305 1.13-.305h3.388v-1.564h-3.383c-.938 0-1.725.274-2.336.813-.543.48-.867 1.165-.926 1.944h-.009V13.794h.003c.022.926.18 1.518.531 2.003.395.549.95.938 1.657 1.156l1.253.393c1.318.414 1.972 1.103 2 2.106.019.632-.154 1.112-.546 1.501-.207.18-.574.274-1.096.274h-3.614v1.567h3.605c.938 0 1.725-.274 2.336-.813.68-.601.926-1.617.926-2.364z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

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");
}
}
});

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

@@ -0,0 +1,45 @@
import {defineStore} from 'pinia';
export interface Bundle {
id: number;
label: string;
presentation: string;
lastQuizzDate: string;
numberOfQuizzes: number;
}
export interface QuestionCreation {
label: string;
description: string;
axeId: number;
index: number;
}
export interface BundleCreationRequest {
label: string;
presentation: string;
questions: QuestionCreation[];
}
export const useBundleStore = defineStore('bundle', {
state: () => ({
selectedBundle: ref<Bundle>()
}),
actions: {
findAll(): Bundle[] {
return useApi("bundles");
},
setCurrentBundle(bundle: Bundle) {
this.selectedBundle = bundle;
},
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 bundle = useBundleStore().selectedBundle;
return useAxeStore().findAxes().then(axes => {
const promises: any[] = [];
this.axes = axes;
axes.forEach(axe => {
promises.push(
useApi(`/bundles/${bundle.id}/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.id}
}).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}