157 lines
6.0 KiB
TypeScript
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>
|
|
);
|
|
}
|