initial commit
This commit is contained in:
8
app/app.vue
Normal file
8
app/app.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
1
app/assets/css/base/_fonts.scss
Normal file
1
app/assets/css/base/_fonts.scss
Normal file
@@ -0,0 +1 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Open+Sans&family=Quicksand:wght@300;400;700&display=swap');
|
||||
8
app/assets/css/base/_reset.scss
Normal file
8
app/assets/css/base/_reset.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
*{
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
html{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
32
app/assets/css/base/_typography.scss
Normal file
32
app/assets/css/base/_typography.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
h1, h2, h3, p, ul, li, label, button, input{
|
||||
font-family: 'quicksand', Arial, Helvetica, sans-serif;
|
||||
//color: blueviolet;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h1{
|
||||
color: blueviolet;
|
||||
padding-top:1em;
|
||||
font-weight: bold;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
h2{
|
||||
color: blueviolet;
|
||||
padding-block:1em;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.base-input input {
|
||||
border: 1px solid var(--input-border);
|
||||
background: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
label{
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
input{
|
||||
text-align:left;
|
||||
}
|
||||
90
app/assets/css/components/_modale.scss
Normal file
90
app/assets/css/components/_modale.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
/*Modale annimation*/
|
||||
|
||||
.modal-animation-enter-from,
|
||||
.modal-animation-leave-to{
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-animation-enter-active{
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.modal-animation-leave-active{
|
||||
transition: opacity 0.3s ease 0.2s;
|
||||
}
|
||||
|
||||
/*Modal inner animation*/
|
||||
|
||||
.modal-frame-animation-enter-from, .modal-frame-animation-leave-to{
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
.modal-frame-animation-enter-active, .modal-frame-animation-leave-active{
|
||||
transition: all 0.3s cubic-bezier(0.52, -0.02, 0.19, 1.02);
|
||||
}
|
||||
|
||||
/*modal mise en forme*/
|
||||
.modale{
|
||||
position:fixed;
|
||||
top:0;
|
||||
left: 00;
|
||||
background: rgba(140, 140, 140, 0.75);
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
.modal-frame{
|
||||
//border : dashed red 2px;
|
||||
position: relative;
|
||||
width: 80%;
|
||||
min-width: 230px;
|
||||
max-width: 350px;
|
||||
max-height: 80vh;
|
||||
overflow: visible;
|
||||
|
||||
|
||||
background:#e5e5e5;
|
||||
border-top-left-radius: 1em;
|
||||
border-bottom-left-radius: 1em;
|
||||
border-bottom-right-radius: 1em;
|
||||
box-sizing:border-box;
|
||||
padding: 2em;
|
||||
margin:1em;
|
||||
}
|
||||
|
||||
h1.modal-title{
|
||||
padding: 0;
|
||||
padding-bottom: 1em;
|
||||
//border:dashed 2px red;
|
||||
text-align:center;
|
||||
}
|
||||
.close-btn{
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: -17px;
|
||||
top: -17px;
|
||||
text-align: center;
|
||||
border: none;
|
||||
border-radius: 0.9em;
|
||||
padding: 0.25em;
|
||||
background-color: #e5e5e5;
|
||||
color: blueviolet;
|
||||
font-size: 1.7em;
|
||||
transition: color 0.5s ease;
|
||||
&:hover{
|
||||
color: hsl(271, 76%, 68%);
|
||||
cursor:pointer;
|
||||
}
|
||||
}
|
||||
.modal-content{
|
||||
//border: 2px dashed red;
|
||||
max-height: calc(80vh - 7.5em);
|
||||
overflow-y: auto;
|
||||
//FireFox
|
||||
scrollbar-width: none;
|
||||
//Edge
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
35
app/assets/css/layout/_app.scss
Normal file
35
app/assets/css/layout/_app.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
.main-container{
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background-color: rgb(218, 218, 218);
|
||||
}
|
||||
|
||||
.app-container{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background-color: rgb(236, 236, 236);
|
||||
box-shadow: 0px 0px 15px rgb(136, 136, 136) ;
|
||||
min-height: 100dvh;
|
||||
width:100%;
|
||||
max-width: 450px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.up{
|
||||
height: 100%;
|
||||
position:relative;
|
||||
border:#f0f 3px solid;
|
||||
|
||||
}
|
||||
|
||||
.app-scroll-view{
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
max-width: 450px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch; /* Fluidité pour iOS */
|
||||
margin-inline:0.5em;
|
||||
}
|
||||
63
app/assets/css/layout/_navBar.scss
Normal file
63
app/assets/css/layout/_navBar.scss
Normal file
@@ -0,0 +1,63 @@
|
||||
|
||||
button.panel-item{
|
||||
border-radius: 0.35em;
|
||||
padding:0.5em 0.5em;
|
||||
border:none;
|
||||
font-size:1em;
|
||||
text-align: center;
|
||||
margin-block:0.25em;
|
||||
width:88%;
|
||||
}
|
||||
|
||||
.panel-nav {
|
||||
//display: none;
|
||||
position: absolute;
|
||||
top:0;
|
||||
right:0;
|
||||
background: #aaaaaa80;
|
||||
backdrop-filter: blur(5px); /* flou du contenu derrière */
|
||||
-webkit-backdrop-filter: blur(5px); /* Safari */
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding-block: 2em;
|
||||
gap: 0.75rem;
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
box-sizing: border-box;
|
||||
z-index: 5;
|
||||
&.open {
|
||||
display: flex;
|
||||
transform: translateX(1px);
|
||||
opacity: 1;
|
||||
}}
|
||||
|
||||
div.nav-bar-up{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
}
|
||||
div.nav-bar-down{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: start;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.panel-item {
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
color: blueviolet;
|
||||
padding: 0.5rem;
|
||||
padding-left: 3.2em;
|
||||
padding-right: 0.8em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.panel-item:hover {
|
||||
color: hsl(271, 76%, 35%);
|
||||
background-color: #aaaaaa80;
|
||||
}
|
||||
7
app/assets/css/main.scss
Normal file
7
app/assets/css/main.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@use "./base/fonts";
|
||||
@use "./base/reset";
|
||||
@use "./base/typography";
|
||||
@use "./components/modale";
|
||||
@use "./layout/app";
|
||||
@use "./layout/navBar";
|
||||
@use "./pages/index";
|
||||
38
app/assets/css/pages/_index.scss
Normal file
38
app/assets/css/pages/_index.scss
Normal file
@@ -0,0 +1,38 @@
|
||||
@use "sass:color";
|
||||
|
||||
.index-main-text > p,
|
||||
.confirm-main-text{
|
||||
text-align: left;
|
||||
text-indent: 1em;
|
||||
margin:0.5em;
|
||||
color:rgb(0, 0, 0);
|
||||
}
|
||||
|
||||
.index-main-text a,
|
||||
.confirm-main-text a {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: blueviolet;
|
||||
transition: 0.3s;
|
||||
&:hover{
|
||||
color: color.adjust(blueviolet, $lightness: -25%);
|
||||
//DEPRECATED
|
||||
//color:darken($color: blueviolet, $amount: 50%)
|
||||
}
|
||||
}
|
||||
|
||||
.index-main-text-last {
|
||||
color: blueviolet;
|
||||
padding-block: 1.5em;
|
||||
font-weight: bold;
|
||||
& a {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: blueviolet;
|
||||
transition: 0.3s;
|
||||
&:hover{
|
||||
color: color.adjust(blueviolet, $lightness: -25%);
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/assets/css/settings/_colors.scss
Executable file
21
app/assets/css/settings/_colors.scss
Executable file
@@ -0,0 +1,21 @@
|
||||
$body-bg:oklab(94.611% 0 -0.00011);
|
||||
$page-bg:hsl(234, 20%, 30%);
|
||||
//$page-bg1:hsl(0, 0%, 60%);
|
||||
|
||||
$renforced-light : hsl(13, 68%, 60%);
|
||||
$renforced-normal: hsl(13, 68%, 40%);
|
||||
$renforced-dark : hsl(13, 68%, 20%);
|
||||
|
||||
$hypertext-light : hsl(245, 100%, 87%);
|
||||
$hypertext-normal : hsl(245, 100%, 82%);
|
||||
|
||||
$main-normal: hsl(52, 50%, 90%);
|
||||
$main-dark : hsl(52, 50%, 70%);
|
||||
|
||||
$title-light : hsl(37, 79%, 90%);
|
||||
$title-normal: hsl(37, 79%, 75%);
|
||||
$title-dark : hsl(37, 79%, 60%);
|
||||
|
||||
$action-light : hsl(139, 57%, 85%);
|
||||
$action-normal: hsl(139, 57%, 77%);
|
||||
$action-dark : hsl(139, 57%, 67%);
|
||||
2
app/assets/css/settings/_widths.scss
Executable file
2
app/assets/css/settings/_widths.scss
Executable file
@@ -0,0 +1,2 @@
|
||||
$maxWidth: 1200px;
|
||||
$imageWidth: 100%;
|
||||
420
app/components/ListElement.vue
Executable file
420
app/components/ListElement.vue
Executable file
@@ -0,0 +1,420 @@
|
||||
<script setup lang="ts">
|
||||
// import { useListStore } from '~/stores/lists'
|
||||
// import { useParamsStore } from '@/stores/Params'
|
||||
// import ColorButtonCpnt from './Layout/ColorButtonCpnt.vue'
|
||||
// import { defineProps, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
id : Number,
|
||||
item_title : String,
|
||||
color : Number,
|
||||
type : String,
|
||||
|
||||
is_done : Boolean || null,
|
||||
trad : String || null,
|
||||
display_verso : Boolean || null,
|
||||
position : String || null,
|
||||
})
|
||||
|
||||
const listStore = useListStore();
|
||||
//const { listData } = storeToRefs(listStore);
|
||||
const paramsStore = useParamsStore();
|
||||
|
||||
const onDisplayMode = ref(true)
|
||||
const newTitle = ref(props.title)
|
||||
const newTrad = ref(props.trad)
|
||||
const tooMuchCharacteres = ref(false)
|
||||
const oldColor = ref(false)
|
||||
// const refColorBtn = ref()
|
||||
|
||||
const handleKeyUp = (event, id) => {
|
||||
if (event.keyCode == 27)
|
||||
handleCancelModify(id);
|
||||
}
|
||||
|
||||
const handleInputBlur = (event, id) => {
|
||||
if (event.relatedTarget === null || (event.relatedTarget['id'] !== 'save-btn-' + id && event.relatedTarget['id'] !== 'cancel-btn-' + id && event.relatedTarget['id'] !== 'color-btn-' + id)){
|
||||
handleSave(event, id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleColorBlur = (event, id) => {
|
||||
if (event.relatedTarget === null || ( event.relatedTarget['id'] !== 'input-' + id && event.relatedTarget['id'] !== 'save-btn-' + id && event.relatedTarget['id'] !== 'cancel-btn-' + id)){
|
||||
handleSave(event, id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveBlur = (event, id) => {
|
||||
if (event.relatedTarget === null || ( event.relatedTarget['id'] !== 'input-' + id && event.relatedTarget['id'] !== 'color-btn-' + id && event.relatedTarget['id'] !== 'cancel-btn-' + id)){
|
||||
handleSave(event, id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelBlur = (event, id) => {
|
||||
if (event.relatedTarget != null && event.relatedTarget['id'] !== 'input-' + id && event.relatedTarget['id'] !== 'save-btn-' + id && event.relatedTarget['id'] !== 'color-btn-' + id ){
|
||||
handleSave(event, id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchToEditMode = (id) => {
|
||||
if (!props.done){
|
||||
listStore.addColorOnItem(id)
|
||||
if (props.color >= paramsStore.paramsData.colors.length){
|
||||
listStore.changeColor(id, 0, 0)
|
||||
// refColorBtn.value.onColorValueChange(0)
|
||||
}
|
||||
onDisplayMode.value = false;
|
||||
oldColor.value = props.color == undefined ? 0 : props.color
|
||||
}
|
||||
setTimeout(function(){ document.getElementById('input-' + id).focus();})
|
||||
}
|
||||
|
||||
const backToDisplayMode = () => {
|
||||
onDisplayMode.value = true;
|
||||
}
|
||||
|
||||
const handleCancelModify = (id) => {
|
||||
newTrad.value = props.trad != undefined ? props.trad : undefined
|
||||
newTitle.value = props.title
|
||||
tooMuchCharacteres.value = false
|
||||
backToDisplayMode();
|
||||
// refColorBtn.value.onCanceled(oldColor.value);
|
||||
listStore.changeColor(id, 0, oldColor.value)
|
||||
oldColor.value = false
|
||||
}
|
||||
|
||||
// const handleGoto = () => {
|
||||
// console.log(props.position)
|
||||
// }
|
||||
|
||||
const handleSave = (event, id) => {
|
||||
event.preventDefault();
|
||||
|
||||
tooMuchCharacteres.value = false
|
||||
if (props.verso == undefined || !props.verso) {
|
||||
if(newTitle.value.length > 75){
|
||||
tooMuchCharacteres.value = true
|
||||
}
|
||||
if(newTitle.value === ''){
|
||||
listStore.delete(id)
|
||||
backToDisplayMode();
|
||||
}
|
||||
|
||||
if(( props.title !== newTitle.value && !tooMuchCharacteres.value ) || (props.color != oldColor.value)){
|
||||
listStore.saveChanges(id, newTitle.value)
|
||||
//console.log(newTitle.value)
|
||||
|
||||
backToDisplayMode();
|
||||
}
|
||||
|
||||
if(props.title == newTitle.value && !tooMuchCharacteres.value){
|
||||
backToDisplayMode();
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(newTrad.value.length > 75){
|
||||
tooMuchCharacteres.value = true
|
||||
}
|
||||
if(newTrad.value === ''){
|
||||
listStore.delete(id)
|
||||
backToDisplayMode();
|
||||
}
|
||||
|
||||
if(( props.trad !== newTrad.value && !tooMuchCharacteres.value ) || (props.color != oldColor.value)){
|
||||
listStore.saveChanges(id, newTrad.value, true)
|
||||
backToDisplayMode();
|
||||
}
|
||||
|
||||
if(props.trad == newTrad.value && !tooMuchCharacteres.value){
|
||||
backToDisplayMode();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="container"
|
||||
:data-id = props.id
|
||||
:draggable = "onDisplayMode"
|
||||
@dragstart = "listStore.dragItemStart( props.id )"
|
||||
@dragenter = "listStore.dragItemEnter( props.id )"
|
||||
@dragend = "listStore.drop()"
|
||||
@touchstart = "listStore.dragItemStart( props.id )"
|
||||
@touchmove = "listStore.dragItemEnter( props.id )"
|
||||
>
|
||||
|
||||
<form
|
||||
@submit="handleSave( $event, props.id )"
|
||||
>
|
||||
<div
|
||||
class="input"
|
||||
v-bind:class = "{ hide: onDisplayMode }">
|
||||
<input v-if="props.verso == undefined || !props.verso"
|
||||
:style = "color != undefined ? 'color :' + paramsStore.paramsData.colors[props.color] : ''"
|
||||
@keyup="handleKeyUp($event, props.id)"
|
||||
@blur="handleInputBlur($event, props.id)"
|
||||
type="text"
|
||||
:id = "'input-' + props.id"
|
||||
v-model="newTitle"
|
||||
>
|
||||
<input v-else
|
||||
:style = "color != undefined ? 'color :' + paramsStore.paramsData.colors[props.color] : ''"
|
||||
@keyup="handleKeyUp($event, props.id)"
|
||||
@blur="handleInputBlur($event, props.id)"
|
||||
type="text"
|
||||
:id = "'input-' + props.id"
|
||||
v-model="newTrad"
|
||||
>
|
||||
<div class="error" v-if="tooMuchCharacteres">
|
||||
vous devez entrer moins de 75 charactères...
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class="form-button-container"
|
||||
v-bind:class = "{ hide: onDisplayMode }"
|
||||
>
|
||||
<ColorButtonCpnt
|
||||
:id = "props.id"
|
||||
:color = "props.color"
|
||||
:isDisplayMode = "onDisplayMode"
|
||||
@colorBlur = "handleColorBlur"
|
||||
@colorKeyUp = "handleKeyUp"
|
||||
ref="refColorBtn"
|
||||
/>
|
||||
<button
|
||||
:id = "'save-btn-' + props.id"
|
||||
@click = "handleSave($event, props.id)"
|
||||
@blur = "handleSaveBlur($event, props.id)"
|
||||
@keyup = "handleKeyUp($event, props.id)"
|
||||
>
|
||||
Enregistrer
|
||||
</button>
|
||||
<button
|
||||
:id = "'cancel-btn-' + props.id"
|
||||
@click="handleCancelModify(props.id)"
|
||||
@blur="handleCancelBlur($event, props.id)"
|
||||
@keyup="handleKeyUp($event, props.id)">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-and-controls-container"
|
||||
:class = "{ hide : !onDisplayMode }" >
|
||||
<div class="text-container">
|
||||
<button
|
||||
:style = "( !props.done ) ? 'color :' + paramsStore.paramsData.colors[props.color] : ''"
|
||||
class = "title"
|
||||
:id = "'p-' + props.id"
|
||||
:class = "props.type == 'text' || props.type == undefined ? { done : props.done } : props.type == 'lang' ? { invisible : props.verso } : ''"
|
||||
@click = "handleSwitchToEditMode(props.id)">
|
||||
{{ newTitle }}
|
||||
</button>
|
||||
<button
|
||||
v-if="props.type == 'lang'"
|
||||
:style = "( !props.done ) ? 'color :' + paramsStore.paramsData.colors[props.color] : ''"
|
||||
class = "trad"
|
||||
:id = "'p-trad-' + props.id"
|
||||
:class = "{ visible : props.verso }"
|
||||
@click = "handleSwitchToEditMode(props.id)"
|
||||
>
|
||||
{{ newTrad }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="controls-container">
|
||||
<button v-if="props.type == 'text' || props.type == undefined"
|
||||
class = "aside-p"
|
||||
:class="{'hide': props.done}"
|
||||
@click="listStore.toggleDone(props.id);">
|
||||
<font-awesome-icon
|
||||
class="square"
|
||||
icon="fa-regular fa-square"
|
||||
alt="Mettre à fait" />
|
||||
</button>
|
||||
<button v-else-if="props.type == 'lang'"
|
||||
class = "aside-p"
|
||||
@click="listStore.toggleRotate(props.id);">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrows-rotate']"
|
||||
alt="Retourner" />
|
||||
</button>
|
||||
<a v-else-if="props.type == 'map'"
|
||||
:href="'http://www.google.com/maps/search/?api=1&query=' + props.position"
|
||||
target="_blank">
|
||||
<button
|
||||
class = "aside-p">
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'diamond-turn-right']"
|
||||
alt="Itinéraire" />
|
||||
</button>
|
||||
|
||||
</a>
|
||||
<button
|
||||
class = "aside-p"
|
||||
@click="listStore.toggleDone(props.id)"
|
||||
v-bind:class="{'hide': !props.done}">
|
||||
<font-awesome-icon
|
||||
color="green"
|
||||
class="square"
|
||||
icon="fa-regular fa-square-check"
|
||||
alt="Mettre en non fait"/>
|
||||
</button>
|
||||
<button
|
||||
class = "aside-p"
|
||||
@click="listStore.delete(props.id)" >
|
||||
<font-awesome-icon
|
||||
color="red"
|
||||
class="square"
|
||||
icon="fa-regular fa-trash-can"
|
||||
alt="Supprimer"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
|
||||
<style lang="scss" scoped>
|
||||
button:not(.container){
|
||||
font-family: 'Quicksand', Arial, Helvetica, sans-serif;
|
||||
//margin-left:0em;
|
||||
//margin-top:0em;
|
||||
//padding: 0.2em 0em;
|
||||
background: rgb(216, 226, 253);
|
||||
color: rgb(0, 11, 163);
|
||||
font-weight:800;
|
||||
border-radius: 0em;
|
||||
border: none;
|
||||
width: 6em;
|
||||
min-height: 1.8em;
|
||||
// box-shadow: 4px 4px 5px #555;
|
||||
transition: 0.3s;
|
||||
font-size: 1em;
|
||||
cursor:pointer;
|
||||
|
||||
&:hover{
|
||||
background:color.adjust(rgb(216, 226, 253), $lightness: -7.5%);
|
||||
//DEPRECATED
|
||||
//background: darken(rgb(216, 226, 253), 7.5%);
|
||||
}
|
||||
|
||||
// &:focus{
|
||||
// // border: grey 1px dashed;
|
||||
// //background: darken(rgb(216, 226, 253), 15%);
|
||||
// }
|
||||
|
||||
&.delete{
|
||||
color: rgb(163, 0, 0);
|
||||
}
|
||||
&.aside-p {
|
||||
width:2.2em;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 1.1em;
|
||||
margin-inline:0.5em
|
||||
}
|
||||
}
|
||||
button.container{
|
||||
margin: 0 ;
|
||||
display:block;
|
||||
width:100%;
|
||||
border:none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
form{
|
||||
width : 100%;
|
||||
}
|
||||
.form-button-container{
|
||||
display : flex;
|
||||
justify-content: flex-start;
|
||||
align-items:center;
|
||||
gap : 1em;
|
||||
}
|
||||
.color-btn{
|
||||
display:flex;
|
||||
justify-content:center;
|
||||
align-items:center;
|
||||
}
|
||||
|
||||
input{
|
||||
box-sizing: border-box;
|
||||
padding: 0.5em;
|
||||
width : 100%;
|
||||
margin-bottom: 0.5em;
|
||||
border:none;
|
||||
}
|
||||
.input {
|
||||
margin: auto;
|
||||
}
|
||||
.error{
|
||||
text-align:center;
|
||||
color : red;
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.text-and-controls-container{
|
||||
position:relative;
|
||||
display : flex;
|
||||
flex-direction : row;
|
||||
justify-content : space-between;
|
||||
align-items : center;
|
||||
//margin:0;
|
||||
//border: 1px solid rebeccapurple;
|
||||
padding-block:0.5em;
|
||||
}
|
||||
.text-container{
|
||||
height:fit-content;
|
||||
}
|
||||
button.title, button.trad{
|
||||
height:fit-content;
|
||||
position : absolute;
|
||||
bottom : 0;
|
||||
top : 0;
|
||||
background-color: transparent;
|
||||
width: calc(100% - 5.5em);
|
||||
font-size:1.2em;
|
||||
font-weight:normal;
|
||||
text-align: left;
|
||||
color:rgb(0, 89, 255);
|
||||
backface-visibility: hidden;
|
||||
transition-duration: 0.5s;
|
||||
|
||||
overflow: hidden;
|
||||
word-wrap:break-word;
|
||||
&:hover
|
||||
{
|
||||
cursor:pointer;
|
||||
background-color: transparent;
|
||||
}
|
||||
&:focus{
|
||||
background-color: transparent;
|
||||
border: 1px dashed grey ;
|
||||
}
|
||||
&.done{
|
||||
text-decoration: line-through;
|
||||
color: rgb(134, 134, 134) ;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
button.trad{
|
||||
transform:rotateX(180deg);
|
||||
&.visible{
|
||||
transform:rotateX(-0deg);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
button.title{
|
||||
&.invisible{
|
||||
transform:rotateX(-180deg);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hide{
|
||||
display:none;
|
||||
}
|
||||
</style>
|
||||
64
app/components/button/base.vue
Normal file
64
app/components/button/base.vue
Normal file
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<button
|
||||
:disabled="disabled"
|
||||
:class="variantClass"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<slot v-if="!loading"></slot>
|
||||
<slot name="spinner" v-else>
|
||||
<UiSpinnerCpnt />
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits(['click'])
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
variant?: 'primary' | 'secondary'
|
||||
}>()
|
||||
|
||||
const variantClass = computed(() => props.variant === 'secondary' ? 'secondary' : 'primary')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@use "sass:color";
|
||||
|
||||
button {
|
||||
margin: 5px 0 2.3em 0;
|
||||
width: 100%;
|
||||
padding: 1em 1.5em;
|
||||
border-radius: 0.5em;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border: none;
|
||||
box-sizing: border-box;
|
||||
transition: 0.3s;
|
||||
background: blueviolet;
|
||||
text-align: center;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
color: color.adjust(blueviolet, $lightness: 15%);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #bbb;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
> div {
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
button.secondary {
|
||||
background: gray;
|
||||
|
||||
&:hover {
|
||||
color: color.adjust(gray, $lightness: 15%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
93
app/components/button/burger.vue
Normal file
93
app/components/button/burger.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
const burger = useBurgerStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="nav-button"
|
||||
@click="burger.toggle()"
|
||||
>
|
||||
<svg class="hamburger" :class="{ open: burger.checked }"
|
||||
viewBox="2 0 100 100">
|
||||
<path
|
||||
class="cross"
|
||||
style="fill:none;
|
||||
stroke-linecap:round;
|
||||
stroke-linejoin:round;
|
||||
stroke-opacity:1"
|
||||
d="M 36,21 H 74 L 74,33 55,52 36,71 36,83 H 74 L 74,71 36,33 Z"
|
||||
id="path978"
|
||||
sodipodi:nodetypes="cccccccccc" />
|
||||
<path
|
||||
class="middle"
|
||||
style="fill:none;
|
||||
stroke-linecap:round;
|
||||
stroke-linejoin:bevel;
|
||||
stroke-opacity:1"
|
||||
d="M 36,52 H 74 C 90,52 82,69 77,74 71,80 63,83 55,83 38,83 24,69 24,52 24,35 38,21 55,21 c 17,0 31,14 31,31 0,9 -3,16 -9,22"
|
||||
id="path982"
|
||||
sodipodi:nodetypes="cccssssc" />
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Panel slide depuis la droite -->
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
button.nav-button{
|
||||
/*centering button*/
|
||||
display : block;
|
||||
position: absolute;
|
||||
bottom:5px;
|
||||
right:5px;
|
||||
border-radius: 5px;
|
||||
/*Formating Button*/
|
||||
width:3em;
|
||||
background-color:#aaaaaa80;
|
||||
border: none;
|
||||
|
||||
transition:1s;
|
||||
z-index: 10;
|
||||
&:hover{
|
||||
cursor: pointer;
|
||||
}
|
||||
& .hamburger{
|
||||
/*Default state*/
|
||||
& .cross, & .middle {
|
||||
stroke-width:7;
|
||||
transition:1s ;
|
||||
-moz-transition:1s ;
|
||||
-webkit-transition:1s ;
|
||||
stroke:black;
|
||||
}
|
||||
& .cross{
|
||||
stroke-dashoffset: 0 ;
|
||||
stroke-dasharray: 0 0 36 80 36 80;
|
||||
}
|
||||
|
||||
& .middle{
|
||||
stroke-dashoffset: 0 ;
|
||||
stroke-dasharray: 36 250;
|
||||
}
|
||||
/*Formating "open" state */
|
||||
&.open{
|
||||
& *{
|
||||
stroke:hsl(0, 100%, 30%);
|
||||
}
|
||||
& .cross{
|
||||
stroke-width:7;
|
||||
stroke-dashoffset: -61 ;
|
||||
stroke-dasharray: 33 82 33 79;
|
||||
}
|
||||
|
||||
& .middle{
|
||||
stroke-width:7;
|
||||
stroke-dashoffset: -60 ;
|
||||
stroke-dasharray: 270 ;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Navigation dans le panel
|
||||
|
||||
</style>
|
||||
38
app/components/button/google-challenge.vue
Normal file
38
app/components/button/google-challenge.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const userEmail = authStore.user?.email
|
||||
|
||||
const { isReady, login } = useCodeClient({
|
||||
// Force l'utilisateur à re-valider explicitement
|
||||
prompt: 'consent',
|
||||
login_hint: userEmail,
|
||||
|
||||
onSuccess: async (codeResponse) => {
|
||||
// 1. ICI, on reçoit un 'code' (ex: 4/0Afge...)
|
||||
console.log("Code reçu pour le BO:", codeResponse.code)
|
||||
|
||||
// 6. Envoi au BO pour échange contre sudo_token
|
||||
try {
|
||||
const result = await $fetch('/api/auth/google-sudo', {
|
||||
method: 'POST',
|
||||
body: { code: codeResponse.code }
|
||||
})
|
||||
// Ton BO pourra alors utiliser ce code pour valider l'identité
|
||||
} catch (err) {
|
||||
console.error("Erreur BO:", err)
|
||||
}
|
||||
},
|
||||
onError: (error) => console.error("Échec Sudo:", error)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:disabled="!isReady"
|
||||
@click="() => login()"
|
||||
class="btn-sudo"
|
||||
>
|
||||
Vérification de sécurité via Google
|
||||
</button>
|
||||
</template>
|
||||
111
app/components/button/google.vue
Normal file
111
app/components/button/google.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useCodeClient,
|
||||
type AuthCodeFlowSuccessResponse,
|
||||
type AuthCodeFlowErrorResponse
|
||||
} from "vue3-google-signin";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const listStore = useListStore();
|
||||
|
||||
// On définit la fonction de succès avec le bon Type
|
||||
const handleSuccess = async (response: AuthCodeFlowSuccessResponse) => {
|
||||
|
||||
const googleCode = response.code;
|
||||
|
||||
try {
|
||||
// On envoie ce ticket à WordPress
|
||||
const data = await $fetch<any>('/api-wp/auth/google', {
|
||||
method: 'POST',
|
||||
body: { code: googleCode }
|
||||
});
|
||||
|
||||
// On stocke le JWT renvoyé par WordPress
|
||||
authStore.setTokenCookie(data.token);
|
||||
authStore.setUserCookie(data.user);
|
||||
listStore.lists = data.lists
|
||||
console.log(listStore.lists) //ok
|
||||
} catch (error) {
|
||||
console.error("Erreur côté WordPress:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: AuthCodeFlowErrorResponse) => {
|
||||
console.error("L'utilisateur a fermé la fenêtre ou erreur Google:", error);
|
||||
};
|
||||
|
||||
// 3. On initialise le client spécifique au "Code"
|
||||
const { login } = useCodeClient({
|
||||
scope: 'openid email profile https://www.googleapis.com/auth/drive.appdata',
|
||||
access_type: 'offline',
|
||||
prompt: 'consent',
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="login" class="g-signin-button">
|
||||
<div class="g-logo-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="g-logo">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||
<path fill="none" d="M0 0h48v48H0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="g-button-text">{{ $t('googleBtnTxt') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.g-signin-button {
|
||||
margin:auto;
|
||||
margin-top:1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
background-color: #ffffff;
|
||||
color: #757575;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
padding: 0em;
|
||||
font-family: 'Roboto', arial, sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,0.302), 0 1px 3px 1px rgba(60,64,67,0.149);
|
||||
transition: background-color .218s, border-color .218s, box-shadow .218s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.g-signin-button:hover {
|
||||
background-color: hsl(210, 17%, 90%);
|
||||
border-color: #d2d2d2;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,0.302), 0 1px 3px 1px rgba(60,64,67,0.149);
|
||||
}
|
||||
|
||||
.g-signin-button:active {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.g-logo-wrapper {
|
||||
padding: 11px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.g-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.g-button-text {
|
||||
padding: 0 12px 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
104
app/components/input/base.vue
Normal file
104
app/components/input/base.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<template>
|
||||
<div class="base-input">
|
||||
<label>
|
||||
{{ label }}
|
||||
<div class="input-wrapper">
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
:name="name"
|
||||
:type="type"
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
autocomplete="off"
|
||||
class="base-input-field"
|
||||
/>
|
||||
<slot name="icon" />
|
||||
</div>
|
||||
<slot name="pwdReset"/>
|
||||
<div class="message">
|
||||
<slot name="message"></slot>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>()
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
name: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
displayPwdReset?:boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.base-input {
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #00aeff;
|
||||
--input-hover-bg: #e0e0e0;
|
||||
--input-text: #003e7c;
|
||||
}
|
||||
|
||||
.base-input label {
|
||||
margin-bottom: 1em;
|
||||
display: block;
|
||||
color: #003e7c;
|
||||
font-family: 'Quicksand', Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
.base-input-field {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
width: 100%;
|
||||
padding: 0.75em 1.5em;
|
||||
background: var(--input-bg);
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--input-border);
|
||||
border-left: 2px solid var(--input-border);
|
||||
box-sizing: border-box;
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.base-input-field:hover,
|
||||
.base-input-field:focus {
|
||||
background: var(--input-hover-bg);
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
.message :deep(.error) {
|
||||
color: #ca0d00;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.message :deep(.success) {
|
||||
color: #00ca22;
|
||||
font-size: 0.9em;
|
||||
font-weight: 600;
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
/* Styles spécifiques à l’email */
|
||||
:deep(.mail-is-valid) {
|
||||
--input-border: #00ca22;
|
||||
}
|
||||
|
||||
:deep(.mail-is-invalid) {
|
||||
--input-border: #ca0d00;
|
||||
}
|
||||
</style>
|
||||
25
app/components/input/email.vue
Normal file
25
app/components/input/email.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<InputBase
|
||||
v-model="model"
|
||||
v-bind="$attrs"
|
||||
:label="label"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
type="email"
|
||||
>
|
||||
<template #message>
|
||||
<slot name="message"></slot>
|
||||
</template>
|
||||
</InputBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>()
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
name: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
68
app/components/input/password.vue
Normal file
68
app/components/input/password.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<InputBase
|
||||
v-model="model"
|
||||
v-bind="$props"
|
||||
:type="seePwd ? 'text' : 'password'"
|
||||
@blur="seePwd = false"
|
||||
>
|
||||
<template #icon>
|
||||
<font-awesome-icon
|
||||
class="eye"
|
||||
icon="fa-solid fa-eye"
|
||||
@click="seePwd = true"
|
||||
:class="{ hide: seePwd }"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
class="eye"
|
||||
icon="fa-solid fa-eye-slash"
|
||||
@click="seePwd = false"
|
||||
:class="{ hide: !seePwd }"
|
||||
/>
|
||||
</template>
|
||||
<template #pwdReset>
|
||||
<div v-if="displayPwdReset" class="pwd-reset-container">
|
||||
<NuxtLink :to="localePath('passwordResetRequest')">
|
||||
{{ $t('loginPage.passwordResetRequest') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
<template #message>
|
||||
<slot name="message"></slot>
|
||||
</template>
|
||||
</InputBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const localePath = useLocalePath()
|
||||
const model = defineModel<string>()
|
||||
const seePwd = ref(false)
|
||||
|
||||
withDefaults(defineProps<{
|
||||
label: string
|
||||
name: string
|
||||
placeholder?: string
|
||||
displayPwdReset?:boolean
|
||||
}>(), {
|
||||
// Valeurs par défault
|
||||
displayPwdReset: false,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.eye {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #003e7c;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.eye.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
24
app/components/input/text.vue
Normal file
24
app/components/input/text.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<template>
|
||||
<InputBase
|
||||
v-model="model"
|
||||
v-bind="$attrs"
|
||||
:label="label"
|
||||
:name="name"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
>
|
||||
<template #message>
|
||||
<slot name="message"></slot>
|
||||
</template>
|
||||
</InputBase>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>()
|
||||
|
||||
defineProps<{
|
||||
label: string
|
||||
name: string
|
||||
placeholder?: string
|
||||
}>()
|
||||
</script>
|
||||
24
app/components/menu/nav.vue
Normal file
24
app/components/menu/nav.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const burger=useBurgerStore()
|
||||
const auth=useAuthStore()
|
||||
const localePath = useLocalePath()
|
||||
const route=useRoute()
|
||||
</script>
|
||||
<template>
|
||||
<nav class="panel-nav"
|
||||
:class="{ open: burger.checked }"
|
||||
@blur="burger.toggle"
|
||||
@click="burger.toggle">
|
||||
<div class="nav-bar-up">
|
||||
<UiLangSelect />
|
||||
<button class="panel-item" v-if="auth.isLoggedIn" @click="auth.logout">{{ $t('nav.logout') }}</button>
|
||||
</div>
|
||||
<div class="nav-bar-down">
|
||||
<NuxtLink v-if="!auth.isLoggedIn && route.meta.pageId != 'signup'" class="panel-item" :to="localePath('signup')">{{ $t('nav.signup') }}</NuxtLink>
|
||||
<NuxtLink v-if="!auth.isLoggedIn && route.meta.pageId != 'login'" class="panel-item" :to="localePath('login')">{{ $t('nav.login') }}</NuxtLink>
|
||||
<NuxtLink v-if="auth.isLoggedIn && route.meta.pageId != 'profile'" class="panel-item" :to="localePath('profile')">{{ $t('nav.profile') }}</NuxtLink>
|
||||
<NuxtLink v-if="auth.isLoggedIn && route.meta.pageId != 'lists'" class="panel-item" :to="localePath('lists')">{{ $t('nav.lists') }}</NuxtLink>
|
||||
<NuxtLink v-if="route.meta.pageId != 'index'" class="panel-item" :to="localePath('index')">{{ $t('nav.home') }}</NuxtLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
116
app/components/passwordChecker.vue
Normal file
116
app/components/passwordChecker.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div>
|
||||
<ul>{{ $t('passwordCheckerShallContain') }}
|
||||
<div :class="[ passwordCheck.isNumberOfCaracteresValid() ? 'has-success' : 'has-error' ]">
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-check"
|
||||
v-bind:class="{'hide': !passwordCheck.isNumberOfCaracteresValid() || passwordCheck.password == ''}"
|
||||
/>
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-xmark"
|
||||
v-bind:class="{ 'hide': passwordCheck.isNumberOfCaracteresValid() }"
|
||||
/>
|
||||
<li :class="[passwordCheck.isNumberOfCaracteresValid() ? 'has-success' : 'has-error']">
|
||||
{{ $t('passwordChecker8-22') }}
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div :class="[passwordCheck.isCapitalizeCaractereValid() ? 'has-success' : 'has-error']">
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-check"
|
||||
v-bind:class="{ 'hide': !passwordCheck.isCapitalizeCaractereValid() || passwordCheck.password == '' }"
|
||||
style="color:green" />
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-xmark"
|
||||
v-bind:class="{'hide': passwordCheck.isCapitalizeCaractereValid() }"
|
||||
style="color:red" />
|
||||
<li :class="[passwordCheck.isCapitalizeCaractereValid() ? 'has-success' : 'has-error']">
|
||||
{{ $t('passwordCheckerUppercase') }}
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div :class ="[passwordCheck.isMinimizeCaractereValid() ? 'has-success' : 'has-error']">
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-check"
|
||||
v-bind:class="{ 'hide': !passwordCheck.isMinimizeCaractereValid() || passwordCheck.password == '' }"
|
||||
style="color:green" />
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-xmark"
|
||||
v-bind:class="{ 'hide': passwordCheck.isMinimizeCaractereValid() }"
|
||||
style="color:red" />
|
||||
<li :class="[passwordCheck.isMinimizeCaractereValid() ? 'has-success' : 'has-error']">
|
||||
{{ $t('passwordCheckerLowercase') }}
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div :class ="[passwordCheck.isNumberValid() ? 'has-success' : 'has-error']">
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-check"
|
||||
v-bind:class="{ 'hide': !passwordCheck.isNumberValid() || passwordCheck.password == '' }"
|
||||
style="color:green" />
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-xmark"
|
||||
v-bind:class="{ 'hide': passwordCheck.isNumberValid() }"
|
||||
style="color:red" />
|
||||
<li :class="[passwordCheck.isNumberValid() ? 'has-success' : 'has-error']">
|
||||
{{ $t('passwordCheckerNb') }}
|
||||
</li>
|
||||
</div>
|
||||
|
||||
<div :class="[passwordCheck.isSpecialCaractereValid() ? 'has-success' : 'has-error']">
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-check"
|
||||
v-bind:class="{'hide': !passwordCheck.isSpecialCaractereValid()|| passwordCheck.password == '' }"
|
||||
style="color:green" />
|
||||
<font-awesome-icon
|
||||
icon="fa-regular fa-circle-xmark"
|
||||
v-bind:class="{ 'hide': passwordCheck.isSpecialCaractereValid()}"
|
||||
style="color:red" />
|
||||
<li :class="[passwordCheck.isSpecialCaractereValid() ? 'has-success' : 'has-error']">
|
||||
{{ $t('passwordCheckerSpecialChar') }}
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const passwordCheck = usePasswordToolBoxStore()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.container{
|
||||
margin-bottom:1.5em;
|
||||
margin-left: 0;
|
||||
}
|
||||
ul{
|
||||
list-style-type: none;
|
||||
font-size: 0.9em;
|
||||
color:rgb(0, 128, 0);
|
||||
margin:0;
|
||||
margin-bottom:1em;
|
||||
&>div{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&.has-error{
|
||||
color:red;
|
||||
transition: 0.5s;
|
||||
transform: rotateX(0deg)
|
||||
}
|
||||
|
||||
&.has-success{
|
||||
color:green;
|
||||
transition: 0.5s;
|
||||
transform: rotateX(360deg)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li{
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
.hide{
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
31
app/components/profile/general.vue
Normal file
31
app/components/profile/general.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div v-if="authStore.user != null">
|
||||
<img class='avatar' :src="authStore.user.avatar"/>
|
||||
<ProfileModulesDisplayName/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
onMounted(() => {
|
||||
if (authStore.user){
|
||||
authStore.user.sudo_token = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
img.avatar {
|
||||
display: block;
|
||||
margin-top:0.75em;
|
||||
margin-inline: auto;
|
||||
border-radius: 50%;
|
||||
//border: 5px solid blueviolet;
|
||||
}
|
||||
|
||||
.modale-btns{
|
||||
display: flex;
|
||||
gap:1em;
|
||||
|
||||
}
|
||||
</style>
|
||||
31
app/components/profile/modales/email-change.vue
Normal file
31
app/components/profile/modales/email-change.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<UiModale :modalActive="modelValue"
|
||||
@close="emit('update:modelValue', false)"
|
||||
title="Confirmer le changement d’email">
|
||||
<div class="modale-content">
|
||||
|
||||
<p>
|
||||
Un email de validation va être envoyé à la nouvelle adresse.
|
||||
Le changement ne sera effectif qu’après confirmation.
|
||||
</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn danger" @click="confirm">Confirmer</button>
|
||||
<button class="btn" @click="emit('update:modelValue', false)">Annuler</button>
|
||||
</div>
|
||||
</div>
|
||||
</UiModale>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm'])
|
||||
|
||||
function confirm() {
|
||||
emit('confirm')
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
74
app/components/profile/modules/delete-account.vue
Normal file
74
app/components/profile/modules/delete-account.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<button class="deleteBtn" @click="openModal">
|
||||
{{ $t('profile.delete_account') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UiModale @close="closeModal" :modalActive="modalActive">
|
||||
<div class="modal-content">
|
||||
<h1>{{ $t('delete.modale.title') }}</h1>
|
||||
<p>{{ $t('delete.modale.text1') }}</p>
|
||||
<!-- <p>{{ $t('emailUpdate.modale.text2') }}</p> -->
|
||||
<div class="modale-btns">
|
||||
<ButtonBase ref=ConfirmBtn @click="confirm" :loading="awaiting">{{ $t('ui.yes') }}</ButtonBase>
|
||||
<ButtonBase class="btn" @click="closeModal">{{ $t('ui.no') }}</ButtonBase>
|
||||
</div>
|
||||
</div>
|
||||
</UiModale>
|
||||
|
||||
<UiModale @close="closeModalInfo" :modalActive="modalInfoActive">
|
||||
<div class="modal-content">
|
||||
<h1>{{ $t('delete.modale2.title') }}</h1>
|
||||
<p>{{ $t('delete.modale2.text1') }}</p>
|
||||
<div class="modale-btns">
|
||||
<ButtonBase ref=btnCloseInfo @click="closeModalInfo">{{ $t('ui.ok') }}</ButtonBase>
|
||||
</div>
|
||||
</div>
|
||||
</UiModale>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {locale} = useI18n()
|
||||
const authStore = useAuthStore();
|
||||
const modalActive = ref(false);
|
||||
const modalInfoActive = ref(false)
|
||||
const awaiting = ref(false)
|
||||
|
||||
const openModal = () => {
|
||||
modalActive.value = true;
|
||||
}
|
||||
const closeModal = () => {
|
||||
modalActive.value = false;
|
||||
}
|
||||
const confirm = async (locale:string) => {
|
||||
awaiting.value = true;
|
||||
await authStore.deleteRequest(locale)
|
||||
awaiting.value=false
|
||||
modalActive.value = false;
|
||||
modalInfoActive.value = true
|
||||
}
|
||||
const closeModalInfo = () => {
|
||||
modalInfoActive.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.deleteBtn{
|
||||
color:red;
|
||||
font-weight: bold;
|
||||
margin:0;
|
||||
border:none;
|
||||
text-align: left;
|
||||
background-color: none;
|
||||
&:hover{
|
||||
color:rgb(83, 0, 0)
|
||||
}
|
||||
}
|
||||
button{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div>
|
||||
<button class="deleteBtn" @click="openModal">
|
||||
{{ $t('profile.delete_account') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<UiModale @close="closeModal" :modalActive="modalActive">
|
||||
<div class="modal-content">
|
||||
<h1>{{ $t('delete.modale.title') }}</h1>
|
||||
<p>{{ $t('delete.modale.text1') }}</p>
|
||||
<!-- <p>{{ $t('emailUpdate.modale.text2') }}</p> -->
|
||||
<div class="modale-btns">
|
||||
<ButtonBase ref=ConfirmBtn @click="confirm" :loading="awaiting">{{ $t('ui.yes') }}</ButtonBase>
|
||||
<ButtonBase class="btn" @click="closeModal">{{ $t('ui.no') }}</ButtonBase>
|
||||
</div>
|
||||
</div>
|
||||
</UiModale>
|
||||
|
||||
<UiModale @close="closeModalInfo" :modalActive="modalInfoActive">
|
||||
<div class="modal-content">
|
||||
<h1>{{ $t('delete.modale2.title') }}</h1>
|
||||
<p>{{ $t('delete.modale2.text1') }}</p>
|
||||
<div class="modale-btns">
|
||||
<ButtonBase ref=btnCloseInfo @click="closeModalInfo">{{ $t('ui.ok') }}</ButtonBase>
|
||||
</div>
|
||||
</div>
|
||||
</UiModale>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {locale} = useI18n()
|
||||
const authStore = useAuthStore();
|
||||
const modalActive = ref(false);
|
||||
const modalInfoActive = ref(false)
|
||||
const awaiting = ref(false)
|
||||
|
||||
const openModal = () => {
|
||||
modalActive.value = true;
|
||||
}
|
||||
const closeModal = () => {
|
||||
modalActive.value = false;
|
||||
}
|
||||
const confirm = async (locale:string) => {
|
||||
awaiting.value = true;
|
||||
await authStore.deleteRequest(locale)
|
||||
awaiting.value=false
|
||||
modalActive.value = false;
|
||||
modalInfoActive.value = true
|
||||
}
|
||||
const closeModalInfo = () => {
|
||||
modalInfoActive.value = false
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.deleteBtn{
|
||||
color:red;
|
||||
font-weight: bold;
|
||||
margin:0;
|
||||
border:none;
|
||||
text-align: left;
|
||||
background-color: none;
|
||||
&:hover{
|
||||
color:rgb(83, 0, 0)
|
||||
}
|
||||
}
|
||||
button{
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
105
app/components/profile/modules/display-name.vue
Normal file
105
app/components/profile/modules/display-name.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="field-container">
|
||||
<label>{{ $t('profile.name_label') }}</label>
|
||||
|
||||
<div v-if="authStore.user?.is_google" class="value-locked">
|
||||
<p>{{ authStore.user.display_name }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="editable-wrapper">
|
||||
<button
|
||||
v-if="!isEditing"
|
||||
@click="startEditing"
|
||||
class="value-btn"
|
||||
:aria-label="$t('profile.edit_name')"
|
||||
>
|
||||
{{ authStore.user?.display_name }}
|
||||
</button>
|
||||
|
||||
<input
|
||||
v-else
|
||||
ref="inputRef"
|
||||
v-model="newName"
|
||||
@blur="handleUpdate"
|
||||
@keydown.enter="handleUpdate"
|
||||
@keydown.esc="cancelEditing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const isEditing = ref(false)
|
||||
const newName = ref(authStore.user?.display_name || '')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const startEditing = async () => {
|
||||
isEditing.value = true
|
||||
// On attend que l'input soit rendu pour lui donner le focus
|
||||
await nextTick()
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
isEditing.value = false
|
||||
newName.value = authStore.user?.display_name || ''
|
||||
}
|
||||
|
||||
const handleUpdate = () => {
|
||||
if (!isEditing.value) return
|
||||
|
||||
// Si le nom a changé et n'est pas vide
|
||||
if (newName.value && newName.value !== authStore.user?.display_name) {
|
||||
authStore.updateDisplayName(newName.value)
|
||||
}
|
||||
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
div.field-container {
|
||||
margin-block: 1rem;
|
||||
display:block
|
||||
}
|
||||
|
||||
div.editable-wrapper{
|
||||
display:inline;
|
||||
}
|
||||
|
||||
.value-locked::after {
|
||||
content:" 🔒";
|
||||
}
|
||||
|
||||
.value-locked {
|
||||
display:inline;
|
||||
& p {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
display: inline;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
label{
|
||||
font-weight:bold;
|
||||
display:inline;
|
||||
}
|
||||
|
||||
.value-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size:1em;
|
||||
&:hover{
|
||||
color : blueviolet;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
app/components/profile/modules/email-update.vue
Normal file
126
app/components/profile/modules/email-update.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="field-container">
|
||||
<label>{{ $t('profile.email_label') }}</label>
|
||||
|
||||
<div v-if="authStore.user?.is_google" class="value-locked">
|
||||
<p>{{ authStore.user.email }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="editable-wrapper">
|
||||
<button
|
||||
v-if="!email_toggle"
|
||||
@click="toggle_to_input"
|
||||
>
|
||||
{{ authStore.user?.email }}
|
||||
</button>
|
||||
<input v-else
|
||||
ref="emailInput"
|
||||
v-model="email"
|
||||
@blur="handleUpdate"
|
||||
@keydown.enter="handleUpdate"
|
||||
@keydown.esc ="cancelEditing"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UiModale @close="closeEmailModal" :modalActive="emailModalActive">
|
||||
<div class="modal-content">
|
||||
<h1>{{ $t('emailUpdate.modale.title') }}</h1>
|
||||
<p>{{ $t('emailUpdate.modale.text1') }}</p>
|
||||
<p>{{ $t('emailUpdate.modale.text2') }}</p>
|
||||
<div class="modale-btns">
|
||||
<ButtonBase ref=ConfirmBtn @click="confirmEmailChange">{{ $t('emailUpdate.modale.confBtn') }}</ButtonBase>
|
||||
<ButtonBase class="btn" @click="closeEmailModal">{{ $t('emailUpdate.modale.cancelBtn') }}</ButtonBase>
|
||||
</div>
|
||||
</div>
|
||||
</UiModale>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const {locale} = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const email_toggle = ref(false)
|
||||
const email = ref(authStore.user?.email)
|
||||
const emailModalActive = ref(false)
|
||||
const emailInput = ref<HTMLInputElement | null>(null)
|
||||
const ConfirmBtn = ref<HTMLInputElement | null>(null)
|
||||
const toggle_to_input = async ()=>{
|
||||
email_toggle.value = true
|
||||
// On attend que Vue passe email_toggle à true et affiche l'input dans le DOM
|
||||
await nextTick()
|
||||
|
||||
// Maintenant l'input existe, on peut lui donner le focus
|
||||
emailInput.value?.focus()
|
||||
}
|
||||
|
||||
const handleUpdate = async ()=>{
|
||||
if (email.value !== authStore.user?.email){
|
||||
emailModalActive.value = true
|
||||
await nextTick()
|
||||
ConfirmBtn.value?.focus()
|
||||
}
|
||||
email_toggle.value = false
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
email_toggle.value = false
|
||||
email.value = authStore.user?.email || ''
|
||||
}
|
||||
|
||||
//* Fonctions de taitement email */
|
||||
const closeEmailModal = () =>{
|
||||
emailModalActive.value = false
|
||||
email.value = authStore.user?.email
|
||||
}
|
||||
|
||||
const confirmEmailChange = () => {
|
||||
emailModalActive.value = false
|
||||
if (email.value){
|
||||
authStore.emailChange(email.value, locale.value)
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
div.field-container {
|
||||
margin-block: 1rem;
|
||||
display:block
|
||||
}
|
||||
|
||||
div.editable-wrapper{
|
||||
display:inline;
|
||||
}
|
||||
|
||||
.value-locked::after {
|
||||
content:" 🔒";
|
||||
}
|
||||
|
||||
.value-locked {
|
||||
display:inline;
|
||||
& p {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
display: inline;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
label{
|
||||
font-weight:bold;
|
||||
display:inline;
|
||||
}
|
||||
|
||||
button{
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size:1em;
|
||||
&:hover{
|
||||
color:blueviolet;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
57
app/components/profile/modules/password-challenge.vue
Normal file
57
app/components/profile/modules/password-challenge.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<form @submit.prevent="handleFormSubmit">
|
||||
<InputPassword
|
||||
name="password"
|
||||
label=""
|
||||
:placeholder="$t('profile.pwd_challenge_input_label')"
|
||||
v-model="password"
|
||||
>
|
||||
<p>{{ password }}</p>
|
||||
<template #message>
|
||||
<p v-if="errors.passwordEmpty" class="error">{{ $t('ui.errorPwdEmpty') }}</p>
|
||||
<p v-if="errors.wrongPassword" class="error">{{ $t('ui.errorWrongPwd') }}</p>
|
||||
</template>
|
||||
</InputPassword>
|
||||
<ButtonBase
|
||||
:disabled="password === '' || awaiting"
|
||||
:loading="awaiting"
|
||||
>
|
||||
{{ $t('loginFormBtn') }}
|
||||
</ButtonBase>
|
||||
</form>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
const authStore=useAuthStore()
|
||||
const password = ref('');
|
||||
const errors = ref({
|
||||
"passwordEmpty":false,
|
||||
"wrongPassword":false
|
||||
})
|
||||
const awaiting = ref(false);
|
||||
const handleFormSubmit = async() => {
|
||||
awaiting.value = true
|
||||
errors.value.wrongPassword = false;
|
||||
errors.value.passwordEmpty = false;
|
||||
|
||||
if (!password.value){
|
||||
errors.value.passwordEmpty = true;
|
||||
awaiting.value = false;
|
||||
return false
|
||||
}
|
||||
|
||||
// Envoie de la requette à l'endpoint JWT et récupération d'un token de connexion
|
||||
const success = await authStore.pwdChallenge(password.value)
|
||||
if (success) {
|
||||
awaiting.value = false
|
||||
}
|
||||
else{
|
||||
errors.value.wrongPassword = true;
|
||||
awaiting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
46
app/components/profile/red-zone.vue
Normal file
46
app/components/profile/red-zone.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div v-if="!authStore.user?.sudo_token" class="auth-challenge-screen">
|
||||
<h3 class="danger">--- {{ $t('profile.danger_zone') }} ---</h3>
|
||||
<p>Cette zone contient des actions sensibles. Veuillez confirmer votre identité.</p>
|
||||
|
||||
<div v-if="authStore.user?.is_google" class="reauth-box">
|
||||
<p>Compte Google détecté : veuillez confirmer votre session.</p>
|
||||
<ButtonGoogleChallenge />
|
||||
</div>
|
||||
|
||||
<div v-else class="reauth-box">
|
||||
<ProfileModulesPasswordChallenge />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="red-zone-content">
|
||||
<div class="module-wrapper">
|
||||
<ProfileModulesEmailUpdate />
|
||||
</div>
|
||||
|
||||
<div class="danger-section">
|
||||
<h3 class="danger">--- {{ $t('profile.danger_zone') }} ---</h3>
|
||||
<ProfileModulesGlobalLogout />
|
||||
<ProfileModulesDeleteAccount />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore();
|
||||
onUnmounted( () => {
|
||||
if (authStore.user) authStore.user.sudo_token = null
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
h3.danger{
|
||||
color : red;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.reauth-box{
|
||||
margin-top: 1em;
|
||||
}
|
||||
</style>
|
||||
31
app/components/ui/LangSelect.vue
Normal file
31
app/components/ui/LangSelect.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
// This Component is Not used any more, as long as I use Buttons to switch between languages.
|
||||
const { locales, locale, setLocale } = useI18n()
|
||||
|
||||
// Langues disponibles sauf celle courante
|
||||
const otherLocales = computed(() =>
|
||||
locales.value.filter(l => l.code !== locale.value)
|
||||
)
|
||||
|
||||
// Changer la langue
|
||||
function changeLocale(locale: 'fr' | 'en') {
|
||||
setLocale(locale)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button class="panel-item" v-for="l in otherLocales" :key="l.code"
|
||||
@click="changeLocale(l.code)"
|
||||
>{{ l.name }}</button>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
|
||||
/* Optional: transition hover pour le select */
|
||||
select:hover {
|
||||
border-color: #999;
|
||||
}
|
||||
</style>
|
||||
68
app/components/ui/Loading.vue
Executable file
68
app/components/ui/Loading.vue
Executable file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<h3>
|
||||
</h3>
|
||||
<div class="loading">
|
||||
<div class="loading__circle loading__circle--color-blue"></div>
|
||||
<div class="loading__circle loading__circle--color-red"></div>
|
||||
<div class="loading__circle loading__circle--color-yellow"></div>
|
||||
<div class="loading__circle loading__circle--color-green"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang=scss scoped>
|
||||
.loading {
|
||||
position: absolute;
|
||||
top:40%;
|
||||
left:calc(50% - (2rem*4 + 0.750rem * 3) / 2);
|
||||
margin:auto;
|
||||
//padding: 0.625rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.loading__circle {
|
||||
min-width: 2rem;
|
||||
aspect-ratio: 1;
|
||||
background-color: lightgrey;
|
||||
border-radius: 50%;
|
||||
margin: 0 0.375rem;
|
||||
animation-name: bump;
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.loading__circle--color-blue {
|
||||
background-color: #4285f4;
|
||||
animation-delay:0;
|
||||
|
||||
}
|
||||
.loading__circle--color-red {
|
||||
background-color: #ea4335;
|
||||
animation-delay:0.5s;
|
||||
}
|
||||
.loading__circle--color-yellow {
|
||||
background-color: #fbbc05;
|
||||
animation-delay:1s;
|
||||
|
||||
}
|
||||
.loading__circle--color-green {
|
||||
background-color: #34a853;
|
||||
animation-delay:1.5s;
|
||||
}
|
||||
|
||||
@keyframes bump {
|
||||
from {
|
||||
transform: scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
50%{
|
||||
transform: scale(1);
|
||||
}
|
||||
100%{
|
||||
transform:scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
33
app/components/ui/Modale.vue
Executable file
33
app/components/ui/Modale.vue
Executable file
@@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<Transition name="modal-animation">
|
||||
<div v-show="modalActive" class="modale">
|
||||
<transition name="modal-frame-animation">
|
||||
<div v-show="modalActive" class="modal-frame">
|
||||
<font-awesome-icon class="close-btn" @click="close" icon="fa-regular fa-circle-xmark" />
|
||||
<div class="modale-content">
|
||||
<slot>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
modalActive: Boolean,
|
||||
title: String
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const close = () => {
|
||||
emit('close');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
78
app/components/ui/SpinnerCpnt.vue
Executable file
78
app/components/ui/SpinnerCpnt.vue
Executable file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
div{
|
||||
height: 1.3em;
|
||||
width: 1.3em;
|
||||
border:3px solid #ff00c8;
|
||||
border-top:3px solid rgb(216, 226, 253);
|
||||
border-radius:50%;
|
||||
|
||||
-webkit-transition-property: -webkit-transform;
|
||||
-webkit-transition-duration: 1.2s;
|
||||
-webkit-animation-name: rotate;
|
||||
-webkit-animation-iteration-count: infinite;
|
||||
-webkit-animation-timing-function: linear;
|
||||
|
||||
-moz-transition-property: -moz-transform;
|
||||
-moz-animation-name: rotate;
|
||||
-moz-animation-duration: 1.2s;
|
||||
-moz-animation-iteration-count: infinite;
|
||||
-moz-animation-timing-function: linear;
|
||||
|
||||
transition-property: transform;
|
||||
animation-name: rotate;
|
||||
animation-duration: 1.2s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;}
|
||||
|
||||
@-webkit-keyframes rotate {
|
||||
from {-webkit-transform: rotate(0deg);}
|
||||
to {-webkit-transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
@-moz-keyframes rotate {
|
||||
from {-moz-transform: rotate(0deg);}
|
||||
to {-moz-transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {transform: rotate(0deg);}
|
||||
to {transform: rotate(360deg);}
|
||||
}
|
||||
|
||||
|
||||
/* Rest of page style*/
|
||||
body{
|
||||
background:#FABC20;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color:#393D3D;
|
||||
}
|
||||
|
||||
#container{
|
||||
width:90%;
|
||||
max-width:700px;
|
||||
margin:1em auto;
|
||||
position:relative;
|
||||
}
|
||||
|
||||
/* spinner positioning */
|
||||
|
||||
#html-spinner, #svg-spinner{
|
||||
position:absolute;
|
||||
top:80px;
|
||||
margin-left:-24px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
22
app/components/ui/unconfirmedBanner.vue
Normal file
22
app/components/ui/unconfirmedBanner.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<h3>
|
||||
</h3>
|
||||
<div class="unconfirmed-banner">
|
||||
<p>{{ $t('unconfirmedBanner.message') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang=scss scoped>
|
||||
.unconfirmed-banner {
|
||||
width:100%;
|
||||
background-color: #ea4335;
|
||||
height:1.3em;
|
||||
|
||||
}
|
||||
|
||||
p{
|
||||
text-align: center;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
</style>
|
||||
20
app/composables/useCounter.ts
Normal file
20
app/composables/useCounter.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const useCounter = () => {
|
||||
const counter = useState('counter', () => 0)
|
||||
const increment = () => {
|
||||
counter.value++
|
||||
}
|
||||
|
||||
const decrement = () => {
|
||||
counter.value--
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
counter.value=0
|
||||
}
|
||||
return {
|
||||
counter,
|
||||
increment,
|
||||
decrement,
|
||||
reset
|
||||
}
|
||||
}
|
||||
28
app/error.vue
Normal file
28
app/error.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<div class="flex flex-col items-center justify-center min-h-screen bg-gray-50 text-gray-800 px-4">
|
||||
<h1 class="text-6xl md:text-8xl font-extrabold text-red-500 mb-4">
|
||||
{{ error.statusCode }}
|
||||
</h1>
|
||||
<p class="text-xl md:text-2xl mb-6 text-center">
|
||||
{{ error.statusMessage || error.message }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="px-6 py-3 bg-indigo-600 text-white rounded-lg shadow hover:bg-indigo-700 transition"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
error: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Une erreur est survenue'
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
13
app/layouts/confirmation.vue
Normal file
13
app/layouts/confirmation.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<div class="app-container">
|
||||
<div class="app-scroll-view">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
</style>
|
||||
20
app/layouts/default.vue
Normal file
20
app/layouts/default.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="main-container">
|
||||
<div class="app-container">
|
||||
<div class="up">
|
||||
<div class="app-scroll-view">
|
||||
<slot />
|
||||
</div>
|
||||
<MenuNav/>
|
||||
<ButtonBurger/>
|
||||
</div>
|
||||
<div v-if="authStore.user && !authStore.user?.confirmed" class="down">
|
||||
<UiUnconfirmedBanner/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
</script>
|
||||
18
app/middleware/auth.global.ts
Normal file
18
app/middleware/auth.global.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export default defineNuxtRouteMiddleware((to) => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Gestion de la 404
|
||||
if (to.matched.length === 0) {
|
||||
return
|
||||
}
|
||||
// 1. Liste des pages publiques
|
||||
const isPublicPage = to.meta.public as boolean
|
||||
|
||||
// 2. Si pas de token et page protégée -> Redirection /403
|
||||
if (!auth.isLoggedIn && !isPublicPage) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "Accès refusé" }
|
||||
)
|
||||
}
|
||||
})
|
||||
73
app/pages/confirmation.vue
Normal file
73
app/pages/confirmation.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<h1>{{ $t('confirmation.title') }}</h1>
|
||||
|
||||
<uiLoading v-if="result === null" />
|
||||
|
||||
<div class="confirm-main-text" v-if="result!=null">
|
||||
<div v-if="result === true">
|
||||
<p v-if="authStore.isLoggedIn" >{{ $t('confirmation.successConnected') }}
|
||||
<NuxtLink :to="localePath('/lists')">{{ $t('confirmation.listsLink') }}</NuxtLink>
|
||||
</p>
|
||||
<p v-else>{{ $t('confirmation.successNotConnected') }} <NuxtLink :to="localePath('/login')">{{ $t('confirmation.loginLink') }}</NuxtLink></p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p class="confirm-main-text" v-if="result === 'expired'">{{ $t('confirmation.failureMessage')}}<br/><span>{{ $t('confirmation.failureCauseExpired') }}</span></p>
|
||||
<p class="confirm-main-text" v-else>{{ $t('confirmation.failureMessage')}}<br/><span>{{ $t('confirmation.failureCauseInvalid') }}</span></p>
|
||||
<p class="confirm-main-text last">{{ $t('confirmation.failureYouCan')}} <NuxtLink :to="localePath('/signup')">{{ $t('confirmation.failureCreateNewAccount') }}</NuxtLink> {{ $t('confirmation.failureOr') }} <NuxtLink :to="localePath('/')">{{ $t('confirmation.backHome') }}</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'confirmation', // utilisation du layout confirm
|
||||
public: true,
|
||||
})
|
||||
import type { ConfirmResult} from '~/types/auth';
|
||||
const authStore = useAuthStore()
|
||||
const localePath = useLocalePath()
|
||||
const loading = ref<boolean>(true)
|
||||
const result = ref<ConfirmResult | null>(null)
|
||||
const userConnected = ref<boolean>(false)
|
||||
|
||||
onMounted(async () => {
|
||||
const route = useRoute()
|
||||
// const user = route.query.user as string
|
||||
const token = route.query.token as string
|
||||
|
||||
if (!token) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
result.value = await authStore.confirmUser(token)
|
||||
if (result.value === true) {
|
||||
if (authStore.isLoggedIn && authStore.user){
|
||||
authStore.user.confirmed = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
throw new Error("Invalid !");
|
||||
|
||||
}
|
||||
finally{
|
||||
loading.value=false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
p > span{
|
||||
color:red;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin-block: 1.2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
.confirm-main-text:first-of-type{
|
||||
margin-top: 2em;
|
||||
}
|
||||
</style>
|
||||
49
app/pages/index.vue
Normal file
49
app/pages/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
public: true,
|
||||
pageId: 'index'
|
||||
})
|
||||
const authStore = useAuthStore();
|
||||
const localePath = useLocalePath()
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ $t('index.title') }}</h1>
|
||||
<h2 class="title">{{ $t('index.subtitle1')}}</h2>
|
||||
<div class="index-main-text" v-html="$t('index.mainText')"></div>
|
||||
|
||||
<div v-if="!authStore.isLoggedIn">
|
||||
<div class="index-main-text-last">
|
||||
<i18n-t keypath="index.lastSentenceUnconnect" tag="p">
|
||||
<template #login>
|
||||
<NuxtLink :to="localePath('/login')">
|
||||
{{ $t('index.login') }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template #signup>
|
||||
<NuxtLink :to="localePath('/signup')">
|
||||
{{ $t('index.signup') }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<ButtonGoogle/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t('index.lastSentenceConnected') }} <NuxtLink :to="localePath('lists')">{{ $t('index.seeLists') }}.</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: #06c;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}</style>
|
||||
51
app/pages/lists.vue
Normal file
51
app/pages/lists.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
pageId: 'lists'
|
||||
})
|
||||
import type {List} from '~/types/lists'
|
||||
const listStore = useListStore()
|
||||
|
||||
// activeTabId réactif
|
||||
const activeTabId = ref<number | undefined>(undefined)
|
||||
|
||||
onMounted(async () => {
|
||||
if (!listStore.lists.length) {
|
||||
await listStore.fetchLists()
|
||||
}
|
||||
|
||||
const defaultOpen = listStore.lists.find((l) => l.is_open === true)
|
||||
activeTabId.value = defaultOpen?.id ?? listStore.lists[0]?.id
|
||||
})
|
||||
|
||||
// Fonction utilitaire pour parser le contenu chiffré
|
||||
const parseContent = (list: List) => {
|
||||
try {
|
||||
return JSON.parse(list.encrypted_content) as any[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!listStore.loading">
|
||||
<div v-for="list in listStore.lists" :key="list.id">
|
||||
<h3>{{ list.list_title }}</h3>
|
||||
<ul>
|
||||
<li v-for="item in parseContent(list)" :key="item.id ?? item.name ?? item">
|
||||
{{ item.name ?? JSON.stringify(item) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<UiLoading/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
h3{
|
||||
font-size: 1.2em;
|
||||
color:blueviolet
|
||||
}
|
||||
</style>
|
||||
114
app/pages/login.vue
Normal file
114
app/pages/login.vue
Normal file
@@ -0,0 +1,114 @@
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
public: true, pageId: 'login'
|
||||
})
|
||||
const localePath = useLocalePath()
|
||||
const authStore = useAuthStore() // Store authenticate
|
||||
const login = ref('')
|
||||
const password = ref('')
|
||||
const awaiting = ref(false)
|
||||
const displayPwdReset=true
|
||||
|
||||
const errors = ref({
|
||||
loginEmpty: false,
|
||||
passwordEmpty: false,
|
||||
loginFailed: false,
|
||||
unconfirmedUser: false,
|
||||
})
|
||||
|
||||
const handleFormSubmit = async() => {
|
||||
// On retire l'erreur précédente
|
||||
errors.value.loginFailed = false;
|
||||
errors.value.unconfirmedUser = false;
|
||||
awaiting.value = true
|
||||
// --- Vérification des données du formulaire --- //
|
||||
|
||||
// En version raccourcie : on stocke le résultat de la condition dans la variable
|
||||
|
||||
errors.value.loginEmpty = ( login.value == "" );
|
||||
errors.value.passwordEmpty = ( password.value == "" );
|
||||
|
||||
// Si une erreur est rencontrée, on n'envoi pas la requete au serveur !
|
||||
if( !errors.value.loginEmpty && !errors.value.passwordEmpty )
|
||||
{
|
||||
|
||||
// Envoie de la requette à l'endpoint JWT et récupération d'un token de connexion
|
||||
const success = await authStore.login(login.value, password.value)
|
||||
if (success) {
|
||||
// Redirection
|
||||
await navigateTo(localePath('/lists'))
|
||||
}
|
||||
|
||||
else{
|
||||
errors.value.loginFailed = true;
|
||||
awaiting.value = false
|
||||
}
|
||||
}
|
||||
else{
|
||||
awaiting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h1>{{$t('loginTitle')}}</h1>
|
||||
<form @submit.prevent="handleFormSubmit">
|
||||
<InputText
|
||||
name="login"
|
||||
:label="$t('loginLabelUser')"
|
||||
placeholder=""
|
||||
v-model="login"
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="errors.loginEmpty" class="error">{{$t('loginErrorLoginEmpty')}}</p>
|
||||
</template>
|
||||
</InputText>
|
||||
|
||||
<InputPassword
|
||||
name="password"
|
||||
:label="$t('loginLabelPwd')"
|
||||
placeholder=""
|
||||
v-model="password"
|
||||
displayPwdReset
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="errors.passwordEmpty" class="error">{{ $t('loginErrorPwdEmpty') }}</p>
|
||||
</template>
|
||||
</InputPassword>
|
||||
|
||||
<div class="connection-error" >
|
||||
<p v-if="errors.loginFailed">{{ $t('loginErrorLoginFailed') }}</p>
|
||||
<p v-if="errors.unconfirmedUser">{{ $t('loginErrorUnconfirmedUSer') }}</p>
|
||||
</div>
|
||||
<ButtonBase
|
||||
:disabled="login === '' || password === '' || awaiting"
|
||||
:loading="awaiting"
|
||||
>
|
||||
{{ $t('loginFormBtn') }}
|
||||
</ButtonBase>
|
||||
</form>
|
||||
<p>{{ $t('loginGoogle') }}</p>
|
||||
<ButtonGoogle/>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-error{
|
||||
height:1.2em;
|
||||
& > p{
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: rgb(185, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
16
app/pages/otherPage.vue
Normal file
16
app/pages/otherPage.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<h1>
|
||||
value is still : {{ counter }}
|
||||
</h1>
|
||||
|
||||
<NuxtLink to="/">
|
||||
retour
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { counter } = useCounter()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
118
app/pages/passwordReset.vue
Normal file
118
app/pages/passwordReset.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<h1>{{$t('pwdReset.title')}}</h1>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
|
||||
<InputPassword
|
||||
name="password"
|
||||
placeholder=""
|
||||
:label="$t('pwdReset.pwdLabel1')"
|
||||
v-model="password"
|
||||
@input="check()"
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="pwErrors.isEmpty" class="error">{{ $t('pwdInput.errorPwdEmpty') }}</p>
|
||||
<p v-if="pwErrors.isntValid" class="error">{{ $t('pwdInput.errorPwdIsntValid') }}</p>
|
||||
</template>
|
||||
</InputPassword>
|
||||
<PasswordChecker/>
|
||||
<InputPassword
|
||||
name="passwordVerif"
|
||||
placeholder=""
|
||||
:label="$t('pwdReset.pwdLabel2')"
|
||||
v-model="passwordVerif"
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="pwErrors.doesNotMatch" class="error">{{ $t('pwdInput.pwdsDoesNotMatch') }}</p>
|
||||
<p v-if="pwErrors.verifyEmpty" class="error">{{ $t('pwdInput.pwdConfirmEmpty') }}</p>
|
||||
</template>
|
||||
</InputPassword>
|
||||
|
||||
<ButtonBase
|
||||
:loading="loading"
|
||||
:disabled="loading || success === true">
|
||||
{{ $t('pwdReset.formBtn') }}
|
||||
</ButtonBase>
|
||||
</form>
|
||||
<div v-if="success">
|
||||
<p>{{ $t('pwdReset.successMessage') }}</p>
|
||||
<NuxtLink :to="localePath('login')">{{ $t('pwdReset.loginLink') }}</NuxtLink>
|
||||
</div>
|
||||
<div v-else-if="success===false" class="error">
|
||||
<p>{{ $t('pwdReset.errorMessage') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
public: true,
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const localePath=useLocalePath()
|
||||
const token = ref(route.query.token)
|
||||
const authStore = useAuthStore()
|
||||
|
||||
|
||||
//** pwdReset up management **/
|
||||
const passwordToolBox = usePasswordToolBoxStore()
|
||||
|
||||
// password relative functions
|
||||
const check = () => {
|
||||
passwordToolBox.updatePassword(password.value)
|
||||
}
|
||||
|
||||
const password=ref('')
|
||||
const passwordVerif=ref('')
|
||||
|
||||
const pwErrors = ref({
|
||||
isEmpty: false as boolean,
|
||||
verifyEmpty: false as boolean,
|
||||
isntValid: false as boolean,
|
||||
doesNotMatch:false as boolean,
|
||||
})
|
||||
const loading = ref(false as boolean)
|
||||
const success = ref(null as null|boolean);
|
||||
|
||||
// form relative functions
|
||||
const handleSubmit = async() => {
|
||||
loading.value = true
|
||||
//réinitialisation des erreurs
|
||||
pwErrors.value.isEmpty = false;
|
||||
pwErrors.value.verifyEmpty = false;
|
||||
pwErrors.value.isntValid = false;
|
||||
pwErrors.value.doesNotMatch = false;
|
||||
// Check errors
|
||||
pwErrors.value.isEmpty = ( password.value == "" );
|
||||
pwErrors.value.verifyEmpty = ( passwordVerif.value == "" );
|
||||
pwErrors.value.isntValid = !passwordToolBox.isPasswordValid();
|
||||
pwErrors.value.doesNotMatch = ( password.value != passwordVerif.value );
|
||||
|
||||
if ( !pwErrors.value.isEmpty &&
|
||||
!pwErrors.value.verifyEmpty &&
|
||||
!pwErrors.value.isntValid &&
|
||||
!pwErrors.value.doesNotMatch) {
|
||||
success.value = await authStore.pwdReset(password.value, token.value)
|
||||
}
|
||||
loading.value = false
|
||||
password.value = ""
|
||||
passwordVerif.value = ""
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error{
|
||||
& > p{
|
||||
font-weight: bold;
|
||||
color: rgb(185, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
105
app/pages/passwordResetRequest.vue
Normal file
105
app/pages/passwordResetRequest.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<h1>{{$t('pwdResetRequest.title')}}</h1>
|
||||
<form @submit.prevent="handleFormSubmit()">
|
||||
<InputEmail
|
||||
v-model="email"
|
||||
name="email"
|
||||
:label="$t('signupLabelEmail')"
|
||||
placeholder=""
|
||||
:class="emailStatus"
|
||||
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="emailErrors.isEmpty" class="error">{{ $t('signupErrorEmailEmpty') }}</p>
|
||||
<p v-else-if="emailErrors.isntValid" class="error">{{ $t('signupErrorEmailIsntValid') }}</p>
|
||||
<p v-else-if="emailValid" class="success">{{ $t('signupEmailIsValid') }}</p>
|
||||
</template>
|
||||
</InputEmail>
|
||||
|
||||
<ButtonBase
|
||||
:disabled="!emailValid || awaiting"
|
||||
:loading="awaiting">
|
||||
{{ $t('pwdResetRequest.formBtn') }}
|
||||
</ButtonBase>
|
||||
|
||||
<div class="error" v-if="formErrors.submitFailed">
|
||||
<p>{{ $t('pwdResetRequest.pb') }}</p>
|
||||
<p>{{ $t('pwdResetRequest.message') }} {{formErrors.message}}</p>
|
||||
</div>
|
||||
<div class="success" v-if="success">
|
||||
<p>{{ $t('pwdResetRequest.successMessage') }}</p>
|
||||
<p>{{ $t('pwdResetRequest.linkValidity') }}</p>
|
||||
<p>{{ $t('pwdResetRequest.seeU') }}</p>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
public: true
|
||||
})
|
||||
const { locale } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const email = ref("")
|
||||
const awaiting = ref(false)
|
||||
const emailErrors = ref({
|
||||
isEmpty: false as boolean,
|
||||
isntValid: false as boolean,
|
||||
})
|
||||
const formErrors = ref({
|
||||
submitFailed: false as boolean,
|
||||
message: null as string | null,
|
||||
})
|
||||
const success = ref(false)
|
||||
|
||||
const emailValid = computed(() => isValidEmail(email.value))
|
||||
const emailStatus = computed(() => {
|
||||
return emailValid.value ? "mail-is-valid" : "mail-is-invalid"
|
||||
})
|
||||
|
||||
function isValidEmail(value: string):boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
}
|
||||
|
||||
const handleFormSubmit = async() => {
|
||||
//* Errors reinitialisation...
|
||||
awaiting.value = true
|
||||
emailErrors.value.isEmpty = false;
|
||||
emailErrors.value.isntValid = false;
|
||||
formErrors.value.submitFailed = false;
|
||||
formErrors.value.message = '';
|
||||
success.value = false;
|
||||
|
||||
//* Now come the verifications
|
||||
emailErrors.value.isEmpty = (email.value == '')
|
||||
emailErrors.value.isntValid = ( !isValidEmail(email.value) );
|
||||
|
||||
if (emailErrors.value.isEmpty || emailErrors.value.isntValid){
|
||||
return false;
|
||||
}
|
||||
const response = await authStore.pwdResetResquest(email.value, locale.value);
|
||||
//* If we have a message, it means we have an error...
|
||||
|
||||
if (response == true){
|
||||
success.value = true;
|
||||
email.value = "";
|
||||
awaiting.value = false;
|
||||
}
|
||||
else {
|
||||
formErrors.value.submitFailed = true;
|
||||
formErrors.value.message = authStore.error;
|
||||
awaiting.value = false;
|
||||
success.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
109
app/pages/profile.vue
Normal file
109
app/pages/profile.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<h1>{{ $t('profile.title') }}</h1>
|
||||
|
||||
<!-- Onglets -->
|
||||
<div role="tablist" aria-label="Profil utilisateur" class="tabs">
|
||||
<button
|
||||
:class="['tab', activeTab === 'general' ? 'active' : '']"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'general'"
|
||||
aria-controls="tab-general"
|
||||
id="tab-button-general"
|
||||
@click="activeTab = 'general'; authStore.user.sudo_token = null"
|
||||
>
|
||||
{{ $t('profile.tabGeneral') }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="['tab', activeTab === 'redzone' ? 'active' : '']"
|
||||
class='redzone'
|
||||
role="tab"
|
||||
:aria-selected="activeTab === 'redzone'"
|
||||
aria-controls="tab-redzone"
|
||||
id="tab-button-redzone"
|
||||
@click="activeTab = 'redzone'; authStore.user.sudo_token = null"
|
||||
>
|
||||
{{ $t('profile.tabRedZone')}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu des onglets -->
|
||||
<div class="tab-panels">
|
||||
<!-- Onglet Général -->
|
||||
<section
|
||||
v-show="activeTab === 'general'"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-button-general"
|
||||
id="tab-general"
|
||||
>
|
||||
<ProfileGeneral />
|
||||
</section>
|
||||
|
||||
<!-- Onglet RED ZONE -->
|
||||
<section
|
||||
v-show="activeTab === 'redzone'"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-button-redzone"
|
||||
id="tab-redzone"
|
||||
>
|
||||
<ProfileRedZone />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const authStore = useAuthStore()
|
||||
// Onglet actif (TypeScript infère automatiquement le type string)
|
||||
const activeTab = ref('general')
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* Onglets */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 0.5rem 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
//
|
||||
.tab:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
border-bottom-color: blueviolet;
|
||||
color: blueviolet;
|
||||
}
|
||||
button.redzone{
|
||||
color:red;
|
||||
&.active{
|
||||
border-bottom-color: red;
|
||||
color:red;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* Contenu onglet */
|
||||
.tab-panels section {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* Animation simple */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(5px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
</style>
|
||||
160
app/pages/signup.vue
Executable file
160
app/pages/signup.vue
Executable file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<section>
|
||||
<UiModale @close="closeModal" :modalActive="modalActive">
|
||||
<div class="modal-content">
|
||||
<h1 class="modal-title">{{ $t('modalTitle') }}</h1>
|
||||
<p>{{ $t('modalText') }}</p>
|
||||
</div>
|
||||
</UiModale>
|
||||
<h1>{{ $t('signupTitle') }}</h1>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<InputEmail
|
||||
v-model="email"
|
||||
name="email"
|
||||
:label="$t('signupLabelEmail')"
|
||||
placeholder=""
|
||||
:class="emailStatus"
|
||||
@blur="touched = true"
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="emailErrors.isEmpty" class="error">{{ $t('signupErrorEmailEmpty') }}</p>
|
||||
<p v-else-if="emailErrors.isntValid" class="error">{{ $t('signupErrorEmailIsntValid') }}</p>
|
||||
<p v-else-if="emailValid" class="success">{{ $t('signupEmailIsValid') }}</p>
|
||||
</template>
|
||||
</InputEmail>
|
||||
|
||||
<InputPassword
|
||||
name="password"
|
||||
placeholder=""
|
||||
:label="$t('signupLabelPwd')"
|
||||
v-model="password"
|
||||
@input="check()"
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="pwErrors.isEmpty" class="error">{{ $t('signupErrorPwdEmpty') }}</p>
|
||||
<p v-if="pwErrors.isntValid" class="error">{{ $t('signupErrorPwsIsntValid') }}</p>
|
||||
</template>
|
||||
</InputPassword>
|
||||
<PasswordChecker/>
|
||||
<InputPassword
|
||||
name="passwordVerif"
|
||||
placeholder=""
|
||||
:label="$t('signupLabelConfirmPwd')"
|
||||
v-model="passwordVerif"
|
||||
>
|
||||
<template #message>
|
||||
<p v-if="pwErrors.doesNotMatch" class="error">{{ $t('signupErrorPwdsDoesNotMatch') }}</p>
|
||||
<p v-if="pwErrors.verifyEmpty" class="error">{{ $t('signupErrorPwdConfirmEmpty') }}</p>
|
||||
</template>
|
||||
</InputPassword>
|
||||
|
||||
<ButtonBase>
|
||||
{{ $t('signupFormBtn') }}
|
||||
</ButtonBase>
|
||||
</form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
public: true
|
||||
})
|
||||
const { locale } = useI18n()
|
||||
//** Modale management **//
|
||||
const localePath=useLocalePath()
|
||||
const modalActive = ref(false)
|
||||
|
||||
const closeModal = () => {
|
||||
modalActive.value = false
|
||||
navigateTo(localePath('lists'))
|
||||
}
|
||||
|
||||
//** Sign up management **/
|
||||
const authStore = useAuthStore()
|
||||
const passwordToolBox = usePasswordToolBoxStore()
|
||||
// email relative functions
|
||||
const email = ref("")
|
||||
const touched = ref(false)
|
||||
|
||||
function isValidEmail(value: string) {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
||||
}
|
||||
|
||||
const emailValid = computed(() => touched.value && isValidEmail(email.value))
|
||||
const emailErrors = ref({
|
||||
isEmpty: false as boolean,
|
||||
isntValid: false as boolean
|
||||
})
|
||||
//const emailInvalid = computed(() => touched.value && !isValidEmail(email.value))
|
||||
|
||||
const emailStatus = computed(() => {
|
||||
if (!touched.value) return ""
|
||||
return emailValid.value ? "mail-is-valid" : "mail-is-invalid"
|
||||
})
|
||||
|
||||
// password relative functions
|
||||
const check = () => {
|
||||
passwordToolBox.updatePassword(password.value)
|
||||
}
|
||||
|
||||
const password=ref('')
|
||||
const passwordVerif=ref('')
|
||||
|
||||
const pwErrors = ref({
|
||||
isEmpty: false as boolean,
|
||||
verifyEmpty: false as boolean,
|
||||
isntValid: false as boolean,
|
||||
doesNotMatch:false as boolean,
|
||||
})
|
||||
|
||||
// form relative functions
|
||||
const handleSubmit = async() => {
|
||||
//réinitialisation des erreurs
|
||||
emailErrors.value.isEmpty = false
|
||||
emailErrors.value.isntValid = false
|
||||
pwErrors.value.isEmpty = false;
|
||||
pwErrors.value.verifyEmpty = false;
|
||||
pwErrors.value.isntValid = false;
|
||||
pwErrors.value.doesNotMatch = false;
|
||||
// Check errors
|
||||
emailErrors.value.isEmpty = ( email.value == "" );
|
||||
emailErrors.value.isntValid = !isValidEmail(email.value)
|
||||
pwErrors.value.isEmpty = ( password.value == "" );
|
||||
pwErrors.value.verifyEmpty = ( passwordVerif.value == "" );
|
||||
pwErrors.value.isntValid = !passwordToolBox.isPasswordValid();
|
||||
pwErrors.value.doesNotMatch = ( password.value != passwordVerif.value );
|
||||
|
||||
if ( !emailErrors.value.isEmpty &&
|
||||
!emailErrors.value.isntValid &&
|
||||
!pwErrors.value.isEmpty &&
|
||||
!pwErrors.value.verifyEmpty &&
|
||||
!pwErrors.value.isntValid &&
|
||||
!pwErrors.value.doesNotMatch) {
|
||||
const success = await authStore.register(email.value, password.value, locale.value)
|
||||
if (success) {
|
||||
modalActive.value=true;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.connection-error{
|
||||
height:1.2em;
|
||||
& > p{
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
color: rgb(185, 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
app/plugins/api.ts
Normal file
51
app/plugins/api.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import ListsRepository from '~/repositories/lists.repository';
|
||||
import UserRepository from '~/repositories/user.repository';
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
const config = useRuntimeConfig();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const apiFetcher = $fetch.create({
|
||||
baseURL: config.public.apiBase,
|
||||
|
||||
onRequest({ options }) {
|
||||
const headers = new Headers(options.headers);
|
||||
if (authStore.token) {
|
||||
headers.set('Authorization', `Bearer ${authStore.token}`);
|
||||
}
|
||||
if (authStore.user?.sudo_token){
|
||||
headers.set('sudo_token', authStore.user?.sudo_token);
|
||||
}
|
||||
options.headers = headers;
|
||||
},
|
||||
|
||||
async onResponseError({ response }) {
|
||||
// Cas 401 : Session expirée ou non autorisée
|
||||
if (response.status === 401) {
|
||||
// Optionnel : nettoyer le store avant d'afficher l'erreur
|
||||
authStore.logout();
|
||||
|
||||
// On déclenche la page d'erreur Nuxt
|
||||
throw showError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Session expirée ou accès non autorisé',
|
||||
fatal: true // 'fatal: true' force le rendu de la page d'erreur même côté client
|
||||
});
|
||||
}
|
||||
|
||||
// Optionnel : Gérer d'autres codes
|
||||
if (response.status >= 500) {
|
||||
console.error("Erreur serveur, réessayez plus tard.");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
provide: {
|
||||
api: {
|
||||
lists: new ListsRepository(apiFetcher as any),
|
||||
user: new UserRepository(apiFetcher as any)
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
30
app/plugins/auth.ts
Normal file
30
app/plugins/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Faire vérifier le token présent dans le cookie
|
||||
// à WP via Pinia au 1er chargement de l'app.
|
||||
|
||||
import type { User } from "~/types/auth.ts"
|
||||
export default defineNuxtPlugin(async () => {
|
||||
const auth = useAuthStore()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// On n'exécute la vérification que s'il y a un token
|
||||
// et qu'on n'a pas encore chargé l'utilisateur (ex: au refresh)
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
// On appelle le endpoint WP qui renvoie l'utilisateur connecté
|
||||
// Note: l'URL dépend du plugin perso auth
|
||||
const data = await $fetch<User>(`${config.public.apiBase}/users/me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${auth.token}`
|
||||
}
|
||||
})
|
||||
|
||||
// Si ça marche, on met à jour l'user dans Pinia
|
||||
auth.user = data
|
||||
} catch (error) {
|
||||
// Si le token est expiré ou invalide, WP renvoie une erreur
|
||||
// On vide tout pour forcer la reconnexion
|
||||
console.error("Session expirée ou token invalide")
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
})
|
||||
34
app/plugins/fontawesome.ts
Normal file
34
app/plugins/fontawesome.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Font awesome
|
||||
/* import the fontawesome core */
|
||||
import { library, config } from '@fortawesome/fontawesome-svg-core'
|
||||
/* import font awesome icon component */
|
||||
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||
|
||||
// import icons //
|
||||
import { faEye, faEyeSlash, faLessThan, faGreaterThan, faPlus, faMinus, faLanguage, faGear, faLocationDot, faArrowsRotate, faDiamondTurnRight} from '@fortawesome/free-solid-svg-icons'
|
||||
import { faCircleCheck, faCircleXmark, faSquare, faSquareCheck, faTrashCan} from '@fortawesome/free-regular-svg-icons'
|
||||
|
||||
export default defineNuxtPlugin((nuxtApp) => {
|
||||
library.add(faCircleCheck,
|
||||
faCircleXmark,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faSquare,
|
||||
faSquareCheck,
|
||||
faTrashCan,
|
||||
faLessThan,
|
||||
faGreaterThan,
|
||||
faPlus,
|
||||
faMinus,
|
||||
faGear,
|
||||
faLocationDot,
|
||||
faArrowsRotate,
|
||||
faDiamondTurnRight,
|
||||
faLanguage)
|
||||
nuxtApp.vueApp.component('FontAwesomeIcon', FontAwesomeIcon)
|
||||
})
|
||||
|
||||
import '@fortawesome/fontawesome-svg-core/styles.css'
|
||||
|
||||
// Empêche FontAwesome d’injecter son CSS automatiquement
|
||||
config.autoAddCss = false
|
||||
11
app/repositories/factory.ts
Normal file
11
app/repositories/factory.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { $Fetch, SearchParameters } from 'ofetch';
|
||||
|
||||
export async function fetchWithRepo<T>(
|
||||
fetcher: $Fetch,
|
||||
url: string,
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
body?: any,
|
||||
params?: SearchParameters
|
||||
): Promise<T> {
|
||||
return await fetcher(url, { method, body, params });
|
||||
}
|
||||
22
app/repositories/lists.repository.ts
Normal file
22
app/repositories/lists.repository.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { $Fetch } from 'ofetch';
|
||||
import type { List } from '~/types/lists'
|
||||
|
||||
export default class ListRepository {
|
||||
private fetcher: $Fetch;
|
||||
|
||||
constructor(fetcher: $Fetch) {
|
||||
this.fetcher = fetcher;
|
||||
}
|
||||
|
||||
async getAll() {
|
||||
return await this.fetcher<List[]>('/lists');
|
||||
}
|
||||
|
||||
async create(data: Partial<List>) {
|
||||
return await this.fetcher<List>('/lists', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
63
app/repositories/user.repository.ts
Normal file
63
app/repositories/user.repository.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { $Fetch } from 'ofetch';
|
||||
import type { User, ConfirmResult } from '~/types/auth'
|
||||
|
||||
export default class UserRepository {
|
||||
private fetcher: $Fetch;
|
||||
|
||||
constructor(fetcher: $Fetch) {
|
||||
this.fetcher = fetcher;
|
||||
}
|
||||
|
||||
async confirm(token: string){
|
||||
return await this.fetcher<ConfirmResult>('/auth/confirm', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
token
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async register(email: string, password:string, locale:string){
|
||||
return await this.fetcher<ConfirmResult>('/user/register', {
|
||||
method: 'POST',
|
||||
body:{
|
||||
email, password, locale
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async emailChange(newEmail: string, locale:string){
|
||||
return await this.fetcher<ConfirmResult>('/user/email-update/request', {
|
||||
method: 'POST',
|
||||
body:{
|
||||
newEmail, locale
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async updateDisplayName(newDisplayName: string){
|
||||
return await this.fetcher<ConfirmResult>('/user/update', {
|
||||
method: 'PUT',
|
||||
body:{
|
||||
display_name : newDisplayName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async deleteRequest(locale:string){
|
||||
return await this.fetcher<ConfirmResult>('/user/delete-request', {
|
||||
method:'DELETE',
|
||||
body:{
|
||||
locale
|
||||
}
|
||||
})
|
||||
}
|
||||
async pwdChallenge(pwd:string){
|
||||
return await this.fetcher<ConfirmResult>('/user/pwdChallenge', {
|
||||
method:'POST',
|
||||
body:{
|
||||
pwd
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
246
app/stores/auth.ts
Normal file
246
app/stores/auth.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import UserRepository from '~/repositories/user.repository'
|
||||
import type { User, LoginResponse, ConfirmResult } from '~/types/auth'
|
||||
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
// On lie l'état directement au cookie
|
||||
token: useCookie<string | null>('auth_token', {
|
||||
// secure: false,
|
||||
// sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
}), // Expire après 7 jours
|
||||
user: useCookie<User | null>('auth_user', {
|
||||
// secure: false,
|
||||
// sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
}),
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
// Le "!!" transforme la valeur en vrai BOULÉEN (true/false)
|
||||
isLoggedIn: (state) => !!state.token,
|
||||
|
||||
// plus tard on pourras ajouter un getter pour récupérer le prénom
|
||||
//userName: (state) => state.user?.name || 'Invité'
|
||||
},
|
||||
|
||||
actions: {
|
||||
setTokenCookie(newToken: string | null) {
|
||||
const cookie = useCookie('auth_token')
|
||||
cookie.value = newToken // On force l'écriture
|
||||
this.token = newToken // On met à jour le state Pinia
|
||||
},
|
||||
|
||||
setUserCookie(newUser: User | null) {
|
||||
// 1. On récupère le cookie sans typage strict ou typé en 'any' pour l'écriture
|
||||
const userCookie = useCookie<any>('auth_user', {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
})
|
||||
|
||||
// 2. On assigne l'objet (Nuxt va faire le JSON.stringify en interne)
|
||||
userCookie.value = newUser
|
||||
|
||||
// 3. On met à jour le state Pinia
|
||||
this.user = newUser
|
||||
},
|
||||
|
||||
async register(email: string, password:string, locale:string ){
|
||||
const { $api } = useNuxtApp();
|
||||
const listsStore = useListStore()
|
||||
const config = useRuntimeConfig()
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await $api.user.register(email, password, locale)
|
||||
|
||||
// En cas de réussite, le nouveau user est connecté.
|
||||
// On assigne les valeurs : useCookie met à jour le state ET le navigateur
|
||||
this.setTokenCookie(data.token)
|
||||
this.setUserCookie(data.user)
|
||||
listsStore.saveLists(data.lists)
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
// En cas d'erreur, on nettoie les cookies
|
||||
this.token = null
|
||||
this.user = null
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async confirmUser(token: string) {
|
||||
const { $api } = useNuxtApp();
|
||||
//console.log(token)
|
||||
this.error = null
|
||||
try {
|
||||
const res: ConfirmResult = await $api.user.confirm(token)
|
||||
return res
|
||||
} catch (err: any) {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
async login(login: string, password: string) {
|
||||
const listsStore = useListStore()
|
||||
const config = useRuntimeConfig()
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await $fetch<LoginResponse>(`${config.public.apiBase}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: { login, password }
|
||||
})
|
||||
|
||||
// On assigne les valeurs : useCookie met à jour le state ET le navigateur
|
||||
this.setTokenCookie(data.token)
|
||||
this.setUserCookie(data.user)
|
||||
listsStore.saveLists(data.lists)
|
||||
|
||||
return true
|
||||
} catch (err: any) {
|
||||
// En cas d'erreur, on nettoie les cookies
|
||||
this.token = null
|
||||
this.user = null
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.setTokenCookie(null)
|
||||
this.setUserCookie(null)
|
||||
const listsStore = useListStore()
|
||||
listsStore.resetLists()
|
||||
return navigateTo('/')
|
||||
},
|
||||
|
||||
async pwdResetResquest( email: string, locale: string) {
|
||||
const config = useRuntimeConfig()
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await $fetch<boolean>(`${config.public.apiBase}/user/pwdReset`, {
|
||||
method: 'POST',
|
||||
body: { email, locale }
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async pwdReset( password: string, token: any) {
|
||||
const config = useRuntimeConfig()
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const data = await $fetch<boolean>(`${config.public.apiBase}/user/pwdReset`, {
|
||||
method: 'PUT',
|
||||
body: { password, token }
|
||||
})
|
||||
|
||||
return data
|
||||
} catch (err: any) {
|
||||
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async emailChange( newEmail:string, locale:string){
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
this.error = null;
|
||||
|
||||
if ( !this.user?.email ){
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const data = await $api.user.emailChange(newEmail, locale)
|
||||
return data
|
||||
} catch (err: any) {
|
||||
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async updateDisplayName(newDisplayName:string){
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
// const config = useRuntimeConfig()
|
||||
this.error = null;
|
||||
|
||||
if ( !this.user?.email ){
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const data = await $api.user.updateDisplayName(newDisplayName)
|
||||
this.user.display_name = newDisplayName
|
||||
return data
|
||||
} catch (err: any) {
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
|
||||
async deleteRequest(locale:string){
|
||||
const { $api } = useNuxtApp();
|
||||
this.error = null
|
||||
|
||||
if ( !this.user?.email ){
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await $api.user.deleteRequest(locale)
|
||||
return data
|
||||
} catch (err: any) {
|
||||
this.error = err.data?.message || "Erreur de connexion"
|
||||
return false
|
||||
}
|
||||
},
|
||||
async pwdChallenge(pwd:string){
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
try {
|
||||
const data = await $api.user.pwdChallenge(pwd)
|
||||
console.log(data)
|
||||
if (this.user){
|
||||
this.user.sudo_token = data.sudo_token
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 403) {
|
||||
console.warn("Mauvais mot de passe, mais on garde la session active.");
|
||||
return false; // On renvoie false pour afficher une erreur dans l'UI
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
21
app/stores/burger.ts
Normal file
21
app/stores/burger.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useBurgerStore = defineStore('burger', {
|
||||
state: () => ({
|
||||
checked: false as boolean,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
toggle() {
|
||||
this.checked = !this.checked
|
||||
},
|
||||
|
||||
open() {
|
||||
this.checked = true
|
||||
},
|
||||
|
||||
close() {
|
||||
this.checked = false
|
||||
},
|
||||
},
|
||||
})
|
||||
62
app/stores/lists.ts
Normal file
62
app/stores/lists.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { List } from '~/types/lists'
|
||||
|
||||
|
||||
export const useListStore = defineStore('lists', {
|
||||
state: () => ({
|
||||
lists: [] as List[],
|
||||
loading: false as boolean,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
saveLists(lists:Array<List>){
|
||||
this.lists = lists
|
||||
},
|
||||
resetLists(){
|
||||
this.lists = []
|
||||
},
|
||||
|
||||
async fetchLists() {
|
||||
// On récupère notre plugin API injecté
|
||||
const { $api } = useNuxtApp();
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
// L'appel est maintenant ultra simple et typé
|
||||
const data = await $api.lists.getAll();
|
||||
this.lists = data;
|
||||
} catch (error) {
|
||||
// La gestion d'erreur est centralisée,
|
||||
// mais tu peux ajouter une logique spécifique ici (ex: notification)
|
||||
console.error("Erreur lors du chargement des listes:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// async updateList(id, title, content) {
|
||||
// const config = useRuntimeConfig();
|
||||
|
||||
// try {
|
||||
// const data = await $fetch<[]|null>(`${config.public.apiBase}/lists`, {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// // On injecte le token ici
|
||||
// 'Authorization': `Bearer ${this.token}`
|
||||
// },
|
||||
// // Si tu as besoin d'envoyer un corps de message vide ou spécifique
|
||||
// // body: {}
|
||||
// });
|
||||
|
||||
// this.lists = data;
|
||||
// console.log(data);
|
||||
// console.log(this.token);
|
||||
// return data;
|
||||
// } catch (error) {
|
||||
// console.error("Erreur lors de la récupération des listes:", error);
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
})
|
||||
64
app/stores/params.ts
Normal file
64
app/stores/params.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useParamsStore = defineStore('params', {
|
||||
|
||||
state: () => ({
|
||||
paramsData: false,
|
||||
}),
|
||||
|
||||
actions:
|
||||
{
|
||||
async findAll(data = null)
|
||||
{
|
||||
if (data){
|
||||
this.paramsData = JSON.parse(data)
|
||||
//console.log(data)
|
||||
}
|
||||
else{
|
||||
this.paramsData = JSON.parse(await paramsService.findAll())
|
||||
//if (this.paramsData)
|
||||
}
|
||||
//console.log(this.paramsData)
|
||||
},
|
||||
|
||||
updateParams(){
|
||||
paramsService.update(JSON.stringify(this.paramsData))
|
||||
},
|
||||
|
||||
changePage(page){
|
||||
this.paramsData.profil_and_params_view = page
|
||||
this.updateParams();
|
||||
},
|
||||
|
||||
deleteColor(){
|
||||
|
||||
if(this.paramsData.colors.length > 1){
|
||||
|
||||
const deletedColor = this.paramsData.colors.pop()
|
||||
if (this.paramsData.unavailable_colors == undefined){
|
||||
this.paramsData = {
|
||||
colors : this.paramsData.colors,
|
||||
unavailable_colors : [],
|
||||
profil_and_params_view : this.paramsData.profil_and_params_view
|
||||
}
|
||||
}
|
||||
this.paramsData.unavailable_colors.push(deletedColor)
|
||||
}
|
||||
this.updateParams();
|
||||
|
||||
},
|
||||
|
||||
addColor(){
|
||||
if(this.paramsData.colors.length < 8){
|
||||
const color = this.paramsData.unavailable_colors.pop()
|
||||
this.paramsData.colors.push(color)
|
||||
this.updateParams();
|
||||
}
|
||||
},
|
||||
|
||||
modifyColor(colorIndex, newColorValue) {
|
||||
this.paramsData.colors[colorIndex] = newColorValue
|
||||
this.updateParams();
|
||||
},
|
||||
}
|
||||
})
|
||||
56
app/stores/passwordToolBox.ts
Normal file
56
app/stores/passwordToolBox.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const usePasswordToolBoxStore = defineStore('passwordToolBox', {
|
||||
state: () => ({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
|
||||
// --- REGEX ---
|
||||
regNumberOfCaracteres: /^.{8,22}$/,
|
||||
regSpecialCaractere: /[^A-Za-z0-9]/,
|
||||
regCapitalizeCaractere: /[A-Z]/,
|
||||
regMinimizeCaractere: /[a-z]/,
|
||||
regNumber: /\d/,
|
||||
regPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,22}$/,
|
||||
}),
|
||||
actions: {
|
||||
updatePassword(newPassword: string) {
|
||||
this.password = newPassword
|
||||
},
|
||||
updateConfirmPassword(newConfirm: string) {
|
||||
this.confirmPassword = newConfirm
|
||||
},
|
||||
|
||||
// --- CHECK FUNCTIONS ---
|
||||
checkRegex(regex: RegExp) {
|
||||
return regex.test(this.password)
|
||||
},
|
||||
|
||||
isNumberOfCaracteresValid() {
|
||||
return this.checkRegex(this.regNumberOfCaracteres)
|
||||
},
|
||||
isSpecialCaractereValid() {
|
||||
return this.checkRegex(this.regSpecialCaractere)
|
||||
},
|
||||
isCapitalizeCaractereValid() {
|
||||
return this.checkRegex(this.regCapitalizeCaractere)
|
||||
},
|
||||
isMinimizeCaractereValid() {
|
||||
return this.checkRegex(this.regMinimizeCaractere)
|
||||
},
|
||||
isNumberValid() {
|
||||
return this.checkRegex(this.regNumber)
|
||||
},
|
||||
isPasswordValid() {
|
||||
return this.checkRegex(this.regPassword)
|
||||
},
|
||||
isPasswordConfirmationValid() {
|
||||
return this.password === this.confirmPassword && this.isPasswordValid()
|
||||
},
|
||||
|
||||
// --- UI HELPER ---
|
||||
uiClass(valid: boolean) {
|
||||
return valid ? 'has-success' : 'has-error'
|
||||
}
|
||||
}
|
||||
})
|
||||
19
app/types/auth.ts
Normal file
19
app/types/auth.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
confirmed: boolean
|
||||
role: string
|
||||
display_name: string
|
||||
avatar: string
|
||||
is_google: boolean
|
||||
sudo_token:string | null
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
token: string
|
||||
lists: any[]
|
||||
}
|
||||
|
||||
export type ConfirmResult = true | false | 'expired'
|
||||
31
app/types/lists.ts
Normal file
31
app/types/lists.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export interface List {
|
||||
id: number;
|
||||
list_title: string;
|
||||
list_type: string;
|
||||
encrypted_content: string; // JSON string
|
||||
is_open: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface taskItem {
|
||||
id : number;
|
||||
item_title : string;
|
||||
is_done : boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface tradItem {
|
||||
id : number;
|
||||
item_title : string;
|
||||
trad : string
|
||||
display_verso : boolean;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface positionItem {
|
||||
id : number;
|
||||
item_title : string;
|
||||
position : string;
|
||||
color : string;
|
||||
}
|
||||
Reference in New Issue
Block a user