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