maestro/ui/src/pages/HelpPage.tsx
oss-sync d061ad08d8
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

157 lines
6.0 KiB
TypeScript

import { useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { loadHelpSections } from '../lib/help-content';
import { filterSections, renderHelpHtml, type HelpCategory, type HelpSection } from '../lib/help';
const CATEGORY_LABEL_KEY: Record<HelpCategory, string> = {
basic: 'category.basic',
advanced: 'category.advanced',
admin: 'category.admin',
};
const CATEGORY_ORDER: HelpCategory[] = ['basic', 'advanced', 'admin'];
interface HelpPageProps {
isAdmin: boolean;
onAskAi: () => void;
/** Currently selected section id (from URL). */
selectedId?: string;
/** Called when the user picks a section; caller writes it to the URL. */
onSelect: (id: string) => void;
}
export function HelpPage({ isAdmin, onAskAi, selectedId, onSelect }: HelpPageProps) {
const { t } = useTranslation('help');
const all = useMemo(() => loadHelpSections(), []);
const visible = useMemo(
() => all.filter((s) => s.category !== 'admin' || isAdmin),
[all, isAdmin],
);
const [query, setQuery] = useState('');
const mainRef = useRef<HTMLElement>(null);
const filtered = useMemo(() => filterSections(visible, query), [visible, query]);
const selected: HelpSection | undefined =
visible.find((s) => s.id === selectedId) ?? visible[0];
const rendered = useMemo(
() => (selected ? renderHelpHtml(selected.body) : { html: '', headings: [] }),
[selected],
);
const categories = CATEGORY_ORDER.filter((c) => c !== 'admin' || isAdmin);
function scrollToHeading(id: string) {
const el = mainRef.current?.querySelector(`#${CSS.escape(id)}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleContentClick(e: React.MouseEvent<HTMLElement>) {
const a = (e.target as HTMLElement).closest('a');
if (!a) return;
const href = a.getAttribute('href');
if (!href) return;
if (href.startsWith('#')) {
e.preventDefault();
scrollToHeading(decodeURIComponent(href.slice(1)));
return;
}
const m = /^\.\/\d+-(.+?)\.md(?:#.*)?$/.exec(href);
if (m) {
const id = m[1];
if (all.some((s) => s.id === id)) {
e.preventDefault();
onSelect(id);
}
}
}
return (
<div className="flex h-full overflow-hidden bg-surface">
{/* Left: TOC */}
<aside className="w-72 shrink-0 border-r border-hairline bg-canvas overflow-y-auto flex flex-col">
<div className="p-4 border-b border-hairline">
<h2 className="text-sm font-semibold text-slate-900 m-0">{t('title')}</h2>
<button
type="button"
onClick={onAskAi}
className="mt-3 w-full px-3 py-2 rounded-md text-xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
{t('askAi')}
</button>
<p className="mt-2 text-2xs text-slate-500 leading-relaxed">
{t('askAiHint')}
</p>
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('searchPlaceholder')}
className="mt-3 w-full px-2.5 py-1.5 rounded border border-hairline text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
aria-label={t('searchAria')}
/>
</div>
<nav className="p-3 flex-1 min-h-0 overflow-y-auto" aria-label={t('tocAria')}>
{filtered.length === 0 && (
<p className="px-2 py-2 text-xs text-slate-500">{t('noResults', { query })}</p>
)}
{categories.map((cat) => {
const items = filtered.filter((s) => s.category === cat);
if (items.length === 0) return null;
return (
<div key={cat} className="mb-3">
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-slate-500 font-semibold">
{t(CATEGORY_LABEL_KEY[cat])}
</div>
{items.map((s) => (
<button
key={s.id}
type="button"
onClick={() => onSelect(s.id)}
aria-current={selected?.id === s.id ? 'page' : undefined}
className={`block w-full text-left px-2 py-1.5 rounded text-[13px] mb-0.5 transition-colors ${
selected?.id === s.id
? 'bg-accent-soft text-accent font-semibold'
: 'text-slate-700 hover:bg-surface'
}`}
>
{s.title}
</button>
))}
</div>
);
})}
</nav>
</aside>
{/* Right: rendered markdown + heading nav */}
<main ref={mainRef} className="flex-1 min-w-0 overflow-y-auto bg-canvas">
<div className="flex max-w-5xl mx-auto px-8 py-8 gap-8">
<article
className="prose prose-sm dark:prose-invert flex-1 min-w-0"
onClick={handleContentClick}
dangerouslySetInnerHTML={{ __html: rendered.html }}
/>
{rendered.headings.length > 1 && (
<nav className="hidden xl:block w-48 shrink-0 sticky top-8 self-start" aria-label={t('sectionTocAria')}>
<div className="text-[10px] uppercase tracking-wide text-slate-500 font-semibold mb-2">
{t('thisPage')}
</div>
{rendered.headings.map((h) => (
<button
key={h.id}
type="button"
onClick={() => scrollToHeading(h.id)}
className={`block w-full text-left py-0.5 text-[12px] text-slate-600 hover:text-accent ${
h.depth === 3 ? 'pl-3' : ''
}`}
>
{h.text}
</button>
))}
</nav>
)}
</div>
</main>
</div>
);
}