All checks were successful
Build & Push Docker Image / build (push) Successful in 6m32s
167 lines
6.5 KiB
JavaScript
167 lines
6.5 KiB
JavaScript
import { NavLink } from 'react-router-dom';
|
|
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
|
|
import BrandLogo from './BrandLogo';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import { useBranding } from '../contexts/BrandingContext';
|
|
import ThemeSelector from './ThemeSelector';
|
|
import { useState, useEffect } from 'react';
|
|
import api from '../services/api';
|
|
|
|
export default function Sidebar({ open, onClose }) {
|
|
const { user } = useAuth();
|
|
const { t } = useLanguage();
|
|
const { imprintUrl, privacyUrl } = useBranding();
|
|
const [themeOpen, setThemeOpen] = useState(false);
|
|
const [federationCount, setFederationCount] = useState(0);
|
|
|
|
// Fetch pending federation invitation count
|
|
useEffect(() => {
|
|
const fetchCount = async () => {
|
|
try {
|
|
const res = await api.get('/federation/invitations/pending-count');
|
|
setFederationCount(res.data.count || 0);
|
|
} catch {
|
|
// Ignore — federation may not be enabled
|
|
}
|
|
};
|
|
fetchCount();
|
|
const interval = setInterval(fetchCount, 30000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const navItems = [
|
|
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
|
|
{ to: '/calendar', icon: CalendarDays, label: t('nav.calendar') },
|
|
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
|
|
{ to: '/settings', icon: Settings, label: t('nav.settings') },
|
|
];
|
|
|
|
if (user?.role === 'admin') {
|
|
navItems.push({ to: '/admin', icon: Shield, label: t('nav.admin') });
|
|
}
|
|
|
|
const linkClasses = ({ isActive }) =>
|
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
|
|
? 'bg-th-accent text-th-accent-t shadow-sm'
|
|
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
|
}`;
|
|
|
|
return (
|
|
<>
|
|
<aside
|
|
className={`fixed top-0 left-0 z-40 h-full w-64 bg-th-side border-r border-th-border
|
|
transition-transform duration-300 ease-in-out
|
|
${open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0`}
|
|
>
|
|
<div className="flex flex-col h-full">
|
|
{/* Logo */}
|
|
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
|
<BrandLogo size="md" className="flex-1 min-w-0" />
|
|
<button
|
|
onClick={onClose}
|
|
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
|
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
|
{t('nav.navigation')}
|
|
</p>
|
|
{navItems.map(item => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
className={linkClasses}
|
|
onClick={onClose}
|
|
>
|
|
<item.icon size={18} />
|
|
{item.label}
|
|
{item.badge > 0 && (
|
|
<span className="ml-auto px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</NavLink>
|
|
))}
|
|
|
|
<div className="pt-4">
|
|
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
|
{t('nav.appearance')}
|
|
</p>
|
|
<button
|
|
onClick={() => setThemeOpen(!themeOpen)}
|
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-th-text-s hover:text-th-text hover:bg-th-hover transition-all duration-200"
|
|
>
|
|
<Palette size={18} />
|
|
{t('nav.changeTheme')}
|
|
</button>
|
|
</div>
|
|
</nav>
|
|
|
|
{/* User info */}
|
|
<div className="p-4 border-t border-th-border">
|
|
<div className="flex items-center gap-3">
|
|
<div
|
|
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
|
|
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
|
>
|
|
{user?.avatar_image ? (
|
|
<img
|
|
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
|
alt="Avatar"
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
) : (
|
|
(user?.display_name || user?.name)?.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) || '?'
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-sm font-medium text-th-text truncate">{user?.display_name || user?.name}</p>
|
|
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Imprint / Privacy Policy links */}
|
|
{(imprintUrl || privacyUrl) && (
|
|
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
|
|
{imprintUrl && (
|
|
<a
|
|
href={imprintUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
|
>
|
|
<FileText size={11} />
|
|
{t('nav.imprint')}
|
|
</a>
|
|
)}
|
|
{imprintUrl && privacyUrl && (
|
|
<span className="text-th-border text-xs">·</span>
|
|
)}
|
|
{privacyUrl && (
|
|
<a
|
|
href={privacyUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
|
>
|
|
<Lock size={11} />
|
|
{t('nav.privacy')}
|
|
</a>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Theme Selector Modal */}
|
|
{themeOpen && <ThemeSelector onClose={() => setThemeOpen(false)} />}
|
|
</>
|
|
);
|
|
}
|