fd98616
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { ChevronDown, Menu, PanelLeftClose, PanelLeftOpen, X } from 'lucide-react'
import { useT } from '@/shared/i18n'
/** Shared, responsive workspace frame. Content panes own their scroll position. */
export interface AppShellProps {
rail?: ReactNode
sideHeader?: ReactNode
/** Pinned rows above the "工作区" caption (e.g. the personal inbox entry). */
navPinned?: ReactNode
nav?: ReactNode
sideFooter?: ReactNode
/** Directory-style column (explore): the rail stays, the space sidebar goes. */
railOnly?: boolean
children: ReactNode
}
export function AppShell({ rail, sideHeader, navPinned, nav, sideFooter, railOnly = false, children }: AppShellProps) {
const { t } = useT()
const [navigationOpen, setNavigationOpen] = useState(false)
const [collapsed, setCollapsed] = useState(false)
const navigation = useRef<HTMLDivElement>(null)
const menu = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!navigationOpen) return
const panel = navigation.current
const previous = document.activeElement as HTMLElement | null
panel?.querySelector<HTMLElement>('button, a[href]')?.focus()
function onKey(event: KeyboardEvent) {
if (event.key === 'Escape') setNavigationOpen(false)
if (event.key !== 'Tab' || !panel) return
const items = [...panel.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), input:not([disabled])')].filter(item => item.getClientRects().length)
const first = items[0]
const last = items[items.length - 1]
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
}
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('keydown', onKey)
if (previous?.isConnected) previous.focus()
}
}, [navigationOpen])
useEffect(() => {
const viewport = window.matchMedia('(min-width: 761px)')
const closeDrawer = () => { if (viewport.matches) setNavigationOpen(false) }
viewport.addEventListener('change', closeDrawer)
return () => viewport.removeEventListener('change', closeDrawer)
}, [])
return (
<div className={`workspace-shell ${collapsed ? 'navigation-collapsed' : ''} ${navigationOpen ? 'navigation-open' : ''} ${railOnly ? 'rail-only' : ''}`}>
<button
type="button"
className="workspace-skip"
onClick={() => document.getElementById('workspace-main')?.focus()}
>
{t('ui.skipToContent')}
</button>
<div className="workspace-mobile-bar">
<button ref={menu} type="button" className="workspace-icon-button" aria-label={t('ui.openNavigation')} aria-expanded={navigationOpen} aria-controls="workspace-navigation" onClick={() => setNavigationOpen(true)}><Menu size={20} /></button>
<span className="font-semibold">GitData<span className="ml-2 text-xs font-normal text-shell-ink-muted">{t('ui.workspaceLabel')}</span></span>
</div>
{navigationOpen ? <button type="button" className="workspace-scrim" aria-label={t('ui.closeNavigation')} tabIndex={-1} onClick={() => setNavigationOpen(false)} /> : null}
<div id="workspace-navigation" ref={navigation} className="workspace-navigation" role={navigationOpen ? 'dialog' : undefined} aria-modal={navigationOpen || undefined} aria-label={t('ui.navigation')} onClick={event => {
if ((event.target as HTMLElement).closest('a[href]')) setNavigationOpen(false)
}}>
<div className="workspace-navigation-columns">
<aside className="workspace-rail" aria-label={t('ui.spaceList')}>{rail}</aside>
<aside className="workspace-sidebar" aria-label={t('ui.currentSpace')}>
<div className="workspace-sidebar-header">
{sideHeader ?? <><span className="min-w-0 flex-1 truncate font-semibold">{t('ui.personalSpace')}</span><ChevronDown size={16} className="text-shell-ink-muted" /></>}
<button type="button" className="workspace-drawer-close workspace-icon-button" aria-label={t('ui.collapseNavigation')} onClick={() => setNavigationOpen(false)}><X size={18} /></button>
</div>
{navPinned ? (
<div className="workspace-nav-pinned" aria-label={t('ui.shortcuts')}>{navPinned}</div>
) : null}
<div className="workspace-nav-caption"><span>{t('ui.workspace')}</span><button type="button" className="workspace-collapse workspace-icon-button" aria-label={t('ui.collapseSidebar')} title={t('ui.collapseSidebar')} onClick={() => setCollapsed(true)}><PanelLeftClose size={16} /></button></div>
<nav className="workspace-nav" aria-label={t('ui.navScope')}>{nav}</nav>
</aside>
</div>
{sideFooter ? <div className="workspace-navigation-footer">{sideFooter}</div> : null}
</div>
<main id="workspace-main" tabIndex={-1} className="workspace-main" inert={navigationOpen || undefined}>
{/* Always mounted: `visibility` hides it when the sidebar is open, which keeps
it out of the tab order while still letting it fade in and out. */}
<button type="button" className="workspace-expand workspace-icon-button" aria-label={t('ui.expandSidebar')} title={t('ui.expandSidebar')} onClick={() => setCollapsed(false)}><PanelLeftOpen size={18} /></button>
{children}
</main>
</div>
)
}
export interface ShellTopBarProps {
icon?: ReactNode
title: ReactNode
/** Replaces the plain title heading with an interactive node (e.g. the asset search menu). */
titleMenu?: ReactNode
topic?: ReactNode
/** Page-level view switch, rendered beside the title. */
tabs?: ReactNode
actions?: ReactNode
}
export function ShellTopBar({ icon, title, titleMenu, topic, tabs, actions }: ShellTopBarProps) {
return (
<header className="shell-top-bar">
{icon ? <span className="shell-top-icon">{icon}</span> : null}
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-3">
{titleMenu ?? <h1 className="truncate text-base font-semibold">{title}</h1>}
{tabs ? <div className="flex shrink-0 items-center">{tabs}</div> : null}
</div>
{topic ? <p className="mt-0.5 truncate text-xs text-shell-ink-muted">{topic}</p> : null}
</div>
{actions ? <div className="shell-top-actions">{actions}</div> : null}
</header>
)
}