initial commit

This commit is contained in:
2026-02-26 21:29:34 +01:00
commit d9d84634e8
72 changed files with 18491 additions and 0 deletions

8
app/app.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>

View File

@@ -0,0 +1 @@
@import url('https://fonts.googleapis.com/css2?family=Open+Sans&family=Quicksand:wght@300;400;700&display=swap');

View File

@@ -0,0 +1,8 @@
*{
margin: 0;
}
html{
margin: 0;
padding: 0;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View 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";

View 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;
}
}
}

View 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%);

View File

@@ -0,0 +1,2 @@
$maxWidth: 1200px;
$imageWidth: 100%;

420
app/components/ListElement.vue Executable file
View 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>

View 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>

View 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>

View 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>

View 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>

View 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 à lemail */
:deep(.mail-is-valid) {
--input-border: #00ca22;
}
:deep(.mail-is-invalid) {
--input-border: #ca0d00;
}
</style>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,31 @@
<template>
<UiModale :modalActive="modelValue"
@close="emit('update:modelValue', false)"
title="Confirmer le changement demail">
<div class="modale-content">
<p>
Un email de validation va être envoyé à la nouvelle adresse.
Le changement ne sera effectif quaprè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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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>

View 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>

View 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>

View 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
View 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>

View 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
View 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>

View 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é" }
)
}
})

View 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
View 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
View 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
View 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
View 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
View 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>

View 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
View 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
View 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
View 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
View 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()
}
}
})

View 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 dinjecter son CSS automatiquement
config.autoAddCss = false

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

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

View 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
View 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
View 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
View 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
View 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();
},
}
})

View 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
View 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
View 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;
}