Files
TREK/client/src/pages/HelpPage.tsx
T
Maurice 5584d287a9 refactor(help): register a screen from one description, and fix the doc links
Every context file carried its own copy of the same little guide factory and
the same context literal. Thirteen screens of a trip would have made thirteen
copies of it, which is both the duplication a quality gate counts and the
hand-mirrored state the repository asks us not to keep, so the shape moves into
defineScreen and a context file becomes data: ids, counts, docs, cross-links.

Four doc links never resolved. Trip-Planner and Sharing-and-Collaboration are
not wiki pages (Trip-Planner-Overview and Trip-Members-and-Sharing are), the
places import anchor is importing-multiple-places, and the offline anchor was
written in GitHub's slug, which is not the one the in-app reader produces.
Nothing caught them, so the registry test now checks every slug and every
anchor against wiki/, with the heading slug itself lifted out of HelpPage so
the reader and the test cannot drift apart.
2026-09-24 14:05:42 +02:00

206 lines
9.2 KiB
TypeScript

import { Children, type ReactNode } from 'react'
import { Link } from 'react-router'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { Search, ChevronRight, Loader2, AlertCircle, BookOpen, PanelLeft, X } from 'lucide-react'
import PageShell from '../components/Layout/PageShell'
import { useTranslation } from '../i18n'
import { useHelp } from './help/useHelp'
import { headingSlug } from '../help/headingSlug'
export default function HelpPage() {
const { t } = useTranslation()
const { page, loading, pageError, query, setQuery, navOpen, setNavOpen, contentRef, activeSlug, filtered } =
useHelp()
const nav = (
<nav className="flex flex-col gap-5">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-content-faint" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('help.search')}
className="w-full bg-surface-tertiary text-content rounded-lg pl-9 pr-3 py-2 text-[13px] outline-none border border-transparent focus:border-edge"
/>
</div>
{filtered.map((section) => (
<div key={section.title}>
{section.title && (
<h3 className="text-[10px] font-semibold tracking-[0.1em] uppercase text-content-faint mb-1.5 px-2">
{section.title}
</h3>
)}
<div className="flex flex-col">
{section.pages.map((p) => {
const active = p.slug === activeSlug
return (
<Link
key={p.slug}
to={`/help/${p.slug}`}
className={`flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[13px] transition-colors ${
active
? 'bg-accent-subtle text-accent font-semibold'
: 'text-content-secondary hover:bg-surface-hover'
}`}
>
{active && <ChevronRight size={13} className="shrink-0" />}
<span className={active ? '' : 'pl-[18px]'}>{p.title}</span>
</Link>
)
})}
</div>
</div>
))}
{!filtered.length && <p className="text-[12px] text-content-faint px-2">{t('help.noResults')}</p>}
</nav>
)
return (
<PageShell className="bg-surface-secondary" navOffset="var(--nav-h, 56px)">
<div className="max-w-[1600px] mx-auto px-4 lg:px-10 py-6 flex gap-10">
{/* Desktop sidebar */}
<aside className="hidden lg:block w-[260px] shrink-0">
<div className="sticky top-[calc(var(--nav-h,56px)+24px)] max-h-[calc(100vh-var(--nav-h,56px)-48px)] overflow-y-auto pr-1">
<div className="flex items-center gap-2 mb-4 px-2">
<BookOpen size={16} className="text-accent" />
<span className="text-[14px] font-bold text-content">{t('help.title')}</span>
</div>
{nav}
</div>
</aside>
{/* Content */}
<main className="flex-1 min-w-0" ref={contentRef}>
{/* Mobile nav toggle */}
<button type="button"
onClick={() => setNavOpen(true)}
className="lg:hidden inline-flex items-center gap-2 mb-4 px-3 py-2 rounded-lg bg-surface-card border border-edge text-[13px] font-medium text-content"
>
<PanelLeft size={15} /> {t('help.contents')}
</button>
{loading ? (
<div className="flex items-center justify-center py-24 text-content-faint">
<Loader2 size={22} className="animate-spin" />
</div>
) : pageError ? (
<div className="flex flex-col items-center justify-center gap-2 py-24 text-center">
<AlertCircle size={28} className="text-content-faint" />
<p className="text-[14px] font-semibold text-content">{t('help.errorTitle')}</p>
<p className="text-[13px] text-content-faint max-w-sm">{t('help.errorBody')}</p>
</div>
) : page ? (
<article className="wiki-prose max-w-[1040px]">
<WikiContent markdown={page.markdown} />
</article>
) : null}
</main>
</div>
{/* Mobile sidebar drawer */}
{navOpen && (
<div className="lg:hidden fixed inset-0 z-[120]" role="presentation" onClick={() => setNavOpen(false)}>
<div className="absolute inset-0 bg-black/40" />
<div
className="absolute left-0 top-0 bottom-0 w-[280px] bg-surface-card p-5 overflow-y-auto shadow-xl"
role="presentation"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<span className="text-[14px] font-bold text-content flex items-center gap-2">
<BookOpen size={16} className="text-accent" /> {t('help.title')}
</span>
<button type="button" onClick={() => setNavOpen(false)} className="text-content-faint">
<X size={18} />
</button>
</div>
{nav}
</div>
</div>
)}
</PageShell>
)
}
/**
* GitHub's heading-anchor slug: lowercase, punctuation dropped, spaces to
* hyphens. Wiki pages link to their own sections with `](#some-heading)`, and
* those hrefs are written against GitHub's scheme — so ours has to match it, or
* in-app anchors point at nothing. The slug itself lives in `help/headingSlug`,
* so the help center's doc links are checked against the same rule.
*/
function headingId(children: ReactNode): string {
const text = Children.toArray(children)
.map(c => (typeof c === 'string' || typeof c === 'number' ? String(c) : ''))
.join('')
return headingSlug(text)
}
/** Markdown renderer with TREK-styled elements and SPA-internal links. */
function WikiContent({ markdown }: { markdown: string }) {
return (
<Markdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => (
<h1 id={headingId(children)} className="text-[26px] font-bold text-content mt-1 mb-4 leading-tight">{children}</h1>
),
h2: ({ children }) => (
<h2 id={headingId(children)} className="text-[19px] font-bold text-content mt-8 mb-3 pb-1.5 border-b border-edge-secondary scroll-mt-24">
{children}
</h2>
),
h3: ({ children }) => <h3 id={headingId(children)} className="text-[15.5px] font-semibold text-content mt-6 mb-2 scroll-mt-24">{children}</h3>,
h4: ({ children }) => <h4 id={headingId(children)} className="text-[14px] font-semibold text-content mt-5 mb-2 scroll-mt-24">{children}</h4>,
p: ({ children }) => <p className="text-[14px] text-content-secondary leading-[1.7] my-3">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-3 space-y-1.5 text-[14px] text-content-secondary">{children}</ol>,
li: ({ children }) => <li className="leading-[1.6]">{children}</li>,
a: ({ href, children }) => {
const url = href ?? ''
if (url.startsWith('#')) return <a href={url} className="text-accent hover:underline">{children}</a>
if (url.startsWith('/')) return <Link to={url} className="text-accent hover:underline font-medium">{children}</Link>
return (
<a href={url} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline font-medium">
{children}
</a>
)
},
img: ({ src, alt }) => (
<img src={typeof src === 'string' ? src : ''} alt={alt} loading="lazy" className="rounded-lg border border-edge my-4 max-w-full" />
),
code: ({ className, children }) => {
const isBlock = (className ?? '').includes('language-')
if (isBlock) return <code className={className}>{children}</code>
return <code className="bg-surface-tertiary text-content rounded px-1.5 py-0.5 text-[12.5px] font-mono">{children}</code>
},
pre: ({ children }) => (
<pre className="bg-surface-tertiary text-content rounded-xl p-4 my-4 overflow-x-auto text-[12.5px] font-mono leading-relaxed border border-edge-secondary">
{children}
</pre>
),
blockquote: ({ children }) => (
<blockquote className="border-l-3 border-accent bg-accent-subtle/40 rounded-r-lg px-4 py-1 my-4 text-content-secondary">
{children}
</blockquote>
),
table: ({ children }) => (
<div className="overflow-x-auto my-4">
<table className="w-full text-[13px] border-collapse">{children}</table>
</div>
),
th: ({ children }) => (
<th className="text-left font-semibold text-content border border-edge-secondary px-3 py-2 bg-surface-tertiary">
{children}
</th>
),
td: ({ children }) => <td className="text-content-secondary border border-edge-secondary px-3 py-2">{children}</td>,
hr: () => <hr className="my-6 border-edge-secondary" />,
}}
>
{markdown}
</Markdown>
)
}