Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { Link, useLocation } from 'react-router-dom'; | |
| import { | |
| HomeIcon, | |
| AcademicCapIcon, | |
| BookOpenIcon, | |
| HandThumbUpIcon, | |
| WrenchScrewdriverIcon, | |
| UserIcon, | |
| ArrowRightOnRectangleIcon | |
| } from '@heroicons/react/24/outline'; | |
| import HitokotoBar from './HitokotoBar'; | |
| import { api } from '../services/api'; | |
| interface User { | |
| name: string; | |
| email: string; | |
| role: string; | |
| } | |
| const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => { | |
| const location = useLocation(); | |
| const [isTransitioning, setIsTransitioning] = useState(false); | |
| const previousPathRef = useRef(location.pathname); | |
| const userData = localStorage.getItem('user'); | |
| const user: User | null = userData ? JSON.parse(userData) : null; | |
| const [unreadCount, setUnreadCount] = useState<number>(0); | |
| // Handle page transitions | |
| useEffect(() => { | |
| // Only trigger transition if path actually changed | |
| if (location.pathname !== previousPathRef.current) { | |
| setIsTransitioning(true); | |
| previousPathRef.current = location.pathname; | |
| // Reset week selection to Week 1 when navigating between pages | |
| const previousPath = previousPathRef.current; | |
| if (location.pathname === '/tutorial-tasks' && | |
| previousPath && | |
| !previousPath.includes('/tutorial-tasks')) { | |
| // Use URL parameter to force Week 1 | |
| window.history.replaceState(null, '', '/tutorial-tasks?week=1'); | |
| localStorage.setItem('selectedTutorialWeek', '1'); | |
| } else if (location.pathname === '/weekly-practice' && | |
| previousPath && | |
| !previousPath.includes('/weekly-practice')) { | |
| // Use URL parameter to force Week 1 | |
| window.history.replaceState(null, '', '/weekly-practice?week=1'); | |
| localStorage.setItem('selectedWeeklyPracticeWeek', '1'); | |
| } | |
| // Determine transition duration based on destination page | |
| let transitionDuration = 800; // Default duration | |
| // Longer duration for heavy pages | |
| if (location.pathname === '/tutorial-tasks' || location.pathname === '/weekly-practice') { | |
| transitionDuration = 1200; // Longer for content-heavy pages | |
| } | |
| // Special case: Weekly Practice → Tutorial Tasks (add 500ms delay) | |
| if (location.pathname === '/tutorial-tasks' && | |
| previousPath && | |
| previousPath.includes('/weekly-practice')) { | |
| // Check if navigating to Week 2 (use localStorage since URL might not be updated yet) | |
| const tutorialWeek = localStorage.getItem('selectedTutorialWeek'); | |
| if (tutorialWeek === '2') { | |
| transitionDuration = 2500; // Extended duration for Week 2 (2000 + 500) | |
| } else { | |
| transitionDuration = 1700; // Standard duration for Week 1 (1200 + 500) | |
| } | |
| } | |
| // End transition after content is loaded (wait for DOM updates) | |
| const timer = setTimeout(() => { | |
| setIsTransitioning(false); | |
| }, transitionDuration); | |
| return () => clearTimeout(timer); | |
| } | |
| }, [location.pathname]); | |
| // Admin unread message badge (non-invasive) | |
| useEffect(() => { | |
| let timer: any; | |
| const run = async () => { | |
| try { | |
| if (user?.role !== 'admin') return; | |
| const token = localStorage.getItem('token') || ''; | |
| const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, ''); | |
| const resp = await fetch(`${base}/api/messages/unread-count`, { | |
| headers: { | |
| 'Authorization': `Bearer ${token}`, | |
| 'user-role': 'admin', | |
| 'user-info': userData || '' | |
| } | |
| }); | |
| if (resp.ok) { | |
| const data = await resp.json(); | |
| if (typeof data?.count === 'number') setUnreadCount(data.count); | |
| } | |
| } catch {} | |
| }; | |
| run(); | |
| if (user?.role === 'admin') { | |
| timer = setInterval(run, 60000); | |
| } | |
| return () => { if (timer) clearInterval(timer); }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [user?.role]); | |
| const handleLogout = () => { | |
| localStorage.removeItem('token'); | |
| localStorage.removeItem('user'); | |
| window.location.href = '/'; | |
| }; | |
| let navigation = [ | |
| { name: 'Home', href: '/dashboard', icon: HomeIcon }, | |
| { name: 'Tutorial Tasks', href: '/tutorial-tasks', icon: AcademicCapIcon }, | |
| { name: 'Weekly Practice', href: '/weekly-practice', icon: BookOpenIcon }, | |
| { name: 'Votes', href: '/votes', icon: HandThumbUpIcon }, | |
| { name: 'Toolkit', href: '/toolkit', icon: WrenchScrewdriverIcon }, | |
| { name: 'Slides', href: '/slides', icon: BookOpenIcon }, | |
| { name: 'Feedback', href: '/feedback', icon: UserIcon }, | |
| ]; | |
| // Hide Slides for visitors | |
| if (!user || user.role === 'visitor') { | |
| navigation = navigation.filter(item => item.name !== 'Slides'); | |
| } | |
| // Add Manage link for admin users | |
| if (user?.role === 'admin') { | |
| navigation.push({ name: 'Manage', href: '/manage', icon: UserIcon }); | |
| } | |
| return ( | |
| <div className="min-h-screen bg-gray-50"> | |
| {/* Navigation */} | |
| <nav className="bg-white shadow-sm border-b border-gray-200"> | |
| <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> | |
| <div className="flex justify-between h-16"> | |
| <div className="flex"> | |
| <div className="flex-shrink-0 flex items-center"> | |
| <Link to="/dashboard" className="text-xl font-bold text-indigo-600"> | |
| Transcreation | |
| </Link> | |
| </div> | |
| <div className="hidden sm:ml-6 sm:flex sm:space-x-8"> | |
| {navigation.map((item) => { | |
| const isActive = location.pathname === item.href; | |
| return ( | |
| <Link | |
| key={item.name} | |
| to={item.href} | |
| className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-all duration-200 ease-in-out ${ | |
| isActive | |
| ? 'border-indigo-500 text-gray-900' | |
| : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' | |
| }`} | |
| > | |
| <item.icon className="h-4 w-4 mr-1" /> | |
| {item.name} | |
| {item.name === 'Feedback' && user?.role === 'admin' && unreadCount > 0 && ( | |
| <span className="ml-2 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-red-600 text-white text-[10px] leading-none">{unreadCount > 99 ? '99+' : unreadCount}</span> | |
| )} | |
| </Link> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| <div className="flex items-center"> | |
| {user && ( | |
| <div className="flex items-center space-x-4"> | |
| <span className="text-sm text-gray-700"> | |
| Welcome, {(user as any).displayName || user.name} | |
| </span> | |
| <button | |
| onClick={handleLogout} | |
| className="text-gray-500 hover:text-gray-700 flex items-center" | |
| > | |
| <ArrowRightOnRectangleIcon className="h-4 w-4 mr-1" /> | |
| Logout | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </nav> | |
| {/* Mobile Navigation */} | |
| <div className="sm:hidden"> | |
| <div className="pt-2 pb-3 space-y-1"> | |
| {navigation.map((item) => { | |
| const isActive = location.pathname === item.href; | |
| return ( | |
| <Link | |
| key={item.name} | |
| to={item.href} | |
| className={`block pl-3 pr-4 py-2 border-l-4 text-base font-medium transition-all duration-200 ease-in-out ${ | |
| isActive | |
| ? 'bg-indigo-50 border-indigo-500 text-indigo-700' | |
| : 'border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800' | |
| }`} | |
| > | |
| <div className="flex items-center"> | |
| <item.icon className="h-4 w-4 mr-2" /> | |
| {item.name} | |
| </div> | |
| </Link> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* Main Content */} | |
| <main> | |
| {!isTransitioning && children} | |
| </main> | |
| {/* Transition Loading Indicator */} | |
| {isTransitioning && ( | |
| <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50"> | |
| <div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3"> | |
| <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div> | |
| <span className="text-gray-700 font-medium">Loading...</span> | |
| </div> | |
| </div> | |
| )} | |
| <HitokotoBar /> | |
| </div> | |
| ); | |
| }; | |
| export default Layout; |