| import React, { useState, useEffect, useRef } from 'react'; |
| import { Link, useLocation } from 'react-router-dom'; |
| |
| import { PowerIcon, HomeIcon, AcademicCapIcon, BookOpenIcon, HandThumbUpIcon, WrenchScrewdriverIcon, ChatBubbleLeftRightIcon, Cog6ToothIcon } 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 [viewMode, setViewMode] = useState<'admin' | 'student' | 'auto'>(() => { |
| try { |
| const saved = localStorage.getItem('viewMode') as any; |
| return saved === 'admin' || saved === 'student' ? saved : 'auto'; |
| } catch { return 'auto'; } |
| }); |
| const [isTransitioning] = useState(false); |
| const userData = localStorage.getItem('user'); |
| const user: User | null = userData ? JSON.parse(userData) : null; |
| const [unreadCount, setUnreadCount] = useState<number>(0); |
|
|
| |
| useEffect(() => { |
| let timer: any; |
| const sendHeartbeat = async () => { |
| try { |
| if (!user?.email) return; |
| const token = localStorage.getItem('token') || ''; |
| const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, ''); |
| await fetch(`${base}/api/auth/online/heartbeat`, { |
| method: 'POST', |
| headers: { |
| 'Authorization': `Bearer ${token}`, |
| 'Content-Type': 'application/json', |
| 'user-role': user.role || 'visitor', |
| 'user-info': userData || '' |
| }, |
| body: JSON.stringify({ email: user.email, path: location.pathname }) |
| }); |
| } catch {} |
| }; |
| sendHeartbeat(); |
| timer = setInterval(sendHeartbeat, 60000); |
| return () => { if (timer) clearInterval(timer); }; |
| |
| }, [user?.email]); |
|
|
| |
| useEffect(() => { |
| const run = async () => { |
| try { |
| if (!user?.email) return; |
| const token = localStorage.getItem('token') || ''; |
| const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, ''); |
| await fetch(`${base}/api/auth/online/heartbeat`, { |
| method: 'POST', |
| headers: { |
| 'Authorization': `Bearer ${token}`, |
| 'Content-Type': 'application/json', |
| 'user-role': user.role || 'visitor', |
| 'user-info': userData || '' |
| }, |
| body: JSON.stringify({ email: user.email, path: location.pathname }) |
| }); |
| } catch {} |
| }; |
| run(); |
| |
| }, [location.pathname]); |
|
|
| |
| useEffect(() => { |
| const handler = (e: any) => { |
| const mode = e?.detail; |
| if (mode === 'admin' || mode === 'student') { |
| setViewMode(mode); |
| } |
| }; |
| window.addEventListener('view-mode-change', handler as any); |
| return () => window.removeEventListener('view-mode-change', handler as any); |
| }, []); |
|
|
| |
| 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); }; |
| |
| }, [user?.role]); |
|
|
| |
| 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); }; |
| |
| }, [user?.role]); |
|
|
| const handleLogout = () => { |
| try { |
| localStorage.removeItem('token'); |
| localStorage.removeItem('user'); |
| } catch {} |
| window.location.href = '/login'; |
| }; |
|
|
| |
|
|
| 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: ChatBubbleLeftRightIcon }, |
| ]; |
|
|
| |
| const effectiveRole = (() => { |
| if (viewMode === 'auto') return user?.role; |
| return viewMode === 'student' ? 'student' : 'admin'; |
| })(); |
|
|
| |
| if (!user || effectiveRole === 'visitor') { |
| navigation = navigation.filter(item => item.name !== 'Slides'); |
| } |
|
|
| |
| if (user?.role === 'admin') { |
| navigation.push({ name: 'Manage', href: '/manage', icon: Cog6ToothIcon }); |
| } |
|
|
| const iconSrcFor = (name: string): string => { |
| switch (name) { |
| case 'Home': return '/icons/home.svg'; |
| case 'Tutorial Tasks': return '/icons/tutorial tasks.svg'; |
| case 'Weekly Practice': return '/icons/weekly practice.svg'; |
| case 'Votes': return '/icons/votes.svg'; |
| case 'Toolkit': return '/icons/toolkit.svg'; |
| case 'Slides': return '/icons/slides.svg'; |
| case 'Feedback': return '/icons/feedback.svg'; |
| case 'Manage': return '/icons/manage.svg'; |
| default: return '/icons/home.svg'; |
| } |
| }; |
|
|
| return ( |
| <div className="min-h-[calc(100vh+25vh)] text-ui-text bg-white app-shell" style={{ backgroundImage: 'url(/background/background.png)', backgroundSize: '100% auto', backgroundPosition: 'bottom', backgroundRepeat: 'no-repeat', paddingBottom: '25vh' }}> |
| {/* Top Bar */} |
| <header className="sticky top-0 z-40 bg-ui-panel/80 backdrop-blur border-b border-ui-border"> |
| <div className="px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between"> |
| <Link to="/dashboard" className="text-[1.6rem] font-bold text-ui-text flex items-center -ml-4 hover:text-ui-text" style={{ fontFamily: 'Lobster, Inter, system-ui, sans-serif' }}> |
| <img src="/favicon-512x512.png" alt="logo" className="h-8 w-8 mr-2" /> |
| TransHub |
| </Link> |
| <div /> |
| </div> |
| </header> |
| |
| {/* Shell: Sidebar + Content */} |
| <div className="flex"> |
| {/* Sidebar */} |
| <aside className="hidden md:flex md:flex-col w-60 fixed top-14 left-0 bottom-0 border-r border-ui-border bg-ui-panel/80 backdrop-blur z-30 sidebar-shell"> |
| <nav className="p-4 space-y-2 flex-1 sidebar-nav"> |
| {navigation.map((item) => { |
| const isActive = location.pathname === item.href; |
| return ( |
| <Link |
| key={item.name} |
| to={item.href} |
| className={`flex items-center px-3 py-2 rounded-lg text-[0.95rem] font-medium transition-colors ${ |
| isActive ? 'text-ui-text' : 'text-ui-text/80 hover:text-ui-text' |
| }`} |
| > |
| <img src={iconSrcFor(item.name)} alt="" className="h-6 w-6 mr-3" /> |
| <span>{item.name}</span> |
| </Link> |
| ); |
| })} |
| </nav> |
| <div className="p-3 border-t border-ui-border mt-auto sidebar-footer"> |
| {user ? ( |
| <button onClick={handleLogout} className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60"> |
| <PowerIcon className="h-4 w-4 mr-2" /> |
| Log Out |
| </button> |
| ) : ( |
| <Link to="/login" className="w-full flex items-center justify-start px-3 py-2 rounded-md text-sm font-medium text-ui-text/80 hover:bg-ui-panel/60"> |
| <PowerIcon className="h-4 w-4 mr-2" /> |
| Log In |
| </Link> |
| )} |
| </div> |
| </aside> |
| |
| {/* Main Content */} |
| <main className="flex-1 p-4 sm:p-6 lg:p-8 md:ml-60"> |
| {!isTransitioning && children} |
| </main> |
| </div> |
| |
| {/* 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-ui-panel rounded-lg shadow-lg p-4 flex items-center space-x-3 border border-ui-border"> |
| <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-ui-neonCyan"></div> |
| <span className="text-ui-text font-medium">Loading...</span> |
| </div> |
| </div> |
| )} |
| {/* <HitokotoBar /> */} |
| </div> |
| ); |
| }; |
|
|
| export default Layout; |