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
|
||||
|
@@ -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: 200px;
|
||||
|
||||
$max_z_index: 999;
|
||||
|
||||
$xxx_small: .5rem;
|
||||
$xx_small: .75rem;
|
||||
$x_small: 1.5rem;
|
||||
$small: 2rem;
|
||||
$medium: 3rem;
|
||||
$x_medium: 3.75rem;
|
||||
$xx_medium: 5rem;
|
||||
$large: 7.5rem;
|
||||
$x_large: 11.875rem;
|
||||
$xx_large: 16.25rem;
|
||||
$xxxx_small: 4px;
|
||||
$xxx_small: 8px;
|
||||
$xx_small: 12px;
|
||||
$x_small: 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,6 +84,17 @@ 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;
|
||||
}
|
||||
@@ -79,8 +104,7 @@ hr {
|
||||
flex-direction: column;
|
||||
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>
|
37
frontend/components/CguModal.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
const emit = defineEmits(["close", "validate"]);
|
||||
defineProps({
|
||||
visible: Boolean
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div :class="`modal${visible ? ' visible' : ''}`">
|
||||
<section class="modal-content">
|
||||
<header class="modal-content-header">
|
||||
<h1>CGU</h1>
|
||||
<button class="close_modal" @click="$emit('close')">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.6 16L0 14.4L6.4 8L0 1.6L1.6 0L8 6.4L14.4 0L16 1.6L9.6 8L16 14.4L14.4 16L8 9.6L1.6 16Z"
|
||||
fill="#1C1B1F"/>
|
||||
</svg>
|
||||
<span>Fermer</span>
|
||||
</button>
|
||||
</header>
|
||||
<main class="modal-content-body">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Architecto consequatur, consequuntur et expedita libero
|
||||
non soluta sunt ullam vel velit voluptatibus voluptatum? Accusamus blanditiis est obcaecati temporibus velit. Dicta
|
||||
doloribus eveniet id incidunt suscipit. Accusamus ad aspernatur at aut, beatae laboriosam modi natus nemo, officia
|
||||
perspiciatis porro quisquam, totam vel.
|
||||
</p>
|
||||
</main>
|
||||
<footer class="modal-content-footer">
|
||||
<button class="button orange" @click="emit('validate')">Accepter</button>
|
||||
</footer>
|
||||
</section>
|
||||
<div class="modal-overlay" @click="$emit('close')"></div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
@import "assets/css/modal";
|
||||
</style>
|
@@ -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,45 @@
|
||||
<template>
|
||||
<div>
|
||||
<Header/>
|
||||
<div class="content">
|
||||
<section>
|
||||
<h1>La démarche</h1>
|
||||
<p>
|
||||
Les crises successives que nous subissons révèlent encore plus la fragilité d'une économie productive
|
||||
prioritairement mondialisée. S'il est impératif que les producteurs locaux reprennent la main sur certaines
|
||||
filières, il faut aussi, pour favoriser la transition écologique et sociale, que la production locale soit
|
||||
avant tout utile, solidaire et soutenable.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<h1>La boussole comme auto-évaluation</h1>
|
||||
<p>
|
||||
Nous proposons dix points de repères pour permettre aux différents écosystèmes de production locale sur un
|
||||
territoire d'agir conjointement et collectivement dans ce sens et faire en sorte que les acteurs locaux
|
||||
puissent innover, expérimenter et renforcer la production locale quantitativement et qualitativement.
|
||||
</p>
|
||||
</section>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button orange" to="/login">Démarrer</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<section>
|
||||
<h1>La Boussole PLUSS, 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>
|
||||
<section>
|
||||
<h1>Contribuer à la Boussole</h1>
|
||||
<p>
|
||||
Le projet actuel est prévu pour travailler sur le thème de la production locale.<br/>
|
||||
Cet outil peut servir à évaluer des collectifs sur 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 : <nuxt-link to="mailto:contact@apes-hdf.org">contact@apes-hdf.org</nuxt-link>
|
||||
</p>
|
||||
</section>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button orange" to="/login">Démarrer</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
@@ -19,9 +19,6 @@
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/spacing";
|
||||
|
||||
ol {
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
26
frontend/components/MainFooter.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<footer class="footer">
|
||||
<nuxt-link to="https://www.apes-hdf.org" target="_blank">
|
||||
<img src="/images/logo/logo_apes.png" height="125" width="200" alt="Logo APES"/>
|
||||
</nuxt-link>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.footer {
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #ededf0;
|
||||
|
||||
margin-top: auto;
|
||||
|
||||
clear: both;
|
||||
height: $footer_height;
|
||||
|
||||
background: $white url(/images/decoration/cube.png) repeat left top;;
|
||||
}
|
||||
</style>
|
||||
<script setup lang="ts">
|
||||
</script>
|
40
frontend/components/MainHeader.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="secondary-logo">
|
||||
<nuxt-link to="https://www.apes-hdf.org" target="_blank">
|
||||
<img src="/images/logo/logo_apes.svg" height="50px" alt="Logo APES"/>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="main-logo">
|
||||
<nuxt-link :to="useRoute().name === 'index' ? '' : '/'">
|
||||
<img src="/images/logo/main_logo.svg" width="245px" alt="Boussole PLUSS"/>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.header {
|
||||
width: 100%;
|
||||
height: 115px;
|
||||
margin-bottom: $xxx_medium;
|
||||
background-repeat: no-repeat;
|
||||
background-image: url("/images/decoration/background.svg");
|
||||
background-position: bottom center, 50%;
|
||||
background-size: cover;
|
||||
|
||||
.secondary-logo img {
|
||||
padding: 12px 0 0 12px;
|
||||
}
|
||||
|
||||
.main-logo {
|
||||
position: absolute;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
top: 40px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 225px;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -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="/" class="title">
|
||||
<svg width="26" height="32" viewBox="0 0 13 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 7.07348C0 6.81265 0.101912 6.56214 0.284 6.37538L5.784 0.73436C6.17647 0.331827 6.82353 0.331826 7.216 0.734359L12.716 6.37538C12.8981 6.56214 13 6.81265 13 7.07348V15C13 15.5523 12.5523 16 12 16H1C0.447715 16 0 15.5523 0 15V7.07348Z"
|
||||
fill="#8BCDCD"/>
|
||||
</svg>
|
||||
Boussole <span class="bold">PLUSS</span>
|
||||
</nuxt-link>
|
||||
<div class="menu-container">
|
||||
<button class="button-icon">
|
||||
<svg class="svg-icon" width="32" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0 7.07348C0 6.81265 0.101912 6.56214 0.284 6.37538L5.784 0.73436C6.17647 0.331827 6.82353 0.331826 7.216 0.734359L12.716 6.37538C12.8981 6.56214 13 6.81265 13 7.07348V15C13 15.5523 12.5523 16 12 16H1C0.447715 16 0 15.5523 0 15V7.07348Z"
|
||||
d="M723.43 508.6c-54.123 47.75-125.977 77.056-205.163 77.056-80.364 0-153.4-30.259-207.765-79.421C184.05 539.325 105.81 652.308 105.81 787.277v68.782c0 160.968 812.39 160.968 812.39 0v-68.782c-0.005-131.415-74.22-242.509-194.77-278.677z m-205.163 28.13c140.165 0 254.095-109.44 254.095-244.64S658.668 47.218 518.267 47.218c-139.93 0-253.855 109.675-253.855 244.874 0 135.204 113.925 244.639 253.855 244.639z m0 0"
|
||||
fill="#8BCDCD"/>
|
||||
</svg>
|
||||
Boussole <span class="bold">PLUSS</span>
|
||||
</nuxt-link>
|
||||
<span class="team">
|
||||
Équipe : {{ team }}
|
||||
</span>
|
||||
</svg>
|
||||
</button>
|
||||
<ul class="menu-container__content">
|
||||
<li>
|
||||
{{ useAuthStore().user.username }}
|
||||
</li>
|
||||
<li>
|
||||
<nuxt-link to="/account">Mon compte</nuxt-link>
|
||||
</li>
|
||||
<li>
|
||||
<button class="button-link" @click="logout">Me déconnecter</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Vue} from "nuxt-property-decorator";
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@Component
|
||||
export default class TeamHeader extends Vue {
|
||||
header {
|
||||
padding: 0 $xx_small;
|
||||
color: $black;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
//position: absolute;
|
||||
//height: $header_height;
|
||||
border-bottom: 2px solid $gray_3;
|
||||
}
|
||||
|
||||
get team() {
|
||||
return this.$auth.user ? this.$auth.user.username : "Non connecté";
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $xxx_small;
|
||||
text-transform: uppercase;
|
||||
color: $black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title svg {
|
||||
margin-right: $xxx_small;
|
||||
}
|
||||
|
||||
.team {
|
||||
font-size: $small-font-size;
|
||||
}
|
||||
|
||||
.menu-container {
|
||||
position: relative;
|
||||
|
||||
& > .menu-container__content {
|
||||
display: none;
|
||||
position: absolute;
|
||||
right: -16px;
|
||||
top: 47px;
|
||||
background: $white;
|
||||
}
|
||||
|
||||
&:focus-within > .menu-container__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menu-container__content {
|
||||
gap: $xx_small;
|
||||
align-items: flex-end;
|
||||
list-style: none;
|
||||
min-width: 150px;
|
||||
padding: $xx_small $xx_small;
|
||||
|
||||
border: 2px solid $gray_3;
|
||||
|
||||
& li {
|
||||
//margin: $xxx_small;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/spacing";
|
||||
@import "assets/css/font";
|
||||
|
||||
header {
|
||||
margin-top: $xx_small;
|
||||
color: $black;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-transform: uppercase;
|
||||
color: $black;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title svg {
|
||||
margin-right: $xxx_small;
|
||||
}
|
||||
|
||||
.team {
|
||||
font-size: $small-font-size;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
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>
|
21
frontend/middleware/auth.global.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {useAuthStore} from "~/store/auth";
|
||||
|
||||
const publicUrl = [
|
||||
"index",
|
||||
"login",
|
||||
"account-password-reset",
|
||||
"account-password-confirm-reset",
|
||||
"account-create",
|
||||
]
|
||||
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const store = useAuthStore();
|
||||
if (store.authenticated && to?.name === 'login') {
|
||||
return navigateTo('/bundle');
|
||||
}
|
||||
// if token doesn't exist redirect to log in if not in public URL
|
||||
if (!store.authenticated && !publicUrl.includes(to?.name)) {
|
||||
abortNavigation();
|
||||
return navigateTo('/login');
|
||||
}
|
||||
});
|
@@ -1,151 +1,50 @@
|
||||
export default {
|
||||
// Disable server-side rendering: https://go.nuxtjs.dev/ssr-mode
|
||||
ssr: false,
|
||||
// Learn more about it on https://go.nuxtjs.dev/static-target
|
||||
target: "static",
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
devtools: { enabled: true },
|
||||
|
||||
router: {
|
||||
middleware:['auth']
|
||||
},
|
||||
|
||||
|
||||
// Global page headers: https://go.nuxtjs.dev/config-head
|
||||
head: {
|
||||
title: 'Boussole PLUSS',
|
||||
htmlAttrs: {
|
||||
lang: 'fr',
|
||||
},
|
||||
meta: [
|
||||
{charset: 'utf-8'},
|
||||
{name: 'viewport', content: 'width=device-width, initial-scale=1'},
|
||||
{hid: 'description', name: 'description', content: ''},
|
||||
{name: 'format-detection', content: 'telephone=no'},
|
||||
],
|
||||
link: [{rel: 'icon', href: '/favicon.svg'}],
|
||||
},
|
||||
|
||||
// Global CSS: https://go.nuxtjs.dev/config-css
|
||||
css: [
|
||||
// SCSS file in the project
|
||||
'~/assets/css/main.scss',
|
||||
'~/assets/css/_color.scss',
|
||||
'~/assets/css/_font.scss',
|
||||
'~/assets/css/_spacing.scss'
|
||||
],
|
||||
|
||||
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
|
||||
plugins: [
|
||||
{ src: '~/plugins/axios-accessor.ts' },
|
||||
{ src: '~/plugins/filters.ts' }
|
||||
],
|
||||
|
||||
// Auto import components: https://go.nuxtjs.dev/config-components
|
||||
components: true,
|
||||
|
||||
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
|
||||
buildModules: [
|
||||
// https://go.nuxtjs.dev/typescript
|
||||
'@nuxt/typescript-build',
|
||||
],
|
||||
|
||||
// Modules: https://go.nuxtjs.dev/config-modules
|
||||
modules: [
|
||||
// https://go.nuxtjs.dev/axios
|
||||
'@nuxtjs/axios',
|
||||
'@nuxtjs/auth-next',
|
||||
'@nuxtjs/eslint-module',
|
||||
"@nuxtjs/device",
|
||||
"@pinia/nuxt"
|
||||
],
|
||||
|
||||
// Axios module configuration: https://go.nuxtjs.dev/config-axios
|
||||
// axios: {
|
||||
// // Workaround to avoid enforcing hard-coded localhost:3000: https://github.com/nuxt-community/axios-module/issues/308
|
||||
// baseURL: '/',
|
||||
// },
|
||||
axios: {
|
||||
baseURL: process.env.BACKEND_BASE_URL || 'http://localhost:8080/'
|
||||
css: ["~/assets/css/main.scss"],
|
||||
ssr: false,
|
||||
|
||||
devServer: {
|
||||
port: Number(process.env.PORT) || 3000
|
||||
},
|
||||
// https://www.wolfpack-digital.com/blogposts/nuxt-auth-refresh-token-authentication-in-your-nuxt-app
|
||||
// auth: {
|
||||
// redirect: {
|
||||
// login: '/login',
|
||||
// logout: '/',
|
||||
// home: "/login"
|
||||
// },
|
||||
// // localStorage: true,
|
||||
// // cookie: {
|
||||
// // prefix: 'auth',
|
||||
// // options: {
|
||||
// // path: "/home",
|
||||
// // maxAge: 1000
|
||||
// // }
|
||||
// // },
|
||||
// strategies: {
|
||||
// local: {
|
||||
// token: {
|
||||
// property: 'token',
|
||||
// maxAge: 1800,
|
||||
// global: true
|
||||
// },
|
||||
// user: {
|
||||
// property: false,
|
||||
// autoFetch: false
|
||||
// },
|
||||
// refreshToken: {
|
||||
// property: 'refreshToken',
|
||||
// data: 'refresh_token',
|
||||
// maxAge: 60 * 60 * 24 * 30
|
||||
// },
|
||||
// endpoints: {
|
||||
// login: {url: 'auth/signin', method: 'post' },
|
||||
// refresh: {url: 'auth/refreshtoken', method: 'post'},
|
||||
// user: {url: 'auth/me', method: 'get'},
|
||||
// logout: {url: 'auth/logout', method: 'post'}
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// },
|
||||
auth:{
|
||||
redirect:{
|
||||
login:'/',
|
||||
logout:'/',
|
||||
home:"/redirect"
|
||||
},
|
||||
localStorage: true,
|
||||
cookie: {
|
||||
prefix:'auth',
|
||||
options:{
|
||||
path:"/redirect",
|
||||
maxAge:1000
|
||||
}
|
||||
},
|
||||
strategies:{
|
||||
local:{
|
||||
token:{
|
||||
property:'token',
|
||||
global:true
|
||||
},
|
||||
user:{
|
||||
property: ''
|
||||
},
|
||||
endpoints:{
|
||||
login: {url: 'auth/signin', method: 'post' },
|
||||
refresh: {url: 'auth/refreshtoken', method: 'post'},
|
||||
user: {url: 'auth/me', method: 'get' },
|
||||
logout: {url: 'auth/logout', method: 'post'}
|
||||
|
||||
runtimeConfig: {
|
||||
public: {
|
||||
baseURL: process.env.BACKEND_BASE_URL || "http://localhost:8080"
|
||||
}
|
||||
},
|
||||
|
||||
components: [
|
||||
{
|
||||
path: "~/components",
|
||||
pathPrefix: false
|
||||
}
|
||||
],
|
||||
|
||||
pinia: {
|
||||
autoImports: [
|
||||
// automatically imports `defineStore`
|
||||
"defineStore", // import { defineStore } from 'pinia'
|
||||
["defineStore", "definePiniaStore"] // import { defineStore as definePiniaStore } from 'pinia'
|
||||
]
|
||||
},
|
||||
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData:
|
||||
'@use "@/assets/css/_color.scss" as *;@use "@/assets/css/_mixin.scss" as *;@use "@/assets/css/_font.scss" as *;@use "@/assets/css/_spacing.scss" as *;'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Build Configuration: https://go.nuxtjs.dev/config-build
|
||||
build: {},
|
||||
|
||||
typescript: {
|
||||
typeCheck: {
|
||||
eslint: {
|
||||
files: './**/*.{ts,vue}'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
compatibilityDate: "2024-07-06"
|
||||
});
|
||||
|
37801
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
|
89
frontend/pages/account/create.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import {useNotificationStore} from "~/store/notification";
|
||||
import {useAccountStore} from "~/store/account";
|
||||
import type {ApiError} from "~/composables/fetch-api";
|
||||
|
||||
definePageMeta({
|
||||
layout: 'main-header'
|
||||
});
|
||||
|
||||
const email = ref();
|
||||
const username = ref();
|
||||
const emailConfirmation = ref();
|
||||
const password = ref();
|
||||
const confirmationPassword = ref();
|
||||
const conditionChecked = ref(false);
|
||||
const cguModalVisible = ref(false);
|
||||
|
||||
function createAccount() {
|
||||
if (emailConfirmation.value !== email.value) {
|
||||
useNotificationStore().pushNotification("warn", {message: "Saisir le même e-mail dans les champs 'E-mail' et 'Confirmation de l'e-mail'."});
|
||||
} else if (password.value !== confirmationPassword.value) {
|
||||
useNotificationStore().pushNotification("warn", {message: "Saisir le même mot de passe dans les champs 'Mot de passe' et 'Confirmation du mot de passe'."});
|
||||
} else {
|
||||
useAccountStore().create(username.value, email.value, password.value)
|
||||
.then(() => {
|
||||
useNotificationStore().pushNotification("success", {
|
||||
message: "Votre compte a bien été créé.",
|
||||
details: "Vous allez recevoir un e-mail."
|
||||
});
|
||||
navigateTo("/login");
|
||||
})
|
||||
.catch((apiError: ApiError) => {
|
||||
let details;
|
||||
if (apiError.fieldErrors) {
|
||||
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
|
||||
}
|
||||
useNotificationStore().pushNotification("warn", {message: apiError.message, details});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<section>
|
||||
<h1>Créer un compte</h1>
|
||||
<form class="form" @submit.prevent="createAccount">
|
||||
<label for="username">Nom de l'équipe *</label>
|
||||
<input id="username" v-model="username" type="text" autocomplete="username" required>
|
||||
<label for="email">E-mail *</label>
|
||||
<input id="email" v-model="email" type="email" autocomplete="email" required>
|
||||
<label for="emailConfirmation">Confirmation de l'e-mail *</label>
|
||||
<input id="emailConfirmation" v-model="emailConfirmation" type="email" required>
|
||||
<p class="form__help">Votre mot de passe doit fait au moins 8 caractères, et être composé 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 v-model="conditionChecked" type="checkbox" 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 {
|
||||
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">
|
||||
<button class="button gray button-back" @click="useRouter().back()" aria-label="Retour à la page précédente">❮
|
||||
</button>
|
||||
<nuxt-link to="/account/password" class="button blue" type="submit">Modifier mon mot de passe</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/spacing";
|
||||
|
||||
.form {
|
||||
& [type="submit"] {
|
||||
grid-column: span 2 / 3;
|
||||
margin-top: $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>
|
||||
|
58
frontend/pages/account/password/reset.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts" setup>
|
||||
import {useAccountStore} from "~/store/account";
|
||||
import {useNotificationStore} from "~/store/notification";
|
||||
import type {ApiError} from "~/composables/fetch-api";
|
||||
|
||||
definePageMeta({
|
||||
layout: 'main-header'
|
||||
});
|
||||
|
||||
const email = ref();
|
||||
const loading = ref(false);
|
||||
|
||||
function sendEmail() {
|
||||
loading.value = true;
|
||||
useAccountStore()
|
||||
.requestPasswordReset(email.value)
|
||||
.then(() => {
|
||||
useNotificationStore().pushNotification("success",{message: "Consultez vos emails pour réinitialiser votre mot de passe."})
|
||||
navigateTo("login");
|
||||
})
|
||||
.catch((apiError: ApiError) => {
|
||||
let details;
|
||||
if (apiError.fieldErrors) {
|
||||
details = apiError.fieldErrors.map(error => `${error.fields!.join(", ")} ${error.detail}`);
|
||||
}
|
||||
useNotificationStore().pushNotification("warn",{message: apiError.message, details});
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>Mot de passe oublié</h1>
|
||||
<form class="form" @submit.prevent="sendEmail">
|
||||
<p>Entrez votre email pour recevoir un lien permettant de réinitialiser le mot de passe associé à votre
|
||||
compte.</p>
|
||||
<input v-model="email" type="email" placeholder="E-mail" required/>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button gray button-back" to="/login" aria-label="Retour à la page de login">❮</nuxt-link>
|
||||
<button class="button orange" type="submit">Envoyer l'e-mail</button>
|
||||
</div>
|
||||
<loader v-if="loading"/>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/spacing";
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $small;
|
||||
}
|
||||
</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">Nouvelle boussole</nuxt-link>
|
||||
</div>
|
||||
</section>
|
||||
<loader v-else/>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.bundle-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(22.5rem, 1fr));
|
||||
grid-gap: $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>
|
@@ -1,74 +1,61 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<team-header/>
|
||||
<hr/>
|
||||
<div v-if="loading" class="center" >
|
||||
<loader/>
|
||||
</div>
|
||||
<section v-if="currentResult" class="last-quiz">
|
||||
<div class="last-quiz-header">
|
||||
<span class="date"> {{ currentResult.createdDate | formatDate }}</span>
|
||||
<nuxt-link
|
||||
class="link" :to="{ path: '/details', query: { quiz: currentResult.id }}">
|
||||
+ Voir le détail
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div v-if="currentResult && currentResult.scores" class="chart-area">
|
||||
<polar-area-chart :data="currentResult.scores.map(value => value.scoreAvg)"/>
|
||||
<Legend/>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="quizzes.length > 0" class="history" >
|
||||
<div v-for="q in quizzes" :key="q.id">
|
||||
<button @click="setCurrent(q)"><span>Boussole - {{ q.createdDate | formatDate }}</span><span>❯</span></button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else-if="!loading" class="center">
|
||||
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
|
||||
</section>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button orange" to="/quiz">Nouveau</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Vue} from "vue-property-decorator";
|
||||
import {AxiosResponse} from "axios";
|
||||
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
|
||||
import {RestResponse} from "~/repositories/models/rest-response.model";
|
||||
import {Quiz} from "~/repositories/models/quiz.model";
|
||||
import {type Quiz, useQuizStore} from "~/store/quiz";
|
||||
import {useBundleStore} from "~/store/bundle";
|
||||
|
||||
@Component
|
||||
export default class History extends Vue {
|
||||
const quizzes = ref<Quiz[]>([]);
|
||||
const currentResult = ref<Quiz>();
|
||||
|
||||
readonly quizRepository = RepositoryFactory.get('quiz');
|
||||
onMounted(() => {
|
||||
useQuizStore().findQuizzes(useBundleStore().selectedBundle).then((response: Page<Quiz>) => {
|
||||
quizzes.value = response.content;
|
||||
currentResult.value = quizzes.value.length > 0 ? quizzes.value[0] : null;
|
||||
});
|
||||
});
|
||||
|
||||
private quizzes: Quiz[] = [];
|
||||
private currentResult: Quiz | null = null;
|
||||
private loading = true;
|
||||
|
||||
async mounted() {
|
||||
await this.quizRepository.findMine().then((response: AxiosResponse<RestResponse<Quiz>>) => {
|
||||
this.quizzes = response.data._embedded.quizzes;
|
||||
});
|
||||
this.currentResult = this.quizzes.length > 0 ? this.quizzes[0] : null;
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
private setCurrent(quiz: Quiz) {
|
||||
this.currentResult = quiz;
|
||||
}
|
||||
function setCurrent(quiz: Quiz) {
|
||||
currentResult.value = quiz;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/spacing";
|
||||
@import "assets/css/font";
|
||||
<template>
|
||||
<section v-if="currentResult" class="last-quiz">
|
||||
<div class="last-quiz-header">
|
||||
<span class="date"> {{ formatDateTime(currentResult.createdDate) }}</span>
|
||||
<nuxt-link
|
||||
class="link" :to="{ path: '/details', query: { quiz: currentResult.id }}">
|
||||
+ Voir le détail
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div v-if="currentResult && currentResult.axes" class="chart-area">
|
||||
<polar-area-chart :data="currentResult.axes.map(value => value.average)"/>
|
||||
<Legend/>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="quizzes.length > 0">
|
||||
<ul class="history">
|
||||
<li class="history__item" v-for="q in quizzes">
|
||||
<input :id="q.id" type="radio" @change="setCurrent(q)" :checked="q === currentResult" name="quiz"/>
|
||||
<label :for="q.id">
|
||||
<span>Boussole - {{ formatDateTime(q.createdDate) }}</span><span>❯</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section v-else class="center">
|
||||
Aucune auto-évaluation n'a été faite. Veuillez en réaliser une première.
|
||||
</section>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button gray button-back" to="/bundle" aria-label="Retour à la page précédente">❮</nuxt-link>
|
||||
<nuxt-link class="button orange" to="/quiz">Nouveau</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.last-quiz {
|
||||
margin-bottom: $x_small 0;
|
||||
margin: $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,76 @@
|
||||
<script lang="ts" setup>
|
||||
|
||||
import {type AxeResponses, type QuizResponse, useQuizStore} from "~/store/quiz";
|
||||
import Quiz from "~/pages/quiz.vue";
|
||||
import {type Axe, useAxeStore} from "~/store/axe";
|
||||
|
||||
const loading = ref(true);
|
||||
const quiz = ref<Quiz>();
|
||||
const axes = ref<Axe[]>();
|
||||
|
||||
onMounted(() => {
|
||||
if (!useRoute().query.quiz) {
|
||||
navigateTo("/dashboard");
|
||||
}
|
||||
loading.value = true;
|
||||
const quizId = Number.parseInt(useRoute().query.quiz as string);
|
||||
useQuizStore().findById(quizId)
|
||||
.then((result: Quiz) => {
|
||||
quiz.value = result;
|
||||
})
|
||||
.then(() => {
|
||||
useAxeStore().findAxes().then(result => {
|
||||
axes.value = result.filter(axe => {
|
||||
return quiz.value.axes.filter(axeResponse => axeResponse.axeIdentifier === axe.identifier).length > 0;
|
||||
});
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
function getAverage(axe: Axe): number {
|
||||
const axeResponses = quiz.value.axes.filter((response: AxeResponses) => response.axeIdentifier === axe.identifier);
|
||||
if (axeResponses.length === 1) {
|
||||
return axeResponses[0].average;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getResponses(axe: Axe): QuizResponse[] {
|
||||
const axeResponses = quiz.value.axes.filter((response: AxeResponses) => response.axeIdentifier === axe.identifier);
|
||||
if (axeResponses.length === 1) {
|
||||
return axeResponses[0].responses;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="content">
|
||||
<team-header/>
|
||||
<hr/>
|
||||
<div v-if="!loading">
|
||||
<span class="date">{{ quiz.createdDate | formatDate }}</span>
|
||||
<quiz-axe-details
|
||||
v-for="axe in axes"
|
||||
:key="axe.identifier"
|
||||
:axe="axe"
|
||||
:score="getScore(axe)"
|
||||
:responses="getResponses(axe)"/>
|
||||
</div>
|
||||
<loader v-else class="center"/>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button orange" to="/dashboard">Retour à l'accueil</nuxt-link>
|
||||
</div>
|
||||
<section v-if="!loading">
|
||||
<span class="date">{{ formatDateTime(quiz.createdDate) }}</span>
|
||||
<quiz-axe-details
|
||||
v-for="axe in axes"
|
||||
:axe="axe"
|
||||
:average="getAverage(axe)"
|
||||
:responses="getResponses(axe)"/>
|
||||
</section>
|
||||
<loader v-else class="center"/>
|
||||
<div class="button-container">
|
||||
<nuxt-link class="button gray button-back" to="/dashboard" aria-label="Retour à l'accueil">❮</nuxt-link>
|
||||
<button class="button orange button-print" @click="print">Imprimer</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import {Component, Vue} from "nuxt-property-decorator";
|
||||
import {AxiosResponse} from "axios";
|
||||
import {RepositoryFactory} from "~/repositories/RepositoryFactory";
|
||||
import {Quiz, ResponseWithQuestion, Score} from "~/repositories/models/quiz.model";
|
||||
import QuizAxeDetails from "~/components/QuizAxeDetails.vue";
|
||||
import {Axe} from "~/repositories/models/axe.model";
|
||||
import {RestResponse} from "~/repositories/models/rest-response.model";
|
||||
|
||||
@Component({
|
||||
components: {QuizAxeDetails}
|
||||
})
|
||||
export default class Result extends Vue {
|
||||
|
||||
readonly axeRepository = RepositoryFactory.get('axe');
|
||||
readonly quizRepository = RepositoryFactory.get('quiz');
|
||||
|
||||
private axes: Axe[] = [];
|
||||
private quiz: Quiz | null = null;
|
||||
private responses: ResponseWithQuestion[] = [];
|
||||
private loading = false;
|
||||
|
||||
created() {
|
||||
if (!this.$route.query.quiz) {
|
||||
this.$router.push("/dashboard");
|
||||
}
|
||||
try {
|
||||
this.loading = true;
|
||||
const quizId = Number.parseInt(this.$route.query.quiz as string);
|
||||
this.quizRepository.findById(quizId)
|
||||
.then((response: AxiosResponse<Quiz>) => {
|
||||
this.quiz = response.data;
|
||||
this.responses = response.data._embedded.responses;
|
||||
return response;
|
||||
})
|
||||
.then(() => {
|
||||
return this.axeRepository.findAll()
|
||||
.then((response: AxiosResponse<RestResponse<Axe>>) => {
|
||||
this.axes = response.data._embedded.axes;
|
||||
return response;
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.info("error", e);
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
getScore(axe: Axe): Score {
|
||||
const responses = this.getResponses(axe);
|
||||
return {
|
||||
axeIdentifier: axe.identifier,
|
||||
scoreAvg: responses.reduce((total, response) => total + response.score, 0) / responses.length
|
||||
};
|
||||
}
|
||||
|
||||
getResponses(axe: Axe) {
|
||||
return this.responses.filter((response: ResponseWithQuestion) => response.axeIdentifier === axe.identifier);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "assets/css/color";
|
||||
@import "assets/css/font";
|
||||
section {
|
||||
margin: $small 0;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-weight: 700;
|
||||
@@ -93,4 +78,10 @@ export default class Result extends Vue {
|
||||
color: $gray_4;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.button-container {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
14
frontend/pages/error.vue
Normal file
@@ -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: 2.7 KiB After Width: | Height: | Size: 2.7 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
|
||||
}, {});
|
||||
}
|
||||
}
|
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}
|