Files
maestro/ui/src/components/layout/TopBar.tsx
T
cladeandswallow 7049a874f3 feat: initial public release (MAESTRO v0.1.0)
Open-source release of MAESTRO, an agent orchestration platform that runs
LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0.
See README.md and docs/ (getting-started, configuration, architecture).
2026-06-03 04:01:14 +00:00

171 lines
6.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import type { PageId } from '../../lib/urlState';
import type { AuthUser } from '../../App';
interface TopBarProps {
currentPage: PageId;
onNavigate: (page: PageId) => void;
isAdmin?: boolean;
authEnabled?: boolean;
user?: AuthUser | null;
appName?: string;
logoUrl?: string | null;
onOpenDrawer: () => void;
hamburgerButtonRef?: React.RefObject<HTMLButtonElement>;
navDrawerOpen?: boolean;
}
export const NAV_ITEMS: Array<{ id: PageId; label: string; adminOnly: boolean; requiresAuth: boolean }> = [
{ id: 'tasks', label: 'タスク', adminOnly: false, requiresAuth: false },
{ id: 'schedules', label: 'スケジュール', adminOnly: false, requiresAuth: false },
{ id: 'pieces', label: 'Pieces', adminOnly: true, requiresAuth: false },
{ id: 'captcha', label: 'CAPTCHA', adminOnly: true, requiresAuth: false },
{ id: 'settings', label: '設定', adminOnly: false, requiresAuth: false },
{ id: 'users', label: 'ユーザー', adminOnly: true, requiresAuth: true },
{ id: 'help', label: 'ヘルプ', adminOnly: false, requiresAuth: false },
{ id: 'userfolder', label: 'ユーザーフォルダ', adminOnly: false, requiresAuth: false },
];
export function estimateCollapseThreshold(navCount: number): number {
return 430 + navCount * 78 + 60;
}
export function useViewportNarrow(threshold: number): boolean {
const [narrow, setNarrow] = useState(() =>
typeof window !== 'undefined' ? window.innerWidth < threshold : false,
);
useEffect(() => {
if (typeof window === 'undefined') return;
const update = () => setNarrow(window.innerWidth < threshold);
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, [threshold]);
return narrow;
}
export function visibleNavItemsFor(isAdmin: boolean, authEnabled: boolean) {
return NAV_ITEMS.filter(item => {
if (item.adminOnly && !isAdmin) return false;
if (item.requiresAuth && !authEnabled) return false;
return true;
});
}
export function useCompactNav(isAdmin: boolean, authEnabled: boolean): boolean {
const visible = visibleNavItemsFor(isAdmin, authEnabled);
return useViewportNarrow(estimateCollapseThreshold(visible.length));
}
export function TopBar({
currentPage,
onNavigate,
isAdmin = true,
authEnabled = false,
user = null,
appName = 'MAESTRO',
logoUrl = null,
onOpenDrawer,
hamburgerButtonRef,
navDrawerOpen = false,
}: TopBarProps) {
const visibleNav = visibleNavItemsFor(isAdmin, authEnabled);
const compactMode = useViewportNarrow(estimateCollapseThreshold(visibleNav.length));
return (
<div
className="flex-shrink-0 bg-white border-b border-hairline px-4 flex items-center"
style={{
paddingTop: 'env(safe-area-inset-top, 0px)',
minHeight: 'calc(48px + env(safe-area-inset-top, 0px))',
}}
>
<div className="flex items-center justify-between gap-3 flex-wrap w-full py-1.5">
<div className="flex items-center gap-4 min-w-0 self-stretch">
{compactMode && (
<button
ref={hamburgerButtonRef}
type="button"
onClick={onOpenDrawer}
aria-label="メニューを開く"
aria-expanded={navDrawerOpen}
aria-haspopup="dialog"
aria-controls="nav-drawer"
className="-ml-2 flex items-center justify-center w-11 h-11 rounded-md text-slate-700 hover:bg-surface focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring transition-colors"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
)}
<img
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
alt=""
className="flex-shrink-0 h-[22px] w-auto max-w-[140px] object-contain"
/>
<span className="text-xs font-semibold tracking-tight text-slate-900 hidden sm:inline">
{appName}
</span>
<span className="text-[10px] font-mono text-slate-400 hidden sm:inline">
v{__APP_VERSION__}
</span>
{!compactMode && (
<nav className="flex gap-5 items-stretch -mb-[13px] ml-2" aria-label="メインナビゲーション">
{visibleNav.map(item => {
const active = currentPage === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => onNavigate(item.id)}
aria-current={active ? 'page' : undefined}
className={`relative px-0.5 pb-3 text-xs border-b-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
active
? 'font-semibold text-slate-900 border-accent'
: 'font-medium text-slate-500 border-transparent hover:text-slate-800'
}`}
>
{item.label}
</button>
);
})}
</nav>
)}
</div>
<div className="flex items-center gap-2">
{user && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.name ?? user.email}
className="w-6 h-6 rounded-full object-cover"
/>
) : (
<div className="w-6 h-6 rounded-full bg-surface-2 text-slate-700 flex items-center justify-center text-2xs font-semibold uppercase">
{(user.name ?? user.email).charAt(0)}
</div>
)}
<span className="text-xs text-slate-600 hidden md:inline max-w-[120px] truncate">
{user.name ?? user.email}
</span>
</div>
<a
href="/auth/logout"
className="px-2 py-1 rounded-md text-2xs text-slate-500 hover:text-slate-800 hover:bg-surface transition-colors"
>
ログアウト
</a>
</div>
)}
</div>
</div>
</div>
);
}