154 lines
6.2 KiB
TypeScript
154 lines
6.2 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|||
|
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
|
import { useAuth } from '@/hooks/useAuth';
|
||
|
|
import { APP_NAME } from '@/utils/constants';
|
||
|
|
import { ClipboardList, LogOut, Moon, Sun, User } from 'lucide-react';
|
||
|
|
import { MOTION_DURATION, MOTION_EASE, fastTransition } from '@/lib/motion';
|
||
|
|
import { useTheme } from '@/hooks/useTheme';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Minimal top shell: product mark + profile menu (Course Status, Logout).
|
||
|
|
* Replaces the former bottom navigation.
|
||
|
|
*/
|
||
|
|
export function AppHeader() {
|
||
|
|
const { isAuthenticated, signOut, isGuest } = useAuth();
|
||
|
|
const { toggleTheme, isDark } = useTheme();
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const location = useLocation();
|
||
|
|
const [menuOpen, setMenuOpen] = useState(false);
|
||
|
|
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
|
||
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
const isAuthPage = location.pathname === '/login' || location.pathname === '/signup';
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setMenuOpen(false);
|
||
|
|
}, [location.pathname]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!menuOpen) return;
|
||
|
|
|
||
|
|
const onPointerDown = (event: MouseEvent) => {
|
||
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||
|
|
setMenuOpen(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
document.addEventListener('mousedown', onPointerDown);
|
||
|
|
return () => document.removeEventListener('mousedown', onPointerDown);
|
||
|
|
}, [menuOpen]);
|
||
|
|
|
||
|
|
const handleSignOut = async () => {
|
||
|
|
await signOut();
|
||
|
|
setShowLogoutConfirm(false);
|
||
|
|
setMenuOpen(false);
|
||
|
|
navigate('/login', { replace: true });
|
||
|
|
};
|
||
|
|
|
||
|
|
if (isAuthPage || (!isAuthenticated && !isGuest)) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<header className="app-header border-b border-border/40 backdrop-blur-2xl">
|
||
|
|
<div className="mx-auto flex h-14 w-full items-center justify-between gap-3 px-4">
|
||
|
|
<Link
|
||
|
|
to="/"
|
||
|
|
className="truncate text-base font-semibold tracking-tight text-foreground"
|
||
|
|
aria-label={APP_NAME}
|
||
|
|
>
|
||
|
|
{APP_NAME}
|
||
|
|
</Link>
|
||
|
|
|
||
|
|
<div className="relative" ref={menuRef}>
|
||
|
|
<motion.button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setMenuOpen((open) => !open)}
|
||
|
|
whileHover={{ scale: 1.05 }}
|
||
|
|
whileTap={{ scale: 0.96 }}
|
||
|
|
transition={fastTransition}
|
||
|
|
className="inline-flex h-9 w-9 items-center justify-center rounded-full border border-cyan-200 bg-white text-cyan-700 shadow-sm transition-colors hover:border-cyan-500 hover:text-cyan-800 dark:border-neon-cyan/40 dark:bg-background/70 dark:text-neon-cyan dark:shadow-none dark:hover:border-neon-cyan dark:hover:text-neon-cyan dark:hover:shadow-glow-sm"
|
||
|
|
aria-label="منوی پروفایل"
|
||
|
|
aria-expanded={menuOpen}
|
||
|
|
aria-haspopup="menu"
|
||
|
|
>
|
||
|
|
<User className="h-4 w-4" />
|
||
|
|
</motion.button>
|
||
|
|
|
||
|
|
<AnimatePresence>
|
||
|
|
{menuOpen && (
|
||
|
|
<motion.div
|
||
|
|
role="menu"
|
||
|
|
initial={{ opacity: 0, scale: 0.98 }}
|
||
|
|
animate={{ opacity: 1, scale: 1 }}
|
||
|
|
exit={{ opacity: 0, scale: 0.98 }}
|
||
|
|
transition={{ duration: MOTION_DURATION.fast, ease: MOTION_EASE }}
|
||
|
|
className="glass-panel-strong absolute left-0 z-[70] mt-2 w-52 origin-top-left p-1"
|
||
|
|
>
|
||
|
|
<Link
|
||
|
|
to="/course-status"
|
||
|
|
role="menuitem"
|
||
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-body transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5 dark:hover:text-white"
|
||
|
|
onClick={() => setMenuOpen(false)}
|
||
|
|
>
|
||
|
|
<ClipboardList className="h-4 w-4 shrink-0 text-neon-cyan" />
|
||
|
|
وضعیت دوره
|
||
|
|
</Link>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-body transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5 dark:hover:text-white"
|
||
|
|
onClick={toggleTheme}
|
||
|
|
>
|
||
|
|
{isDark ? (
|
||
|
|
<Sun className="h-4 w-4 shrink-0 text-neon-cyan" />
|
||
|
|
) : (
|
||
|
|
<Moon className="h-4 w-4 shrink-0 text-neon-cyan" />
|
||
|
|
)}
|
||
|
|
{isDark ? 'حالت روشن' : 'حالت تاریک'}
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
role="menuitem"
|
||
|
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-red-600 transition-colors hover:bg-red-500/10 dark:text-red-300"
|
||
|
|
onClick={() => {
|
||
|
|
setMenuOpen(false);
|
||
|
|
setShowLogoutConfirm(true);
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<LogOut className="h-4 w-4 shrink-0" />
|
||
|
|
خروج
|
||
|
|
</button>
|
||
|
|
</motion.div>
|
||
|
|
)}
|
||
|
|
</AnimatePresence>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
{showLogoutConfirm && (
|
||
|
|
<div className="fixed inset-0 z-[60] flex items-end justify-center bg-navy-deep/70 p-4 backdrop-blur-sm sm:items-center">
|
||
|
|
<div className="glass-panel-strong w-full max-w-sm space-y-4 p-6">
|
||
|
|
<h3 className="text-lg font-semibold text-foreground">خروج از حساب کاربری</h3>
|
||
|
|
<p className="text-sm text-body">آیا مطمئن هستید که میخواهید خارج شوید؟</p>
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setShowLogoutConfirm(false)}
|
||
|
|
className="btn-glass flex-1"
|
||
|
|
>
|
||
|
|
انصراف
|
||
|
|
</button>
|
||
|
|
<button type="button" onClick={handleSignOut} className="btn-danger-neon flex-1">
|
||
|
|
خروج
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|