linguabot's picture
Upload folder using huggingface_hub
5a11b0a verified
import React, { useState, useEffect, useRef } from 'react';
import { Link, useLocation } from 'react-router-dom';
// We keep heroicons for fallback, but nav uses custom SVGs from /public/icons
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);
// Lightweight online presence: send heartbeat periodically
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); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user?.email]);
// Send a heartbeat on route changes for fresher session tracking
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();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// React to external view mode changes (from Manage toggle)
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);
}, []);
// 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]);
// 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 = () => {
try {
localStorage.removeItem('token');
localStorage.removeItem('user');
} catch {}
window.location.href = '/login';
};
// heroicons already imported at top
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 },
];
// Effective role based on viewMode
const effectiveRole = (() => {
if (viewMode === 'auto') return user?.role;
return viewMode === 'student' ? 'student' : 'admin';
})();
// Hide Slides for visitors
if (!user || effectiveRole === 'visitor') {
navigation = navigation.filter(item => item.name !== 'Slides');
}
// Add Manage link for admin users (always keep in nav)
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;