refactor: move frontend in apps/frontend

This commit is contained in:
Giovanni committed 2026-10-03 18:26:34 +02:00
1 parent dc39113335
commit 31304c931d
225 files changed
+67 -65

No files matched your search

@@ -0,0 +1,123 @@
.backgroundContainer {
z-index: 0;
margin: 0;
padding: 0;
min-height: 100vh;
overflow: hidden;
position: relative;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
.backgroundLight {
background: #00F0FF;
}
.backgroundDark {
background: #001C83;
}
.circle {
position: absolute;
border-radius: 50%;
opacity: 0.8;
z-index: 1;
}
.circle-1 {
width: 600px;
height: 600px;
background-color: #019AFF;
top: -10%;
left: -10%;
}
.circle-2 {
width: 250px;
height: 250px;
background-color: #91FCFF;
top: 51%;
left: 10%;
}
.circle-3 {
width: 280px;
height: 280px;
background-color: #91FCFF;
top: 28%;
right: 9%;
}
.circle-4 {
width: 500px;
height: 500px;
background-color: #019AFF;
bottom: 0;
right: 0;
}
.circle-5 {
width: 240px;
height: 240px;
background-color: #015AFF;
top: 2%;
right: 2%;
}
.circle-1,.circle-2, .circle-3, .circle-4, .circle-5 {
animation: float 6s ease-in-out infinite;
}
.circle {
animation: float 6s ease-in-out infinite;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0);
}
50% {
transform: translate(var(--random-x), var(--random-y));
}
}
@media (max-width:600px) {
.circle-1 {
width: 300px;
height: 300px;
top: -5%;
left: -5%;
}
.circle-2 {
width: 175px;
height: 175px;
top: 58%;
left: 10%;
}
.circle-3 {
width: 140px;
height: 140px;
top: 29%;
right: 9%;
}
.circle-4 {
width: 250px;
height: 250px;
bottom: 0;
right: 0;
}
.circle-5 {
width: 240px;
height: 240px;
top: 2%;
right: 10%;
}
}
+79
View File
@@ -0,0 +1,79 @@
import styles from "./Background.module.css";
import { useEffect, useRef, useState, ReactNode } from "react";
interface BackgroundProps {
children?: ReactNode;
className?: string;
}
const Background = ({ children, className }: BackgroundProps) => {
const circlesRef = useRef<(HTMLDivElement | null)[]>([]);
const [theme, setTheme] = useState("light");
const getRandomTranslation = (): { x: number; y: number } => {
const randomX = Math.floor(Math.random() * 100) - 30;
const randomY = Math.floor(Math.random() * 100) - 30;
return { x: randomX, y: randomY };
};
useEffect(() => {
circlesRef.current.forEach((circle) => {
if (circle) {
const { x, y } = getRandomTranslation();
circle.style.setProperty('--random-x', `${x}px`);
circle.style.setProperty('--random-y', `${y}px`);
}
});
}, []);
useEffect(() => {
const updateTheme = () => {
const t = localStorage.getItem("theme") || "light";
setTheme(t);
document.body.classList.toggle("dark", t === "dark");
};
updateTheme();
window.addEventListener("theme-changed", updateTheme);
return () => {
window.removeEventListener("theme-changed", updateTheme);
};
}, []);
return (
<div className={`${styles.backgroundContainer} ${className || ''} ${theme === "light" ? styles.backgroundLight : styles.backgroundDark}`}>
<div
ref={(el) => { circlesRef.current[0] = el; }}
className={`${styles["circle"]} ${styles["circle-1"]}`}
style={{ "--random-x": "0px", "--random-y": "0px" } as React.CSSProperties}
></div>
<div
ref={(el) => { circlesRef.current[1] = el; }}
className={`${styles["circle"]} ${styles["circle-2"]}`}
style={{ "--random-x": "0px", "--random-y": "0px" } as React.CSSProperties}
></div>
<div
ref={(el) => { circlesRef.current[2] = el; }}
className={`${styles["circle"]} ${styles["circle-3"]}`}
style={{ "--random-x": "0px", "--random-y": "0px" } as React.CSSProperties}
></div>
<div
ref={(el) => { circlesRef.current[3] = el; }}
className={`${styles["circle"]} ${styles["circle-4"]}`}
style={{ "--random-x": "0px", "--random-y": "0px" } as React.CSSProperties}
></div>
<div
ref={(el) => { circlesRef.current[4] = el; }}
className={`${styles["circle"]} ${styles["circle-5"]}`}
style={{ "--random-x": "0px", "--random-y": "0px" } as React.CSSProperties}
></div>
{children}
</div>
);
};
export default Background;
@@ -0,0 +1,21 @@
import Button from "../ui/Button/Button";
import { useContext } from "react";
import { AuthContext, AuthContextType } from "../../contexts/Auth/AuthContext";
import { APP_CONFIG } from "../../config/appConfig";
export default function ExportCalendarBtn() {
const { user } = useContext(AuthContext) as AuthContextType;
const calendarUrl = `${import.meta.env.VITE_API_URL}/api/export/ics/${user?.id}`;
return <Button
variant="default"
onClick={() => {
window.open(
`${APP_CONFIG.newCalendarURL}${calendarUrl}`
);
}}
>
Exporter le calendrier
</Button>
}
+69
View File
@@ -0,0 +1,69 @@
.filterContainer {
display: flex;
flex-direction: column;
gap: 0.4rem;
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.5s ease, opacity 0.8s ease, transform 0.5s ease;
}
.filterOpen {
max-height: 200px;
opacity: 1;
transform: translateY(0);
}
.filterClose{
max-height: 200px;
opacity: 0;
display: none;
}
.filterTag {
border: none;
background: none;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 45px;
height: 45px;
}
.filterTag:focus {
outline: none;
}
.filterTag:hover {
cursor: pointer;
}
.active {
border: 2px solid #019AFF;
}
@media screen and (max-width: 768px){
.filterContainer {
flex-direction: row;
flex-wrap: wrap;
}
.filterOpen{
display: flex;
}
.filterClose{
display: none;
}
.filterMenu {
width: 100%;
}
abbr {
width: fit-content;
}
}
+78
View File
@@ -0,0 +1,78 @@
import styles from "./Filter.module.css";
import Button from "../ui/Button/Button";
import React from "react";
type FiltersType = {
alphabetical: "asc" | "desc" | null;
yearOrder: "asc" | "desc" | null;
[key: string]: any;
};
type FilterProps = {
isFilterVisible: boolean;
filters: FiltersType;
setFilters: React.Dispatch<React.SetStateAction<FiltersType>>;
};
function Filter({ isFilterVisible, filters, setFilters }: FilterProps): any {
const setAlphabeticalOrder = (order: "asc" | "desc"): void => {
setFilters((prev) => ({
...prev,
alphabetical: prev.alphabetical === order ? null : order
}));
};
const setYearOrder = (order: "asc" | "desc"): void => {
setFilters((prev) => ({
...prev,
yearOrder: prev.yearOrder === order ? null : order
}));
};
return (
<div className={`${styles.filterContainer} ${isFilterVisible ? styles.filterOpen : styles.filterClose}`}>
<abbr title="Tri alphabétique croissant (A à Z)">
<Button
className={`${styles.filterTag} glassCard ${filters.alphabetical !== "asc" ? "" : styles.active}`}
variant={"default"}
onClick={() => setAlphabeticalOrder("asc")}
>
<img src={"sortByAlpha.svg"} alt="Tri alphabétique croissant (A à Z)" />
</Button>
</abbr>
<abbr title="Tri alphabétique décroissant (Z à A)">
<Button
className={`${styles.filterTag} glassCard ${filters.alphabetical !== "desc" ? "" : styles.active}`}
variant={"default"}
onClick={() => setAlphabeticalOrder("desc")}
>
<img src={"sortByAntiAlpha.svg"} alt="Tri alphabétique décroissant (Z à A)" />
</Button>
</abbr>
<abbr title="Tri chronologique décroissant (du plus récent au plus ancien)">
<Button
className={`${styles.filterTag} glassCard ${filters.yearOrder !== "desc" ? "" : styles.active}`}
variant={"default"}
onClick={() => setYearOrder("desc")}
>
<img src={"timerArrowUp.svg"} alt="Tri chronologique décroissant (du plus récent au plus ancien)" />
</Button>
</abbr>
<abbr title="Tri chronologique croissant (du plus ancien au plus récent)">
<Button
className={`${styles.filterTag} glassCard ${filters.yearOrder !== "asc" ? "" : styles.active}`}
variant={"default"}
onClick={() => setYearOrder("asc")}
>
<img src={"timerArrowDown.svg"} alt="Tri chronologique croissant (du plus ancien au plus récent)" />
</Button>
</abbr>
</div>
);
}
export default Filter;
+76
View File
@@ -0,0 +1,76 @@
.footer {
width: 100%;
padding: 1.5rem 2rem;
margin-top: auto;
}
.footerContent {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.footerNav {
display: flex;
align-items: center;
}
.link {
color: black !important;
text-decoration: none;
font-size: 0.95rem;
transition: color 0.2s ease;
padding: 0 !important;
}
.link:hover {
color: #019AFF !important;
}
.separator {
width: 1px;
height: 16px;
background-color: black;
margin: 0 1rem;
}
.copyright {
color: black;
font-size: 0.9rem;
white-space: nowrap;
}
.section{
margin-top: 2rem;
display: flex;
flex-direction: column;
gap: 2rem;
}
@media screen and (max-width: 768px) {
.footerContent {
flex-direction: column-reverse;
gap: 1rem;
text-align: center;
}
.footerNav {
flex-wrap: wrap;
justify-content: center;
}
.link {
margin: 0.5rem 1rem;
}
.separator {
display: none;
}
}
+63
View File
@@ -0,0 +1,63 @@
import styles from "./Footer.module.css"
import { Link } from "react-router";
import { ReactNode, useState } from "react";
import Modal from "../ui/Modal/Modal";
import Button from "../ui/Button/Button";
import scrollToTop from "../../utils/scrollToTop.js";
export default function Footer(): ReactNode{
const currentYear = new Date().getFullYear();
const [isCopyrightModal, setIsCopyrightModal] = useState<boolean>(false);
return <>
<footer className={`${styles.footer} glassCard`}>
<div className={styles.footerContent}>
<Button
variant={"transparent"}
className={styles.link}
onClick={() => setIsCopyrightModal(true)}
>
Copyright © {currentYear}. Tous droits réservés.
</Button>
<div className={styles.copyright}>
</div>
<nav className={styles.footerNav}>
<Link to="/rgpd" className={styles.link} onClick={scrollToTop}>
Fiche RGPD
</Link>
<span className={styles.separator}></span>
<Link to="/legalNotices" className={styles.link} onClick={scrollToTop}>
Mentions légales
</Link>
<span className={styles.separator}></span>
<Link to={"https://comite.beaupont.fr/contactez-nous"} className={styles.link}>
Contact
</Link>
<span className={styles.separator}></span>
<Link to="/" className={styles.link} onClick={scrollToTop}>
Accueil
</Link>
</nav>
</div>
</footer>
<Modal title={"Réalisé par"} open={isCopyrightModal} onClose={() => setIsCopyrightModal(false)}>
<section className={styles.section}>
<div className={styles.inputContainer}>
<p>Quentin T'JAMPENS</p>
<p>Antoine ORDONNEAU</p>
<p>Malo LEBLOND</p>
<p>
Giovanni JOSSERAND (
<Link to={"https://linktree.josserand.ovh/"}>linktree</Link>
)
</p>
</div>
</section>
</Modal>
</>
}
+248
View File
@@ -0,0 +1,248 @@
.headerContainer {
position: relative;
height: fit-content;
overflow: visible;
}
.header{
display: flex;
align-items: center;
padding: 0.8rem;
height: fit-content;
z-index: 2;
position: relative;
justify-content: space-between;
}
.profileImg{
width:40px;
height:40px;
border-radius: 50%;
}
.activeHeaderContent {
display: flex;
justify-content: space-between;
width: 100%;
opacity: 1;
transition: opacity 0.3s ease;
pointer-events: auto;
}
.inactiveHeaderContent{
display: none;
}
.inactiveMobileMenu{
display: none;
}
.activeMobileMenu{
height: fit-content;
display: flex;
flex-direction: column;
align-items: start;
gap: 0.5rem;
}
.headerLeftContent {
display: flex;
width: fit-content;
align-items: center;
gap: 1.5rem;
font-size: 1.2rem;
}
.headerRightContent {
width: fit-content;
display: flex;
align-items: center;
}
.logo {
height: 4rem;
width: auto;
flex-shrink: 0;
}
.logoLink {
display: inline-flex;
align-items: center;
justify-content: center;
width: fit-content;
height: fit-content;
flex-shrink: 0;
}
.link {
color: #000000;
width: fit-content;
text-align: center;
font-weight: bold;
border-bottom: 2px solid transparent;
}
.link:hover{
border-bottom: 2px solid #019AFF;
}
.activeLink {
width: fit-content;
text-align: center;
font-weight: bold;
color: #019AFF;
border-bottom: 2px solid #019AFF;
}
.bellBtn, .profileLink {
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: transform 0.5s ease;
background: none;
padding: 7px;
border-radius: 50%;
width: 55px;
height: 55px;
position: relative;
}
.notificationsImg {
stroke: black;
stroke-width: 3.5;
stroke-linecap: round;
stroke-linejoin: round;
height: 28px;
}
.bellBtn:focus {
outline: none;
}
.bellBtn:focus-visible {
outline: 2px solid black;
}
.activeBellBtn {
transform: translateY(-4px);
background: rgba(191, 191, 191, 0.5);
}
.bellBtn:hover {
transform: translateY(-4px);
background: rgba(191, 191, 191, 0.5);
}
.profileLink:hover {
transform: translateY(-4px);
background: rgba(191, 191, 191, 0.5);
}
.profileImg {
height: 40px;
}
.notificationDiv {
position: absolute;
width: auto;
top: 105%;
right: 2rem;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
z-index: 0;
max-height: 60vh;
overflow: scroll;
overflow-x: hidden;
backdrop-filter: blur(10px);
}
.activeNotificationDiv {
opacity: 1;
transform: translateY(0);
z-index: 5;
}
.notification{
padding: 0.5em 1em;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 15px;
}
.notificationBadge {
position: absolute;
top: 6px;
right: 9px;
width: 15px;
height: 15px;
background-color: red;
border-radius: 50%;
}
.closeBtn:focus {
outline: none;
}
.closeBtn:focus-visible {
outline: 2px solid black;
}
.burgerBtn {
flex-direction: column;
justify-content: space-between;
width: 30px;
height: 22px;
border: none;
cursor: pointer;
display: none;
z-index: 10;
background: none;
}
.burgerBar {
width: 100%;
height: 3px;
background-color: #000000;
border-radius: 2px;
transition: all 0.3s ease;
}
.activeBurgerBtn .burgerBar:nth-child(1) {
transform: translateY(10px) rotate(45deg);
}
.activeBurgerBtn .burgerBar:nth-child(2) {
opacity: 0;
}
.activeBurgerBtn .burgerBar:nth-child(3) {
transform: translateY(-9px) rotate(-45deg);
}
.navLinks{
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
}
@media screen and (max-width: 768px) {
.navLinks {
display: none;
}
.burgerBtn {
display: flex;
}
}
+199
View File
@@ -0,0 +1,199 @@
import { Link, NavLink } from "react-router";
import { useContext, useEffect, useRef, useState } from "react";
import styles from "./Header.module.css"
import getUserNotifications from "../../utils/notifications/getUserNotifications.js";
import deleteNotificationUser from "../../utils/notifications/deleteNotificationUser.js";
import initEcho from "../../utils/echo/initEcho.js"
import readNotifications from "../../utils/notifications/readNotifications.js";
import { userNotificationsListener } from "../../utils/echo/listeners/userNotificationsListener.js";
import NotificationCard from "../NotificationCard/NotificationCard";
import { EventContext } from "../../contexts/Events/EventContext.js";
import { AuthContext } from "../../contexts/Auth/AuthContext.js";
import { PendingMembersContext } from "../../contexts/PendingMembers/PendingMembersContext";
import Notification from "../../interfaces/notification.interface";
function Header() {
const { update, user } = useContext(AuthContext)!;
const { updateEvent } = useContext(EventContext)!;
const { updatePendingMembers } = useContext(PendingMembersContext) as unknown as { updatePendingMembers: () => void };
const [notificationMenu, setnotificationMenu] = useState<boolean>(false);
const [unreadNotification, setunreadNotification] = useState<boolean>(false);
const [mobileMenu, setMobileMenu] = useState<boolean>(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const notificationRef = useRef<HTMLDivElement>(null);
const profileImageUrl = user?.profile_photo_path
? `${import.meta.env.VITE_API_URL}/storage/${user.profile_photo_path}`
: "/person.svg";
useEffect(() => {
if (!user) return;
updateEvent();
let echoInstance: ReturnType<typeof initEcho> | null = null;
async function initializeHeader() {
try {
const echo = initEcho();
echoInstance = echo;
userNotificationsListener(echo, user, setNotifications, setunreadNotification, updatePendingMembers);
const notifData = await getUserNotifications();
const data: Notification[] = notifData || [];
setNotifications(data);
const hasUnread = data.some(n => n.read_at === null);
setunreadNotification(hasUnread);
} catch (err) {
console.error("Erreur initialisation Header:", err);
}
}
initializeHeader();
const handleClickOutside = (event: MouseEvent) => {
if (
notificationRef.current &&
!notificationRef.current.contains(event.target as Node) &&
!(event.target as Element).closest(`.${styles.bellBtn}`)
) {
setnotificationMenu(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
if (echoInstance) {
echoInstance.disconnect();
}
};
}, []);
const toggleNotificationMenu = async () => {
try {
setnotificationMenu(!notificationMenu);
if (unreadNotification) {
const result = await readNotifications();
if (result) {
setunreadNotification(false);
update();
}
}
} catch (err) {
console.error('Erreur pour ouvrir/fermer le menu de notifications :', err);
}
}
async function deleteNotification(notificationId: string) {
try {
const result = await deleteNotificationUser(notificationId);
if (result) {
setNotifications(prev => prev.filter(n => n.id !== notificationId));
update();
}
} catch (err) {
console.error('Erreur suppression notification :', err);
}
}
return (
<div className={styles.headerContainer}>
<header className={`${styles.header} glassCard`}>
<nav className={`${mobileMenu ? styles.inactiveHeaderContent : styles.activeHeaderContent}`}>
<div className={styles.headerLeftContent}>
<NavLink className={styles.logoLink} to="/">
<img src="/logo.png" alt="logo" className={styles.logo} />
</NavLink>
<div className={styles.navLinks}>
<NavLink to="/" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Accueil
</NavLink>
<NavLink to="/events" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Evènements
</NavLink>
<NavLink to="/volunteers" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Bénévoles
</NavLink>
{user?.isAdmin && (
<NavLink to="/admin" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Administration
</NavLink>
)}
</div>
</div>
<div className={styles.headerRightContent}>
<button
className={`${styles.bellBtn} ${notificationMenu ? styles.activeBellBtn : ""}`}
onClick={() => { toggleNotificationMenu(); }}
>
<img className={styles.notificationsImg} src="/bell.svg" alt="notifications" />
{unreadNotification && <span className={styles.notificationBadge}></span>}
</button>
<Link to="/profile" className={styles.profileLink}>
<img
className={styles.profileImg}
src={profileImageUrl}
alt="profile"
/>
</Link>
</div>
</nav>
<nav className={`${mobileMenu ? styles.activeMobileMenu : styles.inactiveMobileMenu}`}>
<NavLink to="/" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Accueil
</NavLink>
<NavLink to="/events" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Evènements
</NavLink>
<NavLink to="/volunteers" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Bénévoles
</NavLink>
{user?.isAdmin && (
<NavLink to="/admin" className={({ isActive }) => isActive ? styles.activeLink : styles.link}>
Administration
</NavLink>
)}
</nav>
<button
className={`${styles.burgerBtn} ${mobileMenu ? styles.activeBurgerBtn : ""}`}
onClick={() => setMobileMenu(!mobileMenu)}
>
<div className={styles.burgerBar}></div>
<div className={styles.burgerBar}></div>
<div className={styles.burgerBar}></div>
</button>
</header>
<div
ref={notificationRef}
className={`${styles.notificationDiv} glassCard ${notificationMenu ? styles.activeNotificationDiv : ""}`}
>
{notifications.length === 0 ? (
<p className={styles.notification}>Vous n'avez aucune notification !</p>
) : (
<NotificationCard notifications={notifications} deleteNotification={deleteNotification} />
)}
</div>
</div>
);
}
export default Header;
@@ -0,0 +1,45 @@
.notificationCard {
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
transition: background 0.2s ease;
margin: 0.5rem 0;
}
.cardHeader {
display: flex;
justify-content: space-between;
align-items: center;
}
.timeTag {
color: #019AFF;
letter-spacing: 0.2px;
}
.message {
margin: 0;
font-size: 1.1rem;
line-height: 1.5;
color: #2c3e50;
font-weight: 400;
}
.closeIconButton {
background: none;
border: none;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1.1rem;
color: #2c3e50;
}
.closeIconButton:hover {
color: #cc0000;
}
@@ -0,0 +1,38 @@
import formatDate from "../../utils/date/formatDate.js";
import formatTime from "../../utils/date/formatTime.js";
import styles from "./NotificationCard.module.css"
import Notification from "../../interfaces/notification.interface";
interface NotificationCardProps {
notifications: Notification[];
deleteNotification: (id: string) => void;
}
function NotificationCard({ notifications, deleteNotification }: NotificationCardProps) {
return (
notifications.map((notification, index) => (
<div className={`${styles.notificationCard} glassBorder`} key={index}>
<div className={styles.cardHeader}>
<span className={styles.timeTag}>
Le {formatDate(notification.created_at)} à {formatTime(notification.created_at)}
</span>
<button
className={styles.closeIconButton}
onClick={() => deleteNotification(notification.id)}
title="Supprimer"
>
✕
</button>
</div>
<div>
<p className={styles.message}>
{notification.data.message}
</p>
</div>
</div>
))
);
}
export default NotificationCard;
@@ -0,0 +1,87 @@
.searchBarContainer{
position: fixed;
top: 20vh;
left: 50%;
transform: translateX(-50%);
z-index: 100;
width: 60%;
background: #fff;
padding: 0.5rem;
}
.searchBarForm{
display: flex;
}
.elementList {
max-height: 60vh;
overflow-y: auto;
}
.eventImageDiv {
display: flex;
align-items: center;
justify-content: center;
margin-right: 1rem;
width: auto;
height: 2rem;
flex-shrink: 0;
}
.searchedEvent{
display: flex;
padding: 0.5rem;
align-items: center;
}
.searchedEvent:hover {
background: #afafaf;
cursor: pointer;
}
.searchedEvent p {
display: flex;
align-items: center;
text-decoration: none;
color: black;
}
.lineContainer{
display: flex;
justify-content: center;
align-items: center;
}
.line{
height: 0;
width: 96%;
border: #606060 solid 1px;
margin: 0.5rem 0;
}
.eventImage {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 20px;
}
.searchBarForm input{
background: none;
box-shadow: none;
height: 2em;
}
.searchBarForm input:focus{
outline: none;
}
@media screen and (max-width: 480px) {
.searchBarContainer{
width: 80%;
}
}
+120
View File
@@ -0,0 +1,120 @@
import styles from "./SearchBar.module.css";
import { Link } from "react-router";
import { useEffect, useState } from "react";
import searchEvents from "../../utils/events/searchEvents";
import searchUsers from "../../utils/users/searchUsers";
import { useLocation } from "react-router";
function SearchBar(): any {
const [searchQuery, setSearchQuery] = useState<string>("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const location: any = useLocation();
useEffect(() => {
const performSearch = async (query: string): Promise<void> => {
if (query.trim() === "") {
setSearchResults([]);
return;
}
let data: any;
if (location.pathname.includes("events")) {
data = await searchEvents(query);
} else if (location.pathname.includes("volunteers")) {
data = await searchUsers(query);
}
if (data) {
setSearchResults(data);
} else {
setSearchResults([]);
}
};
const handler: ReturnType<typeof setTimeout> = setTimeout(() => {
performSearch(searchQuery);
}, 300);
return () => {
clearTimeout(handler);
};
}, [searchQuery, location.pathname]);
return (
<div className={`${styles.searchBarContainer} glassCard`}>
<form
className={styles.searchBarForm}
onSubmit={(element: React.FormEvent<HTMLFormElement>) =>
element.preventDefault()
}
>
<img src="search.svg" alt="Rechercher" />
<input
type="text"
placeholder="Rechercher un événement"
value={searchQuery}
onChange={(element: React.ChangeEvent<HTMLInputElement>) =>
setSearchQuery(element.target.value)
}
/>
</form>
<div className={styles.lineContainer}>
{searchResults.length > 0 ? (
<div className={styles.line}></div>
) : null}
</div>
<div className={styles.elementList}>
{searchResults.map((result: any, i: number) =>
location.pathname.includes("events") ? (
<Link to={`/events/` + result.id} key={i}>
<div className={styles.searchedEvent}>
<div className={styles.eventImageDiv}>
{result.image == null ? (
<img
src="empty.png"
className={styles.eventImage}
alt="Pas de photo de l'événement"
/>
) : (
<img
src={result.image + `.png`}
alt="Photo de l'événement"
/>
)}
</div>
<p>{result.name}</p>
</div>
</Link>
) : location.pathname.includes("volunteers") ? (
<Link to={`/profile/` + result.id} key={i}>
<div className={styles.searchedEvent}>
<div className={styles.eventImageDiv}>
{result.image == null ? (
<img
src="empty.png"
className={styles.eventImage}
alt="Pas de photo de l'événement"
/>
) : (
<img
src={result.image + `.png`}
alt="Photo de l'événement"
/>
)}
</div>
<p>
{result.name} {result.lastname}
</p>
</div>
</Link>
) : null
)}
</div>
</div>
);
}
export default SearchBar;
+21
View File
@@ -0,0 +1,21 @@
.task {
width: 100%;
padding: 12px;
margin-bottom: 10px;
}
.content {
display: flex;
justify-content: space-between;
flex-direction: column;
}
.desc {
font-size: 0.8rem;
color: #666;
margin: 0 0 0 0;
}
.date {
font-size: 0.8rem;
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import getEventById from "../../utils/events/getEventById";
import styles from "./Task.module.css";
import formatDate from "../../utils/date/formatDate";
interface TaskType {
id: number;
name: string;
description: string;
location: string;
start: string;
end: string;
max_participants: number;
events_id: number;
created_at: string;
updated_at: string;
pivot: {
user_id: number;
task_id: number;
};
}
interface EventType {
id: number;
name: string;
description?: string;
location?: string;
start?: string;
end?: string;
created_at?: string;
updated_at?: string;
}
interface TaskProps {
task: TaskType;
}
const Task: React.FC<TaskProps> = ({ task }) => {
const [event, setEvent] = useState<EventType | null>(null);
useEffect(() => {
const fetchEvent = async (): Promise<void> => {
try {
const e = await getEventById(task.events_id);
setEvent(e.data as EventType);
} catch (error) {
console.error("Erreur lors du chargement de l'événement", error);
}
};
fetchEvent();
}, [task.events_id]);
return (
<div className={`${styles.task} glassBorder`}>
<div className={styles.content}>
<div>
<h2>
{event?.name} - {task.name}
</h2>
<p className={styles.desc}>{task.description}</p>
<p className={styles.date}>
{formatDate(task.start)}
</p>
</div>
</div>
</div>
);
};
export default Task;
+101
View File
@@ -0,0 +1,101 @@
.eventsButtons {
overflow: visible;
position: fixed;
top: 10rem;
right: 2rem;
display: flex;
flex-direction: column;
align-items: flex-end;
width: fit-content;
height: fit-content;
gap: 1rem;
z-index: 4;
}
.filterContainer{
padding: 0;
border-radius: 30px;
overflow: visible;
display: flex;
align-items: center;
flex-direction: column;
}
.filterMenu{
padding: 5px 5px 5px 5px;
width: fit-content;
}
.createButton, .searchButton, .sortButton {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50% !important;
width: 55px !important;
height: 55px !important;
transition: all 0.5s ease;
}
.createButton:hover, .searchButton:hover, .mobileFilter:hover {
transform: translateY(-5px);
cursor: pointer;
}
.searchBarOverlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 100;
background-color: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(4px);
}
.searchBarContainer{
display: flex;
justify-content: center;
}
.sortButton:focus, .searchButton:focus {
outline: none;
}
@media screen and (max-width: 768px) {
.createButton,
.searchButton,
.sortButton {
flex: 0 0 55px;
width: 55px !important;
height: 55px !important;
}
.eventsButtons {
flex-direction: row;
position: relative;
top: 0;
right: 0;
align-items: flex-start;
width: 100%;
margin: 6px 0 16px 0;
}
.filterContainer{
width: fit-content;
flex-direction: row;
}
.filterMenu{
width: fit-content;
}
.createButton:hover, .searchButton:hover, .mobileFilter:hover {
transform: none !important;
}
}
+30
View File
@@ -0,0 +1,30 @@
import styles from "./ToolBar.module.css"
import CreateEventBtn from "./tools/CreateEventBtn/CreateEventBtn";
import SearchBtn from "./tools/SearchBtn";
import FilterBtn from "./tools/FilterBtn";
interface Filters {
alphabetical: "asc" | "desc";
yearOrder: "asc" | "desc";
}
interface ToolBarProps {
setFilters: React.Dispatch<React.SetStateAction<Filters>>;
filters: Filters;
showCreate?: boolean;
}
function ToolBar({ setFilters, filters, showCreate = true }: ToolBarProps) {
return (
<div>
<div className={styles.eventsButtons}>
{showCreate && <CreateEventBtn />}
<SearchBtn />
<FilterBtn setFilters={setFilters} filters={filters} />
</div>
</div>
);
}
export default ToolBar;
@@ -0,0 +1,62 @@
.createButton {
font-size: 1.5rem;
color: black;
}
.createButton {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50% !important;
width: 55px !important;
height: 55px !important;
transition: all 0.5s ease;
}
.createButton:hover {
transform: translateY(-5px);
cursor: pointer;
}
.inputContainer {
margin: 0.5em;
display: flex;
flex-direction: column;
align-items: center;
}
.input {
width: 70%;
}
.create {
align-self: center;
}
.section {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 15px;
margin-bottom: 15px;
}
.dateInputContainer {
display: flex;
flex-direction: row;
gap: 40px;
}
.inputContainer h3{
font-size: 20px;
}
@media screen and (max-width: 900px) {
.dateInputContainer {
flex-direction: column;
gap: 15px;
}
}
@@ -0,0 +1,65 @@
import styles from "./CreateEventBtn.module.css"
import { useState, useContext } from "react";
import Modal from "../../../ui/Modal/Modal";
import Button from "../../../ui/Button/Button";
import TextInput from "../../../ui/Input/Input";
import { EventContext } from "../../../../contexts/Events/EventContext.js";
import { AuthContext } from "../../../../contexts/Auth/AuthContext";
export default function CreateEventBtn() {
const { addEvent } = useContext(EventContext)!;
const { user } = useContext(AuthContext)!;
const [isOpen, setIsOpen] = useState<boolean>(false);
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [start, setStart] = useState<string>("");
const [end, setEnd] = useState<string>("");
const handleCreateEvent = async () => {
if (name !== "" && description !== "" && start !== "" && end !== "") {
await addEvent(name, description, start, end);
}
setIsOpen(false);
}
if(!user?.isAdmin) return null;
return <>
<Button className={`${styles.createButton} glassCard`} variant={"default"} onClick={() => setIsOpen(true)}> + </Button>
<Modal title={"Créer un événement"} open={isOpen} onClose={() => setIsOpen(false)}>
<section className={styles.section}>
<div className={styles.inputContainer}>
<h3>Titre</h3>
<TextInput className={styles.input} placeholder={"Nom de l'événement"} value={name} onChange={e => setName(e.target.value)} />
</div>
<div className={styles.inputContainer}>
<h3>Description</h3>
<TextInput className={styles.input} placeholder={"Description de l'événement"} value={description} onChange={e => setDescription(e.target.value)} />
</div>
<div className={styles.inputContainer}>
<h3>Date</h3>
<div className={styles.dateInputContainer}>
<div className={styles.dateInput}>
<p>Début</p>
<TextInput type={"datetime-local"} value={start} onChange={e => setStart(e.target.value)} />
</div>
<div className={styles.dateInput}>
<p>Fin</p>
<TextInput type={"datetime-local"} value={end} onChange={e => setEnd(e.target.value)} />
</div>
</div>
</div>
<Button onClick={handleCreateEvent} className={styles.create}>Créer</Button>
</section>
</Modal>
</>
}
+32
View File
@@ -0,0 +1,32 @@
import styles from "../ToolBar.module.css"
import Button from "../../ui/Button/Button";
import { useState } from "react";
import Filter from "../../Filter/Filter";
interface Filters {
alphabetical: "asc" | "desc";
yearOrder: "asc" | "desc";
}
interface FilterBtnProps {
setFilters: any
filters: Filters
}
export default function FilterBtn({ setFilters, filters }: FilterBtnProps) {
const [isFilterVisible, setIsFilterVisible] = useState(false);
const toggleFilters = () => {
setIsFilterVisible(prev => !prev);
};
return <div className={`${styles.filterContainer} ${isFilterVisible ? "glassCard" : ""}`}>
<Button className={`${isFilterVisible ? "" : styles.mobileFilter} ${styles.sortButton} glassCard`} variant={"default"} onClick={toggleFilters}>
<img src="filter.svg" alt="Filtrer"/>
</Button>
<div className={styles.filterMenu}>
<Filter isFilterVisible={isFilterVisible} filters={filters} setFilters={setFilters}/>
</div>
</div>
}
+38
View File
@@ -0,0 +1,38 @@
import styles from "../ToolBar.module.css"
import Button from "../../ui/Button/Button";
import { useEffect, useState } from "react";
import SearchBar from "../../SearchBar/SearchBar";
export default function SearchBtn() {
const [isSearchVisible, setIsSearchVisible] = useState(false);
useEffect(() => {
if (isSearchVisible) {
document.documentElement.style.overflow = 'hidden';
} else {
document.documentElement.style.overflow = '';
}
return () => {
document.documentElement.style.overflow = '';
};
}, [isSearchVisible]);
return <>
{isSearchVisible ? (
<div className={styles.searchBarContainer}>
<div className={styles.searchBarOverlay} onClick={() => setIsSearchVisible(false)}></div>
<SearchBar/>
</div>
) : null}
<div>
<Button className={`${styles.searchButton} glassCard`} variant={"default"} onClick={() => setIsSearchVisible(true)}>
<img src="search.svg" alt="Rechercher"/>
</Button>
</div>
</>
}
+38
View File
@@ -0,0 +1,38 @@
.btn {
padding: 0.2rem 0.8rem 0.2rem 0.8rem;
width: fit-content;
border-radius: 20px;
}
.btn:hover {
cursor: pointer;
filter: brightness(0.95);
}
.primary {
color: white;
background-color: #019AFF;
}
.danger {
color: white;
background-color: #FF073A;
}
.transparent {
color: #019AFF;
}
.default {
color: black;
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5),
inset 0 -1px 0 rgba(255, 255, 255, 0.1),
inset 0 0 8px 4px rgba(255, 255, 255, 0.4);
}
+44
View File
@@ -0,0 +1,44 @@
import { ReactNode, MouseEventHandler } from "react";
import styles from "./Button.module.css";
type ButtonVariant = "primary" | "danger" | "transparent" | "default";
interface ButtonProps {
children: ReactNode;
variant?: ButtonVariant;
onClick?: MouseEventHandler<HTMLDivElement>;
className?: string | undefined;
}
const Button = ({
children,
variant = "primary",
onClick,
className = "",
}: ButtonProps) => {
let btnStyle: string;
if (variant === "primary") { // @ts-ignore
btnStyle = styles.primary;
}
else if (variant === "danger") { // @ts-ignore
btnStyle = styles.danger;
}
else if (variant === "transparent") { // @ts-ignore
btnStyle = styles.transparent;
}
else { // @ts-ignore
btnStyle = styles.default;
}
return (
<div
className={`${styles.btn} ${btnStyle} ${className}`}
onClick={onClick}
>
{children}
</div>
);
};
export default Button;
+52
View File
@@ -0,0 +1,52 @@
.input {
border: 1px solid rgba(200, 200, 200, 0.3);
background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: #000;
font-size: 16px;
outline: none;
transition: all 0.3s ease;
width: fit-content;
padding: 8px 10px 8px 10px;
height: fit-content;
}
.input::placeholder {
color: rgba(0, 0, 0, 0.5);
}
.input:focus {
border-color: rgba(200, 200, 200, 0.6);
box-shadow: 0 4px 30px rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.38);
}
.rounded {
border-radius: 25px;
}
.square {
border-radius: 12px;
}
.passwordContainer {
position: relative;
width: 100%;
}
.passwordInput {
width: 100%;
}
.passwordBtn {
position: absolute;
width: 30px;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
font-size: 16px;
}
+64
View File
@@ -0,0 +1,64 @@
import styles from "./Input.module.css";
import { useState, ChangeEvent, InputHTMLAttributes } from "react";
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
placeholder?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
borderStyle?: "square" | "rounded";
password?: boolean;
className?: string;
}
const TextInput = ({
placeholder = "",
onChange,
value = "",
borderStyle = "square",
password = false,
className = "",
...props
}: TextInputProps) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
let inputBorderStyle = styles.square;
if (borderStyle === "rounded") inputBorderStyle = styles.rounded;
const toggleShowPassword = () => {
setShowPassword((prev) => !prev);
};
if (password) {
return (
<div className={styles.passwordContainer}>
<input
type={showPassword ? "text" : "password"}
value={value}
placeholder={placeholder}
onChange={onChange}
className={`${styles.input} ${styles.passwordInput} ${inputBorderStyle} ${className}`}
{...props}
/>
<button
type="button"
onClick={toggleShowPassword}
className={styles.passwordBtn}
>
{showPassword ? "👁️" : "🔒"}
</button>
</div>
);
}
return (
<input
className={`${styles.input} ${inputBorderStyle} ${className}`}
onChange={onChange}
placeholder={placeholder}
value={value}
{...props}
/>
);
};
export default TextInput;
+25
View File
@@ -0,0 +1,25 @@
.wrapper {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
min-height: 200px;
}
.spinner {
width: 48px;
height: 48px;
border: 5px solid #e0e0e0;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import styles from "./Loading.module.css";
const Loading: React.FC = () => {
return (
<div className={styles.wrapper}>
<div className={styles.spinner} />
</div>
);
};
export default Loading;
+33
View File
@@ -0,0 +1,33 @@
.overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.25);
display: flex;
justify-content: center;
z-index: 1000;
align-items: flex-start;
overflow-y: auto;
padding: 40px 0;
}
.modal {
border-radius: 20px;
min-width: 300px;
max-width: 70%;
max-height: none;
position: relative;
width: clamp(300px, 50vw, 600px);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
outline: none;
}
.modalFooter {
display: flex;
align-items: center;
justify-content: center;
margin-top: 10px;
}
+39
View File
@@ -0,0 +1,39 @@
import { createPortal } from "react-dom";
import styles from "./Modal.module.css";
import Button from "../Button/Button";
import { ReactNode, MouseEvent } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
children: ReactNode;
title?: string;
}
const Modal = ({ open, onClose, children, title }: ModalProps) => {
if (!open) return null;
const handleOverlayClick = () => {
onClose();
};
const handleModalClick = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
return createPortal(
<div className={styles.overlay} onClick={handleOverlayClick}>
<div className={`glassCard ${styles.modal}`} onClick={handleModalClick}>
{title && <h2>{title}</h2>}
{children}
<div className={styles.modalFooter}>
<Button variant={"default"} onClick={onClose}>Fermer</Button>
</div>
</div>
</div>,
document.body
);
};
export default Modal;
@@ -0,0 +1,12 @@
.themeBtn {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 5px;
}
.themeIcon {
height: 20px;
width: 20px;
}
@@ -0,0 +1,75 @@
/*
import Button from "../button/button.jsx";
import { useState, useEffect } from "react";
import styles from "./ThemeSwitcher.module.css";
export default function ThemeSwitcher() {
const [theme, setTheme] = useState("");
useEffect(() => {
const t = localStorage.getItem("theme");
if(t) setTheme(t);
else setTheme("light");
}, []);
const switchTheme = () => {
let newTheme = theme === "light" ? "dark" : "light";
if(theme === null || theme === "") newTheme = "dark";
localStorage.setItem("theme", newTheme);
setTheme(newTheme);
window.dispatchEvent(new Event("theme-changed"));
};
return <Button variant={"default"} onClick={switchTheme} className={styles.themeBtn}>
<img src={`/icons/theme/${theme}.svg`} alt={`${theme} icon`} className={styles.themeIcon} />
<p>Changer de thème </p>
</Button>
}
}*/
import Button from "../Button/Button";
import { useState, useEffect } from "react";
import styles from "./ThemeSwitcher.module.css";
type Theme = "light" | "dark";
export default function ThemeSwitcher() {
const [theme, setTheme] = useState<Theme | "">("");
useEffect(() => {
const savedTheme = localStorage.getItem("theme") as Theme | null;
if (savedTheme) {
setTheme(savedTheme);
} else {
setTheme("light");
}
}, []);
const switchTheme = () => {
const newTheme: Theme = theme === "light" ? "dark" : "light";
localStorage.setItem("theme", newTheme);
setTheme(newTheme);
window.dispatchEvent(new Event("theme-changed"));
};
return (
<Button
variant="default"
onClick={switchTheme}
className={styles.themeBtn}
>
<img
src={`/icons/theme/${theme}.svg`}
alt={`${theme} icon`}
className={styles.themeIcon}
/>
<p>Changer de thème</p>
</Button>
);
}
+5
View File
@@ -0,0 +1,5 @@
export const APP_CONFIG = {
contactEmail: "contact@example.com",
contactPhone: "+33 01 02 03 04 05",
newCalendarURL: "https://calendar.google.com/calendar/u/0/r/settings/addbyurl?cid="
}
+14
View File
@@ -0,0 +1,14 @@
import { createContext } from "react";
import User from "../../interfaces/user.interface"
export interface AuthContextType {
user: User | null;
loading: boolean;
update: () => void;
login: (email: string, password: string) => Promise<{ status: number }>;
logout: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextType | null>(null);
+43
View File
@@ -0,0 +1,43 @@
import { useState, useEffect } from "react";
import loginFunction from "../../utils/auth/login.ts";
import getUser from "../../utils/getUser.ts";
import { AuthContext } from "./AuthContext.ts";
import logoutFunction from "../../utils/auth/logout.ts"
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
const u = await getUser();
if(u.status === 200) setUser(u.data);
setLoading(false);
})();
}, []);
const login = async (email, password) => {
const res = await loginFunction(email, password);
const u = res.status === 200 ? await getUser() : null;
if (u) setUser(u.data);
return res;
};
const logout = async () => {
await logoutFunction();
setUser(null);
};
const update = async () => {
const u = await getUser();
setUser(u.data);
}
return (
<AuthContext.Provider value={{ user, loading, login, logout, update }}>
{children}
</AuthContext.Provider>
);
}
@@ -0,0 +1,3 @@
import { createContext } from "react";
export const EventDetailContext = createContext(null);
@@ -0,0 +1,28 @@
import {useState} from 'react';
import { EventDetailContext } from "./EventDetailContext.js"
import getEventById from "../../utils/events/getEventById.ts";
import deleteTaskFunction from "../../utils/tasks/deleteTask.ts";
export function EventDetailProvider({ children }) {
const [tasks, setTasks] = useState([]);
const [eventInfo, setEventInfo] = useState([]);
const updateEventInfo = async (id) => {
const e = await getEventById(id);
setEventInfo(e.data);
setTasks(e.data.tasks)
}
const deleteTask = async (id, eventId) => {
await deleteTaskFunction(id);
await updateEventInfo(eventId);
}
return (
<EventDetailContext.Provider value={{ tasks, eventInfo, updateEventInfo, deleteTask }}>
{children}
</EventDetailContext.Provider>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { createContext } from "react";
export type Task = {
name: string;
description: string;
start: string | Date;
end: string | Date;
};
export type EventGroup = {
date: string;
name: string;
start: string | Date;
tasks: Task[];
};
export type EventContextType = {
events: EventGroup[];
updateEvent: () => void;
addEvent: (name: string, description: string, start: string, end: string) => Promise<void>;
removeEvent:(eventId: number) => void;
};
export const EventContext = createContext<EventContextType | null>(null);
+41
View File
@@ -0,0 +1,41 @@
import { useState, ReactNode } from 'react';
import { EventContext, EventGroup, EventContextType } from "./EventContext";
import getAllEvents from "../../utils/events/getAllEvents";
import createEvent from "../../utils/events/createEvent";
import deleteEvent from "../../utils/events/deleteEvent";
interface EventProviderProps {
children: ReactNode;
}
export function EventProvider({ children }: EventProviderProps) {
const [events, setEvents] = useState<EventGroup[]>([]);
const updateEvent = async () => {
const result = await getAllEvents();
setEvents(result.data.data);
};
const addEvent = async (name: string, description: string, start: string, end: string) => {
await createEvent(name, description, start, end);
await updateEvent();
};
const removeEvent = async (eventId: number) => {
await deleteEvent(eventId);
await updateEvent();
};
const contextValue: EventContextType = {
events,
updateEvent,
addEvent,
removeEvent,
};
return (
<EventContext.Provider value={contextValue}>
{children}
</EventContext.Provider>
);
}
@@ -0,0 +1,3 @@
import { createContext } from "react";
export const PendingMembersContext = createContext(null);
@@ -0,0 +1,37 @@
import { useState } from "react";
import { PendingMembersContext } from "./PendingMembersContext.js";
import getUserToValidate from "../../utils/users/getUserToValidate.ts";
import getUserById from "../../utils/users/getUserById.ts";
export function PendingMembersProvider({ children }) {
const [pendingMembers, setPendingMembers] = useState([]);
const updatePendingMembers = async () => {
const result = await getUserToValidate();
const userIds = result.data;
if (!userIds || userIds.length === 0) {
setPendingMembers([]);
return;
}
const users = await Promise.all(
userIds.map(id => getUserById(id))
);
setPendingMembers(users.map(u => u.data));
};
return (
<PendingMembersContext.Provider
value={{
pendingMembers,
updatePendingMembers
}}
>
{children}
</PendingMembersContext.Provider>
);
}
+115
View File
@@ -0,0 +1,115 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
scrollbar-color: #b5b5b5 transparent;
}
html {
overflow-x: hidden;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
--glass-back: rgba(255, 255, 255, 0.54);
--glass-border: rgba(255, 255, 255, 0.2);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.3),
inset 0 -1px 0 rgba(255, 255, 255, 0.05),
inset 0 0 8px 4px rgba(255, 255, 255, 0.2);
--glass-border-shadow:
inset 1.25px 1.25px 1px rgba(255, 255, 255, 1),
inset -1.25px -1.25px 1px rgba(255, 255, 255, 1),
inset -0.25px 0.25px 1px rgba(0, 0, 0, 0.2),
inset 0.25px -0.25px 1px rgba(0, 0, 0, 0.2);
--glass-border-back: rgba(255, 255, 255, 0.25);
}
body {
margin: 0;
display: flex;
place-items: center;
width:100%;
min-width: 320px;
min-height: 100vh;
}
body.dark {
--glass-back: rgba(160, 160, 160, 0.54);
--glass-border: rgba(160, 160, 160, 0.2);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.1),
inset 0 1px 0 rgba(160, 160, 160, 0.3),
inset 0 -1px 0 rgba(160, 160, 160, 0.05),
inset 0 0 8px 4px rgba(160, 160, 160, 0.2);
--glass-border-shadow:
inset 1.25px 1.25px 1px rgba(180, 180, 180, 0.6),
inset -1.25px -1.25px 1px rgba(180, 180, 180, 0.6),
inset -0.25px 0.25px 1px rgba(0, 0, 0, 0.4),
inset 0.25px -0.25px 1px rgba(0, 0, 0, 0.4);
--glass-border-back: rgba(160, 160, 160, 0.25);
}
.content {
padding: 1.5rem;
z-index: 1;
width: 100%;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
.glassCard {
padding: 10px 20px 10px 20px;
background: var(--glass-back);
backdrop-filter: blur(3px);
-webkit-backdrop-filter: blur(3px);
border-radius: 20px;
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.glassBorder{
padding: 10px 20px 10px 20px;
border-radius: 20px;
border: none;
background: var(--glass-border-back);
box-shadow: var(--glass-border-shadow);
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media screen and (max-width: 768px) {
.content{
padding: 0.7rem;
}
}
+6
View File
@@ -0,0 +1,6 @@
import type User from "./user.interface";
export interface AuthContextType {
user: User | null;
update: () => void;
}
+8
View File
@@ -0,0 +1,8 @@
export default interface Notification {
id: string;
data: {
message: string;
};
read_at: string | null;
created_at: string;
}
+4
View File
@@ -0,0 +1,4 @@
export default interface Response<T> {
status: number;
data: T;
}
+22
View File
@@ -0,0 +1,22 @@
export default interface TaskType {
id: number;
name: string;
description: string;
location: string;
start: string;
end: string;
max_participants: number;
events_id: number;
created_at: string;
updated_at: string;
pivot: {
user_id: number;
task_id: number;
};
}
export default interface Task {
id: number;
title?: string;
completed?: boolean;
[key: string]: unknown;
}
+17
View File
@@ -0,0 +1,17 @@
import TaskType from "./task.interface";
export default interface User {
id: number;
name: string;
lastname: string;
role: string;
email: string;
phone: string | null;
created_at: string;
isAdmin: boolean;
validate: number;
tasks: TaskType[];
profile_photo_path?: string | null,
email_notifications: number,
web_notifications: number;
}
+34
View File
@@ -0,0 +1,34 @@
import { Outlet } from "react-router";
import Footer from "./components/Footer/Footer.tsx"
import Header from "./components/Header/Header.tsx";
import Background from "./components/Background/Background.tsx";
import { Navigate } from "react-router";
import { useContext} from "react";
import { AuthContext } from "./contexts/Auth/AuthContext.ts";
import Loading from "./components/ui/Loading/Loading.tsx";
function Layout(){
const { user, loading } = useContext(AuthContext);
if (loading) return <Background>
<Loading />
</Background>;
if (!user) return <Navigate to="/login" />;
if(user && user.validate === 0) return <Navigate to="/error/validation" />;
return (
<div>
<Background>
<div className="content">
<Header />
<Outlet />
<Footer />
</div>
</Background>
</div>
)
}
export default Layout;
+24
View File
@@ -0,0 +1,24 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import './index.css'
import {RouterProvider} from "react-router";
import router from './router.js';
import {AuthProvider} from "./contexts/Auth/AuthProvider.jsx";
import {EventProvider} from "./contexts/Events/EventProvider.tsx";
import {EventDetailProvider} from "./contexts/EventDetail/EventDetailProvider.jsx";
import {PendingMembersProvider} from "./contexts/PendingMembers/PendingMembersProvider.jsx";
createRoot(document.getElementById('root')).render(
<StrictMode>
<AuthProvider>
<PendingMembersProvider>
<EventProvider>
<EventDetailProvider>
<RouterProvider router={router}/>
</EventDetailProvider>
</EventProvider>
</PendingMembersProvider>
</AuthProvider>
</StrictMode>,
)
+30
View File
@@ -0,0 +1,30 @@
.container {
display: flex;
flex-direction: row;
width: 100%;
padding: 20px;
box-sizing: border-box;
gap: 20px;
}
.rightSection {
width: 60%;
display: flex;
flex-direction: column;
gap: 20px;
}
@media screen and (max-width: 760px) {
.container {
flex-direction: column;
width: 100%;
padding: 0;
margin-top:25px;
}
.rightSection {
width: 100%;
margin: 0;
margin-top:0;
margin-bottom:25px;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { useContext } from "react";
import styles from "./AdminPage.module.css";
import IncompleteEvents from "./components/IncompleteEvent/IncompleteEvent";
import PendingMembers from "./components/PendingMembers/PendingMembers";
import { AuthContext } from "../../contexts/Auth/AuthContext";
import user from "../../interfaces/user.interface"
import { useNavigate } from "react-router";
import RegisterMember from "./components/RegisterMember/RegisterMember";
function AdminPage() {
const authContext = useContext(AuthContext);
const navigate = useNavigate();
if (!authContext?.user?.isAdmin) {
navigate("/");
return null;
}
return (
<div className={styles.container}>
<RegisterMember />
<div className={styles.rightSection}>
<IncompleteEvents />
<PendingMembers />
</div>
</div>
);
}
export default AdminPage;
@@ -0,0 +1,40 @@
import React, { useContext } from "react";
import styles from "./IncompleteEvent.module.css";
import { EventContext } from "../../../../contexts/Events/EventContext.ts";
function IncompleteEvent() {
const { events } = useContext(EventContext);
return (
<div className={`${styles.rightBlock} glassCard`}>
<h2>Derniers événements</h2>
<div className={styles.eventsContainer}>
{Array.isArray(events) && events.length > 0 ? (
events.map((event) => (
<div key={event.id} className={`glassBorder ${styles.eventCard}`}>
<h3>{event.name}</h3>
<p className={styles.eventDate}>
{new Date(event.start).toLocaleDateString("fr-FR")} - {new Date(event.end).toLocaleDateString("fr-FR")}
</p>
<p className={styles.eventDescription}>{event.description}</p>
<h4>Tâches associées :</h4>
<ul className={styles.tasksList}>
{event.tasks?.length ? (
event.tasks.map((task, taskIndex) => (
<li key={taskIndex}>{task.name}</li>
))
) : (
<li>Aucune tâche associée.</li>
)}
</ul>
</div>
))) : (
<p>Aucun événement n'existe pour le moment</p>
)}
</div>
</div>
);
}
export default IncompleteEvent;
@@ -0,0 +1,114 @@
.rightBlock {
width: 100%;
}
.rightBlock > h2 {
font-size: 29px;
}
.eventsContainer {
display: flex;
flex-direction: row;
gap: 10px;
margin-top: 5px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 10px;
scroll-behavior: smooth;
}
.eventCard {
min-width: calc((100% - 20px) / 3);
max-width: calc((100% - 20px) / 3);
min-height: 250px;
max-height: 250px;
overflow-y: auto;
text-align: center;
box-sizing: border-box;
opacity: 0;
transform: translateX(20px);
animation: slideInEvent 1s ease forwards;
flex-shrink: 0;
padding: 10px;
}
.eventCard ul {
align-items: center;
margin-left: 5%;
}
.eventCard ul li {
text-align: left;
list-style-type: circle;
list-style: none;
}
.eventCard > h3 {
font-size: 24px;
}
/* Animation des cartes avec délais */
.eventCard:nth-child(1) { animation-delay: 0s; }
.eventCard:nth-child(2) { animation-delay: 0.1s; }
.eventCard:nth-child(3) { animation-delay: 0.2s; }
.eventCard:nth-child(4) { animation-delay: 0.3s; }
.eventCard:nth-child(5) { animation-delay: 0.4s; }
.eventCard:nth-child(6) { animation-delay: 0.5s; }
.emptyEvent {
min-width: calc((100% - 20px) / 3);
max-width: calc((100% - 20px) / 3);
min-height: 250px;
max-height: 250px;
padding: 10px;
text-align: center;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
opacity: 1;
flex-shrink: 0;
}
@keyframes slideInEvent {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@media screen and (max-width: 760px) {
.eventCard {
min-width: 100%;
min-height: 200px;
max-height: fit-content;
padding-bottom: 0;
}
.eventCard h3 {
font-size: 17px;
}
.eventsContainer {
flex-direction: column;
max-height: 300px;
margin-top: 15px;
height: fit-content;
overflow-y: auto;
padding-bottom: 0;
}
.rightBlock > h2 {
font-size: 18px;
}
.emptyEvent {
min-width: 100%;
min-height: 200px;
max-height: fit-content;
}
}
@@ -0,0 +1,82 @@
import React, {useState, useEffect, useContext} from "react";
import Button from "../../../../components/ui/Button/Button.tsx";
import styles from "./PendingMembers.module.css";
import Modal from "../../../../components/ui/Modal/Modal.tsx";
import validateUser from "../../../../utils/users/validateUser.ts";
import deleteOtherUser from "../../../../utils/users/deleteOtherUser.ts";
import { PendingMembersContext } from "../../../../contexts/PendingMembers/PendingMembersContext.js";
function PendingMembers() {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [title, setTitle] = useState("");
const { pendingMembers, updatePendingMembers } = useContext(PendingMembersContext);
useEffect(() => {
updatePendingMembers();
}, []);
const handleValidate = async (id, name) => {
const result = await validateUser(id);
if(result.status === 200) {
setTitle("Utilisateur accepté avec succès")
setMessage(`L'utilisateur ${name} a été accepté avec succès`)
} else {
setTitle("Erreur")
setMessage(`Une erreur est survenue durant l'acceptation de l'utilisateur ${name}`)
}
setOpen(true);
updatePendingMembers();
}
const handleRefuse = async (id, name) => {
const result = await deleteOtherUser(id);
if(result.status === 200) {
setTitle("Utilisateur refusé avec succès")
setMessage(`L'utilisateur ${name} a été refusé avec succès`)
setOpen(true);
} else {
setTitle("Erreur")
setMessage(`Une erreur est survenue durant le refus de l'utilisateur ${name}`)
setOpen(true);
}
updatePendingMembers();
}
return (
<div className={`${styles.rightBlock} glassCard`}>
<h2>Membres en attente de validation</h2>
<div className={styles.membersContainer}>
{pendingMembers.length === 0 && <p>Aucun membre en attente</p>}
{pendingMembers.map((member, index) => (
<div key={index} className={`glassBorder ${styles.memberCard}`}>
<p>{member.name} {member.lastname}</p>
<div className={styles.buttonGroup}>
<Button variant="primary" onClick={() => handleValidate(member.id, `${member.name} ${member.lastname}`)}>Valider</Button>
<Button variant="danger" onClick={() => handleRefuse(member.id, `${member.name} ${member.lastname}`)}>Refuser</Button>
</div>
</div>
))}
</div>
<Modal open={open} onClose={() => setOpen(false)} title={title}>
{message}
</Modal>
</div>
);
}
export default PendingMembers;
@@ -0,0 +1,76 @@
.rightBlock {
width: 100%;
}
.rightBlock > h2 {
font-size: 29px;
}
.membersContainer {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 20px;
max-height: 185px;
overflow-y: auto;
padding-right: 5px;
scroll-behavior: smooth;
}
.memberCard {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
flex-shrink: 0;
opacity: 0;
transform: translateY(20px);
animation: slideInMember 0.5s ease forwards;
}
.memberCard:nth-child(1) { animation-delay: 0s; }
.memberCard:nth-child(2) { animation-delay: 0.1s; }
.memberCard:nth-child(3) { animation-delay: 0.2s; }
.memberCard p {
font-weight: bold;
font-size: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 250px;
}
.buttonGroup {
display: flex;
gap: 10px;
justify-content: right;
}
@keyframes slideInMember {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@media screen and (max-width: 760px) {
.membersContainer {
max-height: 200px;
}
.rightBlock > h2{
font-size: 18px;
}
.memberCard p{
font-size: 15px;
max-width: 150px;
}
}
@media screen and (min-width: 761px) {
.memberCard p{
white-space: normal;
overflow: visible;
text-overflow: clip;
max-width: none;
}
}
@@ -0,0 +1,52 @@
.leftBlock {
width: 100%;
max-width: 720px;
display: flex;
flex-direction: column;
align-items: center;
min-height: calc(100vh - 260px);
box-sizing: border-box;
padding: 20px;
margin: 0 auto;
}
.registerMemberHeader {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 20px;
}
.registerForm {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
align-items: stretch;
}
.registerForm input {
width: 100%;
box-sizing: border-box;
}
.registerForm button {
width: 100%;
}
.modalContent {
text-align: center;
}
@media (min-width: 768px) {
.leftBlock {
width: 70%;
}
}
@media (min-width: 1024px) {
.leftBlock {
width: 40%;
}
}
@@ -0,0 +1,80 @@
import React, { useState } from "react"
import styles from "./RegisterMember.module.css"
import TextInput from "../../../../components/ui/Input/Input"
import Button from "../../../../components/ui/Button/Button";
import Response from "../../../../interfaces/response.interface";
import ResponseData from "./responseData.inteface";
import adminCreateUser from "../../../../utils/users/adminCreateUser";
import Modal from "../../../../components/ui/Modal/Modal";
const RegisterMember: React.FC = () => {
const [name, setName] = useState<string>("");
const [lastname, setLastName] = useState<string>("");
const [email, setEmail] = useState<string>("");
const [phone, setPhone] = useState<string>("");
const [password, setPassword] = useState<string>("");
const [open, setOpen] = useState<boolean>(false);
const [title, setTitle] = useState<string>("");
const [message, setMessage] = useState<string>("");
const handleCreate = async (): Promise<void> => {
if(name !== "" && lastname !== "" && email !== "" && phone !== "" ) {
const response: Response<ResponseData> = await adminCreateUser(email, name, lastname, phone);
if(response.status === 200) {
setPassword(response.data.password);
setTitle(`Utilisateur enregistré avec succès`);
setMessage(`L'utilisateur ${name} ${lastname} a été enregistré avec le mot de passe: ${response.data.password}`);
setOpen(true);
} else {
setTitle(`Erreur`);
setMessage(`Une erreur est survenue lors de l'enregistrement de ${name} ${lastname}`);
setOpen(true);
}
} else {
setTitle("Erreur");
setMessage("Un des champs n'a pas été rempli");
setOpen(true);
}
}
const showPassword = (): void => {
setTitle(`Mot de passe de ${name} ${lastname}`);
setMessage(password);
setOpen(true);
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleCreate();
}
};
return <div className={`${styles.leftBlock} glassCard`}>
<div className={styles.registerMemberHeader}>
<h2>Enregister un bénévole</h2>
</div>
<div className={styles.registerForm} onKeyDown={handleKeyDown}>
<TextInput placeholder={"Prenom"} type={"text"} value={name} onChange={e => setName(e.target.value)} />
<TextInput placeholder={"Nom"} type={"text"} value={lastname} onChange={e => setLastName(e.target.value)} />
<TextInput placeholder={"Adresse Email"} type={"email"} value={email} onChange={e => setEmail(e.target.value)} />
<TextInput placeholder={"Numéro de téléphone"} type={"tel"} value={phone} onChange={e => setPhone(e.target.value)} />
{password !== "" && <Button onClick={showPassword}> Afficher le mot de passe </Button>}
<Button variant={"default"} onClick={handleCreate}>Enregistrer</Button>
</div>
<Modal open={open} onClose={() => setOpen(false)} title={title}>
<p className={styles.modalContent}>{message}</p>
</Modal>
</div>
}
export default RegisterMember
@@ -0,0 +1,4 @@
export default interface ResponseData {
message: string;
password: string;
}
@@ -0,0 +1,26 @@
.title {
color: white;
font-size: xx-large;
margin-bottom: 20px;
}
.content {
display: flex;
flex-direction: column;
z-index: 2;
padding: 10px;
}
.container {
padding: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 15px;
p {
font-size: large;
white-space: pre-line;
}
}
@@ -0,0 +1,41 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router";
import confirmEmailChange from "../../utils/users/confirmEmailChange";
import styles from "./ConfirmEmailChangePage.module.css";
import Background from "../../components/Background/Background";
export default function ConfirmEmailChange() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [message, setMessage] = useState<string>('Validation en cours...');
const [success, setSuccess] = useState<boolean | null>(null);
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setMessage('Token manquant.');
setSuccess(false);
return;
}
confirmEmailChange(token)
.then(() => {
setMessage('Votre adresse email a bien été mise à jour.\nVous allez être redirigé vers la page de connexion...');
setSuccess(true);
setTimeout(() => navigate('/login'), 3000);
})
.catch(() => {
setMessage('Token invalide ou expiré.');
setSuccess(false);
});
}, []);
return <Background>
<div className={styles.content}>
<h1 className={styles.title}> Validation de votre adresse email </h1>
<div className={`${styles.container} glassCard`}>
<p>{message}</p>
</div>
</div>
</Background>
}
@@ -0,0 +1,94 @@
.allContent{
margin: 16px 0 16px 0;
align-self: center;
width: 90%;
}
.textImage {
display: flex;
gap: 24px;
}
.eventImage {
width: 40%;
aspect-ratio: 16 / 9;
background-color: #e0e0e0;
border-radius: 16px;
flex-shrink: 0;
}
.eventInfo {
flex: 1;
min-width: 0;
overflow-wrap: break-word;
}
.eventInfo p {
white-space: normal;
overflow-wrap: break-word;
}
.eventImage {
width: 40%;
height: 100%;
aspect-ratio: 16 / 9;
background-color: #e0e0e0;
border-radius: 16px;
flex-shrink: 0;
}
.registerButton:hover {
opacity: 0.85;
}
.tasks {
list-style: none;
padding: 0;
}
.eventContent {
display: flex;
justify-content: space-between;
}
.manageBtn {
height: fit-content;
}
.modalContent {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.manageEvent {
display: flex;
flex-direction: row;
gap: 10px;
}
@media screen and (max-width: 768px) {
.eventContent {
flex-direction: column;
}
.textImage {
flex-direction: column;
}
.manageEvent {
justify-content: center;
}
.eventImage {
width: 100%;
}
.manageEvent{
margin: 16px 0 16px 0;
}
}
+93
View File
@@ -0,0 +1,93 @@
import {useEffect, useContext, useRef} from "react";
import styles from "./EventDetailPage.module.css";
import { useParams } from "react-router";
import { formatDateLetter } from "../../utils/date/formatDateLetter";
import EventTask from "./components/EventTask/EventTask";
import { AuthContext } from "../../contexts/Auth/AuthContext";
import { EventDetailContext } from "../../contexts/EventDetail/EventDetailContext";
import CreateTaskBtn from "./components/CreateTaskBtn/CreateTaskBtn";
import DeleteEventBtn from "./components/DeleteEventBtn/DeleteEventBtn";
import EditEventBtn from "./components/EditEventBtn/EditEventBtn";
import uploadEventPhoto from "../../utils/events/uploadEventPhoto";
function EventDetailPage() {
const params = useParams();
const id = params.id as string;
const { user } = useContext(AuthContext) as any;
const { tasks, updateEventInfo, eventInfo } = useContext(EventDetailContext) as any;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const imageUrl = eventInfo?.event_photo_path
? `${import.meta.env.VITE_API_URL}/storage/${eventInfo.event_photo_path}`
: "/empty.png";
console.log(eventInfo);
useEffect(() => {
if (id) {
updateEventInfo(id);
}
}, []);
const handleImageClick = () => {
if (user?.isAdmin && fileInputRef.current) {
fileInputRef.current?.click();
}
};
const handleFileChange = async (e: any) => {
const file = e.target.files[0];
if (!file) return;
await uploadEventPhoto(Number(id), file);
updateEventInfo(id);
};
return (
<div className={styles.allContent}>
<div className={`glassCard`}>
<div className={styles.textImage}>
<img
src={imageUrl}
alt="Photo événement"
className={styles.eventImage}
onClick={handleImageClick}
style={{ cursor: user?.isAdmin ? "pointer" : "default" }}
/>
<input
type="file"
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
<div className={styles.eventInfo}>
<h2>{eventInfo?.name}</h2>
<div className={styles.eventContent}>
<div>
<p>{eventInfo && formatDateLetter(eventInfo.start)}</p>
<p>{eventInfo?.description}</p>
</div>
{user?.isAdmin && (
<div className={styles.manageEvent}>
<EditEventBtn id={id} />
<DeleteEventBtn id={id} />
<CreateTaskBtn id={id} />
</div>
)}
</div>
</div>
</div>
<h2>Tâches</h2>
<div className={styles.tasks}>
{tasks?.map((task: any, index: number) => (
<EventTask key={task.id} taskId={task.id} index={index} />
))}
</div>
</div>
</div>
);
}
export default EventDetailPage;
@@ -0,0 +1,19 @@
.usersList {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
.userCard {
display: flex;
align-items: center;
justify-content: space-between;
}
.userName {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,100 @@
import {ReactNode, useContext, useEffect, useState} from "react";
import Button from "../../../../components/ui/Button/Button";
import Modal from "../../../../components/ui/Modal/Modal";
import User from "../../../Volunteers/interfaces/user.interface";
import getAllUser from "../../../../utils/users/getAllUsers";
import Loading from "../../../../components/ui/Loading/Loading";
import getUserById from "../../../../utils/users/getUserById";
import styles from "./AssignUserBtn.module.css";
import {AuthContext} from "../../../../contexts/Auth/AuthContext";
import assignOtherUser from "../../../../utils/tasks/assignOtherUser";
import unAssignOtherUser from "../../../../utils/tasks/unAssignOtherUser";
import {EventDetailContext} from "../../../../contexts/EventDetail/EventDetailContext";
interface AssignUserBtnProps {
taskId: string | number;
eventId: string | number;
}
export default function AssignUserBtn({ taskId, eventId }: AssignUserBtnProps): ReactNode {
const [open, setOpen] = useState<boolean>(false);
const [users, setUsers] = useState<Array<User>>([]);
const [loading, setLoading] = useState<boolean>(true);
const { user } = useContext(AuthContext) as any;
const { updateEventInfo } = useContext(EventDetailContext) as any;
if (!user?.isAdmin) return null;
useEffect(() => {
(async () => {
setLoading(true);
const result = await getAllUser();
if (result.status !== 200 || !Array.isArray(result.data)) {
console.error("Erreur lors du chargement des utilisateurs", result);
return;
}
const usersData = await Promise.all(
result.data.map(async (id: number): Promise<User | null> => {
const res = await getUserById(id);
if (res.status === 200) {
return res.data as User;
}
return null;
})
);
setUsers(usersData.filter((user): user is User => user !== null));
setLoading(false);
})();
}, []);
const [assignedIds, setAssignedIds] = useState<Set<number>>(new Set());
useEffect(() => {
setAssignedIds(new Set(
users.filter(u => u.tasks.some(t => t.id === taskId)).map(u => u.id)
));
}, [users, taskId]);
const handleAssign = async (selectedUser: User) => {
setAssignedIds(prev => new Set(prev).add(selectedUser.id));
await assignOtherUser(taskId as number, selectedUser.id);
updateEventInfo(eventId);
};
const handleUnassign = async (selectedUser: User) => {
setAssignedIds(prev => { const next = new Set(prev); next.delete(selectedUser.id); return next; });
await unAssignOtherUser(taskId as number, selectedUser.id);
updateEventInfo(eventId);
};
return <>
<Button variant={"default"} onClick={() => setOpen(true)}>Assigner</Button>
<Modal open={open} onClose={() => setOpen(false)} title={"Assigner un utilisateur à cette tâche"}>
<div className={styles.usersList}>
{loading ? <Loading /> : users.map((u: User) => (
<div key={u.id} className={`${styles.userCard} glassBorder`}>
<p className={styles.userName}>{u.name} {u.lastname}</p>
{assignedIds.has(u.id) ? (
<Button variant={"danger"} onClick={() => handleUnassign(u)}>
Désassigner
</Button>
) : (
<Button onClick={() => handleAssign(u)}>
Assigner
</Button>
)}
</div>
))}
</div>
</Modal>
</>
}
@@ -0,0 +1,116 @@
import styles from "../../EventDetailPage.module.css";
import Button from "../../../../components/ui/Button/Button.tsx";
import {useContext, useState} from "react";
import TextInput from "../../../../components/ui/Input/Input.tsx";
import Modal from "../../../../components/ui/Modal/Modal.tsx";
import createTaskApi from "../../../../utils/tasks/createTask.ts";
import {EventDetailContext} from "../../../../contexts/EventDetail/EventDetailContext.js";
export default function CreateTaskBtn({ id }) {
const [openTaskModal, setOpenTaskModal] = useState(false);
const [taskName, setTaskName] = useState("");
const [taskDescription, setTaskDescription] = useState("");
const [taskLocation, setTaskLocation] = useState("");
const [maxParticipants, setMaxParticipants] = useState("");
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
const { updateEventInfo } = useContext(EventDetailContext)
const createTask = async () => {
const data = await createTaskApi({
eventId: id,
name: taskName,
description: taskDescription,
start: startTime,
end: endTime,
location: taskLocation,
maxParticipants,
});
if (data?.status === 200) {
setOpenTaskModal(false);
setTaskName("");
setTaskDescription("");
setTaskLocation("");
setMaxParticipants("");
setStartTime("");
setEndTime("");
updateEventInfo(id)
} else {
console.log("⚠️ Échec de la création de la task");
}
};
return <>
<Button variant={"default"} className={styles.manageBtn} onClick={() => setOpenTaskModal(true)}>Créer tâche</Button>
<Modal open={openTaskModal} onClose={() => setOpenTaskModal(false)} title={"Créer tâche"}>
<div className={styles.modalContent}>
<div>
<p>Nom de la tâche</p>
<TextInput
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
placeholder="Nom de la tâche"
/>
</div>
<div>
<p>Description</p>
<TextInput
value={taskDescription}
onChange={(e) => setTaskDescription(e.target.value)}
placeholder="Description de la tâche"
/>
</div>
<div>
<p>Lieu</p>
<TextInput
value={taskLocation}
onChange={(e) => setTaskLocation(e.target.value)}
placeholder="Lieu"
/>
</div>
<div>
<p>Nombre de participants max</p>
<TextInput
type="number"
value={maxParticipants}
onChange={(e) => setMaxParticipants(e.target.value)}
placeholder="Participants max"
/>
</div>
<div>
<p>Heure de début</p>
<TextInput
type="datetime-local"
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
<div>
<p>Heure de fin</p>
<TextInput
type="datetime-local"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
<Button variant="primary" onClick={createTask}> Créer la tâche </Button>
</div>
</Modal>
</>
}
@@ -0,0 +1,129 @@
import styles from "../../EventDetailPage.module.css";
import Button from "../../../../components/ui/Button/Button";
import { useContext, useState } from "react";
import TextInput from "../../../../components/ui/Input/Input";
import Modal from "../../../../components/ui/Modal/Modal";
import createTaskApi from "../../../../utils/tasks/createTask";
import { EventDetailContext } from "../../../../contexts/EventDetail/EventDetailContext";
type CreateTaskBtnProps = {
id: string | number;
};
export default function CreateTaskBtn({ id }: CreateTaskBtnProps) {
const [openTaskModal, setOpenTaskModal] = useState(false);
const [taskName, setTaskName] = useState<string>("");
const [taskDescription, setTaskDescription] = useState<string>("");
const [taskLocation, setTaskLocation] = useState<string>("");
const [maxParticipants, setMaxParticipants] = useState<string>("");
const [startTime, setStartTime] = useState<string>("");
const [endTime, setEndTime] = useState<string>("");
const { updateEventInfo } = useContext(EventDetailContext) as any;
const createTask = async () => {
const data = await createTaskApi({
eventId: id,
name: taskName,
description: taskDescription,
start: startTime,
end: endTime,
location: taskLocation,
maxParticipants,
});
if (data?.status === 200) {
setOpenTaskModal(false);
setTaskName("");
setTaskDescription("");
setTaskLocation("");
setMaxParticipants("");
setStartTime("");
setEndTime("");
updateEventInfo(id);
} else {
console.log("⚠️ Échec de la création de la task");
}
};
return (
<>
<Button
variant={"default"}
className={styles.manageBtn}
onClick={() => setOpenTaskModal(true)}
>
Créer tâche
</Button>
<Modal
open={openTaskModal}
onClose={() => setOpenTaskModal(false)}
title={"Créer tâche"}
>
<div className={styles.modalContent}>
<div>
<p>Nom de la tâche</p>
<TextInput
value={taskName}
onChange={(e: any) => setTaskName(e.target.value)}
placeholder="Nom de la tâche"
/>
</div>
<div>
<p>Description</p>
<TextInput
value={taskDescription}
onChange={(e: any) => setTaskDescription(e.target.value)}
placeholder="Description de la tâche"
/>
</div>
<div>
<p>Lieu</p>
<TextInput
value={taskLocation}
onChange={(e: any) => setTaskLocation(e.target.value)}
placeholder="Lieu"
/>
</div>
<div>
<p>Nombre maximum de participants</p>
<TextInput
type="number"
value={maxParticipants}
onChange={(e: any) => setMaxParticipants(e.target.value)}
placeholder="Participants max"
/>
</div>
<div>
<p>Heure de début</p>
<TextInput
type="datetime-local"
value={startTime}
onChange={(e: any) => setStartTime(e.target.value)}
/>
</div>
<div>
<p>Heure de fin</p>
<TextInput
type="datetime-local"
value={endTime}
onChange={(e: any) => setEndTime(e.target.value)}
/>
</div>
<Button variant="default" onClick={createTask}>
Créer la tâche
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,56 @@
import styles from "../../EventDetailPage.module.css";
import Button from "../../../../components/ui/Button/Button";
import Modal from "../../../../components/ui/Modal/Modal";
import { useContext, useState } from "react";
import { EventDetailContext } from "../../../../contexts/EventDetail/EventDetailContext";
import { EventContext } from "../../../../contexts/Events/EventContext";
import { useNavigate } from "react-router";
type DeleteEventBtnProps = {
id: string | number;
};
export default function DeleteEventBtn({ id }: DeleteEventBtnProps) {
const { eventInfo } = useContext(EventDetailContext) as any;
const { removeEvent } = useContext(EventContext) as any;
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const handleDelete = () => {
setOpen(true);
};
const delEvent = async () => {
const result = await removeEvent(id);
if (result.status === 200 || result.status === 204) {
setOpen(false);
navigate("/events");
}
};
return (
<>
<Button
variant={"danger"}
className={styles.manageBtn}
onClick={handleDelete}
>
Supprimer
</Button>
<Modal open={open} onClose={() => setOpen(false)} title={"Suppression"}>
<div className={styles.modalContent}>
<p>
Etes-vous sur de vouloir supprimer l'événement {eventInfo?.name} ?
</p>
<Button variant={"primary"} onClick={delEvent}>
Confirmer
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,48 @@
import { useContext, useState } from "react";
import { AuthContext } from "../../../../contexts/Auth/AuthContext";
import { EventDetailContext } from "../../../../contexts/EventDetail/EventDetailContext";
import Button from "../../../../components/ui/Button/Button";
import Modal from "../../../../components/ui/Modal/Modal";
import styles from "../../EventDetailPage.module.css";
type DeleteTaskBtnProps = {
taskId: string | number;
eventId: string | number;
taskName: string;
};
export default function DeleteTaskBtn({ taskId, eventId, taskName }: DeleteTaskBtnProps) {
const { user } = useContext(AuthContext) as any;
const { deleteTask } = useContext(EventDetailContext) as any;
const [openModal, setOpenModal] = useState<boolean>(false);
if (!user?.isAdmin) return null;
return (
<>
<Button variant={"danger"} onClick={() => setOpenModal(true)}>
Supprimer
</Button>
<Modal open={openModal} onClose={() => setOpenModal(false)} title={"Suppression"}>
<div className={styles.modalContent}>
<p className={styles.modalContent}>
Etes-vous sur de vouloir supprimer la tâche {taskName} ?
</p>
<Button
variant={"danger"}
onClick={() => {
deleteTask(taskId, eventId);
setOpenModal(false);
}}
>
Confirmer
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,92 @@
import styles from "../../EventDetailPage.module.css";
import Button from "../../../../components/ui/Button/Button";
import Modal from "../../../../components/ui/Modal/Modal";
import TextInput from "../../../../components/ui/Input/Input";
import { useContext, useState, useEffect } from "react";
import { EventDetailContext } from "../../../../contexts/EventDetail/EventDetailContext";
import updateEvent from "../../../../utils/events/updateEvent";
type EditEventBtnProps = {
id: string | number;
};
export default function EditEventBtn({ id }: EditEventBtnProps) {
const { eventInfo, updateEventInfo } = useContext(EventDetailContext) as any;
const [open, setOpen] = useState(false);
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [start, setStart] = useState<string>("");
const [end, setEnd] = useState<string>("");
useEffect(() => {
if (open && eventInfo) {
setName(eventInfo.name ?? "");
setDescription(eventInfo.description ?? "");
setStart(eventInfo.start ? eventInfo.start.slice(0, 16) : "");
setEnd(eventInfo.end ? eventInfo.end.slice(0, 16) : "");
}
}, [open, eventInfo]);
const handleSubmit = async () => {
const res = await updateEvent(Number(id), name, description, start, end);
await updateEventInfo(id);
setOpen(false);
};
return (
<>
<Button
variant={"default"}
className={styles.manageBtn}
onClick={() => setOpen(true)}
>
Modifier
</Button>
<Modal open={open} onClose={() => setOpen(false)} title={"Modifier l'événement"}>
<div className={styles.modalContent}>
<div>
<p>Nom</p>
<TextInput
value={name}
onChange={(e: any) => setName(e.target.value)}
placeholder="Nom de l'événement"
/>
</div>
<div>
<p>Description</p>
<TextInput
value={description}
onChange={(e: any) => setDescription(e.target.value)}
placeholder="Description"
/>
</div>
<div>
<p>Début</p>
<TextInput
type="datetime-local"
value={start}
onChange={(e: any) => setStart(e.target.value)}
/>
</div>
<div>
<p>Fin</p>
<TextInput
type="datetime-local"
value={end}
onChange={(e: any) => setEnd(e.target.value)}
/>
</div>
<Button variant="default" onClick={handleSubmit}>
Confirmer
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,124 @@
import { useContext, useEffect, useState } from "react";
import { AuthContext } from "../../../../contexts/Auth/AuthContext";
import { EventDetailContext } from "../../../../contexts/EventDetail/EventDetailContext";
import Button from "../../../../components/ui/Button/Button";
import Modal from "../../../../components/ui/Modal/Modal";
import TextInput from "../../../../components/ui/Input/Input";
import styles from "../../EventDetailPage.module.css";
import updateTask from "../../../../utils/tasks/updateTask";
type EditTaskBtnProps = {
taskId: number;
eventId: number | string;
};
export default function EditTaskBtn({ taskId, eventId }: EditTaskBtnProps) {
const { user } = useContext(AuthContext) as any;
const { tasks, updateEventInfo } = useContext(EventDetailContext) as any;
const task = tasks?.find((t: any) => t.id === taskId);
const [open, setOpen] = useState(false);
const [name, setName] = useState<string>("");
const [description, setDescription] = useState<string>("");
const [start, setStart] = useState<string>("");
const [end, setEnd] = useState<string>("");
const [location, setLocation] = useState<string>("");
const [maxParticipants, setMaxParticipants] = useState<string>("");
useEffect(() => {
if (open && task) {
setName(task.name ?? "");
setDescription(task.description ?? "");
setStart(task.start ? task.start.slice(0, 16) : "");
setEnd(task.end ? task.end.slice(0, 16) : "");
setLocation(task.location ?? "");
setMaxParticipants(task.max_participants?.toString() ?? "");
}
}, [open, task]);
if (!user?.isAdmin) return null;
const handleSubmit = async () => {
await updateTask(taskId, {
name,
description,
start,
end,
location,
max_participants: Number(maxParticipants),
});
await updateEventInfo(eventId);
setOpen(false);
};
return (
<>
<Button variant={"default"} onClick={() => setOpen(true)}>
Modifier
</Button>
<Modal open={open} onClose={() => setOpen(false)} title={"Modifier la tâche"}>
<div className={styles.modalContent}>
<div>
<p>Nom</p>
<TextInput
value={name}
onChange={(e: any) => setName(e.target.value)}
placeholder="Nom de la tâche"
/>
</div>
<div>
<p>Description</p>
<TextInput
value={description}
onChange={(e: any) => setDescription(e.target.value)}
placeholder="Description"
/>
</div>
<div>
<p>Lieu</p>
<TextInput
value={location}
onChange={(e: any) => setLocation(e.target.value)}
placeholder="Lieu"
/>
</div>
<div>
<p>Participants max</p>
<TextInput
type="number"
value={maxParticipants}
onChange={(e: any) => setMaxParticipants(e.target.value)}
placeholder="Participants max"
/>
</div>
<div>
<p>Début</p>
<TextInput
type="datetime-local"
value={start}
onChange={(e: any) => setStart(e.target.value)}
/>
</div>
<div>
<p>Fin</p>
<TextInput
type="datetime-local"
value={end}
onChange={(e: any) => setEnd(e.target.value)}
/>
</div>
<Button variant="default" onClick={handleSubmit}>
Confirmer
</Button>
</div>
</Modal>
</>
);
}
@@ -0,0 +1,103 @@
.task {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.taskDate {
opacity: 0.5;
}
.manageTaskBtn {
display: flex;
flex-direction: row;
gap: 10px;
}
.content {
margin: 16px 0 16px 0;
animation: fadeInUp 0.6s ease-out forwards;
animation-delay: calc(var(--i) * 0.1s);
}
.participants {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.participantsHeader {
display: flex;
align-items: center;
gap: 8px;
}
.participantsLabel {
font-size: 0.8rem;
opacity: 0.55;
}
.participantsBadge {
font-size: 0.8rem;
font-weight: 600;
padding: 2px 10px;
border-radius: 999px;
}
.badgeOpen {
background: var(--glass-back);
color: #00F0FF;
}
.badgeAlmost {
background: var(--glass-back);
color: #019AFF;
}
.badgeFull {
background: var(--glass-back);
color: #001C83;
}
.progressBar {
width: 100%;
max-width: 200px;
height: 5px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.progressFill {
height: 100%;
border-radius: 999px;
transition: width 0.4s ease;
}
.fillOpen { background: #00F0FF; }
.fillAlmost { background: #019AFF; }
.fillFull { background: #001C83; }
.modalContent {
text-align: center;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media screen and (max-width: 768px) {
.task {
flex-direction: column;
}
}
@@ -0,0 +1,156 @@
import styles from "./EventTask.module.css";
import { formatDateLetter } from "../../../../utils/date/formatDateLetter";
import Button from "../../../../components/ui/Button/Button";
import assign from "../../../../utils/tasks/assign";
import unAssign from "../../../../utils/tasks/unAssign";
import { useContext, useEffect, useState } from "react";
import isAssigned from "../../../../utils/tasks/isAssigned";
import Modal from "../../../../components/ui/Modal/Modal";
import { AuthContext } from "../../../../contexts/Auth/AuthContext";
import { EventDetailContext } from "../../../../contexts/EventDetail/EventDetailContext";
import DeleteTaskBtn from "../DeleteTaskBtn/DeleteTaskBtn";
import AssignUserBtn from "../AssignUserBtn/AssignUserBtn";
import EditTaskBtn from "../EditTaskBtn/EditTaskBtn";
type EventTaskProps = {
taskId: number;
index: number;
};
export default function EventTask({ taskId, index }: EventTaskProps) {
const { update } = useContext(AuthContext) as any;
const { tasks, eventInfo, updateEventInfo } = useContext(EventDetailContext) as any;
const task = tasks?.find((t: any) => t.id === taskId);
const eventId = eventInfo?.id;
const [assigned, setAssigned] = useState<boolean>(false);
const [open, setOpen] = useState<boolean>(false);
const [title, setTitle] = useState<string>("");
const [message, setMessage] = useState<string>("");
async function checkAssign() {
const data = await isAssigned(task.id);
setAssigned(data?.data.assigned);
}
useEffect(() => {
checkAssign();
}, []);
const handleRegisterBtn = async () => {
const res = await assign(task.id);
switch (res.status) {
case 200:
update();
await updateEventInfo(eventId);
setTitle("Inscription réussie");
setMessage(`Vous êtes maintenant inscrit à la tâche ${task.name}`);
setOpen(true);
setAssigned(true);
break;
case 409:
setTitle("Erreur lors de l'inscription");
setMessage("Vous êtes déjà inscrit à cette tâche");
setOpen(true);
break;
case 403:
setTitle("Erreur lors de l'inscription");
setMessage("Le nombre maximum d'inscription pour cette tâche à été atteint");
setOpen(true);
break;
}
};
const handleUnassign = async () => {
const res = await unAssign(task.id);
switch (res.status) {
case 200:
update();
await updateEventInfo(eventId);
setTitle("Désinscription réussie");
setMessage(`Vous n'êtes plus inscrit à la tâche ${task.name}`);
setOpen(true);
setAssigned(false);
break;
case 409:
setTitle("Erreur lors de la désinscription");
setMessage(`Vous n'êtes pas inscrit à cette tâche`);
setOpen(true);
break;
}
};
return (
<div style={{ "--i": index } as any} key={index} className={`${styles.content} glassBorder`}>
<div className={styles.task}>
<div>
<span>
<strong>{task.name}</strong>
</span>
<p className={styles.taskDate}>
{formatDateLetter(task.start)} - {formatDateLetter(task.end)}
</p>
<p>{task.description}</p>
<div className={styles.participants}>
<div className={styles.participantsHeader}>
<span className={styles.participantsLabel}>Participants</span>
<span className={`${styles.participantsBadge} ${
task.users_count >= task.max_participants
? styles.badgeFull
: task.users_count / task.max_participants >= 0.75
? styles.badgeAlmost
: styles.badgeOpen
}`}>
{task.users_count} / {task.max_participants}
</span>
</div>
<div className={styles.progressBar}>
<div
className={`${styles.progressFill} ${
task.users_count >= task.max_participants
? styles.fillFull
: task.users_count / task.max_participants >= 0.75
? styles.fillAlmost
: styles.fillOpen
}`}
style={{ width: `${Math.min((task.users_count / task.max_participants) * 100, 100)}%` }}
/>
</div>
</div>
</div>
<div className={styles.manageTaskBtn}>
{assigned ? (
<Button variant={"danger"} onClick={() => handleUnassign()}>
Se désinscrire
</Button>
) : (
<Button onClick={() => handleRegisterBtn()}>
S’inscrire
</Button>
)}
<EditTaskBtn taskId={task.id} eventId={eventId} />
<DeleteTaskBtn
eventId={eventId}
taskId={task.id}
taskName={task.name}
/>
<AssignUserBtn taskId={task.id} eventId={eventId} />
</div>
</div>
<Modal open={open} onClose={() => setOpen(false)} title={title}>
<p className={styles.modalContent}>{message}</p>
</Modal>
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
.eventsContainer {
width: 100%;
position: relative;
box-sizing: border-box;
margin: 16px 0 16px 0;
}
.eventsList {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.eventCard {
width: 100%;
max-width: 360px;
padding: 12px 10px;
border-radius: 12px;
box-sizing: border-box;
display: flex;
flex-direction: column;
background-color: #ffffffcc;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.eventCard h3 {
margin: 0 0 5px 0;
font-size: 1rem;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eventCard p {
margin: 0;
font-size: 0.9rem;
color: #333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.eventImage {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 8px;
margin-bottom: 8px;
}
@media screen and (min-width: 1024px) {
.eventsContainer {
}
.eventCard {
max-width: 700px;
padding: 20px;
}
.eventCard h3 {
font-size: 1.4rem;
}
.eventCard p {
font-size: 1.1rem;
}
.eventImage {
height: 180px;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { useState, useContext } from "react";
import Event from "./components/Event/Event";
import styles from "./EventsPage.module.css";
import ToolBar from "../../components/ToolBar/ToolBar";
import filter from "../../utils/filter";
import { EventContext, EventGroup, EventContextType } from "../../contexts/Events/EventContext";
interface FilterOptions {
alphabetical: "asc" | "desc";
yearOrder: "asc" | "desc";
}
function EventsPage() {
const eventContext = useContext(EventContext);
if (!eventContext) {
throw new Error("EventsPage must be used within an EventContext.Provider");
}
const { events } = eventContext;
const [filters, setFilters] = useState<FilterOptions>({
alphabetical: "asc",
yearOrder: "desc",
});
return (
<div className={styles.eventsContainer}>
<ToolBar setFilters={setFilters} filters={filters} />
<div className={styles.eventsList}>
{Array.isArray(events) && events.length > 0 ? (
filter(events, filters, "event").map((event: EventGroup, index: number) => (
<Event key={index} event={event} />
))
) : (
<p>Aucun événement n'existe pour le moment</p>
)}
</div>
</div>
);
}
export default EventsPage;
+37
View File
@@ -0,0 +1,37 @@
import styles from "./Event.module.css";
import { useNavigate } from "react-router";
import Button from "/apps/frontend/src/components/ui/Button/Button"
function Event({event}){
const navigate = useNavigate();
const handleNavigate = () => navigate(`/events/` + event.id);
const imageUrl = event.event_photo_path
? `${import.meta.env.VITE_API_URL}/storage/${event.event_photo_path}`
: "/empty.png";
console.log(imageUrl);
return (
<div className={`${styles.eventContainer} glassCard`} onClick={handleNavigate}>
<div className={styles.eventContainerTop}>
<div className={styles.eventContent}>
<div className={styles.eventImageDiv}>
<img
src={imageUrl}
className={styles.eventImage}
alt="Photo de l'événement"
/>
</div>
<div className={styles.eventInfos}>
<p className={styles.eventTitle}>{event.name}</p>
<p>{event.description}</p>
</div>
</div>
<Button onClick={handleNavigate} className={styles.openBtn}>Voir plus</Button>
</div>
</div>
)
}
export default Event;
@@ -0,0 +1,140 @@
.eventContainer {
display: flex;
flex-direction: column;
width: 80%;
}
.eventContainer:hover {
cursor: pointer;
}
.eventContainerTop {
display: flex;
justify-content: space-between;
}
.eventContainerBottom{
display: flex;
align-items: center;
justify-content: end;
}
.moreLink {
width: fit-content;
}
.eventImageDiv {
width: 10rem;
height: 7rem;
flex-shrink: 0;
overflow: hidden;
border-radius: 20px;
margin-right: 1rem;
}
.eventImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.eventContent {
display: flex;
width: 100%;
}
.eventTitle {
font-size: 2rem;
}
.eventButtons {
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
width: fit-content;
overflow: visible;
}
.eventButtons a {
white-space: nowrap;
}
.moreButton {
background: none;
border: none;
color: #019AFF;
font-size: 1.5rem;
transition: all 0.2s ease;
cursor: pointer;
border-radius: 50%;
width: 55px;
height: 55px;
}
.moreButton:hover {
font-size: 2rem;
background: rgba(191, 191, 191, 0.5);
}
.moreButton:focus {
outline: none;
}
.moreButton:focus-visible {
outline: 2px solid black;
}
.moreButtonClicked {
rotate: 90deg;
}
.eventMenuOpen {
display: flex;
flex-direction: column;
margin-left: 1.5em;
}
.eventMenuClose {
display: none;
}
.openBtn {
align-self: center;
white-space: nowrap;
}
@media screen and (max-width: 768px) {
.eventImage {
width: 100%;
height: auto;
display: block;
object-fit: cover;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.eventContainerTop {
flex-direction: column;
justify-content: initial;
padding-bottom: 10px;
}
.eventContainer{
padding: 0;
margin: 16px 0 16px 0;
}
.eventImageDiv {
margin: 0;
}
.eventContent {
flex-direction: column;
}
.eventInfos, .eventContainerBottom {
padding: 10px 20px 10px 20px;
}
}
+14
View File
@@ -0,0 +1,14 @@
.allContent{
display: flex;
flex-direction: row;
justify-content: center;
gap: 80px;
}
@media (max-width: 768px) {
.allContent{
display: flex;
flex-direction: column;
gap: 0;
}
}
+12
View File
@@ -0,0 +1,12 @@
import styles from "./HomePage.module.css";
import Calendar from "./components/Calendar/Calendar";
import EventList from "./components/EventList/EventList";
import { ReactNode } from "react";
export default function HomePage(): ReactNode {
return <div className={styles.allContent}>
<Calendar/>
<EventList/>
</div>
}
@@ -0,0 +1,160 @@
import {useState, useMemo, useEffect, useContext} from "react";
import styles from "./Calendar.module.css";
import {formatDateLetterJS} from "../../../../utils/date/formatDateLetter.ts";
import {AuthContext} from "../../../../contexts/Auth/AuthContext.ts";
import ExportBtn from "../../../../components/ExportCalendarBtn/ExportBtn.tsx";
function
Calendar() {
const { user } = useContext(AuthContext);
const monthNames = [
"Janvier", "Février", "Mars", "Avril", "Mai", "Juin",
"Juillet", "Août", "Septembre", "Octobre", "Novembre", "Décembre"
];
const today = new Date();
const [currentMonth, setCurrentMonth] = useState(today.getMonth());
const [currentYear, setCurrentYear] = useState(today.getFullYear());
const [selectedDay, setSelectedDay] = useState(null);
const eventsByDate = useMemo(() => {
if (!user?.tasks) return {};
return user.tasks.reduce((eventsByDate, task) => {
if (!task?.start) return eventsByDate;
const dateKey = task.start.split(" ")[0];
if (!eventsByDate[dateKey]) eventsByDate[dateKey] = [];
eventsByDate[dateKey].push(task);
return eventsByDate;
}, {});
}, [user]);
useEffect(() => {
const key = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
setSelectedDay({
date: key,
events: eventsByDate[key] || []
});
}, [eventsByDate]);
const daysInMonth = new Date(currentYear, currentMonth + 1, 0).getDate();
const firstDay = new Date(currentYear, currentMonth, 1).getDay();
const startDay = (firstDay + 6) % 7;
const days = [];
for (let i = 0; i < startDay; i++) days.push(null);
for (let i = 1; i <= daysInMonth; i++) days.push(i);
const selectDay = (year, month, day) => {
const key = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
setSelectedDay({
date: key,
events: eventsByDate[key] || []
});
};
const handleDayClick = (day) => {
if (!day) return;
selectDay(currentYear, currentMonth, day);
};
return (
<div className={`${styles.glassCard} glassCard`}>
<div className={styles.calendarContainer}>
<div className={styles.calendarHeader}>
<button
onClick={() => {
if (currentMonth === 0) {
setCurrentMonth(11);
setCurrentYear(y => y - 1);
} else {
setCurrentMonth(m => m - 1);
}
}}
>
◀
</button>
<p>{monthNames[currentMonth]} {currentYear}</p>
<button
onClick={() => {
if (currentMonth === 11) {
setCurrentMonth(0);
setCurrentYear(y => y + 1);
} else {
setCurrentMonth(m => m + 1);
}
}}
>
▶
</button>
</div>
<table className={styles.calendar}>
<thead>
<tr>
<th>Lun</th>
<th>Mar</th>
<th>Mer</th>
<th>Jeu</th>
<th>Ven</th>
<th>Sam</th>
<th>Dim</th>
</tr>
</thead>
<tbody>
{Array.from({length: Math.ceil(days.length / 7)}).map((_, w) => (
<tr key={w}>
{days.slice(w * 7, w * 7 + 7).map((day, i) => {
if (!day) return <td key={i}></td>;
const key = `${currentYear}-${String(currentMonth + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const hasEvent = (eventsByDate[key]?.length ?? 0) > 0;
return (
<td
key={i}
className={[
hasEvent ? styles.hasEvent : "",
selectedDay?.date === key ? styles.selected : ""
].join(" ")}
onClick={() => handleDayClick(day)}
>
{day}
</td>
);
})}
</tr>
))}
</tbody>
</table>
{selectedDay && (
<div className={`${styles.eventBox} glassBorder`}>
<h3>Événements du {formatDateLetterJS(selectedDay.date)}</h3>
{selectedDay.events.length > 0 ? (
<ul>
{selectedDay.events.map(event => (
<li key={event.id}>
<strong>{event.name}</strong><br/>
- {event.description}
</li>
))}
</ul>
) : (
<p>Aucun événement ce jour-là.</p>
)}
</div>
)}
<div className={styles.exportbox}>
<ExportBtn />
</div>
</div>
</div>
);
}
export default Calendar;
@@ -0,0 +1,155 @@
.calendarContainer {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
.calendarHeader {
display: flex;
width: 100%;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.calendarHeader p {
width: auto;
text-align: center;
font-weight: bold;
font-size: 40px;
}
.calendarHeader button {
top: 50%;
left: 50%;
width: 40px;
height: 40px;
border-radius: 50%;
border: none;
background: transparent;
box-shadow:
inset 1.25px 1.25px 1px rgba(255, 255, 255, 1),
inset -1.25px -1.25px 1px rgba(255, 255, 255, 1),
inset -0.25px 0.25px 1px rgba(0, 0, 0, 0.2),
inset 0.25px -0.25px 1px rgba(0, 0, 0, 0.2);
font-size: 15px;
color: #019AFF;
cursor: pointer;
}
.calendar {
border-collapse: collapse;
width: 80%;
max-width: 500px;
}
.calendar th, .calendar td {
border: none;
padding: 10px;
width: 14.2%;
line-height: 130%;
text-align: center;
cursor: pointer;
}
.today {
color: #007bff;
}
.hasEvent {
position: relative;
}
.hasEvent::before {
content: "";
position: absolute;
left: 50%;
bottom: 10px;
width: 20px;
height: 2px;
background-color: #007bff;
transform: translateX(-50%);
border-radius: 10px;
}
.hasEvent.selected::before {
width: 25px;
height: 2px;
bottom: 10px;
}
.selected {
position: relative;
font-size: 18px;
font-weight: 600;
}
.selected::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
transform: translate(-50%, -50%);
border-radius: 38%;
pointer-events: none;
background: transparent;
box-shadow:
inset 1.25px 1.25px 1px rgba(255, 255, 255, 1),
inset -1.25px -1.25px 1px rgba(255, 255, 255, 1),
inset -0.25px 0.25px 1px rgba(0, 0, 0, 0.2),
inset 0.25px -0.25px 1px rgba(0, 0, 0, 0.2);
}
.eventBox {
width: 100%;
padding: 15px;
margin-top: 16px;
}
.eventBox ul {
list-style: none;
}
.glassCard {
width: 45%;
max-height: 90vh;
margin: 16px 0 16px 0;
height: fit-content;
overflow: scroll;
overflow-x: hidden;
}
.glassCard::-webkit-scrollbar{
display: none;
}
.exportbox{
padding-top: 10px;
}
.exportbtn {
display: flex;
align-items: center;
}
@media (max-width: 768px) {
.glassCard {
width: 100%;
margin-top: 16px;
margin-bottom: 16px;
max-height: 450px;
}
.calendarContainer {
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
}
}
@@ -0,0 +1,40 @@
import styles from "./EventList.module.css";
import { formatDateLetter } from "../../../../utils/date/formatDateLetter";
import { useNavigate } from "react-router";
type Task = {
name: string;
description: string;
start: string | Date;
end: string | Date;
};
type EventGroup = {
id: number;
date: string;
name: string;
start: string | Date;
tasks: Task[];
};
type EventItemProps = {
eventGroup: EventGroup;
};
export default function EventItem({ eventGroup }: EventItemProps) {
const navigate = useNavigate();
return (
<div
key={eventGroup.date}
className={`${styles.eventGroup} glassBorder`}
onClick={() => navigate(`/events/${eventGroup.id}`)}
>
<div className={styles.eventTitle}>
<p className={styles.title}>{eventGroup.name}</p>
<p>{formatDateLetter(eventGroup.start)}</p>
</div>
</div>
);
}
@@ -0,0 +1,69 @@
.glassCard {
width: 45%;
max-height: 90vh;
margin: 16px 0 16px 0;
height: fit-content;
overflow: scroll;
overflow-x: hidden;
}
.glassCard::-webkit-scrollbar{
display: none;
}
.allContent{
display: flex;
flex-direction: row;
justify-content: center;
gap: 76px;
}
.eventList {
list-style: none;
padding: 0;
margin: 0;
}
.eventDetails {
margin-top: 10px;
padding: 0;
}
.dateSelected{
font-size: 100px;
}
h2{
font-size: 30px;
display: flex;
justify-content: center;
}
.eventGroup{
margin: 16px 0 16px 0;
}
.eventGroup:hover{
cursor: pointer;
}
.eventTitle {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.title {
font-weight: bold;
}
@media (max-width: 768px) {
.glassCard {
width: 100%;
margin-top: 16px;
margin-bottom: 16px;
max-height: 450px;
display: flex;
flex-direction: column;
}
}
@@ -0,0 +1,53 @@
import { useMemo, useContext } from "react";
import styles from "./EventList.module.css";
import EventItem from "./EventItem";
import { EventContext } from "../../../../contexts/Events/EventContext";
type Task = {
name: string;
description: string;
start: string | Date;
end: string | Date;
};
type EventGroup = {
date: string;
name: string;
start: string | Date;
tasks: Task[];
};
type EventContextType = {
events?: EventGroup[];
};
function EventList() {
const { events = [] } = useContext(EventContext) as EventContextType;
const sortedEvents = useMemo<EventGroup[]>(() => {
if (!Array.isArray(events)) return [];
return [...events].sort(
(a, b) =>
new Date(a.start).getTime() - new Date(b.start).getTime()
);
}, [events]);
return (
<div className={`${styles.glassCard} glassCard`}>
<h2>Liste des événements à venir</h2>
{sortedEvents.length > 0 ? (
<div className={styles.eventList}>
{sortedEvents.map((eventGroup, index) => (
<EventItem eventGroup={eventGroup} key={index} />
))}
</div>
) : (
<p>Aucun événement planifié pour l'instant.</p>
)}
</div>
);
}
export default EventList;
@@ -0,0 +1,16 @@
.h2 {
justify-content: left;
}
.section {
margin: 2rem 0;
}
.section ul {
padding : 0 2em;
}
.legalNotices{
margin: 16px 2em;
}
+40
View File
@@ -0,0 +1,40 @@
import styles from "./LegalNoticesPage.module.css";
import {Link} from "react-router";
export default function legalNoticesPage() {
return (
<div className={`${styles.legalNotices} glassCard`}>
<h1>Mentions Légales</h1>
<section className={styles.section}>
<h2 className={styles.h2}>1. Édition de l'application</h2>
<p>
L'édition et la direction de la publication de l'application sont assurées par Monsieur Robin Frère
<br />Contact : <Link to={"https://comite.beaupont.fr/contactez-nous"}>https://comite.beaupont.fr/contactez-nous</Link>
</p>
</section>
<section className={styles.section}>
<h2 className={styles.h2}>2. Conception et Développement</h2>
<p>L'application a été conçue et développée par :</p>
<ul>
<li>Quentin T'jampens</li>
<li>Antoine Ordonneau</li>
<li>Malo Leblond</li>
<li>Giovanni Josserand</li>
</ul>
</section>
<section className={styles.section}>
<h2 className={styles.h2}>3. Hébergement</h2>
<p>
L'application est hébergée par la société OVH.
<ul>
<li>Siège social : 2 rue Kellermann - 59100 Roubaix - France</li>
<li>Site web : <Link to={"www.ovh.com"}>www.ovh.com</Link></li>
</ul>
</p>
</section>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import styles from "./LoginPage.module.css";
import LoginForm from "./components/LoginForm/LoginForm.tsx";
import Background from "../../components/Background/Background.tsx";
import { useEffect, useContext } from "react";
import { AuthContext } from "../../contexts/Auth/AuthContext.ts";
import { useNavigate } from "react-router";
function LoginPage() {
const { user, loading } = useContext(AuthContext)
const navigate = useNavigate();
useEffect(() => {
console.log("loading" + loading);
console.log("user" + user);
if (!loading && user) {
navigate("/");
}
}, [loading, navigate, user]);
return (
<Background>
<div className={styles.container}>
<h1 className={styles.title}>Connexion</h1>
<LoginForm />
</div>
</Background>
);
}
export default LoginPage;
+64
View File
@@ -0,0 +1,64 @@
/*.container {
position: absolute;
z-index: 9999;
touch-action: manipulation;
width: 90%;
max-width: 380px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border-radius: 14px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}*/
.container {
position: relative; /* au lieu d'absolute */
z-index: 9999;
touch-action: manipulation;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 90%;
max-width: 420px; /* RegisterPage : 420px / LoginPage : 380px */
padding: 20px;
border-radius: 14px;
margin: auto; /* centre dans le parent flex */
}
.title {
font-size: 1.8rem;
color: white;
text-align: center;
margin-bottom: 20px;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
@media (min-width: 600px) {
.container {
max-width: 460px;
padding: 24px;
}
.title {
font-size: 2.2rem;
}
}
@media (min-width: 1024px) {
.container {
max-width: 600px;
padding: 32px;
}
.title {
font-size: 2.6rem;
}
}
+28
View File
@@ -0,0 +1,28 @@
import styles from "./LoginPage.module.css";
import LoginForm from "./components/LoginForm/LoginForm";
import Background from "../../components/Background/Background.js";
import { useEffect, useContext } from "react";
import { AuthContext, AuthContextType } from "../../contexts/Auth/AuthContext";
import { useNavigate } from "react-router";
function LoginPage() {
const { user, loading } = useContext(AuthContext) as AuthContextType;
const navigate = useNavigate();
useEffect(() => {
if (!loading && user) {
navigate("/");
}
}, [loading, user, navigate]);
return (
<Background className="">
<div className={styles.container}>
<h1 className={styles.title}>Connexion</h1>
<LoginForm />
</div>
</Background>
);
}
export default LoginPage;
@@ -0,0 +1,68 @@
:global(.glassCard) {
--glass-bg: #ffffff9f;
}
input {
width: 100%;
height: 60px;
padding: 0 10px;
border: none;
border-radius: 10px;
font-size: 19px;
color: #111;
background: var(--glass-bg);
box-shadow: 0 2px 6px rgba(91, 90, 90, 0.06) inset;
}
input::placeholder{
font-size: 20px;
color: rgba(0,0,0,0.45);
}
.formgroup{
margin-top:23px;
}
.logButton{
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
margin-top: 12px;
}
.inputContainer {
padding: 15px;
width: 100%;
}
.loginButton {
display: inline-block;
height: 50px;
width:90%;
border-radius: 16px;
margin-bottom:3%;
margin-top:3%;
background-color: rgba(255, 255, 255, 0.335);
color: #000000;
font-weight: 600;
border: none;
cursor: pointer;
transition: background-color 160ms ease, transform 120ms ease, box-shadow 160ms ease;
font-size: 15px;
}
.loginButton:hover {
background-color: #54585e;
transform: translateY(-2px);
box-shadow: 0 6px 14px rgba(43,108,176,0.18);
}
.loginButton:active {
transform: translateY(0);
box-shadow: 0 3px 8px rgba(43,108,176,0.12);
}
@@ -0,0 +1,71 @@
import { useState, useContext, FormEvent } from "react";
import styles from "./LoginForm.module.css";
import Button from "../../../../components/ui/Button/Button";
import Input from "../../../../components/ui/Input/Input";
import Modal from "../../../../components/ui/Modal/Modal";
import { useNavigate } from "react-router";
import { AuthContext, AuthContextType } from "../../../../contexts/Auth/AuthContext";
interface LoginFormProps {
className?: string;
}
function LoginForm({ className }: LoginFormProps) {
const navigate = useNavigate();
const authContext = useContext(AuthContext) as AuthContextType;
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [open, setOpen] = useState(false);
const { login } = authContext;
async function handleSubmit(e?: FormEvent) {
if (e) e.preventDefault();
const res = await login(email, password);
if (res.status === 401 || res.status === 422) setOpen(true);
else navigate("/");
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit();
}
};
return (
<div className={`${className} glassCard ${styles.inputContainer}`} onKeyDown={handleKeyDown}>
<form onSubmit={handleSubmit}>
<div className={styles.formgroup}>
<Input
type="email"
value={email}
placeholder="Adresse mail"
onChange={e => setEmail(e.target.value)}
style={{ width: '100%' }}
/>
</div>
<div className={styles.formgroup}>
<Input
password={true}
value={password}
placeholder="Mot de passe"
onChange={e => setPassword(e.target.value)}
/>
</div>
<div className={styles.logButton}>
<Button variant={"transparent"} onClick={() => navigate("/register")}>Vous n'avez pas de compte ?</Button>
<Button onClick={handleSubmit} variant={"default"}>Se connecter</Button>
</div>
</form>
<Modal title={"Impossible de se connecter"} open={open} onClose={() => setOpen(false)}>
<p>L'Email ou le mot de passe est incorrect</p>
</Modal>
</div>
);
}
export default LoginForm;
@@ -0,0 +1,33 @@
.title {
color: white;
font-size: xx-large;
margin-bottom: 20px;
}
.content {
display: flex;
flex-direction: column;
z-index: 2;
padding: 10px;
}
.container {
padding: 20px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 15px;
p {
font-size: large;
}
}
.redirectSpan {
color: blue;
text-decoration: underline;
}
.redirectSpan:hover {
cursor: pointer;
}
+25
View File
@@ -0,0 +1,25 @@
import Background from "../../components/Background/Background";
import styles from "./PageNotFoundPage.module.css"
import { useNavigate } from "react-router";
import { APP_CONFIG } from "../../config/appConfig.js";
export default function PageNotFoundPage() {
const navigate = useNavigate();
return <Background>
<div className={styles.content}>
<h1 className={styles.title}> Page non trouvée </h1>
<div className={`${styles.container} glassCard`}>
<p>
Désolé, la page que vous recherchez n'existe pas ou n'est plus disponible. <br/>
Vous pouvez : <br/>
- Vérifier l'URL pour une éventuelle erreur de saisie. <br/>
- Revenir à l'accueil <span onClick={() => navigate("/")} className={styles.redirectSpan}>ici.</span> <br/>
</p>
<p> Contact : {APP_CONFIG.contactEmail} </p>
</div>
</div>
</Background>
}
+220
View File
@@ -0,0 +1,220 @@
.container {
width: 100%;
padding: 12px;
box-sizing: border-box;
margin: 0 auto;
}
.profileboard {
width: 100%;
padding: 16px;
border-radius: 20px;
box-sizing: border-box;
}
.settingAccount {
display: flex;
justify-content: center;
align-items: center;
}
.contentWrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 20px;
}
.profilePictureContainer {
width: 120px;
height: 120px;
margin-bottom: 0;
position: relative;
display: flex;
justify-content: center;
}
.profilePicture {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.editPictureButton {
margin-top: 16px;
padding-top: 12px;
padding-bottom: 12px;
background: rgba(255, 255, 255, 0.95);
border: none;
border-radius: 24px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
color: #e74c3c;
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
backdrop-filter: blur(5px);
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: auto;
min-width: 140px;
}
.editPictureButton:hover {
background: rgba(255, 255, 255, 1);
transform: translateY(-2px);
box-shadow: 0 5px 12px rgba(0, 0, 0, 0.2);
color: #c0392b;
}
.description {
width: 100%;
text-align: left;
}
.headerProfile {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.description h2 {
font-size: 1.6rem;
margin-bottom: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.description button {
width: 44px;
height: 44px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.modifyIcon {
width: 22px;
height: 22px;
display: block;
margin: 0;
}
.topDescription {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
margin-bottom: 20px;
}
.infoBlock {
width: 100%;
padding: 12px;
box-sizing: border-box;
}
.settingAcount {
width: 100%;
display: flex;
justify-content: center;
margin-top: 16px;
}
.profilePictureContainer {
position: relative;
}
.pictureOverlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
color: white;
text-align: center;
padding: 2px;
border-radius: 0 0 90% 90%;
opacity: 0;
transition: opacity 0.2s;
}
.clickable {
cursor: pointer;
}
.profilePictureContainer:hover .pictureOverlay {
opacity: 1;
}
@media (min-width: 768px) {
.contentWrapper {
flex-direction: row;
align-items: flex-start;
gap: 24px;
}
.profilePictureContainer {
width: 150px;
height: 150px;
margin-right: 0;
}
.description {
width: calc(100% - 180px);
}
.topDescription {
flex-direction: row;
justify-content: space-between;
flex-wrap: wrap;
}
.infoBlock {
flex: 1;
min-width: calc(50% - 8px);
max-width: calc(50% - 8px);
}
.editPictureButton {
margin: 16px auto 0 auto;
display: block;
}
.headerProfile h2 {
font-size: 1.8rem;
}
}
@media (min-width: 1024px) {
.container {
max-width: 1200px;
padding: 24px;
}
.profileboard {
padding: 28px;
}
.profilePictureContainer {
width: 180px;
height: 180px;
}
.description {
width: calc(100% - 220px);
}
.headerProfile h2 {
font-size: 2rem;
}
}
+148
View File
@@ -0,0 +1,148 @@
import {useContext, useEffect, useState} from "react";
import styles from "./ProfilePage.module.css";
import {AuthContext} from "../../contexts/Auth/AuthContext";
import formatDate from "../../utils/date/formatDate";
import Task from "../../components/Task/Task";
import SettingsModal from "./components/SettingsModal/SettingsModal";
import {useParams} from "react-router";
import {useNavigate} from "react-router";
import {useRef} from "react";
import getUserById from "../../utils/users/getUserById.js";
import ManageMember from "./components/ManageMember/ManageMember";
import uploadProfilePhoto from "../../utils/users/uploadProfilePhoto";
import deleteProfilePhoto from "../../utils/users/deleteProfilePhoto";
import type { AuthContextType } from "../../interfaces/auth.interface";
import type TaskType from "../../interfaces/task.interface";
import type User from "../../interfaces/user.interface";
function ProfilePage() {
const navigate = useNavigate();
const { id } = useParams();
const { user, update } = useContext(AuthContext) as AuthContextType;
const [profileUser, setProfileUser] = useState<User | null>(null);
const [profilePicture, setProfilePicture] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const isOwnProfile = !id || user?.id.toString() === id;
useEffect(() => {
(async () => {
if (!isOwnProfile && id) {
const res = await getUserById(Number(id));
if (res.status === 404) {
navigate("/404");
return;
}
if (res.status !== 200) {
console.log("Erreur lors du chargement du profil", res);
return;
}
setProfileUser(res.data);
} else {
setProfileUser(user);
if (user?.profile_photo_path) {
setProfilePicture(`${import.meta.env.VITE_API_URL}/storage/${user.profile_photo_path}`);
}
}
})();
}, [user, id]);
const handleEditPictureClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const res = await uploadProfilePhoto(file);
if (res.ok) {
setProfilePicture(`${import.meta.env.VITE_API_URL}/storage/${res.data.profile_photo_path}`);
console.log("Photo mise à jour")
await update();
} else {
console.log(res.data?.message || res.error || "Erreur lors de l'upload.");
}
} catch (err) {
console.log("Erreur réseau.");
}
};
const handleDeletePicture = async () => {
await deleteProfilePhoto();
setProfilePicture(null);
};
return (
<div className={styles.container}>
<div className={`${styles.profileboard} glassCard`}>
<div className={styles.contentWrapper}>
<div className={styles.profilePictureContainer} onClick={isOwnProfile ? handleEditPictureClick : undefined} style={isOwnProfile ? { cursor: "pointer" } : {}}>
<img
src={isOwnProfile ? (profilePicture || "/react.svg") : "/react.svg"}
alt="Photo de profil"
className={styles.profilePicture}
/>
{isOwnProfile && (
<div className={styles.pictureOverlay}>
Modifier
</div>
)}
</div>
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="image/*" style={{ display: "none" }}/>
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
accept="image/*"
style={{ display: "none" }}
/>
<div className={styles.description}>
<div className={styles.headerProfile}>
<h2>
{profileUser?.name} {profileUser?.lastname}
</h2>
{user?.isAdmin && profileUser && !isOwnProfile && (
<ManageMember userToManage={profileUser} />
)}
{isOwnProfile && (
<div className={styles.settingAccount}>
<SettingsModal />
</div>
)}
</div>
<div className={styles.topDescription}>
<div className={`${styles.infoBlock} glassBorder`}>
<p><strong>Role :</strong> {profileUser?.role}</p>
<p><strong>Membre depuis :</strong> {profileUser && formatDate(profileUser.created_at)}</p>
</div>
<div className={`${styles.infoBlock} glassBorder`}>
<p><strong>Mail :</strong> {profileUser?.email}</p>
<p><strong>Téléphone :</strong> {profileUser?.phone ?? "Non renseigné"}</p>
</div>
</div>
<h2>Tâches :</h2>
{profileUser?.tasks.length ? (
profileUser.tasks.map((task) => <Task key={task.id} task={task} />)
) : (
<p>Aucune tâche.</p>
)}
</div>
</div>
</div>
</div>
);
}
export default ProfilePage;
@@ -0,0 +1,31 @@
import Button from "../../../../../components/ui/Button/Button.tsx";
import Modal from "../../../../../components/ui/Modal/Modal.tsx";
import { useState } from "react";
import deactivateUser from "../../../../../utils/users/deactivateUser.ts"
import { useNavigate } from "react-router";
export default function DeactivateMemberBtn({ name, id, ...props }) {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const deactivateMember = async () => {
const result = await deactivateUser(id);
if (result.status === 200 || result.status === 204) {
setOpen(false);
navigate("/volunteers");
}
}
return <div {...props}>
<Button variant={"danger"} onClick={() => setOpen(true)}>Désactiver</Button>
<Modal open={open} onClose={() => setOpen(false)} title={"Supprimer"}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 15 }}>
<p>Etes-vous sur de vouloir désactiver le compte {name} ?</p>
<Button variant={"danger"} onClick={deactivateMember}>Désactiver</Button>
</div>
</Modal>
</div>
}
@@ -0,0 +1,31 @@
import {ReactNode, useState} from "react";
import Button from "../../../../../components/ui/Button/Button";
import Modal from "../../../../../components/ui/Modal/Modal";
import { useNavigate } from "react-router";
import deleteOtherUser from "../../../../../utils/users/deleteOtherUser";
export default function DeleteMemberBtn({name, id, className}: { name: string, id: number, className?: any }): ReactNode {
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const deleteMember = async () => {
const result = await deleteOtherUser(id);
if (result.status === 200 || result.status === 204) {
setOpen(false);
navigate("/volunteers");
}
}
return <div className={className}>
<Button variant={"danger"} onClick={() => setOpen(true)}>Supprimer</Button>
<Modal open={open} onClose={() => setOpen(false)} title={"Supprimer"}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 15 }}>
<p>Etes-vous sur de vouloir supprimer le compte {name} ?</p>
<Button variant={"danger"} onClick={deleteMember}>Supprimer</Button>
</div>
</Modal>
</div>
}
@@ -0,0 +1,41 @@
.icon {
width: 24px;
}
.manageBtn {
display: flex;
flex-direction: row;
}
.manageSection {
display: flex;
flex-direction: column;
gap: 8px;
margin: 1.5em;
}
.manageBlockContent {
margin: 0.5em;
}
.modifyRole {
display: flex;
flex-direction: row;
justify-content: space-evenly;
align-items: center;
}
.select {
width: 70%;
font-size: 14px;
font-weight: 500;
color: #1d1d1f;
background: rgb(255, 255, 255, 0.75);
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.modalContent {
text-align: center;
}
@@ -0,0 +1,43 @@
import Button from "../../../../components/ui/Button/Button";
import Modal from "../../../../components/ui/Modal/Modal";
import {useContext, useState} from "react";
import styles from "./ManageMember.module.css"
import DeactivateMemberBtn from "./DeactivateMember/DeactivateMemberBtn.jsx";
import ModifyRole from "./ModifyRole/ModifyRole.jsx";
import {AuthContext, AuthContextType} from "../../../../contexts/Auth/AuthContext.js";
import DeleteMemberBtn from "./DeleteMember/DeleteMemberBtn";
import User from "../../../../interfaces/user.interface";
export default function ManageMember({userToManage}: { userToManage: User }) {
const {user} = useContext(AuthContext) as AuthContextType;
const [open, setOpen] = useState(false);
return <>
<Button onClick={() => setOpen(true)} variant={"default"} className={styles.manageBtn}>
<img src="/icons/tools.svg" alt="tools icon" className={styles.icon}/>
<p>Gérer</p>
</Button>
<Modal open={open} onClose={() => setOpen(false)} title={`Gestion de l'utilisateur ${userToManage.name}`}>
{(user?.id != userToManage.id) ? (
<div className={`${styles.manageSection} glassBorder`}>
<h3>Désactiver le compte</h3>
<DeactivateMemberBtn name={userToManage.name} id={userToManage.id}
className={styles.manageBlockContent}/>
<h3> Supprimer le compte</h3>
<DeleteMemberBtn name={userToManage.name} id={userToManage.id}
className={styles.manageBlockContent}/>
</div>
) : null}
<div className={`${styles.manageSection} glassBorder`}>
<h3>Modifier le rôle</h3>
<ModifyRole user={userToManage}/>
</div>
</Modal>
</>
}
@@ -0,0 +1,60 @@
import styles from "../ManageMember.module.css";
import Button from "../../../../../components/ui/Button/Button.tsx";
import modifyRole from "../../../../../utils/users/modifyRole.ts";
import { useState, useEffect } from "react";
import getRoles from "../../../../../utils/getRoles.ts";
import Modal from "../../../../../components/ui/Modal/Modal.tsx";
export default function ModifyRole({ user }) {
const [roles, setRoles] = useState([]);
const [selectedRole, setSelectedRole] = useState(3);
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
useEffect(() => {
getRoles().then(data => {
if (data) setRoles(data.data);
//TODO: Fermer les modal + actualiser les infos lors du changement de role
});
}, [])
const handleRoleUpdate = async () => {
const res = await modifyRole(selectedRole, user.id);
if (res.status === 200) {
setDescription("Rôle modifié avec succès");
setTitle("Modification")
setOpen(true);
} else {
setDescription("Erreur lors de la modification du rôle");
setTitle("Erreur")
setOpen(true);
}
};
return <div className={`${styles.manageBlockContent} ${styles.modifyRole}`}>
<select
value={selectedRole}
onChange={(e) => setSelectedRole(e.target.value)}
className={`${styles.select} glassBorder`}
>
{roles.map((role, index) => (
<option key={index} value={index+1}>
{role}
</option>
))}
</select>
<Button onClick={handleRoleUpdate} variant={"default"}>
Enregistrer
</Button>
<Modal open={open} onClose={() => setOpen(false)} title={title}>
<p className={styles.modalContent}>{description}</p>
</Modal>
</div>
}
@@ -0,0 +1,110 @@
.content {
width: 100%;
display: flex;
flex-direction: column;
gap: 20px;
}
.divNotif {
display: flex;
align-items: center;
justify-content: space-between;
}
.checkboxLabel {
display: flex;
align-items: center;
cursor: pointer;
}
.checkboxLabel input {
display: none;
}
.checkmark {
width: 30px;
height: 30px;
border-radius: 6px;
border: 2px solid #019AFF;
background-color: transparent;
transition: all 0.2s ease;
position: relative;
}
.checkboxLabel input:checked + .checkmark {
background-color: #019AFF;
}
.checkmark::after {
content: "";
position: absolute;
display: none;
}
.checkboxLabel input:checked + .checkmark::after {
display: block;
left: 8px;
top: 4px;
width: 6px;
height: 12px;
border: solid white;
border-width: 0 3px 3px 0;
transform: rotate(45deg);
}
.modifyIcon {
height: 26px;
width: 26px;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.modifyIcon:hover {
transform: rotate(15deg);
opacity: 0.8;
}
.section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 18px;
}
.section h3 {
font-size: 1.15rem;
font-weight: 600;
}
.section h4 {
font-size: 1rem;
font-weight: 500;
opacity: 0.85;
}
.section button {
width: 100%;
}
.submitBtn {
align-self: center;
text-align: center;
margin-top: 10px;
}
.alignText {
text-align: center;
line-height: 1.5;
}
.section :global(input) {
width: 100%;
box-sizing: border-box;
}
@media (min-width: 640px) {
.submitBtn {
width: auto;
}
.section button {
width: fit-content;
}
}
@@ -0,0 +1,298 @@
import {useContext, useState, ChangeEvent, useEffect} from "react";
import Modal from "../../../../components/ui/Modal/Modal";
import Button from "../../../../components/ui/Button/Button";
import styles from "./SettingsModal.module.css";
import ThemeSwitcher from "../../../../components/ui/ThemeSwitcher/ThemeSwitcher";
import {AuthContext} from "../../../../contexts/Auth/AuthContext";
import TextInput from "../../../../components/ui/Input/Input";
import updateUser from "../../../../utils/users/updateUser.js";
import deleteUser from "../../../../utils/users/deleteUser.js";
import ExportCalendarBtn from "../../../../components/ExportCalendarBtn/ExportBtn";
import { useNavigate } from "react-router";
import toggleEmailNotifications from "../../../../utils/users/toggleEmailNotifications";
import toggleWebNotifications from "../../../../utils/users/toggleWebNotifications";
import updateUserPassword from "../../../../utils/users/updateUserPassword";
import updateUserEmail from "../../../../utils/users/updateUserEmail";
interface TaskType {
id: number;
title?: string;
completed?: boolean;
[key: string]: unknown;
}
interface User {
id: number;
name: string;
lastname: string;
role: string;
email: string;
phone: string | null;
created_at: string;
isAdmin: boolean;
tasks: TaskType[],
email_notifications: number,
web_notifications: number;
}
interface AuthContextType {
user: User;
update: () => void;
logout: () => void;
}
export default function SettingsModal() {
const navigate = useNavigate();
const {user, logout, update} = useContext(AuthContext) as unknown as AuthContextType;
const [open, setOpen] = useState<boolean>(false);
const [isDelete, setIsDelete] = useState<boolean>(false);
const [name, setName] = useState<string>(user?.name ?? "");
const [lastname, setLastName] = useState<string>(user?.lastname ?? "");
const [phone, setPhone] = useState<string>(user?.phone ?? "");
const [emailNotifications, setEmailNotifications] = useState<number>(user?.email_notifications ?? 0);
const [webNotifications, setWebNotifications] = useState<number>(user?.web_notifications ?? 0);
const [currentPassword, setCurrentPassword] = useState<string>('');
const [newPassword, setNewPassword] = useState<string>('');
const [newPasswordConfirm, setNewPasswordConfirm] = useState<string>('');
const [email, setEmail] = useState<string>('');
const handleLogout = (): void => {
logout();
};
const handleSubmit = async (): Promise<void> => {
await updateUser(name, lastname, phone || null);
update();
setOpen(false);
};
const handleDelete = async (): Promise<void> => {
await deleteUser();
logout();
navigate('/');
update();
};
const handleNameChange = (e: ChangeEvent<HTMLInputElement>): void => {
setName(e.target.value);
};
const handleLastnameChange = (e: ChangeEvent<HTMLInputElement>): void => {
setLastName(e.target.value);
};
const handlePhoneChange = (e: ChangeEvent<HTMLInputElement>): void => {
setPhone(e.target.value);
};
const handleEmailToggle = async () => {
try {
await toggleEmailNotifications(user.id);
setEmailNotifications(prev => prev === 0 ? 1 : 0);
update();
} catch (err) {
console.error(err);
}
};
const handleWebToggle = async () => {
try {
await toggleWebNotifications(user.id);
setWebNotifications(prev => prev === 0 ? 1 : 0);
update();
} catch (err) {
console.error(err);
}
};
const handlePasswordUpdate = async () => {
try {
await updateUserPassword(currentPassword, newPassword, newPasswordConfirm);
setCurrentPassword('');
setNewPassword('');
setNewPasswordConfirm('');
} catch (err) {
console.error(err);
}
};
const handleEmailUpdate = async () => {
try {
await updateUserEmail(email);
setEmail('');
update();
} catch (err) {
console.error(err);
}
};
return (
<>
<Button variant="transparent" onClick={() => setOpen((prev) => !prev)}>
<img
src="/icons/settings-wheel.svg"
alt="Modify btn"
className={styles.modifyIcon}
/>
</Button>
<Modal
title="Paramètres"
open={open}
onClose={() => setOpen(false)}
>
<div className={styles.content}>
<div className={`glassBorder ${styles.section}`}>
<h3>Modifier les informations du compte</h3>
<div>
<h4>Changer de Prénom</h4>
<TextInput
value={name}
onChange={handleNameChange}
type="text"
/>
</div>
<div>
<h4>Changer de nom</h4>
<TextInput
value={lastname}
onChange={handleLastnameChange}
type="text"
/>
</div>
<div>
<h4>Changer de numéro de téléphone</h4>
<TextInput
value={phone}
onChange={handlePhoneChange}
type="text"
/>
</div>
<Button
className={styles.submitBtn}
onClick={handleSubmit}
>
Confirmer
</Button>
</div>
<div className={`glassBorder ${styles.section}`}>
<h3>Changer l'adresse email</h3>
<div>
<h4>Nouvelle adresse email</h4>
<TextInput value={email} onChange={e => setEmail(e.target.value)} type="email" />
</div>
<Button className={styles.submitBtn} onClick={handleEmailUpdate}>
Confirmer
</Button>
</div>
<div className={`glassBorder ${styles.section}`}>
<h3>Changer le mot de passe</h3>
<div>
<h4>Mot de passe actuel</h4>
<TextInput value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} type="password" />
</div>
<div>
<h4>Nouveau mot de passe</h4>
<TextInput value={newPassword} onChange={e => setNewPassword(e.target.value)} type="password" />
</div>
<div>
<h4>Confirmer le nouveau mot de passe</h4>
<TextInput value={newPasswordConfirm} onChange={e => setNewPasswordConfirm(e.target.value)} type="password" />
</div>
<Button className={styles.submitBtn} onClick={handlePasswordUpdate}>
Confirmer
</Button>
</div>
<div className={`glassBorder ${styles.section}`}>
<h3>Apparence</h3>
<ThemeSwitcher/>
</div>
<div className={`glassBorder ${styles.section}`}>
<h3>Notifications</h3>
<div className={styles.divNotif}>
<h4>Notifications par email</h4>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={emailNotifications === 1}
onChange={handleEmailToggle}
/>
<span className={styles.checkmark}></span>
</label>
</div>
<div className={styles.divNotif}>
<h4>Notifications sur le site</h4>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={webNotifications === 1}
onChange={handleWebToggle}
/>
<span className={styles.checkmark}></span>
</label>
</div>
</div>
<div className={`glassBorder ${styles.section}`}>
<h3>Divers</h3>
<ExportCalendarBtn />
</div>
<div className={`glassBorder ${styles.section}`}>
<h3>Sécurité du compte</h3>
<Button variant="danger" onClick={handleLogout}>
Déconnexion
</Button>
<Button
variant="danger"
onClick={() => setIsDelete(true)}
>
Supprimer le compte
</Button>
</div>
</div>
</Modal>
<Modal
title="Validation"
open={isDelete}
onClose={() => setIsDelete(false)}
>
<div className={styles.section}>
<p className={styles.alignText}>
Êtes vous vraiment sûr de vouloir supprimer votre compte ?
</p>
<Button
variant="danger"
className={styles.submitBtn}
onClick={handleDelete}
>
Confirmer
</Button>
</div>
</Modal>
</>
);
}
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more