- Use explicit border color (#e2e8f0) for table cell borders - Navbar shows 'Team' (singular) linking directly to team dashboard when user has only 1 team - Navbar shows 'Teams' (plural) linking to teams list when user has multiple teams - Remove project/member counts from team dashboard header
234 lines
10 KiB
TypeScript
234 lines
10 KiB
TypeScript
import { ReactNode, useState, useRef, useEffect } from 'react';
|
|
import { Link, NavLink, useLocation, useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { GlobalSearch } from './GlobalSearch';
|
|
import { listTeams } from '../api';
|
|
import { TeamDetail } from '../types';
|
|
import './Layout.css';
|
|
|
|
interface LayoutProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
function Layout({ children }: LayoutProps) {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
const { user, loading, logout } = useAuth();
|
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
|
const [userTeams, setUserTeams] = useState<TeamDetail[]>([]);
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Fetch user's teams
|
|
useEffect(() => {
|
|
if (user) {
|
|
listTeams({ limit: 10 }).then(data => {
|
|
setUserTeams(data.items);
|
|
}).catch(() => {
|
|
setUserTeams([]);
|
|
});
|
|
} else {
|
|
setUserTeams([]);
|
|
}
|
|
}, [user]);
|
|
|
|
// Close menu when clicking outside
|
|
useEffect(() => {
|
|
function handleClickOutside(event: MouseEvent) {
|
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
|
setShowUserMenu(false);
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await logout();
|
|
setShowUserMenu(false);
|
|
navigate('/');
|
|
} catch {
|
|
// Error handled in context
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="layout">
|
|
<header className="header">
|
|
<div className="container header-content">
|
|
<Link to="/" className="logo">
|
|
<div className="logo-icon">
|
|
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
{/* Three fruit trees representing an orchard */}
|
|
{/* Left tree - rounded canopy */}
|
|
<path d="M6 14 Q6 8 3 8 Q6 4 6 4 Q6 4 9 8 Q6 8 6 14" fill="currentColor" opacity="0.6"/>
|
|
<rect x="5.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
|
|
{/* Center tree - larger rounded canopy */}
|
|
<path d="M12 12 Q12 5 8 5 Q12 1 12 1 Q12 1 16 5 Q12 5 12 12" fill="currentColor"/>
|
|
<rect x="11.25" y="11" width="1.5" height="5" fill="currentColor"/>
|
|
{/* Right tree - rounded canopy */}
|
|
<path d="M18 14 Q18 8 15 8 Q18 4 18 4 Q18 4 21 8 Q18 8 18 14" fill="currentColor" opacity="0.6"/>
|
|
<rect x="17.25" y="13" width="1.5" height="4" fill="currentColor" opacity="0.6"/>
|
|
{/* Ground */}
|
|
<ellipse cx="12" cy="19" rx="9" ry="1.5" fill="currentColor" opacity="0.3"/>
|
|
</svg>
|
|
</div>
|
|
<span className="logo-text">Orchard</span>
|
|
</Link>
|
|
<GlobalSearch />
|
|
<nav className="nav">
|
|
<Link to="/" className={location.pathname === '/' ? 'active' : ''}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
|
|
<polyline points="9,22 9,12 15,12 15,22"/>
|
|
</svg>
|
|
Projects
|
|
</Link>
|
|
<Link to="/dashboard" className={location.pathname === '/dashboard' ? 'active' : ''}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
|
</svg>
|
|
Dashboard
|
|
</Link>
|
|
{user && userTeams.length > 0 && (
|
|
<Link
|
|
to={userTeams.length === 1 ? `/teams/${userTeams[0].slug}` : '/teams'}
|
|
className={location.pathname.startsWith('/teams') ? 'active' : ''}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
{userTeams.length === 1 ? 'Team' : 'Teams'}
|
|
</Link>
|
|
)}
|
|
<a href="/docs" className="nav-link-muted">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
|
<polyline points="14,2 14,8 20,8"/>
|
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
|
</svg>
|
|
Docs
|
|
</a>
|
|
|
|
{/* User Menu */}
|
|
{loading ? (
|
|
<div className="user-menu-loading">
|
|
<div className="user-menu-spinner"></div>
|
|
</div>
|
|
) : user ? (
|
|
<div className="user-menu" ref={menuRef}>
|
|
<button
|
|
className="user-menu-trigger"
|
|
onClick={() => setShowUserMenu(!showUserMenu)}
|
|
aria-expanded={showUserMenu}
|
|
aria-haspopup="true"
|
|
>
|
|
<div className="user-avatar">
|
|
{user.username.charAt(0).toUpperCase()}
|
|
</div>
|
|
<span className="user-name">{user.display_name || user.username}</span>
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<polyline points="6 9 12 15 18 9"/>
|
|
</svg>
|
|
</button>
|
|
|
|
{showUserMenu && (
|
|
<div className="user-menu-dropdown">
|
|
<div className="user-menu-header">
|
|
<span className="user-menu-username">{user.username}</span>
|
|
{user.is_admin && (
|
|
<span className="user-menu-badge">Admin</span>
|
|
)}
|
|
</div>
|
|
<div className="user-menu-divider"></div>
|
|
<NavLink
|
|
to="/settings/api-keys"
|
|
className="user-menu-item"
|
|
onClick={() => setShowUserMenu(false)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>
|
|
</svg>
|
|
API Keys
|
|
</NavLink>
|
|
{user.is_admin && (
|
|
<>
|
|
<NavLink
|
|
to="/admin/users"
|
|
className="user-menu-item"
|
|
onClick={() => setShowUserMenu(false)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
|
<circle cx="9" cy="7" r="4"/>
|
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
|
</svg>
|
|
User Management
|
|
</NavLink>
|
|
<NavLink
|
|
to="/admin/oidc"
|
|
className="user-menu-item"
|
|
onClick={() => setShowUserMenu(false)}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
|
</svg>
|
|
SSO Configuration
|
|
</NavLink>
|
|
</>
|
|
)}
|
|
<div className="user-menu-divider"></div>
|
|
<button className="user-menu-item" onClick={handleLogout}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
|
<polyline points="16 17 21 12 16 7"/>
|
|
<line x1="21" y1="12" x2="9" y2="12"/>
|
|
</svg>
|
|
Sign out
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Link to="/login" className="nav-login">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/>
|
|
<polyline points="10 17 15 12 10 7"/>
|
|
<line x1="15" y1="12" x2="3" y2="12"/>
|
|
</svg>
|
|
Login
|
|
</Link>
|
|
)}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
<main className="main">
|
|
<div className="container">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
<footer className="footer">
|
|
<div className="container footer-content">
|
|
<div className="footer-brand">
|
|
<span className="footer-logo">Orchard</span>
|
|
<span className="footer-tagline">Content-Addressable Storage</span>
|
|
</div>
|
|
<div className="footer-links">
|
|
<a href="/docs">Documentation</a>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Layout;
|