This commit is contained in:
alma 2025-05-04 22:42:54 +02:00
parent 0ad05e08ce
commit 838a06f215
10 changed files with 205 additions and 414 deletions

View File

@ -219,11 +219,11 @@ export function AnnouncementForm({ userRole }: AnnouncementFormProps) {
{availableRoles.map(role => ( {availableRoles.map(role => (
<Badge <Badge
key={role.id} key={role.id}
variant={selectedRoles.includes(role.id) ? "secondary" : "outline"} variant={selectedRoles.includes(role.id) ? "default" : "outline"}
className={`cursor-pointer px-3 py-1 ${ className={`cursor-pointer px-3 py-1 ${
selectedRoles.includes(role.id) selectedRoles.includes(role.id)
? "bg-blue-600 hover:bg-blue-700 text-white" ? "bg-blue-600 hover:bg-blue-700"
: "bg-white hover:bg-gray-100 text-gray-700 border-gray-200" : "hover:bg-gray-100"
}`} }`}
onClick={() => handleRoleToggle(role.id)} onClick={() => handleRoleToggle(role.id)}
> >

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
import { import {
Table, Table,
TableBody, TableBody,
@ -34,50 +34,58 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Announcement } from "@/app/types/announcement"; import { Announcement } from "@/app/types/announcement";
import { useToast } from "@/components/ui/use-toast"; import { useToast } from "@/components/ui/use-toast";
import { useUnreadAnnouncements } from '@/hooks/use-unread-announcements';
interface AnnouncementsListProps { interface AnnouncementsListProps {
userRole: string[]; userRole: string[];
} }
export function AnnouncementsList({ userRole }: AnnouncementsListProps) { export function AnnouncementsList({ userRole }: AnnouncementsListProps) {
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null); const [selectedAnnouncement, setSelectedAnnouncement] = useState<Announcement | null>(null);
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false); const [isViewDialogOpen, setIsViewDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
// Use the updated hook which provides announcements data and loading state // Fetch announcements
const { const fetchAnnouncements = async () => {
announcements, try {
isLoading: loading, setLoading(true);
checkForUnreadAnnouncements: fetchAnnouncements, const response = await fetch('/api/announcements');
markAsRead
} = useUnreadAnnouncements(); if (!response.ok) {
throw new Error('Failed to fetch announcements');
}
const data = await response.json();
setAnnouncements(data);
setError(null);
} catch (err) {
console.error('Error fetching announcements:', err);
setError('Failed to load announcements');
} finally {
setLoading(false);
}
};
useEffect(() => { useEffect(() => {
// Use the fetch function from the hook
fetchAnnouncements(); fetchAnnouncements();
}, [fetchAnnouncements]);
// Handle viewing an announcement
const handleViewAnnouncement = useCallback((announcement: Announcement) => {
setSelectedAnnouncement(announcement);
setIsViewDialogOpen(true);
// Use setTimeout to separate the state updates
setTimeout(() => {
markAsRead(announcement.id);
}, 0);
}, [markAsRead]);
// Handle deleting an announcement
const handleDeleteClick = useCallback((announcement: Announcement) => {
setSelectedAnnouncement(announcement);
setIsDeleteDialogOpen(true);
}, []); }, []);
const confirmDelete = useCallback(async () => { // Handle viewing an announcement
const handleViewAnnouncement = (announcement: Announcement) => {
setSelectedAnnouncement(announcement);
setIsViewDialogOpen(true);
};
// Handle deleting an announcement
const handleDeleteClick = (announcement: Announcement) => {
setSelectedAnnouncement(announcement);
setIsDeleteDialogOpen(true);
};
const confirmDelete = async () => {
if (!selectedAnnouncement) return; if (!selectedAnnouncement) return;
try { try {
@ -89,14 +97,10 @@ export function AnnouncementsList({ userRole }: AnnouncementsListProps) {
throw new Error('Failed to delete announcement'); throw new Error('Failed to delete announcement');
} }
// Close dialog first // Update the local state
setAnnouncements(announcements.filter(a => a.id !== selectedAnnouncement.id));
setIsDeleteDialogOpen(false); setIsDeleteDialogOpen(false);
// Then refresh data using the hook
setTimeout(() => {
fetchAnnouncements();
}, 0);
toast({ toast({
title: "Announcement deleted", title: "Announcement deleted",
description: "The announcement has been deleted successfully.", description: "The announcement has been deleted successfully.",
@ -109,22 +113,22 @@ export function AnnouncementsList({ userRole }: AnnouncementsListProps) {
variant: "destructive", variant: "destructive",
}); });
} }
}, [selectedAnnouncement, fetchAnnouncements, toast]); };
// Format roles for display // Format roles for display
const formatRoles = useCallback((roles: string[]) => { const formatRoles = (roles: string[]) => {
return roles.map(role => { return roles.map(role => {
const roleName = role === "all" const roleName = role === "all"
? "All Users" ? "All Users"
: role.charAt(0).toUpperCase() + role.slice(1); : role.charAt(0).toUpperCase() + role.slice(1);
return ( return (
<Badge key={role} variant="secondary" className="mr-1 bg-gray-100 hover:bg-gray-200 text-gray-800 border-gray-200"> <Badge key={role} variant="outline" className="mr-1">
{roleName} {roleName}
</Badge> </Badge>
); );
}); });
}, []); };
return ( return (
<Card> <Card>
@ -178,7 +182,6 @@ export function AnnouncementsList({ userRole }: AnnouncementsListProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleViewAnnouncement(announcement)} onClick={() => handleViewAnnouncement(announcement)}
className="bg-white hover:bg-gray-50 border-gray-200 text-gray-700"
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
<span className="sr-only">View</span> <span className="sr-only">View</span>
@ -187,7 +190,6 @@ export function AnnouncementsList({ userRole }: AnnouncementsListProps) {
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handleDeleteClick(announcement)} onClick={() => handleDeleteClick(announcement)}
className="bg-white hover:bg-gray-50 border-gray-200 text-gray-700"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span> <span className="sr-only">Delete</span>

View File

@ -2,29 +2,19 @@
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import { useEffect, useCallback } from "react"; import { useEffect } from "react";
export function AuthCheck({ children }: { children: React.ReactNode }) { export function AuthCheck({ children }: { children: React.ReactNode }) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
// Memoize the redirect logic to prevent unnecessary re-renders useEffect(() => {
const handleAuthRedirect = useCallback(() => {
if (status === "unauthenticated" && pathname !== "/signin") { if (status === "unauthenticated" && pathname !== "/signin") {
router.push("/signin"); router.push("/signin");
} }
}, [status, router, pathname]); }, [status, router, pathname]);
useEffect(() => {
// Use setTimeout to avoid immediate state updates during rendering
const timer = setTimeout(() => {
handleAuthRedirect();
}, 0);
return () => clearTimeout(timer);
}, [handleAuthRedirect]);
if (status === "loading") { if (status === "loading") {
return <div>Chargement...</div>; return <div>Chargement...</div>;
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect } from "react";
const backgroundImages = [ const backgroundImages = [
"/background/Autumn birger-strahl-6YZgnYaPD5s-unsplash.jpeg", "/background/Autumn birger-strahl-6YZgnYaPD5s-unsplash.jpeg",
@ -48,17 +48,14 @@ const backgroundImages = [
export function useBackgroundImage() { export function useBackgroundImage() {
const [currentBackground, setCurrentBackground] = useState(backgroundImages[0]); const [currentBackground, setCurrentBackground] = useState(backgroundImages[0]);
// Memoize the change background function const changeBackground = () => {
const changeBackground = useCallback(() => { const currentIndex = backgroundImages.indexOf(currentBackground);
setCurrentBackground(prevBackground => {
const currentIndex = backgroundImages.indexOf(prevBackground);
const nextIndex = (currentIndex + 1) % backgroundImages.length; const nextIndex = (currentIndex + 1) % backgroundImages.length;
return backgroundImages[nextIndex]; setCurrentBackground(backgroundImages[nextIndex]);
}); };
}, []);
useEffect(() => { useEffect(() => {
// Set initial random background only once on mount // Set initial random background
const randomIndex = Math.floor(Math.random() * backgroundImages.length); const randomIndex = Math.floor(Math.random() * backgroundImages.length);
setCurrentBackground(backgroundImages[randomIndex]); setCurrentBackground(backgroundImages[randomIndex]);
}, []); }, []);
@ -71,16 +68,16 @@ export function BackgroundSwitcher({ children }: { children: React.ReactNode })
const [imageError, setImageError] = useState(false); const [imageError, setImageError] = useState(false);
// Function to preload an image // Function to preload an image
const preloadImage = useCallback((src: string): Promise<string> => { const preloadImage = (src: string): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image();
img.src = src; img.src = src;
img.onload = () => resolve(src); img.onload = () => resolve(src);
img.onerror = () => reject(new Error(`Failed to load image: ${src}`)); img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
}); });
}, []); };
const getRandomBackground = useCallback(async () => { const getRandomBackground = async () => {
let attempts = 0; let attempts = 0;
const maxAttempts = backgroundImages.length; const maxAttempts = backgroundImages.length;
@ -92,6 +89,7 @@ export function BackgroundSwitcher({ children }: { children: React.ReactNode })
if (newBackground !== background) { if (newBackground !== background) {
// Try to preload the image // Try to preload the image
await preloadImage(newBackground); await preloadImage(newBackground);
console.log("Successfully loaded:", newBackground);
return newBackground; return newBackground;
} }
} catch (error) { } catch (error) {
@ -102,38 +100,28 @@ export function BackgroundSwitcher({ children }: { children: React.ReactNode })
// If all attempts fail, return the first image as fallback // If all attempts fail, return the first image as fallback
return backgroundImages[0]; return backgroundImages[0];
}, [background, preloadImage]); };
useEffect(() => { useEffect(() => {
let isMounted = true;
const initBackground = async () => { const initBackground = async () => {
try { try {
const newBg = await getRandomBackground(); const newBg = await getRandomBackground();
if (isMounted) {
setBackground(newBg); setBackground(newBg);
setImageError(false); setImageError(false);
}
} catch (error) { } catch (error) {
console.error("Error setting initial background:", error); console.error("Error setting initial background:", error);
if (isMounted) {
setImageError(true); setImageError(true);
} }
}
}; };
initBackground(); initBackground();
}, []);
// Cleanup function to prevent state updates after unmount const handleClick = async (e: React.MouseEvent) => {
return () => {
isMounted = false;
};
}, [getRandomBackground]);
const handleClick = useCallback(async (e: React.MouseEvent) => {
if (e.target === e.currentTarget) { if (e.target === e.currentTarget) {
try { try {
const newBg = await getRandomBackground(); const newBg = await getRandomBackground();
console.log("Changing background to:", newBg);
setBackground(newBg); setBackground(newBg);
setImageError(false); setImageError(false);
} catch (error) { } catch (error) {
@ -141,7 +129,7 @@ export function BackgroundSwitcher({ children }: { children: React.ReactNode })
setImageError(true); setImageError(true);
} }
} }
}, [getRandomBackground]); };
return ( return (
<div <div

View File

@ -1,6 +1,5 @@
"use client"; "use client";
import { memo, useMemo } from 'react';
import { MainNav } from "@/components/main-nav"; import { MainNav } from "@/components/main-nav";
import { Footer } from "@/components/footer"; import { Footer } from "@/components/footer";
import { AuthCheck } from "@/components/auth/auth-check"; import { AuthCheck } from "@/components/auth/auth-check";
@ -13,19 +12,16 @@ interface LayoutWrapperProps {
isAuthenticated: boolean; isAuthenticated: boolean;
} }
// Use memo to prevent unnecessary rerenders of the entire layout export function LayoutWrapper({ children, isSignInPage, isAuthenticated }: LayoutWrapperProps) {
export const LayoutWrapper = memo(function LayoutWrapper({
children,
isSignInPage,
isAuthenticated
}: LayoutWrapperProps) {
const { currentBackground, changeBackground } = useBackgroundImage(); const { currentBackground, changeBackground } = useBackgroundImage();
// Memoize the background style to prevent recalculation on each render return (
const backgroundStyle = useMemo(() => { <AuthCheck>
if (isSignInPage) return {}; {!isSignInPage && isAuthenticated && <MainNav />}
<div
return { className={isSignInPage ? "" : "min-h-screen"}
style={
!isSignInPage ? {
backgroundImage: `url('${currentBackground}')`, backgroundImage: `url('${currentBackground}')`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
@ -33,15 +29,8 @@ export const LayoutWrapper = memo(function LayoutWrapper({
backgroundAttachment: 'fixed', backgroundAttachment: 'fixed',
cursor: 'pointer', cursor: 'pointer',
transition: 'background-image 0.5s ease-in-out' transition: 'background-image 0.5s ease-in-out'
}; } : {}
}, [currentBackground, isSignInPage]); }
return (
<AuthCheck>
{!isSignInPage && isAuthenticated && <MainNav />}
<div
className={isSignInPage ? "" : "min-h-screen"}
style={!isSignInPage ? backgroundStyle : {}}
onClick={!isSignInPage ? changeBackground : undefined} onClick={!isSignInPage ? changeBackground : undefined}
> >
<main>{children}</main> <main>{children}</main>
@ -50,4 +39,4 @@ export const LayoutWrapper = memo(function LayoutWrapper({
<Toaster /> <Toaster />
</AuthCheck> </AuthCheck>
); );
}); }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useMemo, memo } from "react"; import { useState } from "react";
import { import {
Calendar, Calendar,
MessageSquare, MessageSquare,
@ -38,7 +38,6 @@ import { format } from 'date-fns';
import { fr } from 'date-fns/locale'; import { fr } from 'date-fns/locale';
import { NotificationBadge } from './notification-badge'; import { NotificationBadge } from './notification-badge';
import { NotesDialog } from './notes-dialog'; import { NotesDialog } from './notes-dialog';
import { useUnreadAnnouncements } from '@/hooks/use-unread-announcements';
const requestNotificationPermission = async () => { const requestNotificationPermission = async () => {
try { try {
@ -50,18 +49,17 @@ const requestNotificationPermission = async () => {
} }
}; };
// Use React.memo to memoize the entire component export function MainNav() {
export const MainNav = memo(function MainNav() {
const [isSidebarOpen, setIsSidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const [userStatus, setUserStatus] = useState<'online' | 'busy' | 'away'>('online'); const [userStatus, setUserStatus] = useState<'online' | 'busy' | 'away'>('online');
const [isNotesDialogOpen, setIsNotesDialogOpen] = useState(false); const [isNotesDialogOpen, setIsNotesDialogOpen] = useState(false);
// Use the unread announcements hook with memo to prevent unnecessary re-renders console.log("Session:", session);
const { hasUnread } = useUnreadAnnouncements(); console.log("Status:", status);
// Updated function to get user initials - memoize this // Updated function to get user initials
const getUserInitials = useCallback(() => { const getUserInitials = () => {
if (session?.user?.name) { if (session?.user?.name) {
// Split the full name and get initials // Split the full name and get initials
const names = session.user.name.split(' '); const names = session.user.name.split(' ');
@ -72,15 +70,15 @@ export const MainNav = memo(function MainNav() {
return names[0].slice(0, 2).toUpperCase(); return names[0].slice(0, 2).toUpperCase();
} }
return "?"; return "?";
}, [session?.user?.name]); };
// Function to get display name - memoize this // Function to get display name
const getDisplayName = useCallback(() => { const getDisplayName = () => {
return session?.user?.name || "User"; return session?.user?.name || "User";
}, [session?.user?.name]); };
// Function to get user role - memoize this // Function to get user role
const getUserRole = useCallback(() => { const getUserRole = () => {
if (session?.user?.role) { if (session?.user?.role) {
if (Array.isArray(session.user.role)) { if (Array.isArray(session.user.role)) {
// Filter out technical roles and format remaining ones // Filter out technical roles and format remaining ones
@ -106,15 +104,17 @@ export const MainNav = memo(function MainNav() {
return session.user.role; return session.user.role;
} }
return ""; return "";
}, [session?.user?.role]); };
// Function to check if user has a specific role - memoize this // Function to check if user has a specific role
const hasRole = useCallback((requiredRoles: string[]) => { const hasRole = (requiredRoles: string[]) => {
if (!session?.user?.role) { if (!session?.user?.role) {
console.log('No user roles found');
return false; return false;
} }
const userRoles = Array.isArray(session.user.role) ? session.user.role : [session.user.role]; const userRoles = Array.isArray(session.user.role) ? session.user.role : [session.user.role];
console.log('Raw user roles:', userRoles);
// Clean up user roles by removing prefixes and converting to lowercase // Clean up user roles by removing prefixes and converting to lowercase
const cleanUserRoles = userRoles.map(role => const cleanUserRoles = userRoles.map(role =>
@ -122,18 +122,21 @@ export const MainNav = memo(function MainNav() {
.replace(/^ROLE_/, '') // Remove ROLE_ prefix .replace(/^ROLE_/, '') // Remove ROLE_ prefix
.toLowerCase() .toLowerCase()
); );
console.log('Clean user roles:', cleanUserRoles);
// Clean required roles // Clean required roles
const cleanRequiredRoles = requiredRoles.map(role => role.toLowerCase()); const cleanRequiredRoles = requiredRoles.map(role => role.toLowerCase());
console.log('Clean required roles:', cleanRequiredRoles);
// Check if user has any of the required roles // Check if user has any of the required roles
const hasAnyRole = cleanRequiredRoles.some(role => cleanUserRoles.includes(role)); const hasAnyRole = cleanRequiredRoles.some(role => cleanUserRoles.includes(role));
console.log('Has any role:', hasAnyRole);
return hasAnyRole; return hasAnyRole;
}, [session?.user?.role]); };
// Status configurations // Status configurations
const statusConfig = useMemo(() => ({ const statusConfig = {
online: { online: {
color: 'text-green-500', color: 'text-green-500',
label: 'Online', label: 'Online',
@ -149,10 +152,10 @@ export const MainNav = memo(function MainNav() {
label: 'Away', label: 'Away',
notifications: false notifications: false
}, },
}), []); };
// Handle status change // Handle status change
const handleStatusChange = useCallback(async (newStatus: 'online' | 'busy' | 'away') => { const handleStatusChange = async (newStatus: 'online' | 'busy' | 'away') => {
setUserStatus(newStatus); setUserStatus(newStatus);
if (newStatus !== 'online') { if (newStatus !== 'online') {
@ -174,19 +177,19 @@ export const MainNav = memo(function MainNav() {
// Re-enable notifications if going back online // Re-enable notifications if going back online
requestNotificationPermission(); requestNotificationPermission();
} }
}, []); };
// Base menu items (available for everyone) // Base menu items (available for everyone)
const baseMenuItems = useMemo(() => [ const baseMenuItems = [
{ {
title: "QG", title: "QG",
icon: Target, icon: Target,
href: '/qg', href: '/qg',
}, },
], []); ];
// Role-specific menu items // Role-specific menu items
const roleSpecificItems = useMemo(() => [ const roleSpecificItems = [
{ {
title: "ShowCase", title: "ShowCase",
icon: Lightbulb, icon: Lightbulb,
@ -205,64 +208,18 @@ export const MainNav = memo(function MainNav() {
href: '/the-message', href: '/the-message',
requiredRoles: ["mediation", "expression"], requiredRoles: ["mediation", "expression"],
}, },
], []); ];
// Get visible menu items based on user roles // Get visible menu items based on user roles
const visibleMenuItems = useMemo(() => [ const visibleMenuItems = [
...baseMenuItems, ...baseMenuItems,
...roleSpecificItems.filter(item => hasRole(item.requiredRoles)) ...roleSpecificItems.filter(item => hasRole(item.requiredRoles))
], [baseMenuItems, roleSpecificItems, hasRole]); ];
// Format current date and time // Format current date and time
const dateTimeDisplay = useMemo(() => {
const now = new Date(); const now = new Date();
const formattedDate = format(now, "d MMMM yyyy", { locale: fr }); const formattedDate = format(now, "d MMMM yyyy", { locale: fr });
const formattedTime = format(now, "HH:mm"); const formattedTime = format(now, "HH:mm");
return { formattedDate, formattedTime };
}, []);
// Handle sidebar toggle
const toggleSidebar = useCallback(() => {
setIsSidebarOpen(prev => !prev);
}, []);
// Handle notes dialog
const toggleNotesDialog = useCallback(() => {
setIsNotesDialogOpen(prev => !prev);
}, []);
// Handle logout
const handleLogout = useCallback(async () => {
try {
// First sign out from NextAuth
await signOut({
callbackUrl: '/signin',
redirect: false
});
// Then redirect to Keycloak logout with proper parameters
const keycloakLogoutUrl = new URL(
`${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`
);
// Add required parameters
keycloakLogoutUrl.searchParams.append(
'post_logout_redirect_uri',
window.location.origin
);
keycloakLogoutUrl.searchParams.append(
'id_token_hint',
session?.accessToken || ''
);
// Redirect to Keycloak logout
window.location.href = keycloakLogoutUrl.toString();
} catch (error) {
console.error('Error during logout:', error);
// Fallback to simple redirect if something goes wrong
window.location.href = '/signin';
}
}, [session?.accessToken]);
return ( return (
<> <>
@ -271,7 +228,7 @@ export const MainNav = memo(function MainNav() {
{/* Left side */} {/* Left side */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<button <button
onClick={toggleSidebar} onClick={() => setIsSidebarOpen(true)}
className="text-white/80 hover:text-white" className="text-white/80 hover:text-white"
> >
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />
@ -293,7 +250,7 @@ export const MainNav = memo(function MainNav() {
<span className="sr-only">TimeTracker</span> <span className="sr-only">TimeTracker</span>
</Link> </Link>
<button <button
onClick={toggleNotesDialog} onClick={() => setIsNotesDialogOpen(true)}
className='text-white/80 hover:text-white' className='text-white/80 hover:text-white'
> >
<PenLine className='w-5 h-5' /> <PenLine className='w-5 h-5' />
@ -315,11 +272,8 @@ export const MainNav = memo(function MainNav() {
<RadioIcon className='w-5 h-5' /> <RadioIcon className='w-5 h-5' />
<span className="sr-only">Radio</span> <span className="sr-only">Radio</span>
</Link> </Link>
<Link href='/announcement' className='text-white/80 hover:text-white relative'> <Link href='/announcement' className='text-white/80 hover:text-white'>
<Megaphone className='w-5 h-5' /> <Megaphone className='w-5 h-5' />
{hasUnread && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 bg-red-500 rounded-full"></span>
)}
<span className="sr-only">Announcement</span> <span className="sr-only">Announcement</span>
</Link> </Link>
</div> </div>
@ -328,8 +282,8 @@ export const MainNav = memo(function MainNav() {
<div className="flex items-center space-x-8"> <div className="flex items-center space-x-8">
{/* Date and Time with smaller text */} {/* Date and Time with smaller text */}
<div className="text-white/80 text-sm"> <div className="text-white/80 text-sm">
<span className="mr-2">{dateTimeDisplay.formattedDate}</span> <span className="mr-2">{formattedDate}</span>
<span>{dateTimeDisplay.formattedTime}</span> <span>{formattedTime}</span>
</div> </div>
<NotificationBadge /> <NotificationBadge />
@ -391,7 +345,37 @@ export const MainNav = memo(function MainNav() {
))} ))}
<DropdownMenuItem <DropdownMenuItem
className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer" className="text-white/80 hover:text-white hover:bg-black/50 cursor-pointer"
onClick={handleLogout} onClick={async () => {
try {
// First sign out from NextAuth
await signOut({
callbackUrl: '/signin',
redirect: false
});
// Then redirect to Keycloak logout with proper parameters
const keycloakLogoutUrl = new URL(
`${process.env.NEXT_PUBLIC_KEYCLOAK_ISSUER}/protocol/openid-connect/logout`
);
// Add required parameters
keycloakLogoutUrl.searchParams.append(
'post_logout_redirect_uri',
window.location.origin
);
keycloakLogoutUrl.searchParams.append(
'id_token_hint',
session?.accessToken || ''
);
// Redirect to Keycloak logout
window.location.href = keycloakLogoutUrl.toString();
} catch (error) {
console.error('Error during logout:', error);
// Fallback to simple redirect if something goes wrong
window.location.href = '/signin';
}
}}
> >
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span>Déconnexion</span> <span>Déconnexion</span>
@ -415,4 +399,4 @@ export const MainNav = memo(function MainNav() {
/> />
</> </>
); );
}); }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { memo, useCallback, useMemo } from "react"; import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -44,8 +44,7 @@ interface MenuItem {
requiredRole?: string | string[]; requiredRole?: string | string[];
} }
// Memoize the entire Sidebar component to prevent unnecessary re-renders export function Sidebar({ isOpen, onClose }: SidebarProps) {
export const Sidebar = memo(function Sidebar({ isOpen, onClose }: SidebarProps) {
const { data: session, status } = useSession(); const { data: session, status } = useSession();
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
@ -60,8 +59,8 @@ export const Sidebar = memo(function Sidebar({ isOpen, onClose }: SidebarProps)
return null; return null;
} }
// Function to check if user has a specific role - memoize this logic // Function to check if user has a specific role
const hasRole = useCallback((requiredRole: string | string[] | undefined) => { const hasRole = (requiredRole: string | string[] | undefined) => {
// If no role is required, allow access // If no role is required, allow access
if (!requiredRole) { if (!requiredRole) {
return true; return true;
@ -108,10 +107,10 @@ export const Sidebar = memo(function Sidebar({ isOpen, onClose }: SidebarProps)
} }
return false; return false;
}, [session]); };
// Base menu items (available for everyone) // Base menu items (available for everyone)
const baseMenuItems: MenuItem[] = useMemo(() => [ const baseMenuItems: MenuItem[] = [
{ {
title: "Pages", title: "Pages",
icon: FileText, icon: FileText,
@ -159,10 +158,10 @@ export const Sidebar = memo(function Sidebar({ isOpen, onClose }: SidebarProps)
href: "/agilite", href: "/agilite",
iframe: process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL, iframe: process.env.NEXT_PUBLIC_IFRAME_AGILITY_URL,
}, },
], []); ];
// Role-specific menu items // Role-specific menu items
const roleSpecificItems: MenuItem[] = useMemo(() => [ const roleSpecificItems: MenuItem[] = [
{ {
title: "Artlab", title: "Artlab",
icon: Palette, icon: Palette,
@ -191,45 +190,25 @@ export const Sidebar = memo(function Sidebar({ isOpen, onClose }: SidebarProps)
iframe: process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL, iframe: process.env.NEXT_PUBLIC_IFRAME_MEDIATIONS_URL,
requiredRole: "mediation", requiredRole: "mediation",
}, },
], []); ];
// Combine base items with role-specific items based on user roles // Combine base items with role-specific items based on user roles
const visibleMenuItems = useMemo(() => [ const visibleMenuItems = [
...baseMenuItems, ...baseMenuItems,
...roleSpecificItems.filter(item => hasRole(item.requiredRole)) ...roleSpecificItems.filter(item => {
], [baseMenuItems, roleSpecificItems, hasRole]); const isVisible = hasRole(item.requiredRole);
return isVisible;
})
];
const handleNavigation = useCallback((href: string, external?: boolean) => { const handleNavigation = (href: string, external?: boolean) => {
if (external && href) { if (external && href) {
window.open(href, "_blank"); window.open(href, "_blank");
} else { } else {
router.push(href); router.push(href);
} }
onClose(); onClose();
}, [router, onClose]); };
// Memoize the menu render to prevent unnecessary recalculation
const renderMenu = useMemo(() => (
<div className="px-2 py-2">
{visibleMenuItems.map((item, index) => (
<div key={`${item.title}-${index}`} className="mb-1">
<Button
variant="ghost"
className={cn(
"w-full justify-start font-normal",
pathname === item.href
? "bg-accent text-accent-foreground"
: "hover:bg-accent hover:text-accent-foreground"
)}
onClick={() => handleNavigation(item.href, item.external)}
>
<item.icon className="mr-2 h-4 w-4" />
{item.title}
</Button>
</div>
))}
</div>
), [visibleMenuItems, pathname, handleNavigation]);
return ( return (
<> <>
@ -268,15 +247,25 @@ export const Sidebar = memo(function Sidebar({ isOpen, onClose }: SidebarProps)
/> />
</div> </div>
{/* Menu Items - Use memoized menu */} {/* Menu Items */}
{renderMenu} <div className="space-y-1 p-4">
{visibleMenuItems.map((item) => (
{/* Calendar Navigation */} <Button
<div className="px-2 pb-4"> key={item.title}
<CalendarNav /> variant="ghost"
className={cn(
"w-full justify-start gap-2 text-black hover:bg-gray-100",
pathname === item.href && !item.external && "bg-gray-100"
)}
onClick={() => handleNavigation(item.href, item.external)}
>
<item.icon className="h-5 w-5" />
<span>{item.title}</span>
</Button>
))}
</div> </div>
</ScrollArea> </ScrollArea>
</div> </div>
</> </>
); );
}); }

View File

@ -5,8 +5,25 @@ import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
// Memoize ScrollBar component to prevent unnecessary re-renders const ScrollArea = React.forwardRef<
const ScrollBar = React.memo(React.forwardRef< React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>, React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar> React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => ( >(({ className, orientation = "vertical", ...props }, ref) => (
@ -25,26 +42,7 @@ const ScrollBar = React.memo(React.forwardRef<
> >
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" /> <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar> </ScrollAreaPrimitive.ScrollAreaScrollbar>
))) ))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
// Memoize ScrollArea component to prevent unnecessary re-renders
const ScrollArea = React.memo(React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
export { ScrollArea, ScrollBar } export { ScrollArea, ScrollBar }

View File

@ -1,56 +0,0 @@
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T | ((val: T) => T)) => void] {
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue;
}
try {
// Get from local storage by key
const item = window.localStorage.getItem(key);
// Parse stored json or if none return initialValue
return item ? JSON.parse(item) : initialValue;
} catch (error) {
// If error also return initialValue
console.error('Error reading from localStorage:', error);
return initialValue;
}
});
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore =
value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(valueToStore);
// Save to local storage
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(valueToStore));
}
} catch (error) {
// A more advanced implementation would handle the error case
console.error('Error saving to localStorage:', error);
}
};
// Update stored value if the key changes
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
try {
const item = window.localStorage.getItem(key);
setStoredValue(item ? JSON.parse(item) : initialValue);
} catch (error) {
console.error('Error updating from localStorage:', error);
setStoredValue(initialValue);
}
}, [key, initialValue]);
return [storedValue, setValue];
}

View File

@ -1,93 +0,0 @@
import { useState, useEffect, useCallback } from 'react';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { Announcement } from '@/app/types/announcement';
type AnnouncementRead = {
[id: string]: boolean;
};
export function useUnreadAnnouncements() {
const [hasUnread, setHasUnread] = useState(false);
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
const [readAnnouncements, setReadAnnouncements] = useLocalStorage<AnnouncementRead>('read-announcements', {});
const [isLoading, setIsLoading] = useState(true);
// Memoized function to check if there are unread announcements
const checkUnreadStatus = useCallback((anncs: Announcement[], readState: AnnouncementRead) => {
return anncs.some((announcement: Announcement) => !readState[announcement.id]);
}, []);
// Fetch announcements and check if there are any unread
const checkForUnreadAnnouncements = useCallback(async () => {
try {
setIsLoading(true);
const response = await fetch('/api/announcements');
if (!response.ok) {
throw new Error('Failed to fetch announcements');
}
const data = await response.json();
// Check if there are any unread announcements with the current read state
const hasUnreadAnnouncements = checkUnreadStatus(data, readAnnouncements);
// Update both states in a single render cycle
setAnnouncements(data);
setHasUnread(hasUnreadAnnouncements);
} catch (err) {
console.error('Error checking for unread announcements:', err);
} finally {
setIsLoading(false);
}
}, [readAnnouncements, checkUnreadStatus]);
// Mark an announcement as read
const markAsRead = useCallback((announcementId: string) => {
setReadAnnouncements((prev: AnnouncementRead) => {
// Create new state object without mutating the original
const newReadState = { ...prev, [announcementId]: true };
// Schedule the unread status update for the next tick
// This prevents state updates from interfering with each other
setTimeout(() => {
setHasUnread(checkUnreadStatus(announcements, newReadState));
}, 0);
return newReadState;
});
}, [announcements, checkUnreadStatus, setReadAnnouncements]);
// Mark all announcements as read
const markAllAsRead = useCallback(() => {
const allRead: AnnouncementRead = {};
announcements.forEach(announcement => {
allRead[announcement.id] = true;
});
setReadAnnouncements(allRead);
// Schedule the unread status update for the next tick
setTimeout(() => {
setHasUnread(false);
}, 0);
}, [announcements, setReadAnnouncements]);
// Check for unread announcements on mount or when dependencies change
useEffect(() => {
const timer = setTimeout(() => {
checkForUnreadAnnouncements();
}, 0);
return () => clearTimeout(timer);
}, [checkForUnreadAnnouncements]);
return {
hasUnread,
isLoading,
announcements,
checkForUnreadAnnouncements,
markAsRead,
markAllAsRead
};
}