merge dev branch

This commit is contained in:
isUnknown 2025-09-09 17:28:45 +02:00
commit 58fbacaa52
25 changed files with 765 additions and 104 deletions

View file

@ -23,11 +23,9 @@ build:
paths:
- node_modules/
deploy:
.deploy_template: &deploy_template
stage: deploy
image: node:latest
only:
- main
before_script:
- apt-get update -qq && apt-get install -y rsync sshpass
script:

View file

@ -28,11 +28,14 @@ return [
],
'routes' => [
require(__DIR__ . '/routes/logout.php'),
require(__DIR__ . '/routes/login.php'),
require(__DIR__ . '/routes/toggle-favorite.php'),
require(__DIR__ . '/routes/upload-images.php'),
require(__DIR__ . '/routes/save-page.php'),
require(__DIR__ . '/routes/save-file.php'),
require(__DIR__ . '/routes/remove-file.php'),
require(__DIR__ . '/routes/update-password.php'),
require(__DIR__ . '/routes/update-email.php'),
require(__DIR__ . '/routes/upload-pdf.php'),
require(__DIR__ . '/routes/validate-brief.php'),
require(__DIR__ . '/routes/request-project-creation.php'),

View file

@ -0,0 +1,35 @@
<?php
return [
"pattern" => "login.json",
"method" => "POST",
"action" => function () {
$json = file_get_contents("php://input");
$data = json_decode($json);
$kirby = kirby();
$email = $data->email;
$password = $data->password;
if(V::email($email)) {
try {
$kirby->auth()->login($email, $password, true);
return json_encode([
"status" => "success",
"role" => (string) $kirby->user()->role()
]);
} catch (Exception $e) {
return json_encode([
"status" => "error",
"message" => "<strong>Email ou mot de passe invalide.</strong><br>Contactez l'administrateur pour demander la réinitialisation de vos informations de connexion."
]);
}
} else {
return json_encode([
"status" => "error",
"message" => "<strong>Email invalide.</strong>"
]);
}
},
];

View file

@ -10,6 +10,6 @@ return [
session_start();
}
go(site()->panel()->url());
go(site()->url() . '/login');
},
];

View file

@ -0,0 +1,22 @@
<?php
return [
'pattern' => '/update-email.json',
'method' => 'POST',
'action' => function() {
$json = file_get_contents("php://input");
$data = json_decode($json);
try {
kirby()->user()->changeEmail($data->email);
return [
'status' => 'success'
];
} catch (\Throwable $th) {
return [
'status' => 'error',
'message' => 'Impossible de mettre à jour l\'email : ' . $th->getMessage() . ' in file ' . $th->getFile() . ' line ' . $th->getLine()
];
}
}
];

View file

@ -0,0 +1,22 @@
<?php
return [
'pattern' => '/update-password.json',
'method' => 'POST',
'action' => function() {
$json = file_get_contents("php://input");
$data = json_decode($json);
try {
kirby()->user()->changePassword($data->password);
return [
'status' => 'success'
];
} catch (\Throwable $th) {
return [
'status' => 'error',
'message' => 'Impossible de mettre à jour le mot de passe : ' . $th->getMessage() . ' in file ' . $th->getFile() . ' line ' . $th->getLine()
];
}
}
];

View file

@ -1,27 +1,46 @@
<?php
return function ($page, $kirby, $site) {
if (!$kirby->user()) {
go($site->panel()->url());
if (!$kirby->user() && $page->uri() !== 'login') {
go('/login');
}
$data = $page->toArray();
$data['template'] = (string) $page->template();
$data['newInspirations'] = (bool) page('inspirations')->children()->findBy('new', 'true');
$userData = [
"role" => (string) $kirby->user()->role(),
"uuid" => (string) $kirby->user()->uuid()
];
if ($kirby->user()->client()->exists() && $kirby->user()->client()->isNotEmpty()) {
$userData['client'] = [
"name" => (string) $kirby->user()->client()->toPage()->title(),
"uuid" => (string) $kirby->user()->client()->toPage()->uuid()
if ($kirby->user()) {
$userData = [
"name" => (string) $kirby->user()->name()->or(null),
"email" => (string) $kirby->user()->email(),
"role" => (string) $kirby->user()->role(),
"uuid" => (string) $kirby->user()->uuid()
];
if ($kirby->user()->client()->exists() && $kirby->user()->client()->isNotEmpty()) {
$userData['client'] = [
"name" => (string) $kirby->user()->client()->toPage()->title(),
"uuid" => (string) $kirby->user()->client()->toPage()->uuid()
];
if ($kirby->user()->client()->toPage()->logo()->isNotEmpty()) {
$userData['client']["logo"] = $kirby->user()->client()->toPage()->logo()->toFile()->url();
}
}
if ($kirby->user()->projects()->exists() && $kirby->user()->projects()->isNotEmpty()) {
$userData['projects'] = $kirby->user()->projects()->toPages()->map(function ($project) {
return [
"title" => (string) $project->title(),
"uri" => (string) $project->uri(),
"step" => (string) $project->getStepLabel(),
];
})->data;
}
}
return [
'genericData' => $data,
'userData' => $userData
'userData' => $userData ?? null
];
};

View file

@ -108,7 +108,7 @@ class ProjectPage extends NotificationsPage {
];
foreach ($track->views()->toFiles() as $view) {
$trackData['files'][] = getFileData($view);
$trackData['files'][] = getFileData($view, true);
}
$files['dynamic'][] = $trackData;

View file

@ -1,10 +1,12 @@
<?php
function getFileData($file) {
function getFileData($file, $preserveQuality = false) {
if (!$file) return null;
$data = [
'modified' => $file->modified('YYYY-MM-dd'),
'url' => $file->thumb([
'url' => $preserveQuality ? $file->thumb([
'format' => 'webp'
])->url() : $file->thumb([
'width' => 1000,
'quality' => 80,
'format' => 'webp'

View file

@ -0,0 +1,6 @@
<?php
echo json_encode([
"page" => $genericData,
"user" => $userData
]);

View file

@ -0,0 +1 @@
<?php snippet('generic-template') ?>

View file

@ -0,0 +1,13 @@
<?php
$specificData = [
"exampleField" => $page->exampleField(),
"exampleHardData" => 'Example hard value'
];
$pageData = array_merge($genericData, $specificData);
echo json_encode([
"page" => $pageData,
"user" => $userData
]);

View file

@ -0,0 +1 @@
<?php snippet('generic-template') ?>

View file

@ -1,7 +1,15 @@
<?php
if (!$kirby->user()) {
return json_encode([
'page' => $genericData,
'user' => []
]);
}
function getProjectData($project)
{
{
$data = [
'title' => $project->title()->value(),
'url' => $project->url(),

View file

@ -1,7 +1,7 @@
<template>
<h1 v-if="page" class="sr-only">{{ page.content.title }}</h1>
<div class="with-sidebar">
<Menu />
<div :class="{ 'with-sidebar': page?.template !== 'login' }">
<Menu v-if="isLogged" />
<RouterView />
</div>
</template>
@ -11,6 +11,7 @@ import { storeToRefs } from 'pinia';
import Menu from './components/Menu.vue';
import { usePageStore } from './stores/page';
import { detect } from 'detect-browser';
import { useUserStore } from './stores/user';
const browser = detect();
@ -20,4 +21,5 @@ if (browser) {
).dataset.browser = `${browser.name} ${browser.version} ${browser.os}`;
}
const { page } = storeToRefs(usePageStore());
const { isLogged } = storeToRefs(useUserStore());
</script>

View file

@ -294,6 +294,25 @@ input[type="checkbox"]:checked::before {
button {
cursor: pointer;
}
.field {
position: relative;
}
[aria-invalid="true"]:focus-visible {
outline-color: #ef8d8d;
}
button[aria-controls*="password"] {
position: absolute;
right: var(--space-8);
bottom: var(--space-8);
max-height: unset;
height: 2rem;
padding: var(--space-8);
border: none;
}
button[aria-controls*="password"] > svg {
width: 1rem;
height: 1rem;
}
/* General interactive states */

View file

@ -78,10 +78,15 @@ input[type="checkbox"]:checked + .btn--primary {
--btn-color: var(--color-white);
}
.btn--white {
.btn--white,
.btn--white[aria-pressed="true"],
.btn--white[aria-pressed="true"]:focus-visible {
--btn-background: var(--color-white);
--btn-color: var(--color-grey-700);
}
.btn--white[aria-pressed="true"]:focus-visible {
outline: transparent;
}
.btn--white-10 {
--btn-background: var(--color-white-10);
--btn-color: var(--color-white-80);
@ -168,11 +173,19 @@ input[type="checkbox"]:checked + .btn--primary {
.btn:hover {
--btn-background: var(--color-primary-hover);
}
.btn[aria-pressed="true"]:hover {
--btn-background: var(--color-primary-hover);
--btn-border-color: var(--color-primary-hover);
}
.btn--primary:hover {
--btn-background: var(--color-primary-10);
}
.btn--white:hover {
--btn-background: var(--color-black-10);
}
.btn--white[aria-pressed="true"]:hover {
--btn-background: var(--color-white);
--btn-border-color: transparent;
}
.btn--white-10:hover {
--btn-background: var(--color-white-05);
@ -192,10 +205,6 @@ input[type="checkbox"]:checked + .btn--primary {
.btn--transparent:hover {
--btn-background: var(--color-white-05);
}
.btn[aria-pressed="true"]:hover {
--btn-background: var(--color-primary-hover);
--btn-border-color: var(--color-primary-hover);
}
.btn--image:hover {
--btn-background: var(--color-primary-10);
}

View file

@ -15,7 +15,7 @@
xmlns="http://www.w3.org/2000/svg"
>
<title id="menu-toggle">
{{ isExpanded ? "Masquer le menu" : "Afficher le menu" }}
{{ isExpanded ? 'Masquer le menu' : 'Afficher le menu' }}
</title>
<path
d="M10.6751 2.625L3.00007 10.3125C2.94028 10.3686 2.89263 10.4364 2.86005 10.5116C2.82748 10.5869 2.81067 10.668 2.81067 10.75C2.81067 10.832 2.82748 10.9131 2.86005 10.9884C2.89263 11.0636 2.94028 11.1314 3.00007 11.1875L10.6751 18.875M17.1876 2.625L9.50007 10.3125C9.38555 10.4293 9.32141 10.5864 9.32141 10.75C9.32141 10.9136 9.38555 11.0707 9.50007 11.1875L17.1876 18.875"
@ -53,13 +53,13 @@
<span
v-if="mainItem.title === 'Inspirations' && page?.newInspirations"
class="pill pill--secondary"
>{{ "Nouveautés" }}</span
>{{ 'Nouveautés' }}</span
>
</li>
</ul>
<details :class="{ skeleton: !currentProjects.length }" open>
<details :class="{ skeleton: !currentProjects }" open>
<summary>Projets en cours</summary>
<ul v-if="currentProjects.length">
<ul v-if="currentProjects.length > 0">
<li
v-for="project in currentProjects"
:class="{ active: isCurrent(project) }"
@ -95,7 +95,11 @@
<footer class="w-full">
<ul class="flex">
<li data-icon="user">
<a href="/panel/account" @click="collapse()">Profil</a>
<a
:href="user.role === 'admin' ? '/panel/account' : '/account'"
@click="collapse()"
>Profil</a
>
</li>
<li data-icon="logout">
<a href="/logout" @click="collapse()">Déconnexion</a>
@ -106,13 +110,13 @@
</template>
<script setup>
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import { useProjectsStore } from "../stores/projects";
import { useRoute } from "vue-router";
import { useUserStore } from "../stores/user";
import { usePageStore } from "../stores/page";
import { useProjectStore } from "../stores/project";
import { storeToRefs } from 'pinia';
import { computed, ref } from 'vue';
import { useProjectsStore } from '../stores/projects';
import { useRoute } from 'vue-router';
import { useUserStore } from '../stores/user';
import { usePageStore } from '../stores/page';
import { useProjectStore } from '../stores/project';
const route = useRoute();
const isExpanded = ref(true);
@ -132,30 +136,30 @@ const unreadNotificationsCount = computed(() => {
const mainItems = [
{
title: "Home",
path: "/",
icon: "home",
title: 'Home',
path: '/',
icon: 'home',
},
{
title: "Notifications",
path: "/notifications",
icon: "megaphone",
title: 'Notifications',
path: '/notifications',
icon: 'megaphone',
},
{
title: "Réunions",
path: "/reunions",
icon: "calendar",
title: 'Réunions',
path: '/reunions',
icon: 'calendar',
disabled: true,
},
{
title: "Design to Light",
path: "/design-to-light",
icon: "leaf",
title: 'Design to Light',
path: '/design-to-light',
icon: 'leaf',
},
{
title: "Inspirations",
path: "/inspirations",
icon: "inspiration",
title: 'Inspirations',
path: '/inspirations',
icon: 'inspiration',
},
];
@ -176,7 +180,7 @@ function hasUnreadNotification(project) {
if (!user.value) return false;
return notifications.value.some((notification) => {
return (
notification.isread != "true" &&
notification.isread != 'true' &&
project.uri.includes(notification.location.project.uri)
);
});
@ -190,31 +194,31 @@ function collapse() {
</script>
<style>
button[aria-controls="menu"] {
button[aria-controls='menu'] {
position: fixed;
z-index: 101;
width: 2.5rem;
height: 2.5rem;
}
button[aria-controls="menu"] svg {
button[aria-controls='menu'] svg {
width: 100%;
height: 100%;
}
button[aria-controls="menu"][aria-expanded="true"] {
button[aria-controls='menu'][aria-expanded='true'] {
margin-top: 1rem;
padding: 0.625rem; /* 10px */
left: var(--gutter);
transform: translateX(calc(var(--sidebar-width) - 100% - 1rem));
}
button[aria-controls="menu"][aria-expanded="false"] {
button[aria-controls='menu'][aria-expanded='false'] {
min-width: 3.5rem;
min-height: 3.5rem;
padding: 1.125rem;
transform: rotate(180deg);
}
button[aria-controls="menu"][aria-expanded="false"]
button[aria-controls='menu'][aria-expanded='false']
+ main
> header:not([role="tablist"]) {
> header:not([role='tablist']) {
margin-left: 4rem;
width: calc(100% - 4rem);
}
@ -238,7 +242,7 @@ button[aria-controls="menu"][aria-expanded="false"]
}
@media (max-width: 1023px) {
button[aria-controls="menu"][aria-expanded="true"] {
button[aria-controls='menu'][aria-expanded='true'] {
left: 0;
margin-top: 0.4rem;
}
@ -252,8 +256,8 @@ button[aria-controls="menu"][aria-expanded="false"]
height: 100vh;
padding-top: 2.5rem;
}
button[aria-controls="menu"][aria-expanded="true"] + #menu::before {
content: "";
button[aria-controls='menu'][aria-expanded='true'] + #menu::before {
content: '';
position: fixed;
top: 0;
right: 0;
@ -281,7 +285,7 @@ button[aria-controls="menu"][aria-expanded="false"]
z-index: 1;
}
#menu header::before {
content: "";
content: '';
display: block;
position: absolute;
top: -1rem;
@ -350,7 +354,7 @@ button[aria-controls="menu"][aria-expanded="false"]
align-items: center;
}
#menu li a::before {
content: "";
content: '';
position: absolute;
inset: 0;
z-index: 1;
@ -386,7 +390,7 @@ button[aria-controls="menu"][aria-expanded="false"]
width: fit-content;
}
#menu details .new::after {
content: "" / "Nouvelles modifications";
content: '' / 'Nouvelles modifications';
color: transparent;
position: relative;
display: inline-block;

View file

@ -1,9 +1,8 @@
import { createWebHistory, createRouter } from "vue-router";
import routes from "./routes";
import { useApiStore } from "../stores/api";
import { usePageStore } from "../stores/page";
import { useUserStore } from "../stores/user";
import { getActivePinia } from "pinia";
import { createWebHistory, createRouter } from 'vue-router';
import routes from './routes';
import { useApiStore } from '../stores/api';
import { usePageStore } from '../stores/page';
import { useUserStore } from '../stores/user';
const router = createRouter({
history: createWebHistory(),
@ -11,14 +10,17 @@ const router = createRouter({
});
router.beforeEach(async (to, from, next) => {
const pinia = getActivePinia();
const api = useApiStore(pinia);
const pageStore = usePageStore(pinia);
const userStore = useUserStore(pinia);
const pageStore = usePageStore();
const userStore = useUserStore();
const api = useApiStore();
try {
const res = await api.fetchData(to.path);
if (to.path === '/login' && res.user) {
location.href = '/';
}
pageStore.page = res.page;
userStore.user = res.user;
next();

View file

@ -1,60 +1,72 @@
import Home from "../views/Home.vue";
import Notifications from "../views/Notifications.vue";
import Reunions from "../views/Reunions.vue";
import Inspirations from "../views/Inspirations.vue";
import Kanban from "../views/Kanban.vue";
import Brief from "../views/Brief.vue";
import DesignToLight from "../views/DesignToLight.vue";
import Home from '../views/Home.vue';
import Notifications from '../views/Notifications.vue';
import Reunions from '../views/Reunions.vue';
import Inspirations from '../views/Inspirations.vue';
import Kanban from '../views/Kanban.vue';
import Brief from '../views/Brief.vue';
import DesignToLight from '../views/DesignToLight.vue';
import Login from '../views/Login.vue';
import Account from '../views/Account.vue';
const routes = [
{
path: "/",
path: '/',
component: Home,
},
{
path: "/notifications",
name: 'Login',
path: '/login',
component: Login,
},
{
name: 'Account',
path: '/account',
component: Account,
},
{
path: '/notifications',
component: Notifications,
},
{
path: "/reunions",
path: '/reunions',
component: Reunions,
},
{
path: "/inspirations",
path: '/inspirations',
component: Inspirations,
},
{
path: "/design-to-light",
path: '/design-to-light',
component: DesignToLight,
},
{
path: "/projects/:id",
path: '/projects/:id',
component: Kanban,
},
{
path: "/projects/:id/client-brief",
path: '/projects/:id/client-brief',
component: Brief,
},
{
path: "/projects/:id/extended-brief",
path: '/projects/:id/extended-brief',
component: Brief,
},
// Redirections
{
path: "/projects/:id/industrial-ideation",
path: '/projects/:id/industrial-ideation',
redirect: (to) => {
return (
"/projects/" +
'/projects/' +
to.params.id +
"?dialog=industrial-ideation&comments=true"
'?dialog=industrial-ideation&comments=true'
);
},
},
{
path: "/projects/:id/proposal",
path: '/projects/:id/proposal',
redirect: (to) => {
return "/projects/" + to.params.id + "?dialog=proposal&comments=true";
return '/projects/' + to.params.id + '?dialog=proposal&comments=true';
},
},
];

View file

@ -4,7 +4,7 @@ import { ref, computed } from 'vue';
export const useProjectsStore = defineStore('projects', () => {
const isProjectsLoading = ref(true);
const projects = ref([]);
const projects = ref(null);
const currentProjects = computed(() => {
return projects.value

View file

@ -1,10 +1,14 @@
import { defineStore, storeToRefs } from "pinia";
import { ref, computed } from "vue";
import { useProjectsStore } from "./projects";
import { defineStore, storeToRefs } from 'pinia';
import { ref, computed } from 'vue';
import { useProjectsStore } from './projects';
export const useUserStore = defineStore("user", () => {
export const useUserStore = defineStore('user', () => {
const user = ref(null);
const isLogged = computed(() => {
return user.value?.hasOwnProperty('role');
});
const { projects } = storeToRefs(useProjectsStore());
const notifications = computed(() => {
@ -22,7 +26,7 @@ export const useUserStore = defineStore("user", () => {
});
function readNotification(notificationId, projectId) {
console.log("Read notification", notificationId, projectId);
console.log('Read notification', notificationId, projectId);
projects.value = projects.value.map((project) => ({
...project,
notifications:
@ -57,6 +61,7 @@ export const useUserStore = defineStore("user", () => {
return {
user,
isLogged,
notifications,
readNotification,
readAllNotifications,

332
src/views/Account.vue Normal file
View file

@ -0,0 +1,332 @@
<template>
<main class="flex flex-col items-stretch | w-full">
<header
v-if="user.name"
class="flex | bg-white rounded-2xl | p-8"
style="height: 3.5rem"
>
<h1 class="font-serif | px-8">{{ user.name }}</h1>
</header>
<div class="auto-grid" style="--min: 20rem">
<section
:class="{ 'is-editing': isEditingEmail }"
class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="email-label"
>
<h2 class="sr-only" id="email-label">Email</h2>
<div
class="field | flow w-full"
role="group"
aria-labelledby="username"
>
<label for="username" class="text-grey-700">Email</label>
<input
v-if="isEditingEmail"
type="email"
v-model="email"
id="username"
placeholder="mail@exemple.com"
autocomplete="username"
class="w-full rounded-md border border-grey-200 px-16 py-12 mt-8"
:class="{ invalid: !isEmailValid }"
required
@keyup.escape="isEditingEmail = false"
@keyup.enter="isEmailValid ? updateEmail() : false"
/>
<input
v-else
type="email"
:value="user.email"
id="username"
class="w-full rounded-md border border-grey-200 px-16 py-12 mt-8"
disabled
/>
<button
v-if="isEditingEmail"
class="btn | w-full text-md"
@click="updateEmail"
:disabled="!isEmailValid"
>
{{ emailBtn.text }}
</button>
<button
v-else
@click="isEditingEmail = true"
class="btn | w-full text-md"
>
Modifier
</button>
<button
v-if="isEditingEmail"
class="btn btn--secondary | w-full text-md"
@click="isEditingEmail = false"
>
Annuler
</button>
</div>
</section>
<section
:class="{ 'is-editing': isEditingEmail }"
class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="password-label"
>
<h2 class="sr-only" id="password-label">Mot de passe</h2>
<div class="flow">
<div class="field | w-full" role="group" aria-labelledby="password">
<label for="password" class="text-grey-700"
>Nouveau mot de passe</label
>
<input
:type="isPasswordVisible ? 'text' : 'password'"
v-model="password"
id="password"
placeholder="Minimum 8 caractères"
autocomplete="current-password"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="w-full rounded-md border border-grey-200 px-16 py-12 mt-8"
:class="{ invalid: password.length < 8 }"
required
/>
<button
class="btn btn--white btn--icon"
type="button"
:aria-pressed="isPasswordVisible ? 'true' : 'false'"
aria-controls="password"
@click="isPasswordVisible = !isPasswordVisible"
:title="
isPasswordVisible
? 'Masquer le mot de passe'
: 'Afficher le mot de passe'
"
>
<svg
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
fill="currentColor"
>
<path
v-if="isPasswordVisible"
d="M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z"
></path>
<path
v-else
d="M17.8827 19.2968C16.1814 20.3755 14.1638 21.0002 12.0003 21.0002C6.60812 21.0002 2.12215 17.1204 1.18164 12.0002C1.61832 9.62282 2.81932 7.5129 4.52047 5.93457L1.39366 2.80777L2.80788 1.39355L22.6069 21.1925L21.1927 22.6068L17.8827 19.2968ZM5.9356 7.3497C4.60673 8.56015 3.6378 10.1672 3.22278 12.0002C4.14022 16.0521 7.7646 19.0002 12.0003 19.0002C13.5997 19.0002 15.112 18.5798 16.4243 17.8384L14.396 15.8101C13.7023 16.2472 12.8808 16.5002 12.0003 16.5002C9.51498 16.5002 7.50026 14.4854 7.50026 12.0002C7.50026 11.1196 7.75317 10.2981 8.19031 9.60442L5.9356 7.3497ZM12.9139 14.328L9.67246 11.0866C9.5613 11.3696 9.50026 11.6777 9.50026 12.0002C9.50026 13.3809 10.6196 14.5002 12.0003 14.5002C12.3227 14.5002 12.6309 14.4391 12.9139 14.328ZM20.8068 16.5925L19.376 15.1617C20.0319 14.2268 20.5154 13.1586 20.7777 12.0002C19.8603 7.94818 16.2359 5.00016 12.0003 5.00016C11.1544 5.00016 10.3329 5.11773 9.55249 5.33818L7.97446 3.76015C9.22127 3.26959 10.5793 3.00016 12.0003 3.00016C17.3924 3.00016 21.8784 6.87992 22.8189 12.0002C22.5067 13.6998 21.8038 15.2628 20.8068 16.5925ZM11.7229 7.50857C11.8146 7.50299 11.9071 7.50016 12.0003 7.50016C14.4855 7.50016 16.5003 9.51488 16.5003 12.0002C16.5003 12.0933 16.4974 12.1858 16.4919 12.2775L11.7229 7.50857Z"
></path>
</svg>
<span class="sr-only">{{
isPasswordVisible
? 'Masquer le mot de passe'
: 'Afficher le mot de passe'
}}</span>
</button>
</div>
<div
class="field | w-full"
role="group"
aria-labelledby="password-confirm"
>
<label for="passwordConfirm" class="text-grey-700"
>Confirmez le nouveau mot de passe</label
>
<input
:type="isPasswordVisible ? 'text' : 'password'"
v-model="passwordConfirm"
id="password-confirm"
placeholder="Minimum 8 caractères"
autocomplete="current-password"
autocapitalize="none"
autocorrect="off"
spellcheck="false"
class="w-full rounded-md border border-grey-200 px-16 py-12 mt-8"
:class="{ invalid: !isPasswordConfirmed }"
required
/>
<button
class="btn btn--white btn--icon"
type="button"
:aria-pressed="isPasswordVisible ? 'true' : 'false'"
aria-controls="password-confirm"
@click="isPasswordVisible = !isPasswordVisible"
:title="
isPasswordVisible
? 'Masquer le mot de passe'
: 'Afficher le mot de passe'
"
>
<svg
aria-hidden="true"
focusable="false"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="16"
height="16"
fill="currentColor"
>
<path
v-if="isPasswordVisible"
d="M12.0003 3C17.3924 3 21.8784 6.87976 22.8189 12C21.8784 17.1202 17.3924 21 12.0003 21C6.60812 21 2.12215 17.1202 1.18164 12C2.12215 6.87976 6.60812 3 12.0003 3ZM12.0003 19C16.2359 19 19.8603 16.052 20.7777 12C19.8603 7.94803 16.2359 5 12.0003 5C7.7646 5 4.14022 7.94803 3.22278 12C4.14022 16.052 7.7646 19 12.0003 19ZM12.0003 16.5C9.51498 16.5 7.50026 14.4853 7.50026 12C7.50026 9.51472 9.51498 7.5 12.0003 7.5C14.4855 7.5 16.5003 9.51472 16.5003 12C16.5003 14.4853 14.4855 16.5 12.0003 16.5ZM12.0003 14.5C13.381 14.5 14.5003 13.3807 14.5003 12C14.5003 10.6193 13.381 9.5 12.0003 9.5C10.6196 9.5 9.50026 10.6193 9.50026 12C9.50026 13.3807 10.6196 14.5 12.0003 14.5Z"
></path>
<path
v-else
d="M17.8827 19.2968C16.1814 20.3755 14.1638 21.0002 12.0003 21.0002C6.60812 21.0002 2.12215 17.1204 1.18164 12.0002C1.61832 9.62282 2.81932 7.5129 4.52047 5.93457L1.39366 2.80777L2.80788 1.39355L22.6069 21.1925L21.1927 22.6068L17.8827 19.2968ZM5.9356 7.3497C4.60673 8.56015 3.6378 10.1672 3.22278 12.0002C4.14022 16.0521 7.7646 19.0002 12.0003 19.0002C13.5997 19.0002 15.112 18.5798 16.4243 17.8384L14.396 15.8101C13.7023 16.2472 12.8808 16.5002 12.0003 16.5002C9.51498 16.5002 7.50026 14.4854 7.50026 12.0002C7.50026 11.1196 7.75317 10.2981 8.19031 9.60442L5.9356 7.3497ZM12.9139 14.328L9.67246 11.0866C9.5613 11.3696 9.50026 11.6777 9.50026 12.0002C9.50026 13.3809 10.6196 14.5002 12.0003 14.5002C12.3227 14.5002 12.6309 14.4391 12.9139 14.328ZM20.8068 16.5925L19.376 15.1617C20.0319 14.2268 20.5154 13.1586 20.7777 12.0002C19.8603 7.94818 16.2359 5.00016 12.0003 5.00016C11.1544 5.00016 10.3329 5.11773 9.55249 5.33818L7.97446 3.76015C9.22127 3.26959 10.5793 3.00016 12.0003 3.00016C17.3924 3.00016 21.8784 6.87992 22.8189 12.0002C22.5067 13.6998 21.8038 15.2628 20.8068 16.5925ZM11.7229 7.50857C11.8146 7.50299 11.9071 7.50016 12.0003 7.50016C14.4855 7.50016 16.5003 9.51488 16.5003 12.0002C16.5003 12.0933 16.4974 12.1858 16.4919 12.2775L11.7229 7.50857Z"
></path>
</svg>
<span class="sr-only">{{
isPasswordVisible
? 'Masquer le mot de passe'
: 'Afficher le mot de passe'
}}</span>
</button>
</div>
<button
class="btn | w-full text-md"
:class="'btn--' + passwordBtn.status"
:disabled="!isPasswordConfirmed ? true : undefined"
@click="updatePassword"
>
{{ passwordBtn.text }}
</button>
</div>
</section>
<section
class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="client-label"
>
<h2 id="client-label" class="text-grey-700 mb-16">
{{ user.client ? 'Client' : 'Pas de client associé' }}
</h2>
<div class="flex" style="--column-gap: 2rem">
<template v-if="user.client">
<img
:src="user.client.logo"
:alt="'logo' + user.client.name"
class="rounded-md"
width="72"
height="72"
/>
<p class="font-serif text-lg">{{ user.client.name }}</p>
</template>
</div>
</section>
<section
v-if="user.hasOwnProperty('projects')"
class="bg-white rounded-2xl px-16 py-24"
aria-labelledby="projects-label"
>
<h2 class="sr-only" id="projects-label">Projets</h2>
<p class="text-grey-700 mb-16">Nombre de projets</p>
<p class="text-xl">{{ Object.values(user.projects).length }}</p>
</section>
</div>
</main>
</template>
<script setup>
import { storeToRefs } from 'pinia';
import { useUserStore } from '../stores/user';
import { computed, ref, watch } from 'vue';
const { user } = storeToRefs(useUserStore());
// Email
const email = ref('');
const emailBtn = ref({
text: 'Mettre à jour',
status: 'ready',
});
const isEditingEmail = ref(false);
const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
const isEmailValid = computed(() => {
return emailRegex.test(email.value);
});
async function updateEmail() {
emailBtn.value.text = 'En cours…';
emailBtn.value.status = 'pending';
const headers = {
method: 'POST',
body: JSON.stringify({
email: email.value,
}),
};
const response = await fetch('update-email.json', headers);
const json = await response.json();
if (json.status === 'success') {
emailBtn.value.text = 'Mise à jour réussie';
emailBtn.value.status = 'succeed';
setTimeout(() => {
emailBtn.value.text = 'Mettre à jour';
emailBtn.value.status = 'ready';
isEditingEmail.value = false;
}, 1500);
} else {
console.log(json.message);
}
}
// Password
const password = ref('');
const passwordBtn = ref({
text: 'Mettre à jour',
status: 'ready',
});
const passwordConfirm = ref('');
const isPasswordVisible = ref(false);
const isPasswordConfirmed = computed(() => {
return (
passwordConfirm.value.length > 7 && passwordConfirm.value === password.value
);
});
async function updatePassword() {
passwordBtn.value.text = 'en cours…';
passwordBtn.value.status = 'pending';
const headers = {
method: 'POST',
body: JSON.stringify({
password: password.value,
}),
};
const response = await fetch('update-password.json', headers);
const json = await response.json();
if (json.status === 'success') {
password.value = '';
passwordConfirm.value = '';
passwordBtn.value.text = 'mise à jour réussie';
passwordBtn.value.status = 'succeed';
setTimeout(() => {
passwordBtn.value.text = 'Mettre à jour';
passwordBtn.value.status = 'ready';
}, 1500);
} else {
console.log(json.message);
}
}
</script>
<style scoped>
input.invalid:focus-visible {
outline: 2px solid #ef8d8d;
}
.btn--pending {
background-color: #ffdb88;
}
.btn--succeed {
background-color: rgb(182, 211, 255);
}
</style>

View file

@ -6,7 +6,7 @@
@close="isProjectRequestDialogOpen = false"
/>
<ProjectRequestButton
v-if="user.role === 'client'"
v-if="user?.role === 'client'"
@click="isProjectRequestDialogOpen = true"
/>
</main>

146
src/views/Login.vue Normal file

File diff suppressed because one or more lines are too long