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).
171 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|