From 667a0035c692e6bd231a83376575b407a130692f Mon Sep 17 00:00:00 2001 From: Louis Mazin Date: Fri, 25 Sep 2026 10:29:24 +0200 Subject: [PATCH] test --- backend/events/urls.py | 2 + backend/users/views.py | 36 + frontend/src/pages/CalendarPage.jsx | 1767 ++++++++++++++++++++++++++- frontend/src/pages/MyspacePage.jsx | 201 ++- 4 files changed, 1987 insertions(+), 19 deletions(-) diff --git a/backend/events/urls.py b/backend/events/urls.py index 991e4cd..a4eea0a 100644 --- a/backend/events/urls.py +++ b/backend/events/urls.py @@ -5,4 +5,6 @@ urlpatterns = [ path('', views.EventListView.as_view(), name='event-list'), path('/', views.EventDetailView.as_view(), name='event-detail'), path('create/', views.EventCreateView.as_view(), name='event-create'), + path('change-password/', views.ChangePasswordView.as_view(), name='change-password', +), ] \ No newline at end of file diff --git a/backend/users/views.py b/backend/users/views.py index f23b413..da750c8 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -7,6 +7,7 @@ from django.utils import timezone from datetime import timedelta from .models import Invitation, User from .serializers import UserSerializer, RegisterSerializer +from django.contrib.auth import password_validation class RegisterView(generics.CreateAPIView): queryset = User.objects.all() @@ -73,7 +74,42 @@ class UserPromoteView(APIView): except User.DoesNotExist: return Response({'error': 'Utilisateur non trouvé'}, status=404) +class ChangePasswordView(APIView): + permission_classes = [IsAuthenticated] + def post(self, request): + current_password = request.data.get("current_password", "") + new_password = request.data.get("new_password", "") + confirm_password = request.data.get("confirm_password", "") + + if not request.user.check_password(current_password): + return Response( + {"current_password": ["Mot de passe actuel incorrect."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if new_password != confirm_password: + return Response( + {"confirm_password": ["Les mots de passe ne correspondent pas."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + password_validation.validate_password( + new_password, + user=request.user, + ) + except DjangoValidationError as error: + return Response( + {"new_password": error.messages}, + status=status.HTTP_400_BAD_REQUEST, + ) + + request.user.set_password(new_password) + request.user.save(update_fields=["password"]) + + return Response({"detail": "Mot de passe modifié."}) + class UserDeleteView(UserDetailView): http_method_names = ['delete', 'options'] diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx index dafab93..431c40f 100644 --- a/frontend/src/pages/CalendarPage.jsx +++ b/frontend/src/pages/CalendarPage.jsx @@ -1,8 +1,13 @@ import React, { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, } from 'react'; @@ -14,7 +19,9 @@ import MainCard from '@/components/MainCard'; import HoverInfo from '@/components/HoverInfo'; import { + Card, + CardContent, } from '@/components/ui/card'; @@ -22,11 +29,17 @@ import { import { Button } from '@/components/ui/button'; import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, } from '@/components/ui/dialog'; @@ -38,11 +51,17 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { + ChevronLeft, + ChevronRight, + Calendar, + Plus, + Pencil, + Trash2, } from 'lucide-react'; @@ -52,33 +71,57 @@ import { eventApi } from '../api/event'; import { Alert, AlertDescription } from '@/components/ui/alert'; const MONTHS = [ + 'Janvier', + 'Février', + 'Mars', + 'Avril', + 'Mai', + 'Juin', + 'Juillet', + 'Août', + 'Septembre', + 'Octobre', + 'Novembre', + 'Décembre', ]; const DAYS_IN_MONTH = [ + 31, + 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, ]; @@ -100,1757 +143,3473 @@ const EVENT_LANE_GAP = 11; const EVENT_LEFT_PADDING = 5; const CalendarPage = () => { + const [requestError, setRequestError] = useState(''); + const [year, setYear] = useState( + new Date().getFullYear(), + ); const [selectedMonth, setSelectedMonth] = useState( + new Date().getMonth(), + ); + const [isMobile, setIsMobile] = + useState(false); + const [availableHeight, setAvailableHeight] = + useState(600); + const [events, setEvents] = + useState([]); + const [loadingEvents, setLoadingEvents] = + useState(true); + const [hoveredEvent, setHoveredEvent] = + useState(null); + const [hoverPosition, setHoverPosition] = + useState(null); + /* + * ============================================================ + * MENU CONTEXTUEL + * ============================================================ + */ + const [contextMenu, setContextMenu] = + useState(null); + /* + * contextMenu : + * + * { + * x: number, + * y: number, + * type: 'day' | 'event', + * date?: 'YYYY-MM-DD', + * event?: object + * } + */ + /* + * ============================================================ + * DIALOG ÉVÉNEMENT + * ============================================================ + */ + const [eventDialogOpen, setEventDialogOpen] = + useState(false); + const [editingEvent, setEditingEvent] = + useState(null); + const [savingEvent, setSavingEvent] = + useState(false); + const [eventForm, setEventForm] = + useState({ + name: '', + description: '', + start_date: '', + end_date: '', + start_time: '', + end_time: '', + color: DEFAULT_EVENT_COLOR, + }); + const calendarRef = useRef(null); + /* SÉLECTION D'UNE PÉRIODE */ + const [selectionStart, setSelectionStart] = useState(null); + const [selectionEnd, setSelectionEnd] = useState(null); + const [isSelecting, setIsSelecting] = useState(false); + const selectionEndRef = useRef(null); + const isSelectingRef = useRef(false); + /* + * ============================================================ + * UTILITAIRES + * ============================================================ + */ + const isLeapYear = (value) => { + return ( + (value % 4 === 0 && + value % 100 !== 0) || + value % 400 === 0 + ); + }; + const getDaysInMonth = (monthIndex) => { + if ( + monthIndex === 1 && + isLeapYear(year) + ) { + return 29; + } + return DAYS_IN_MONTH[monthIndex]; + }; + const formatDateForInput = ( + monthIndex, + day, + ) => { + return `${year}-${String( + monthIndex + 1, + ).padStart(2, '0')}-${String(day).padStart( + 2, + '0', + )}`; + }; + const parseDate = (value) => { + if (!value) return null; + const [ + yearValue, + monthValue, + dayValue, + ] = String(value) + .split('-') + .map(Number); + if ( + !yearValue || + !monthValue || + !dayValue + ) { + return null; + } + return new Date( + yearValue, + monthValue - 1, + dayValue, + ); + }; + const getEventStartDate = ( + event, + ) => { + return event?.start_date ?? ''; + }; + const getEventEndDate = ( + event, + ) => { + return ( + event?.end_date ?? + event?.start_date ?? + '' + ); + }; + const isDateInEvent = ( + event, + monthIndex, + day, + ) => { + const eventStart = + parseDate( + getEventStartDate(event), + ); + const eventEnd = + parseDate( + getEventEndDate(event), + ); + if ( + !eventStart || + !eventEnd + ) { + return false; + } + const cellDate = new Date( + year, + monthIndex, + day, + ); + return ( + cellDate >= eventStart && + cellDate <= eventEnd + ); + }; + const isSingleDayEvent = ( + event, + ) => { + return ( + getEventStartDate(event) === + getEventEndDate(event) + ); + }; + const getPeriodEventPosition = ( + event, + monthIndex, + day, + ) => { + const startDate = + parseDate( + getEventStartDate(event), + ); + const endDate = + parseDate( + getEventEndDate(event), + ); + if ( + !startDate || + !endDate + ) { + return null; + } + const currentDate = new Date( + year, + monthIndex, + day, + ); + return { + isStart: + currentDate.getTime() === + startDate.getTime(), + isEnd: + currentDate.getTime() === + endDate.getTime(), + }; + }; + const getOrderedSelection = () => { + if (!selectionStart || !selectionEnd) return null; + return selectionStart <= selectionEnd + ? { start: selectionStart, end: selectionEnd } + : { start: selectionEnd, end: selectionStart }; + }; + const isDateSelected = (date) => { - const selection = getOrderedSelection(); + + const selection = isMobile ? null : getOrderedSelection(); + return Boolean( + selection && + date >= selection.start && + date <= selection.end, + ); + }; + const clearSelection = () => { + setSelectionStart(null); + setSelectionEnd(null); + setIsSelecting(false); + selectionEndRef.current = null; + isSelectingRef.current = false; + }; + const beginSelection = (pointerEvent, date) => { - if (pointerEvent.button !== 0) return; + + if (isMobile || pointerEvent.pointerType !== 'mouse' || pointerEvent.button !== 0) return; + pointerEvent.preventDefault(); + closeContextMenu(); + setHoveredEvent(null); + setHoverPosition(null); + selectionEndRef.current = date; + isSelectingRef.current = true; + setSelectionStart(date); + setSelectionEnd(date); + setIsSelecting(true); + }; + const updateSelectionFromPointer = (pointerEvent) => { - if (!isSelectingRef.current) return; + + if (!isSelectingRef.current || isMobile) return; + const element = document.elementFromPoint( + pointerEvent.clientX, + pointerEvent.clientY, + ); + const cell = element?.closest?.('[data-calendar-date]'); + const date = cell?.dataset?.calendarDate; + if (!date || date === selectionEndRef.current) return; + selectionEndRef.current = date; + setSelectionEnd(date); + }; + const finishSelection = () => { + if (!isSelectingRef.current) return; + isSelectingRef.current = false; + setIsSelecting(false); + }; + /* + * ============================================================ + * CHARGEMENT DES ÉVÉNEMENTS + * ============================================================ + */ + const loadEvents = async () => { + try { + setRequestError(''); + setLoadingEvents(true); + const data = + await eventApi.getAllEvents(); + const loadedEvents = + Array.isArray(data) + ? data + : data?.results ?? []; + setEvents(loadedEvents); + } catch (error) { + setRequestError(error.message); + console.error( + 'Erreur lors du chargement des événements :', + error, + ); + setEvents([]); + } finally { + setLoadingEvents(false); + } + }; + useEffect(() => { + loadEvents(); + }, []); + /* + * ============================================================ + * RESPONSIVE + * ============================================================ + */ + useEffect(() => { + const updateMobile = () => { + setIsMobile( + window.innerWidth < 768, + ); + }; + updateMobile(); + window.addEventListener( + 'resize', + updateMobile, + ); + return () => { + window.removeEventListener( + 'resize', + updateMobile, + ); + }; + }, []); + useEffect(() => { + const handlePointerMove = (pointerEvent) => { + updateSelectionFromPointer(pointerEvent); + }; + const handlePointerUp = () => { + finishSelection(); + }; + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', handlePointerUp); + window.addEventListener('pointercancel', handlePointerUp); + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerUp); + }; + }, []); + /* + * ============================================================ + * DIMENSIONS CALENDRIER + * ============================================================ + */ + useLayoutEffect(() => { + if (loadingEvents) return; + const updateDimensions = () => { + const calendar = + calendarRef.current; + if (!calendar) return; + const cardContent = + calendar.closest( + '[data-slot="card-content"]', + ); + if (!cardContent) return; + const calendarTop = + calendar.getBoundingClientRect() + .top; + const contentRect = + cardContent.getBoundingClientRect(); + const footer = + document.querySelector('footer'); + const footerTop = footer + ? footer.getBoundingClientRect() + .top + : window.innerHeight; + const bottom = Math.min( + contentRect.bottom, + footerTop, + window.innerHeight, + ); + const height = + bottom - calendarTop - 4; + setAvailableHeight( + Math.max(300, height), + ); + }; + updateDimensions(); + window.addEventListener( + 'resize', + updateDimensions, + ); + let observer = null; + if ( + typeof ResizeObserver !== + 'undefined' && + calendarRef.current + ) { + observer = new ResizeObserver( + updateDimensions, + ); + observer.observe( + calendarRef.current, + ); + } + return () => { + window.removeEventListener( + 'resize', + updateDimensions, + ); + observer?.disconnect(); + }; + }, [year, selectedMonth, isMobile, loadingEvents]); + /* + * ============================================================ + * ANNÉE + * ============================================================ + */ + const previousYear = () => { + setYear((current) => current - 1); + }; const nextYear = () => { + setYear((current) => current + 1); + }; const currentYear = () => { + setYear(new Date().getFullYear()); + }; const previousPeriod = () => { + if (!isMobile) { + previousYear(); + return; + } if (selectedMonth === 0) { + setSelectedMonth(11); + setYear((current) => current - 1); + return; + } setSelectedMonth((current) => current - 1); + }; const nextPeriod = () => { + if (!isMobile) { + nextYear(); + return; + } if (selectedMonth === 11) { + setSelectedMonth(0); + setYear((current) => current + 1); + return; + } setSelectedMonth((current) => current + 1); + }; const currentPeriod = () => { + const now = new Date(); + setYear(now.getFullYear()); if (isMobile) { + setSelectedMonth(now.getMonth()); + } + }; + /* + * ============================================================ + * SURVOL + * ============================================================ + */ + const handleEventMouseEnter = ( + event, + mouseEvent, + ) => { + setHoveredEvent(event); + setHoverPosition({ + x: mouseEvent.clientX, + y: mouseEvent.clientY, + }); + }; + const handleEventMouseMove = ( + mouseEvent, + ) => { + setHoverPosition({ + x: mouseEvent.clientX, + y: mouseEvent.clientY, + }); + }; + const handleEventMouseLeave = () => { + setHoveredEvent(null); + setHoverPosition(null); + }; + /* + * ============================================================ + * MENU CONTEXTUEL + * ============================================================ + */ + const closeContextMenu = () => { + setContextMenu(null); + }; + const handleDayContextMenu = ( + mouseEvent, + date, + ) => { + mouseEvent.preventDefault(); + mouseEvent.stopPropagation(); + setHoveredEvent(null); + setHoverPosition(null); - const selection = getOrderedSelection(); + + const selection = isMobile ? null : getOrderedSelection(); + const useSelection = Boolean( + selection && + date >= selection.start && + date <= selection.end, + ); + setContextMenu({ + x: mouseEvent.clientX, + y: mouseEvent.clientY, + type: useSelection ? 'selection' : 'day', + date, + startDate: useSelection ? selection.start : date, + endDate: useSelection ? selection.end : date, + }); + }; + const handleEventContextMenu = ( + mouseEvent, + event, + ) => { + mouseEvent.preventDefault(); + mouseEvent.stopPropagation(); + setHoveredEvent(null); + setHoverPosition(null); + setContextMenu({ + x: mouseEvent.clientX, + y: mouseEvent.clientY, + type: 'event', + event, + }); + }; + useEffect(() => { + if (!contextMenu) return; + const handleOutsideClick = () => { + closeContextMenu(); + }; + const handleEscape = ( + keyboardEvent, + ) => { + if ( + keyboardEvent.key === 'Escape' + ) { + closeContextMenu(); + } + }; + document.addEventListener( + 'mousedown', + handleOutsideClick, + ); + document.addEventListener( + 'keydown', + handleEscape, + ); + return () => { + document.removeEventListener( + 'mousedown', + handleOutsideClick, + ); + document.removeEventListener( + 'keydown', + handleEscape, + ); + }; + }, [contextMenu]); + /* + * ============================================================ + * FORMULAIRE ÉVÉNEMENT + * ============================================================ + */ + const resetEventForm = () => { + setEventForm({ + name: '', + description: '', + start_date: '', + end_date: '', + start_time: '', + end_time: '', + color: DEFAULT_EVENT_COLOR, + }); + }; + const openCreateDialog = ( + startDate = '', + endDate = startDate, + ) => { + setEditingEvent(null); + setEventForm({ + name: '', + description: '', + start_date: startDate, + end_date: endDate || startDate, + start_time: '', + end_time: '', + color: DEFAULT_EVENT_COLOR, + }); + clearSelection(); + closeContextMenu(); + setEventDialogOpen(true); + }; + const openEditDialog = ( + event, + ) => { + setEditingEvent(event); + setEventForm({ + name: event?.name ?? '', + description: + event?.description ?? '', + start_date: + event?.start_date ?? '', + end_date: + event?.end_date ?? + event?.start_date ?? + '', + start_time: + event?.start_time + ? String( + event.start_time, + ).slice(0, 5) + : '', + end_time: + event?.end_time + ? String( + event.end_time, + ).slice(0, 5) + : '', + color: + event?.color || + DEFAULT_EVENT_COLOR, + }); + closeContextMenu(); + setEventDialogOpen(true); + }; + const handleFormChange = ( + field, + value, + ) => { + setEventForm( + (current) => ({ + ...current, + [field]: value, + }), + ); + }; + const handleStartDateChange = ( + value, + ) => { + setEventForm( + (current) => { + const next = { + ...current, + start_date: value, + }; + /* + * Si la date de fin est vide ou + * antérieure à la nouvelle date + * de début, on la recale sur + * la date de début. + */ + if ( + !current.end_date || + current.end_date < value + ) { + next.end_date = value; + } + return next; + }, + ); + }; + const handleSaveEvent = async ( + submitEvent, + ) => { + setRequestError(''); + if (!eventForm.name.trim()) { + return; + } + if (!eventForm.start_date) { + return; + } + const startDate = + parseDate( + eventForm.start_date, + ); + const endDate = + parseDate( + eventForm.end_date || + eventForm.start_date, + ); + if ( + !startDate || + !endDate || + endDate < startDate + ) { + return; + } + try { + setSavingEvent(true); + const payload = { + name: eventForm.name.trim(), + description: + eventForm.description.trim(), + start_date: + eventForm.start_date, + end_date: + eventForm.end_date || + eventForm.start_date, + start_time: + eventForm.start_time || null, + end_time: + eventForm.end_time || null, + color: + eventForm.color || + DEFAULT_EVENT_COLOR, + }; + if (submitEvent) { + await eventApi.updateEvent( + editingEvent.id, + payload, + ); + } else { + await eventApi.createEvent( + payload, + ); + } + setEventDialogOpen(false); + setEditingEvent(null); + resetEventForm(); + await loadEvents(); + } catch (error) { + setRequestError(error.message); + console.error( + submitEvent + ? 'Erreur lors de la modification de l’événement :' + : 'Erreur lors de la création de l’événement :', + error, + ); + } finally { + setSavingEvent(false); + } + }; + const handleDeleteEvent = async ( + event, + ) => { + closeContextMenu(); + const confirmed = + window.confirm( + `Supprimer l’événement « ${ + event?.name || 'Sans nom' + } » ?`, + ); + if (!confirmed) { + return; + } + try { + await eventApi.deleteEvent( + event.id, + ); + await loadEvents(); + } catch (error) { + setRequestError(error.message); + console.error( + 'Erreur lors de la suppression de l’événement :', + error, + ); + } + }; + /* + * ============================================================ + * AUJOURD'HUI + * ============================================================ + */ + const today = new Date(); + const isToday = ( + monthIndex, + day, + ) => { + return ( + today.getFullYear() === year && + today.getMonth() === monthIndex && + today.getDate() === day + ); + }; + /* + * ============================================================ + * ÉVÉNEMENTS DE L'ANNÉE + * ============================================================ + */ + const yearEvents = events.filter( + (event) => { + const start = parseDate( + getEventStartDate(event), + ); + const end = parseDate( + getEventEndDate(event), + ); + if (!start || !end) { + return false; + } + const yearStart = new Date( + year, + 0, + 1, + ); + const yearEnd = new Date( + year, + 11, + 31, + ); + return ( + end >= yearStart && + start <= yearEnd + ); + }, + ); + /* + * ============================================================ + * LANES DES ÉVÉNEMENTS CONTINUS + * ============================================================ + */ + const eventLanes = useMemo(() => { + const periodEvents = yearEvents + .filter((event) => !isSingleDayEvent(event)) + .map((event) => ({ + event, + start: parseDate(getEventStartDate(event)), + end: parseDate(getEventEndDate(event)), + })) + .filter(({ start, end }) => start && end) + .sort((a, b) => { + const startDifference = a.start - b.start; + if (startDifference !== 0) return startDifference; + const endDifference = b.end - a.end; + if (endDifference !== 0) return endDifference; + return String(a.event.id).localeCompare(String(b.event.id)); + }); + const laneEndDates = []; + const lanes = new Map(); + periodEvents.forEach(({ event, start, end }) => { + let lane = laneEndDates.findIndex( + (laneEndDate) => laneEndDate < start, + ); + if (lane === -1) { + lane = laneEndDates.length; + } + laneEndDates[lane] = end; + lanes.set(String(event.id), lane); + }); + return lanes; + }, [yearEvents]); + /* + * ============================================================ + * DIMENSIONS + * ============================================================ + */ + const displayedMonthIndices = isMobile + ? [selectedMonth] + : MONTHS.map((_, index) => index); const rowCount = isMobile + ? getDaysInMonth(selectedMonth) + : 31; const availableGridHeight = Math.max( + availableHeight - HEADER_HEIGHT, + rowCount * MIN_ROW_HEIGHT, + ); const rowHeight = Math.max( + MIN_ROW_HEIGHT, + availableGridHeight / rowCount, + ); const minimumGridWidth = isMobile + ? 0 + : DAY_COLUMN_WIDTH + + MONTHS.length * MIN_MONTH_WIDTH; const gridTemplateColumns = isMobile + ? `${DAY_COLUMN_WIDTH}px minmax(0, 1fr)` + : ` + ${DAY_COLUMN_WIDTH}px + repeat( + 12, + minmax( + ${MIN_MONTH_WIDTH}px, + 1fr + ) + ) + `; + /* + * ============================================================ + * RENDER + * ============================================================ + */ + if (loadingEvents) { + return ( + + ); + } + return ( + <> + {/* ====================================================== + HOVER EVENT + ====================================================== */} + {hoveredEvent && ( + + )} + {/* ====================================================== + MENU CONTEXTUEL + ====================================================== */} + {contextMenu && ( +
{ + mouseEvent.stopPropagation(); + }} + onContextMenu={( + mouseEvent, + ) => { + mouseEvent.preventDefault(); + mouseEvent.stopPropagation(); + }} + > + {(contextMenu.type === 'day' || + contextMenu.type === 'selection') && ( + + )} + {contextMenu.type === + 'event' && ( + <> + +
+ + + )} +
+ )} + {/* ====================================================== + DIALOG CRÉATION / MODIFICATION + ====================================================== */} + { + if ( + savingEvent && + !open + ) { + return; + } + setEventDialogOpen(open); + if (!open) { + setEditingEvent(null); + resetEventForm(); + } + }} + > + + + + {editingEvent + ? 'Modifier l’événement' + : 'Créer un événement'} + + + {editingEvent + ? 'Modifie les informations de cet événement.' + : 'Ajoute un nouvel événement au calendrier.'} + + + {requestError && ( + {requestError} + )} +
+ {/* NOM */} +
+ + + handleFormChange( + 'name', + event.target.value, + ) + } + placeholder="Nom de l'événement" + autoFocus + /> +
+ {/* DESCRIPTION */} +
+ +