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
@@ -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
|
@@ -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
|
||||
|
@@ -1,5 +1,11 @@
|
||||
# boussole-pluss-frontend
|
||||
|
||||
## Contribuer à la Boussole
|
||||
Le projet actuel est prévu pour travailler sur le thème de la production locale.
|
||||
Cet outil peut servir à évaluer des collectifs sur d’autres thématiques. Vous êtes encouragé à y réfléchir et à y
|
||||
travailler. La licence AGPL version 3 prévoit un partage des améliorations que vous porterez à l’outil. Si vous
|
||||
désirez faire un fork de l’outil, contactez-nous : contact@apes-hdf.org
|
||||
|
||||
## Build Setup
|
||||
|
||||
```bash
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
}
|
||||
|
86
frontend/assets/css/_modal.scss
Normal 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;
|
||||
}
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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;
|
||||
|
@@ -1,45 +1,59 @@
|
||||
@import "_color";
|
||||
@import "_font";
|
||||
@import "_placeholders";
|
||||
@import "_mixin";
|
||||
@import "_spacing";
|
||||
|
||||
$breakpoint: 1024px;
|
||||
|
||||
* {
|
||||
*,
|
||||
::after,
|
||||
::before {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: $font-primary;
|
||||
background: $white;
|
||||
color: $gray_4;
|
||||
body {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
color: $gray_4;
|
||||
font-family: $font-primary;
|
||||
background: $white; // #ededf0
|
||||
}
|
||||
|
||||
a {
|
||||
color: $light_blue;
|
||||
text-decoration: none;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
.main-content {
|
||||
min-height: calc(100vh - $footer_height - $header_height);
|
||||
max-width: $content_width;
|
||||
margin: 0 auto;
|
||||
|
||||
@media only screen and (max-width: $breakpoint) {
|
||||
margin: 0 $small;
|
||||
}
|
||||
}
|
||||
|
||||
a, .button-link {
|
||||
color: $gray_4;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.button-link {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: $xx_small $small;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
line-height: 2.125rem;
|
||||
margin: $medium 0 $small 0;
|
||||
margin: $medium 0;
|
||||
color: $black;
|
||||
text-align: center;
|
||||
|
||||
@media only screen and (max-width: $breakpoint) {
|
||||
margin: $xx_small;
|
||||
margin: $xx_small 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +62,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 +74,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 +84,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 +118,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 +161,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 +202,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;
|
||||
}
|
||||
}
|
||||
|
243
frontend/components/BundleAxeModal.vue
Normal 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>
|
149
frontend/components/Cgu.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<h2>Bienvenue sur la Boussole PLUSS</h2>
|
||||
|
||||
<p>
|
||||
« Une application de valorisation des ressources mobilisées dans les projets locaux. Le petit plus c’est l’identification 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 où vous en êtes !
|
||||
Le principe : il s’agit d’auto-é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 « Agir pour une production locale ». Les participants choisissent 3 questions par balise.
|
||||
Cette application fait partie d’une boîte à outils plus vaste, initiée par l’Apes et ses adhérents. En savoir PLUSS
|
||||
</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 d’utilisation (CGU)
|
||||
</p>
|
||||
<h2>1. Définitions</h2>
|
||||
<p>Les termes mentionnés ci-dessous ont, dans les présentes Conditions Générales d’Utilisation, la signification suivante, qu’ils soient utilisés au singulier ou au pluriel :</p>
|
||||
<ul>
|
||||
<li>Balise : il s’agit d’un axe du référentiel PLUSS accessible ici :</li>
|
||||
<li>Boussole : le présent outil d’évaluation accessible sur le site éponyme</li>
|
||||
<li>http://apes-hdf.org/_docs/Fichier/2022/21-220610094246.pdf</li>
|
||||
<li>Compte : désigne l’espace personnel de l’Utilisateur sur le Site. L’accès au Compte</li>
|
||||
<li>se fait grâce aux Identifiants.</li>
|
||||
<li>Équipe : il s’agit du collectif qui va utiliser la Boussole pour s’évaluer</li>
|
||||
<li>Identifiants : désigne l’adresse courriel de l’Utilisateur 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 d’accueil accessible après identification sur laquelle l’Utilisateur 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 d’Utilisation ont pour objet de définir les conditions et les
|
||||
modalités d’accès à la Boussole PLUSS
|
||||
|
||||
<h2>3. Acceptation des Conditions Générales d’Utilisation</h2>
|
||||
L’utilisation des fonctionnalités de la Plateforme implique l’acceptation des présentes CGU.
|
||||
L’Utilisateur s’engage à lire attentivement les présentes Conditions Générales d’Utilisation lors de
|
||||
l’accè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 d’accueil au moyen d’un 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 s’appliquent pendant toute la durée de l’utilisation des fonctionnalités du site (c’est-à-dire, notamment, tant que l’Utilisateur est titulaire d’un 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 à l’adresse 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 d’auto-é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 d’Utilisateur</li>
|
||||
<li>Générer une évaluation à partir d’une 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 d’un Compte, gestion et accès à l’espace « Mon Compte »</h3>
|
||||
<h4>4.1.1 Création d’un Compte</h4>
|
||||
<p>Sur la page d’accueil de la Boussole PLUSS il est possible de se créer un Compte en cliquant sur « créer un compte ». L’Utilisateur est alors invité à remplir les champs du formulaire : nom de l’équipe, adresse e-mail et mot de passe.
|
||||
Un mail automatique est envoyé à l’Utilisateur pour confirmer son inscription.
|
||||
L’Utilisateur est seul responsable de l’utilisation de ses Identifiants. Il doit veiller à conserver secret
|
||||
son mot de passe et à ne pas le divulguer.
|
||||
Il sera responsable de l’utilisation de ses Identifiants par des tiers, qu’elle soit frauduleuse ou non.</p>
|
||||
|
||||
<h4>4.1.2 Connexion à l’espace personnel si le Compte est déjà créé</h4>
|
||||
<p>Sur la page d’accueil de la Boussole PLUSS, l’Utilisateur 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, l’utilisateur 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 d’accueil de la Boussole PLUSS un lien « j’ai oublié mon mot de passe » permet de réinitialiser le mot de passe d’accès au Compte. L’e-mail utilisé lors de la création du Compte est demandé afin qu’un 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 l’administrateur à contact@apes-hdf.org</p>
|
||||
|
||||
<h3>4.2 Réalisation d’une évaluation</h3>
|
||||
|
||||
<h4>4.2.1 Réalisation d’une évaluation sur la Boussole Classique</h4>
|
||||
<p>Dès la première connexion l’Utilisateur 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 Classique ». L’Utilisateur 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 s’ajouter 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 d’une nouvelle Boussole</h4>
|
||||
<p>La création d’une 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 d’accé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 d’une évaluation sur une nouvelle Boussole</h4>
|
||||
<p>
|
||||
Le mode opératoire est le même que pour la Boussole classique (voir 4.2.1).
|
||||
</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 : It’s 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 à l’adresse mail de l’Utilisateur, 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 d’outil analytique ou de mesure d’audience.</p>
|
||||
|
||||
<h2>7. Accessibilité du site</h2>
|
||||
<p>L’Administration fait de son mieux pour s’assurer 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 d’actualisation des informations publiées.</li>
|
||||
<li>impossibilité momentanée d’accès au Site (et/ou aux sites internet et applications lui étant liés) en raison de problèmes techniques et ce quelles qu’en soient l’origine 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 à l’Utilisateur, quelle qu’en soit la nature, résultant de l’accès, ou de l’utilisation du Site (et/ou des sites ou applications qui lui sont liés)</li>
|
||||
<li>utilisation anormale ou d’une 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;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-position: inside;
|
||||
margin: $xxx_small 0;
|
||||
li {
|
||||
padding: $xxxx_small 0;
|
||||
}
|
||||
//margin: $xx_small;
|
||||
}
|
||||
|
||||
</style>
|
32
frontend/components/CguModal.vue
Normal 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 d’Utilisation 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>
|
@@ -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>
|
@@ -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, c’est quoi ?</h1>
|
||||
<p>
|
||||
« Une application de valorisation des ressources mobilisées dans les projets locaux. Le petit plus c’est
|
||||
l’identification 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 où vous en
|
||||
êtes !
|
||||
Le principe : il s’agit d’auto-é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 d’une 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>
|
||||
|
@@ -19,9 +19,6 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/spacing";
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
31
frontend/components/MainFooter.vue
Normal 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 d’Utilisation 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>
|
31
frontend/components/MainHeader.vue
Normal 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>
|
@@ -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>
|
||||
|
||||
|
@@ -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 {
|
||||
|
@@ -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>
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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";
|
||||
|
||||
|
@@ -1,56 +1,105 @@
|
||||
<script lang="ts" setup>
|
||||
import {useAuthStore} from "~/store/auth";
|
||||
|
||||
function logout() {
|
||||
useAuthStore().logout();
|
||||
navigateTo("/");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<nuxt-link to="/" class="title">
|
||||
<svg width="13" height="16" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<nuxt-link to="/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: -16px;
|
||||
top: 47px;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
&:focus-within > .menu-container__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-container__content {
|
||||
gap: $xx_small;
|
||||
align-items: flex-end;
|
||||
list-style: none;
|
||||
min-width: 150px;
|
||||
padding: $xx_small $xx_small;
|
||||
|
||||
border: 2px solid $gray_3;
|
||||
|
||||
& li {
|
||||
//margin: $xxx_small;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/spacing";
|
||||
@import "assets/css/font";
|
||||
|
||||
header {
|
||||
margin-top: $xx_small;
|
||||
color: $black;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
color: $black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title svg {
|
||||
margin-right: $xxx_small;
|
||||
}
|
||||
|
||||
.team {
|
||||
font-size: $small-font-size;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
91
frontend/components/Toaster.vue
Normal 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>
|
52
frontend/composables/fetch-api.ts
Normal 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
@@ -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>
|
20
frontend/layouts/default.vue
Normal 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>
|
23
frontend/layouts/main-header.vue
Normal 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>
|
22
frontend/middleware/auth.global.ts
Normal 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');
|
||||
}
|
||||
});
|
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
128
frontend/pages/account/create.vue
Normal 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é d’au 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, j’accepte
|
||||
<button class="button-link" @click="cguModalVisible = true" type="button">les conditions d'utilisation de Boussole PLUSS et j’ai 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>
|
68
frontend/pages/account/index.vue
Normal 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>
|
55
frontend/pages/account/password/confirm-reset.vue
Normal 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>Ré-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é d’au 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>
|
||||
|
46
frontend/pages/account/password/index.vue
Normal 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é d’au 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>
|
||||
|
61
frontend/pages/account/password/reset.vue
Normal 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>
|
190
frontend/pages/bundle/create.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import {type QuestionCreation, useBundleStore} from "~/store/bundle";
|
||||
import {useNotificationStore} from "~/store/notification";
|
||||
import type {ApiError} from "~/composables/fetch-api";
|
||||
import {type Axe, useAxeStore} from "~/store/axe";
|
||||
import {type Question, useQuestionStore} from "~/store/question";
|
||||
|
||||
const axes = ref<Axe[]>();
|
||||
const label = ref();
|
||||
const questions = ref<Map<number, QuestionCreation[]>>(new Map());
|
||||
const questionsExample = ref<Map<number, QuestionCreation[]>>(new Map());
|
||||
const modalVisible = ref(false);
|
||||
const currentAxe = ref<Axe>();
|
||||
const currentQuestions = ref<Question[]>();
|
||||
|
||||
onMounted(() => {
|
||||
useAxeStore().findAxes().then(response => {
|
||||
response.forEach(axe => {
|
||||
useQuestionStore().findDefaults(axe.id).then(response => {
|
||||
questions.value.set(axe.id, response);
|
||||
});
|
||||
useQuestionStore().findAll(axe.id).then(response => {
|
||||
questionsExample.value.set(axe.id, response);
|
||||
});
|
||||
});
|
||||
axes.value = response;
|
||||
});
|
||||
});
|
||||
|
||||
function createBundle() {
|
||||
const newQuestions = [];
|
||||
let errors = [];
|
||||
questions.value.forEach((value, axeId) => {
|
||||
if (value.length === 0) {
|
||||
const axeNumber = axes.value.filter(a => a.id === axeId)[0].identifier;
|
||||
errors.push(`L'axe ${axeNumber} n'a pas de question.`);
|
||||
}
|
||||
value.forEach((q: Question, index)=> {
|
||||
newQuestions.push({
|
||||
axeId: axeId,
|
||||
label: q.label,
|
||||
description: q.description,
|
||||
index: index+1
|
||||
});
|
||||
if (!q.label.trim()) {
|
||||
const axeNumber = axes.value.filter(a => a.id === axeId)[0].identifier;
|
||||
errors.push(`L'une des question de l'axe ${axeNumber} contient un libellé non rempli.`);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
useNotificationStore().pushNotification("warn",
|
||||
{message: `La boussole contient des erreurs !`, details: errors});
|
||||
}else {
|
||||
useBundleStore()
|
||||
.create({
|
||||
label: label.value,
|
||||
questions: newQuestions
|
||||
})
|
||||
.then(() => {
|
||||
useNotificationStore().pushNotification("success",{message: "La boussole a bien été créée."});
|
||||
navigateTo("/bundle");
|
||||
})
|
||||
.catch((apiError: ApiError) => {
|
||||
let details;
|
||||
if (apiError.fieldErrors) {
|
||||
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
|
||||
}
|
||||
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// const questionsInput = computed<boolean>(() => {
|
||||
// let emptyAxe = 0;
|
||||
// for (const [key, value] of questions.value.entries()) {
|
||||
// if (value.length === 0) {
|
||||
// emptyAxe += 1;
|
||||
// }
|
||||
// console.log(`${key}: ${value.length}`);
|
||||
// }
|
||||
// return emptyAxe === questions.value.keys().length;
|
||||
// });
|
||||
|
||||
function css(axe: Axe) {
|
||||
return {
|
||||
'--color': axe.color
|
||||
}
|
||||
}
|
||||
|
||||
function showAxeModal(axe: Axe) {
|
||||
currentAxe.value = axe;
|
||||
modalVisible.value = true;
|
||||
currentQuestions.value = questions.value.get(axe.id);
|
||||
document.body.style.overflowY = "hidden";
|
||||
}
|
||||
|
||||
function hideAxeModal() {
|
||||
currentAxe.value = undefined;
|
||||
currentQuestions.value = undefined;
|
||||
modalVisible.value = false;
|
||||
document.body.style.overflowY = "auto";
|
||||
}
|
||||
|
||||
function onQuestionsChange({axeId, newQuestions}) {
|
||||
questions.value.set(axeId, newQuestions);
|
||||
}
|
||||
|
||||
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">
|
||||
<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>
|
95
frontend/pages/bundle/index.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import {type Bundle, useBundleStore} from "~/store/bundle";
|
||||
|
||||
const bundles = ref<Bundle[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
loading.value = true;
|
||||
useBundleStore().findAll().then((response: Quiz[]) => {
|
||||
bundles.value = response;
|
||||
}).finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
function selectBundle(bundle: Bundle) {
|
||||
useBundleStore().setCurrentBundle(bundle);
|
||||
navigateTo("/dashboard");
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="!loading">
|
||||
<ul class="bundle-list">
|
||||
<li v-for="bundle in bundles">
|
||||
<article class="bundle-list__item">
|
||||
<main>
|
||||
<h1>{{ bundle.label }}</h1>
|
||||
<dl class="bundle-list__item__attribute">
|
||||
<dt>Dernière auto-évaluation réalisée</dt>
|
||||
<dd>{{bundle.lastQuizzDate ? formatDate(bundle.lastQuizzDate): 'NA' }}</dd>
|
||||
</dl>
|
||||
<dl class="bundle-list__item__attribute">
|
||||
<dt>Nombre d'auto-évaluation</dt>
|
||||
<dd>{{ bundle.numberOfQuizzes || 0 }}</dd>
|
||||
</dl>
|
||||
</main>
|
||||
<footer class="bundle-list__item__button-container">
|
||||
<button class="button blue" @click="selectBundle(bundle)">Voir</button>
|
||||
</footer>
|
||||
</article>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="button-container">
|
||||
<nuxt-link to="/bundle/create">Personnaliser votre Boussole</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
<loader v-else/>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.bundle-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));
|
||||
grid-gap: $small;
|
||||
|
||||
list-style: none;
|
||||
margin-top: $x_medium;
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $small;
|
||||
padding: $small;
|
||||
|
||||
border-radius: 20px;
|
||||
|
||||
@include border-shadow();
|
||||
|
||||
h1 {
|
||||
margin: 0 0 $small 0;
|
||||
}
|
||||
|
||||
&__attribute {
|
||||
display: flex;
|
||||
gap: $xxx_small;
|
||||
dt {
|
||||
font-weight: bold;
|
||||
&:after {
|
||||
content: ' :';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__button-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
20
frontend/pages/cgu.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
definePageMeta({
|
||||
layout: 'main-header'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>Conditions Générales d’Utilisation 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>
|
@@ -1,74 +1,61 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<team-header/>
|
||||
<hr/>
|
||||
<div v-if="loading" class="center" >
|
||||
<loader/>
|
||||
</div>
|
||||
<section v-if="currentResult" class="last-quiz">
|
||||
<div class="last-quiz-header">
|
||||
<span class="date"> {{ currentResult.createdDate | formatDate }}</span>
|
||||
<nuxt-link
|
||||
class="link" :to="{ path: '/details', query: { quiz: currentResult.id }}">
|
||||
+ Voir le détail
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div v-if="currentResult && currentResult.scores" class="chart-area">
|
||||
<polar-area-chart :data="currentResult.scores.map(value => value.scoreAvg)"/>
|
||||
<Legend/>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="quizzes.length > 0" class="history" >
|
||||
<div v-for="q in quizzes" :key="q.id">
|
||||
<button @click="setCurrent(q)"><span>Boussole - {{ q.createdDate | formatDate }}</span><span>❯</span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else-if="!loading" class="center">
|
||||
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
|
||||
</section>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button orange" to="/quiz">Nouveau</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Vue} from "vue-property-decorator";
|
||||
import {AxiosResponse} from "axios";
|
||||
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
|
||||
import {RestResponse} from "~/repositories/models/rest-response.model";
|
||||
import {Quiz} from "~/repositories/models/quiz.model";
|
||||
import {type Quiz, useQuizStore} from "~/store/quiz";
|
||||
import {useBundleStore} from "~/store/bundle";
|
||||
|
||||
@Component
|
||||
export default class History extends Vue {
|
||||
const quizzes = ref<Quiz[]>([]);
|
||||
const currentResult = ref<Quiz>();
|
||||
|
||||
readonly quizRepository = RepositoryFactory.get('quiz');
|
||||
onMounted(() => {
|
||||
useQuizStore().findQuizzes(useBundleStore().selectedBundle).then((response: Page<Quiz>) => {
|
||||
quizzes.value = response.content;
|
||||
currentResult.value = quizzes.value.length > 0 ? quizzes.value[0] : null;
|
||||
});
|
||||
});
|
||||
|
||||
private quizzes: Quiz[] = [];
|
||||
private currentResult: Quiz | null = null;
|
||||
private loading = true;
|
||||
|
||||
async mounted() {
|
||||
await this.quizRepository.findMine().then((response: AxiosResponse<RestResponse<Quiz>>) => {
|
||||
this.quizzes = response.data._embedded.quizzes;
|
||||
});
|
||||
this.currentResult = this.quizzes.length > 0 ? this.quizzes[0] : null;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private setCurrent(quiz: Quiz) {
|
||||
this.currentResult = quiz;
|
||||
}
|
||||
function setCurrent(quiz: Quiz) {
|
||||
currentResult.value = quiz;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/spacing";
|
||||
@import "assets/css/font";
|
||||
<template>
|
||||
<section v-if="currentResult" class="last-quiz">
|
||||
<div class="last-quiz-header">
|
||||
<span class="date"> {{ formatDateTime(currentResult.createdDate) }}</span>
|
||||
<nuxt-link
|
||||
class="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>Boussole - {{ 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">Nouveau</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>
|
||||
|
@@ -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
@@ -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>
|
@@ -1,3 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'main-header'
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Home />
|
||||
<home />
|
||||
</template>
|
||||
|
@@ -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, j’accepte les conditions d'utilisation de Boussole PLUSS et j’ai 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>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
@@ -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>
|
||||
|
@@ -1,7 +0,0 @@
|
||||
import { initializeAxios } from '~/utils/api'
|
||||
|
||||
const accessor: ({$axios}: { $axios: any }) => void = ({ $axios }) => {
|
||||
initializeAxios($axios)
|
||||
}
|
||||
|
||||
export default accessor
|
5
frontend/plugins/chartjs.ts
Normal 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)
|
||||
})
|
@@ -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);
|
||||
})
|
||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 184 B After Width: | Height: | Size: 184 B |
BIN
frontend/public/images/decoration/cube.png
Normal file
After Width: | Height: | Size: 66 KiB |
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 374 B |
BIN
frontend/public/images/logo/logo_apes.png
Normal file
After Width: | Height: | Size: 23 KiB |
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
@@ -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]
|
||||
};
|
@@ -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");
|
||||
}
|
||||
}
|
@@ -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[];
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
import {RestLinks} from "~/repositories/models/rest-response.model";
|
||||
|
||||
export interface Question extends RestLinks {
|
||||
id: number;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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[] };
|
||||
}
|
@@ -1,4 +0,0 @@
|
||||
export interface Token {
|
||||
"name": string,
|
||||
"password": "password"
|
||||
}
|
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
@@ -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
|
||||
}, {});
|
||||
}
|
||||
}
|
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,19 @@
|
||||
import {defineStore} from 'pinia';
|
||||
|
||||
export interface Axe {
|
||||
id: number;
|
||||
identifier: number;
|
||||
shortTitle: string;
|
||||
description: string;
|
||||
title: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const useAxeStore = defineStore('axe', {
|
||||
state: () => ({}),
|
||||
actions: {
|
||||
findAxes() {
|
||||
return useApi("axes");
|
||||
}
|
||||
}
|
||||
});
|
44
frontend/store/bundle.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {defineStore} from 'pinia';
|
||||
|
||||
export interface Bundle {
|
||||
id: number;
|
||||
label: string;
|
||||
lastQuizzDate: string;
|
||||
numberOfQuizzes: number;
|
||||
}
|
||||
|
||||
export interface QuestionCreation {
|
||||
label: string;
|
||||
description: string;
|
||||
axeId: number;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface BundleCreationRequest {
|
||||
label: string;
|
||||
questions: QuestionCreation[];
|
||||
}
|
||||
|
||||
export const useBundleStore = defineStore('bundle', {
|
||||
state: () => ({
|
||||
selectedBundle: ref<number>(useCookie('bundleId').value),
|
||||
}),
|
||||
actions: {
|
||||
|
||||
findAll(): Bundle[] {
|
||||
return useApi("bundles");
|
||||
},
|
||||
|
||||
setCurrentBundle(bundle: Bundle) {
|
||||
this.selectedBundle = bundle.id;
|
||||
useCookie('bundleId').value = bundle.id;
|
||||
},
|
||||
|
||||
create(request: BundleCreationRequest) {
|
||||
return useApi("bundles", {
|
||||
method: "POST",
|
||||
body: request
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
@@ -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'
|
29
frontend/store/notification.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
29
frontend/store/question.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
@@ -1,76 +1,155 @@
|
||||
import {Module, VuexModule, Mutation} from 'vuex-module-decorators'
|
||||
import {Question} from "~/repositories/models/question.model";
|
||||
import {QuizRate, Response} from "~/repositories/models/quiz.model";
|
||||
import {defineStore} from 'pinia';
|
||||
import {useBundleStore} from "~/store/bundle";
|
||||
import {Axe, useAxeStore} from "~/store/axe";
|
||||
import type {Question} from "~/store/question";
|
||||
|
||||
@Module({
|
||||
name: 'quiz',
|
||||
stateFactory: true,
|
||||
namespaced: true,
|
||||
})
|
||||
export default class Quiz extends VuexModule {
|
||||
|
||||
responses = new Map<number, QuizRate>;
|
||||
questionsRatedPerAxe = new Map<number, { questionId: number; rated: boolean }[]>;
|
||||
|
||||
@Mutation
|
||||
initialize(questions: Map<number, Question[]>) {
|
||||
questions.forEach((questions, axeId) => this.questionsRatedPerAxe.set(axeId, questions.map(value => {
|
||||
return {
|
||||
questionId: value.id,
|
||||
rated: this.responses.has(value.id)
|
||||
}
|
||||
})));
|
||||
}
|
||||
|
||||
@Mutation
|
||||
reset() {
|
||||
this.responses.clear();
|
||||
this.questionsRatedPerAxe.forEach((questions) => {
|
||||
questions
|
||||
.map(value => {
|
||||
value.rated = false;
|
||||
return value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation
|
||||
updateScoreResponse(response: Response) {
|
||||
const previous = this.responses.get(response.questionId);
|
||||
if (previous) {
|
||||
this.responses.set(response.questionId, {
|
||||
comment: previous.comment,
|
||||
score: response.score
|
||||
});
|
||||
} else {
|
||||
this.responses.set(response.questionId, {
|
||||
score: response.score
|
||||
});
|
||||
}
|
||||
const questionsRated = this.questionsRatedPerAxe.get(response.axeId);
|
||||
if (questionsRated) {
|
||||
questionsRated
|
||||
.filter(value => value.questionId === response.questionId)
|
||||
.map(value => {
|
||||
value.rated = true;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
// else should not happen
|
||||
}
|
||||
|
||||
@Mutation
|
||||
updateCommentResponse(response: Response) {
|
||||
const previous = this.responses.get(response.questionId);
|
||||
if (previous) {
|
||||
this.responses.set(response.questionId, {
|
||||
score: previous.score,
|
||||
comment: response.comment
|
||||
});
|
||||
} else {
|
||||
this.responses.set(response.questionId, {
|
||||
comment: response.comment
|
||||
});
|
||||
}
|
||||
}
|
||||
export interface QuizResponse {
|
||||
question: string;
|
||||
score: number;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface AxeResponses {
|
||||
axeIdentifier: number;
|
||||
average: number;
|
||||
responses: QuizResponse[];
|
||||
}
|
||||
|
||||
export interface Quiz {
|
||||
id: number;
|
||||
createdDate: string;
|
||||
axes: AxeResponses[]
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
axeId: number;
|
||||
questionId: number;
|
||||
score?: number;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface QuizRate { // ?
|
||||
score?: number;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export const useQuizStore = defineStore('quiz', {
|
||||
state: () => ({
|
||||
axes: ref<Axe[]>([]),
|
||||
questions: ref<Map<number, Question[]>>(new Map()),
|
||||
responses: ref<Map<number, QuizRate>>(new Map()),
|
||||
questionsRatedPerAxe: ref<Map<number, Array<{ questionId: number; rated: boolean }>>>(new Map())
|
||||
}),
|
||||
actions: {
|
||||
|
||||
initialize() {
|
||||
const bundleId = useBundleStore().selectedBundle;
|
||||
return useAxeStore().findAxes().then(axes => {
|
||||
const promises: any[] = [];
|
||||
this.axes = axes;
|
||||
axes.forEach(axe => {
|
||||
promises.push(
|
||||
useApi(`/bundles/${bundleId}/questions/search`, {
|
||||
params: {
|
||||
axeId: axe.id
|
||||
}
|
||||
})
|
||||
.then((questions: Question[]) => {
|
||||
return {
|
||||
axeId: axe.identifier,
|
||||
questions: questions
|
||||
};
|
||||
}));
|
||||
});
|
||||
Promise.all(promises).then((axeQuestions) => {
|
||||
axeQuestions.forEach(axeQuestion => {
|
||||
this.questions.set(axeQuestion.axeId, axeQuestion.questions)
|
||||
});
|
||||
this.questions.forEach((questions, axeId) => this.questionsRatedPerAxe.set(axeId, questions.map(value => {
|
||||
return {
|
||||
questionId: value.id,
|
||||
rated: this.responses.has(value.id)
|
||||
}
|
||||
})));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
resetResponses() {
|
||||
this.responses.clear();
|
||||
this.questionsRatedPerAxe.forEach((questions) => {
|
||||
questions
|
||||
.map(value => {
|
||||
value.rated = false;
|
||||
return value;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
updateScoreResponse(response: Response) {
|
||||
const previous = this.responses.get(response.questionId);
|
||||
if (previous) {
|
||||
this.responses.set(response.questionId, {
|
||||
comment: previous.comment,
|
||||
score: response.score
|
||||
});
|
||||
} else {
|
||||
this.responses.set(response.questionId, {
|
||||
score: response.score
|
||||
});
|
||||
}
|
||||
const questionsRated = this.questionsRatedPerAxe.get(response.axeId);
|
||||
if (questionsRated) {
|
||||
questionsRated
|
||||
.filter(value => value.questionId === response.questionId)
|
||||
.map(value => {
|
||||
value.rated = true;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
// else should not happen
|
||||
},
|
||||
|
||||
updateCommentResponse(response: Response) {
|
||||
const previous = this.responses.get(response.questionId);
|
||||
if (previous) {
|
||||
this.responses.set(response.questionId, {
|
||||
score: previous.score,
|
||||
comment: response.comment
|
||||
});
|
||||
} else {
|
||||
this.responses.set(response.questionId, {
|
||||
comment: response.comment
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
findQuizzes(bundleId: number) {
|
||||
return useApi("/quizzes/search", {
|
||||
params: {
|
||||
bundleId: bundleId,
|
||||
sort: "createdDate,desc"
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
findById(quizId: number) {
|
||||
return useApi(`/quizzes/${quizId}`);
|
||||
},
|
||||
|
||||
save() {
|
||||
const responses = [];
|
||||
this.responses.forEach((value, key) => {
|
||||
responses.push({
|
||||
score: value.score ? value.score : 0,
|
||||
comment: value.comment,
|
||||
questionId: key
|
||||
});
|
||||
});
|
||||
return useApi("/quizzes", {
|
||||
method: "POST",
|
||||
body: {responses, bundleId: useBundleStore().selectedBundle}
|
||||
}).finally(() => this.resetResponses());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -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"]
|
||||
}
|
||||
|
@@ -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 }
|
4
frontend/utils/formatDate.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default (date: string) => {
|
||||
const dateObject = new Date(date);
|
||||
return dateObject.toLocaleDateString();
|
||||
}
|
4
frontend/utils/formatDateTime.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export default (date: string) => {
|
||||
const dateObject = new Date(date);
|
||||
return dateObject.toLocaleDateString() + ' ' + dateObject.toLocaleTimeString();
|
||||
}
|
@@ -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}
|