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

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

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

120
i18n/locales/en.json Normal file
View File

@@ -0,0 +1,120 @@
{
"nav":{
"home":"Home",
"login": "Login",
"logout":"Logout",
"signup": "Sign up",
"profile": "Profile",
"lists": "My Lists"
},
"index":{
"title": "Welcome on the awesome lists manager !",
"subtitle1":"Overview",
"mainText": "<p>Create your own lists of places, foreign language words (like flashcards), or simply a todo list. Have fun, its really useful!</p><p>The source code for this project is <a href=\"https://github.com/Raffiskender\" target=\"_blank\">available here</a>.</p><p>In this 3rd version, the back office is still handled by two WP plugins, BUT their architecture has been completely redesigned. The user management plugin via REST API is now lighter and communicates with the list management plugin through hooks or filters.</p><p>The front end is built with Nuxt JS.</p>",
"lastSentence":"To get started, you need to {login} or {signup}, or...",
"lastSentenceUnconnect": "To get started, you need to {login} or {signup} or even...",
"login":"login",
"signup": "sign up",
"lastSentenceConnected": "You are logged in, you can now",
"seeLists": "access your lists"
},
"googleBtnTxt":"Sign in with Google",
"loginTitle": "Login",
"loginLabelUser": "Username or email",
"loginLabelPwd": "Password",
"loginErrorPwdEmpty": "You must enter a password",
"loginErrorLoginEmpty": "You must enter a username",
"loginErrorLoginFailed": "Invalid credentials",
"loginErrorUnconfirmedUser": "You cannot log in because your email has not been verified!",
"loginFormBtn": "Log In",
"loginGoogle":"Or take the easy way...",
"loginPage":{
"passwordResetRequest":"Forgot password?"
},
"signupTitle": "Sign Up",
"signupLabelEmail": "Email",
"signupLabelPwd": "Password",
"signupLabelConfirmPwd": "Confirm password",
"signupErrorEmailEmpty": "You must enter an email",
"signupErrorEmailIsntValid": "Invalid email",
"signupEmailIsValid": "Email validated",
"signupFormBtn": "Sign up",
"modalTitle": "Registration successful!",
"modalText": "You can now close this window and start managing your lists.",
"passwordCheckerShallContain": "Must contain",
"passwordChecker8-22": "between 8 and 22 characters,",
"passwordCheckerUppercase": "one uppercase letter,",
"passwordCheckerLowercase": "one lowercase letter,",
"passwordCheckerNb": "one number,",
"passwordCheckerSpecialChar": "one special character",
"pwdInput":{
"errorPwdEmpty":"You must enter a password",
"errorPwdIsntValid":"You must choose a stronger password",
"pwdsDoesNotMatch":"Passwords do not match!",
"pwdConfirmEmpty":"You must confirm your password!"
},
"confirmation": {
"title": "Confirmation page",
"successConnected": "Your email has been confirmed! You are now logged in.",
"successNotConnected": "Your email has been confirmed! Please log in to continue:",
"failureMessage": "Your email could not be confirmed.",
"failureCauseInvalid": "Reason: Invalid",
"failureCauseExpired": "Reason: Expired",
"failureYouCan": "You can",
"failureCreateNewAccount": "create a new account",
"failureOr": "or",
"backHome": "go back to the home page",
"loginLink": "Log in",
"listsLink": "View my lists"
},
"unconfirmedBanner":{
"message":"Email not verified"
},
"pwdResetRequest": {
"title": "Reset Password",
"formBtn":"Send",
"pb": "An error occurred while submitting the form.",
"message": "Message: ",
"successMessage": "Sent! If your email is in our database, you will receive an email with a link to change your password.",
"linkValidity": "This link will expire in 30 minutes.",
"seeU": "See you soon!"
},
"pwdReset":{
"title":"Reset your password",
"pwdLabel1":"New password",
"pwdLabel2":"Confirm new password",
"formBtn":"Send",
"successMessage":"Your password has been successfully reset. You can now access your account by visiting the login page:",
"loginLink":"Login",
"errorMessage":"An error occurred while resetting your password. Please try again."
},
"profile":{
"title":"Profile",
"tabGeneral":"General",
"tabRedZone": "RED ZONE",
"username":"Username",
"name" : "Display name:",
"email": "Email:",
"avatar": "Avatar"
},
"emailUpdate":{
"modale":{
"title":"Email Change",
"text1":"A validation email will be sent to the new address.",
"text2":"The change will only take effect after confirmation.",
"confBtn":"Confirm",
"cancelBtn":"Cancel"
}
}
}

144
i18n/locales/fr.json Normal file
View File

@@ -0,0 +1,144 @@
{
"nav":{
"home":"Accueil",
"login": "Connexion",
"logout":"Déconnexion",
"signup": "S'enregistrer",
"profile": "Profil",
"lists": "Mes listes"
},
"index":{
"title": "Bienvenue sur le génial gestionnaire de listes !",
"subtitle1":"Présentation",
"mainText": "<p>Créez vos listes de lieux, de mots en langue étrangère (comme une carte recto / verso), ou simplement une todo-list. Amusez-vous c'est très utile !</p><p>Le code de ce projet est <a href=\"https://github.com/Raffiskender\" target=\"_blank\">disponible ici</a>.</p><p>Sur cette 3ème version, le back office est toujours géré par 2 plugins WP MAIS ! Leur architecture a été complètement repensée. Ainsi le plugin de gestion des users via REST API est plus léger, et communique avec le plugin de gestion des listes via hooks ou filtres.</p><p>Le front est en Nuxt JS.</p>",
"lastSentenceUnconnect": "Pour commencer il faut {login} ou {signup} ou encore...",
"login":"vous connecter",
"signup":"vous enregistrer",
"lastSentenceConnected": "Vous êtes connecté, vous pouvez",
"seeLists":"acceder à vos listes"
},
"googleBtnTxt":"Se connecter avec Google",
"loginPage":{
"passwordResetRequest":"Mot de passe oublié."
},
"loginTitle":"Connexion",
"loginLabelUser":"Identifiant ou e-mail",
"loginLabelPwd":"Mot de passe",
"loginErrorPwdEmpty":"Vous devez saisir un mot de passe",
"loginErrorLoginEmpty":"Vous devez saisir un identifiant",
"loginErrorLoginFailed":"Identifiants incorrects",
"loginErrorUnconfirmedUser":"Vous ne pouvez pas vous connecter car votre email n'a pas été vérifié !",
"loginFormBtn":"Se connecter",
"loginGoogle":"Ou prenez le raccourcis...",
"signupTitle":"S'enregistrer",
"signupLabelEmail":"E-mail",
"signupLabelPwd":"Mot de passe",
"signupLabelConfirmPwd":"Confirmez le mot de passe",
"signupErrorEmailEmpty":"Vous devez saisir un mail",
"signupErrorEmailIsntValid":"Email invalide",
"signupEmailIsValid":"Email validé",
"signupFormBtn":"Envoyer",
"modalTitle":"Inscription réussie !",
"modalText":"Vous pouvez fermer cette fenêtre et commencer à gérer vos listes",
"passwordCheckerShallContain":"Doit contenir",
"passwordChecker8-22":"entre 8 et 22 caractères,",
"passwordCheckerUppercase":"une majuscule,",
"passwordCheckerLowercase":"une minuscule",
"passwordCheckerNb":"un chiffre,",
"passwordCheckerSpecialChar":"un caractère spécial",
"pwdInput":{
"errorPwdEmpty":"Vous devez saisir un mot de passe",
"errorPwdIsntValid":"Vous devez choisir un mot de passe plus fort",
"pwdsDoesNotMatch":"Les 2 mots de passe ne correspondent pas !",
"pwdConfirmEmpty":"Vous devez saisir la confirmation du mot de passe !"
},
"confirmation": {
"title": "Page de confirmation",
"successConnected": "Votre email a été confirmé ! Vous êtes connecté.",
"successNotConnected": "Votre email a été confirmé ! Veuillez vous connecter pour continuer :",
"failureMessage": "Votre email n'a pas pu être confirmé.",
"failureCauseInvalid":"Cause : Invalide",
"failureCauseExpired":"Cause : Lien expiré",
"failureYouCan":"Vous pouvez",
"failureCreateNewAccount":"créer un nouveau compte",
"failureOr":"ou",
"backHome":"revenir à l'accueil.",
"loginLink": "Se connecter",
"listsLink": "Voir mes listes"
},
"unconfirmedBanner":{
"message":"Email non vérifié"
},
"pwdResetRequest": {
"title": "Demande de réinitialisation du mot de passe",
"formBtn":"Envoyer",
"pb": "Une erreur est survenue lors de l'envoi du formulaire.",
"message": "Message : ",
"successMessage": "C'est envoyé ! Si votre adresse est enregistrée dans notre base, vous recevrez un e-mail contenant un lien pour modifier votre mot de passe.",
"linkValidity": "Ce lien expirera dans 30 minutes.",
"seeU": "À très vite !"
},
"pwdReset":{
"title":"Réinitialisation du mot de passe",
"pwdLabel1":"Nouveau mot de passe",
"pwdLabel2":"Confirmez le mot de passe",
"formBtn":"Envoyer",
"successMessage":"Votre mot de passe a bien été réinitialisé. Vous pouvez maintenant vous connecter en cliquant sur le lien suivant :",
"loginLink":"Se connecter",
"errorMessage":"Une erreur est survenue lors de la mise à jour du mot de passe. Veuillez réessayer."
},
"profile":{
"title":"Profil",
"tabGeneral":"Général",
"tabRedZone": "RED ZONE",
"username":"Username",
"name_label" : "Nom affiché : ",
"email_label": "Email : ",
"avatar": "Avatar",
"danger_zone":"ZONE DE DANGER !",
"pwd_challenge_input_label":"Mot de passe",
"delete_account":"Suppression du compte"
},
"emailUpdate":{
"modale":{
"title":"Changement d'email",
"text1":"Un email de validation va être envoyé à la nouvelle adresse.",
"text2":"Le changement ne sera effectif quaprès confirmation.",
"confBtn":"Envoyer",
"cancelBtn":"Annuler"
}
},
"delete":{
"modale":{
"title":"Suppression du compte",
"text1": "Êtes-vous certains de vouloir supprimer votre compte ? Toutes vos données seront effacées. Cette action est IRRÉVERSIBLE !"
},
"modale2":{
"title":"Si vous y tenez...",
"text1": "Un email de validation vous a été envoyé. Merci de valider l'opération en cliquant sur le lien qu'il contient."
}
},
"ui":{
"yes":"Oui",
"no":"Non",
"ok":"Ok",
"confirm":"Confirmer",
"cancel":"Annuler",
"errorWrongPwd":"Mot de passe erroné",
"errorPwdEmpty":"Entrez un mot de passe"
}
}

52
nuxt.config.ts Normal file
View File

@@ -0,0 +1,52 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
devtools: { enabled: true },
runtimeConfig: {
public: {
apiBase: 'http://localhost:81/wp-json/app/v1'
}
},
modules: ['@pinia/nuxt',
// '@nuxtjs/tailwindcss'
'@nuxtjs/i18n',
'nuxt-vue3-google-signin'
],
pinia: {},
i18n: {
strategy: 'prefix',
defaultLocale: 'fr',
langDir: 'locales',
customRoutes: 'config',
locales: [
{ code: 'fr', iso: 'fr-FR', name: 'Français', file:'fr.json' },
{ code: 'en', iso: 'en-US', name: 'English', file:'en.json' }
],
compilation: {
strictMessage: false, // autorise HTML dans les messages
escapeHtml: false // néchappe pas le HTML
}
// pages: {
// login: {
// fr: '/connexion',
// en: '/login'
// },
// signin: {
// fr: '/senregistrer',
// en: '/signin'
// }
// }
},
googleSignIn: {
clientId: '364175735286-8o0djchefske6j2ofgsibhsb7t4jk4l7.apps.googleusercontent.com',
},
css: ['@/assets/css/main.scss'],
routeRules: {
'/api-wp/**': { proxy: 'http://localhost:81/wp-json/app/v1/**',
changeOrigin: true, // Crucial pour éviter la 502
prependPath: true
}
},
})

14253
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "nuxt_project",
"type": "module",
"private": true,
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-brands-svg-icons": "^7.1.0",
"@fortawesome/free-regular-svg-icons": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/vue-fontawesome": "^3.1.3",
"@nuxtjs/i18n": "^10.2.1",
"@nuxtjs/tailwindcss": "^6.14.0",
"@pinia/nuxt": "^0.11.3",
"nuxt": "^4.2.2",
"nuxt-vue3-google-signin": "^0.0.13",
"pinia": "^3.0.4",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"vue3-google-signin": "^2.1.1"
},
"devDependencies": {
"sass-embedded": "^1.97.2"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"files": [],
"references": [
{
"path": "./.nuxt/tsconfig.app.json"
},
{
"path": "./.nuxt/tsconfig.server.json"
},
{
"path": "./.nuxt/tsconfig.shared.json"
},
{
"path": "./.nuxt/tsconfig.node.json"
}
]
}