refactor: extract UserDropdown component and useClickOutside hook
- Create reusable UserDropdown component for user menu - Create useClickOutside hook for click-outside detection - Refactor admin/layout.tsx and employe/layout.tsx to use shared components - Reduces code duplication between layouts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
4962ef6848
commit
062d05d0f0
|
|
@ -3,12 +3,11 @@
|
||||||
import Sidebar from "@/components/admin/Sidebar";
|
import Sidebar from "@/components/admin/Sidebar";
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { Loading } from "@/components/ui/Loading";
|
import { Loading } from "@/components/ui/Loading";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { LogOut, User, ChevronDown } from "lucide-react";
|
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
import Link from "next/link";
|
import UserDropdown from "@/components/UserDropdown";
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -17,7 +16,6 @@ export default function AdminLayout({
|
||||||
}) {
|
}) {
|
||||||
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated) {
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
|
@ -32,24 +30,6 @@ export default function AdminLayout({
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, user, router]);
|
}, [isLoading, isAuthenticated, user, router]);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (!target.closest('.user-dropdown')) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (dropdownOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [dropdownOpen]);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
|
@ -81,48 +61,12 @@ export default function AdminLayout({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Dropdown */}
|
<UserDropdown
|
||||||
<div className="relative user-dropdown">
|
user={user}
|
||||||
<button
|
profilePath="/admin/profil"
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onLogout={handleLogout}
|
||||||
className="flex items-center gap-3 px-4 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
accentColor="blue"
|
||||||
>
|
/>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold">
|
|
||||||
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-sm font-bold text-gray-900">
|
|
||||||
{user?.firstName} {user?.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">{user?.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{dropdownOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
|
||||||
<Link
|
|
||||||
href="/admin/profil"
|
|
||||||
onClick={() => setDropdownOpen(false)}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<User className="w-4 h-4 text-gray-600" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Profil</span>
|
|
||||||
</Link>
|
|
||||||
<div className="border-t border-gray-200 my-1"></div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-red-50 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4 text-red-600" />
|
|
||||||
<span className="text-sm font-medium text-red-600">Déconnexion</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@
|
||||||
|
|
||||||
import { useAuth } from "@/contexts/AuthContext";
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { Loading } from "@/components/ui/Loading";
|
import { Loading } from "@/components/ui/Loading";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import { LogOut, Ticket, BarChart3, User, ChevronDown } from "lucide-react";
|
import { Ticket, BarChart3 } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import Logo from "@/components/Logo";
|
import Logo from "@/components/Logo";
|
||||||
|
import UserDropdown from "@/components/UserDropdown";
|
||||||
|
|
||||||
export default function EmployeLayout({
|
export default function EmployeLayout({
|
||||||
children,
|
children,
|
||||||
|
|
@ -18,7 +19,6 @@ export default function EmployeLayout({
|
||||||
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
const { user, isAuthenticated, isLoading, logout } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isAuthenticated) {
|
if (!isLoading && !isAuthenticated) {
|
||||||
|
|
@ -33,24 +33,6 @@ export default function EmployeLayout({
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, user, router]);
|
}, [isLoading, isAuthenticated, user, router]);
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
const target = event.target as HTMLElement;
|
|
||||||
if (!target.closest('.user-dropdown')) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (dropdownOpen) {
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [dropdownOpen]);
|
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
await logout();
|
await logout();
|
||||||
router.push("/login");
|
router.push("/login");
|
||||||
|
|
@ -129,48 +111,12 @@ export default function EmployeLayout({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Dropdown */}
|
<UserDropdown
|
||||||
<div className="relative user-dropdown">
|
user={user}
|
||||||
<button
|
profilePath="/employe/profil"
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
onLogout={handleLogout}
|
||||||
className="flex items-center gap-3 px-4 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
accentColor="green"
|
||||||
>
|
/>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-full bg-green-600 flex items-center justify-center text-white font-bold">
|
|
||||||
{user?.firstName?.charAt(0)}{user?.lastName?.charAt(0)}
|
|
||||||
</div>
|
|
||||||
<div className="text-left">
|
|
||||||
<p className="text-sm font-bold text-gray-900">
|
|
||||||
{user?.firstName} {user?.lastName}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-gray-500">{user?.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${dropdownOpen ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{dropdownOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
|
||||||
<Link
|
|
||||||
href="/employe/profil"
|
|
||||||
onClick={() => setDropdownOpen(false)}
|
|
||||||
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-100 transition-colors"
|
|
||||||
>
|
|
||||||
<User className="w-4 h-4 text-gray-600" />
|
|
||||||
<span className="text-sm font-medium text-gray-700">Profil</span>
|
|
||||||
</Link>
|
|
||||||
<div className="border-t border-gray-200 my-1"></div>
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-red-50 transition-colors text-left"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4 text-red-600" />
|
|
||||||
<span className="text-sm font-medium text-red-600">Déconnexion</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
93
components/UserDropdown.tsx
Normal file
93
components/UserDropdown.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { LogOut, User, ChevronDown } from 'lucide-react';
|
||||||
|
import { useClickOutside } from '@/hooks/useClickOutside';
|
||||||
|
import { cn } from '@/utils/helpers';
|
||||||
|
|
||||||
|
interface UserDropdownProps {
|
||||||
|
user: {
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
email?: string;
|
||||||
|
} | null;
|
||||||
|
profilePath: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
accentColor?: 'blue' | 'green';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const accentColors = {
|
||||||
|
blue: 'bg-blue-600',
|
||||||
|
green: 'bg-green-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserDropdown: React.FC<UserDropdownProps> = ({
|
||||||
|
user,
|
||||||
|
profilePath,
|
||||||
|
onLogout,
|
||||||
|
accentColor = 'blue',
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useClickOutside(dropdownRef, () => setIsOpen(false), isOpen);
|
||||||
|
|
||||||
|
const initials = `${user?.firstName?.charAt(0) || ''}${user?.lastName?.charAt(0) || ''}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={dropdownRef} className={cn('relative', className)}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="flex items-center gap-3 px-4 py-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={cn(
|
||||||
|
'w-10 h-10 rounded-full flex items-center justify-center text-white font-bold',
|
||||||
|
accentColors[accentColor]
|
||||||
|
)}>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<p className="text-sm font-bold text-gray-900">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn(
|
||||||
|
'w-4 h-4 text-gray-500 transition-transform',
|
||||||
|
isOpen && 'rotate-180'
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
|
||||||
|
<Link
|
||||||
|
href={profilePath}
|
||||||
|
onClick={() => setIsOpen(false)}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 text-gray-600" />
|
||||||
|
<span className="text-sm font-medium text-gray-700">Profil</span>
|
||||||
|
</Link>
|
||||||
|
<div className="border-t border-gray-200 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(false);
|
||||||
|
onLogout();
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-red-50 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4 text-red-600" />
|
||||||
|
<span className="text-sm font-medium text-red-600">Deconnexion</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserDropdown;
|
||||||
31
hooks/useClickOutside.ts
Normal file
31
hooks/useClickOutside.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { useEffect, RefObject } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to detect clicks outside of a specified element
|
||||||
|
* @param ref - Reference to the element to monitor
|
||||||
|
* @param callback - Function to call when clicking outside
|
||||||
|
* @param enabled - Whether the hook is active (default: true)
|
||||||
|
*/
|
||||||
|
export function useClickOutside<T extends HTMLElement>(
|
||||||
|
ref: RefObject<T | null>,
|
||||||
|
callback: () => void,
|
||||||
|
enabled: boolean = true
|
||||||
|
): void {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(event.target as Node)) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [ref, callback, enabled]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useClickOutside;
|
||||||
Loading…
Reference in New Issue
Block a user