|
|
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; |