| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useMemo, useRef, useState } from "react"; |
| 4 | import Link from "next/link"; |
| 5 | import { useRouter } from "next/navigation"; |
| 6 | import { Icon } from "@/components/icon"; |
| 7 | import type { RatatuiCopy } from "@/lib/content/ratatui"; |
| 8 | import { pickText } from "@/lib/i18n/dictionaries"; |
| 9 | import { motionId, previewAssetPath, searchEntries, type Catalogue, type EntryPreview } from "@/lib/ratatui/catalogue"; |
| 10 | import { TASKS, displayApi, filterByTask, getGuidance } from "@/lib/ratatui/learning"; |
| 11 | import { CodeBlock } from "./highlight"; |
| 12 | import { getRecipe, recipeSourceUrl, learningGuideUrl } from "@/lib/ratatui/recipes"; |
| 13 | import "./explorer.css"; |
| 14 | |
| 15 | type MotionTrack = { |
| 16 | id: string; |
| 17 | title: string; |
| 18 | profile: string; |
| 19 | width: number; |
| 20 | height: number; |
| 21 | frameMs: number; |
| 22 | frames: string[]; |
| 23 | source: string; |
| 24 | }; |
| 25 | |
| 26 | export function CopyButton({ text, copy }: { text: string; copy: RatatuiCopy }) { |
| 27 | const [status, setStatus] = useState<"idle" | "copied" | "error">("idle"); |
| 28 | useEffect(() => { |
| 29 | if (status === "idle") return; |
| 30 | const timer = window.setTimeout(() => setStatus("idle"), 2400); |
| 31 | return () => window.clearTimeout(timer); |
| 32 | }, [status]); |
| 33 | return ( |
| 34 | <span className="rat-copy"> |
| 35 | <button className="rat-button" type="button" disabled={!text} onClick={async () => { |
| 36 | try { await navigator.clipboard.writeText(text); setStatus("copied"); } |
| 37 | catch { setStatus("error"); } |
| 38 | }}><Icon name={status === "copied" ? "check" : "copy"} />{status === "copied" ? copy.copied : copy.copy}</button> |
| 39 | <span className="rat-copy-status" role="status">{status === "error" ? copy.copyFailed : ""}</span> |
| 40 | </span> |
| 41 | ); |
| 42 | } |
| 43 | |
| 44 | /** SVGs are actual library buffers, displayed as isolated images rather than injected markup. */ |
| 45 | function TerminalImage({ svg, label, fit, width, height }: { svg: string; label: string; fit: boolean; width: number; height: number }) { |
| 46 | const [url, setUrl] = useState(""); |
| 47 | useEffect(() => { |
| 48 | const objectUrl = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); |
| 49 | setUrl(objectUrl); |
| 50 | return () => URL.revokeObjectURL(objectUrl); |
| 51 | }, [svg]); |
| 52 | // Small buffers would sit tiny in a wide panel; vector SVGs scale without blur. |
| 53 | // Stylesheet width beats the sizing attributes, so scaled renders set it. |
| 54 | const scale = fit && width <= 60 ? 2 : 1; |
| 55 | return <div className={`rat-terminal${fit ? " rat-terminal-fit" : ""}${scale > 1 ? " rat-terminal-scaled" : ""}`} role="region" aria-label={label} tabIndex={0} dir="ltr"> |
| 56 | {/* next/image cannot optimize browser-owned blob URLs; the SVG retains its precise cell grid. */} |
| 57 | {/* eslint-disable-next-line @next/next/no-img-element */} |
| 58 | {url && <img src={url} alt={label} width={width * 10 * scale} height={height * 20 * scale} style={scale > 1 ? { width: width * 10 * scale, height: "auto" } : undefined} />} |
| 59 | </div>; |
| 60 | } |
| 61 | |
| 62 | function MotionPlayer({ id, copy, fit }: { id: string; copy: RatatuiCopy; fit: boolean }) { |
| 63 | const [track, setTrack] = useState<MotionTrack | null>(null); |
| 64 | const [error, setError] = useState(false); |
| 65 | const [attempt, setAttempt] = useState(0); |
| 66 | const [frame, setFrame] = useState(0); |
| 67 | const [playing, setPlaying] = useState(false); |
| 68 | const [speed, setSpeed] = useState(1); |
| 69 | const [reduced, setReduced] = useState(false); |
| 70 | useEffect(() => { |
| 71 | const media = window.matchMedia("(prefers-reduced-motion: reduce)"); |
| 72 | const update = () => { setReduced(media.matches); if (media.matches) setPlaying(false); }; |
| 73 | update(); media.addEventListener("change", update); |
| 74 | const hidden = () => { if (document.hidden) setPlaying(false); }; |
| 75 | document.addEventListener("visibilitychange", hidden); |
| 76 | return () => { media.removeEventListener("change", update); document.removeEventListener("visibilitychange", hidden); }; |
| 77 | }, []); |
| 78 | useEffect(() => { |
| 79 | const controller = new AbortController(); |
| 80 | setTrack(null); setError(false); setPlaying(false); setFrame(0); |
| 81 | fetch(`/ratatui/motion/${id}.json`, { signal: controller.signal }) |
| 82 | .then((response) => { if (!response.ok) throw new Error("Motion unavailable"); return response.json(); }) |
| 83 | .then((data: MotionTrack) => { |
| 84 | if (data.id !== id || !Array.isArray(data.frames) || data.frames.length < 2 || !data.frameMs) throw new Error("Invalid motion track"); |
| 85 | setTrack(data); |
| 86 | }).catch(() => { if (!controller.signal.aborted) setError(true); }); |
| 87 | return () => controller.abort(); |
| 88 | }, [id, attempt]); |
| 89 | useEffect(() => { |
| 90 | if (!playing || !track) return; |
| 91 | const timer = window.setInterval(() => setFrame((value) => (value + 1) % track.frames.length), track.frameMs / speed); |
| 92 | return () => window.clearInterval(timer); |
| 93 | }, [playing, track, speed]); |
| 94 | if (error) return <div className="rat-load" role="alert"><p>{copy.previewError}</p><button className="rat-button" onClick={() => setAttempt((value) => value + 1)}>{copy.retry}</button></div>; |
| 95 | if (!track) return <div className="rat-load" role="status">{copy.loading}</div>; |
| 96 | return <div className="rat-motion"> |
| 97 | <div className="rat-motion-intro"><p>{copy.relatedMotion}: {track.title}</p><span>{track.profile} · {copy.frameCount.replace("{count}", String(track.frames.length))}</span></div> |
| 98 | <TerminalImage svg={track.frames[frame]} label={`${track.title} · ${copy.frame} ${frame + 1}`} fit={fit} width={track.width} height={track.height} /> |
| 99 | <div className="rat-motion-controls"> |
| 100 | <button type="button" className="rat-button rat-button-primary" onClick={() => setPlaying(!playing)} aria-pressed={playing}>{playing ? copy.pause : copy.play}</button> |
| 101 | <button type="button" className="rat-button" onClick={() => { setFrame(0); setPlaying(false); }}>{copy.reset}</button> |
| 102 | <label className="rat-scrubber">{copy.frame}<input aria-label={copy.frame} type="range" min="0" max={track.frames.length - 1} value={frame} onChange={(event) => { setPlaying(false); setFrame(Number(event.target.value)); }} /><output>{frame + 1}/{track.frames.length}</output></label> |
| 103 | <label className="rat-field">{copy.speed}<select aria-label={copy.speed} value={speed} onChange={(event) => setSpeed(Number(event.target.value))}><option value="0.5">0.5×</option><option value="1">1×</option><option value="2">2×</option></select></label> |
| 104 | </div> |
| 105 | <p className="rat-note">{reduced ? copy.reducedMotionNote : copy.motionDescription} <a href={track.source}>{copy.openSource}<Icon name="external" /></a></p> |
| 106 | </div>; |
| 107 | } |
| 108 | |
| 109 | /** This explorer switches captured library output; the interactive native host is the Cargo example. */ |
| 110 | export function RatatuiExplorer({ catalogue, locale, copy, initialEntry, detail }: { catalogue: Catalogue; locale: string; copy: RatatuiCopy; initialEntry?: string; detail?: boolean }) { |
| 111 | const first = catalogue.entries.find((entry) => entry.name === (initialEntry ?? "showcase-work")) ?? catalogue.entries[0]; |
| 112 | const router = useRouter(); |
| 113 | const [selected, setSelected] = useState(first.name); |
| 114 | const [query, setQuery] = useState(""); |
| 115 | const [family, setFamily] = useState("all"); |
| 116 | const [task, setTask] = useState("all"); |
| 117 | const [browserExpanded, setBrowserExpanded] = useState(true); |
| 118 | const [mobile, setMobile] = useState(false); |
| 119 | const [profile, setProfile] = useState("dark-truecolor"); |
| 120 | const [size, setSize] = useState("native"); |
| 121 | const [fit, setFit] = useState(true); |
| 122 | const [tab, setTab] = useState<"preview" | "use" | "code" | "motion">("preview"); |
| 123 | useEffect(() => { |
| 124 | const requested = new URLSearchParams(window.location.search).get("tab"); |
| 125 | if (requested === "use" || requested === "code" || requested === "motion") setTab(requested); |
| 126 | }, []); |
| 127 | useEffect(() => { |
| 128 | const url = new URL(window.location.href); |
| 129 | if (tab === "preview") url.searchParams.delete("tab"); |
| 130 | else url.searchParams.set("tab", tab); |
| 131 | window.history.replaceState(null, "", url); |
| 132 | }, [tab]); |
| 133 | const [data, setData] = useState<EntryPreview | null>(null); |
| 134 | const [error, setError] = useState(false); |
| 135 | const [attempt, setAttempt] = useState(0); |
| 136 | const searchRef = useRef<HTMLInputElement>(null); |
| 137 | const familyEntries = useMemo(() => catalogue.entries.filter((item) => item.family === first.family), [catalogue.entries, first.family]); |
| 138 | const filtered = useMemo(() => detail |
| 139 | ? familyEntries |
| 140 | : searchEntries(filterByTask(catalogue.entries, task), query, family), |
| 141 | [catalogue.entries, query, family, task, detail, familyEntries]); |
| 142 | const entry = filtered.find((item) => item.name === selected) ?? filtered[0] ?? first; |
| 143 | const collection = catalogue.families.find((item) => item.id === entry.family); |
| 144 | const guidance = getGuidance(entry, locale); |
| 145 | const recipe = getRecipe(guidance.recipeId); |
| 146 | const animation = motionId(entry, profile); |
| 147 | const activeTab = tab === "motion" && !animation ? "preview" : tab; |
| 148 | const currentIndex = filtered.findIndex((item) => item.name === entry.name); |
| 149 | const svg = data?.previews[profile]?.[size]; |
| 150 | const label = copy.terminalPreviewLabel.replace("{name}", entry.title); |
| 151 | const sourceUrl = entry.source.url; |
| 152 | const columns = size === "native" ? entry.width : Number(size); |
| 153 | useEffect(() => { |
| 154 | const controller = new AbortController(); |
| 155 | setData(null); setError(false); |
| 156 | fetch(previewAssetPath(entry), { signal: controller.signal }) |
| 157 | .then((response) => { if (!response.ok) throw new Error("Preview unavailable"); return response.json(); }) |
| 158 | .then((preview: EntryPreview) => { |
| 159 | if (preview.name !== entry.name || !preview.previews) throw new Error("Invalid preview"); |
| 160 | setData(preview); |
| 161 | }).catch(() => { if (!controller.signal.aborted) setError(true); }); |
| 162 | return () => controller.abort(); |
| 163 | }, [entry, attempt]); |
| 164 | useEffect(() => { |
| 165 | const focusSearch = (event: KeyboardEvent) => { |
| 166 | const target = event.target as HTMLElement; |
| 167 | if (event.key === "/" && !event.metaKey && !event.ctrlKey && !event.altKey && !target.closest("input,textarea,select,[contenteditable]")) { |
| 168 | event.preventDefault(); searchRef.current?.focus(); |
| 169 | } |
| 170 | }; |
| 171 | window.addEventListener("keydown", focusSearch); |
| 172 | return () => window.removeEventListener("keydown", focusSearch); |
| 173 | }, []); |
| 174 | useEffect(() => { |
| 175 | const media = window.matchMedia("(max-width: 760px)"); |
| 176 | const update = () => { setMobile(media.matches); setBrowserExpanded(!media.matches); if (media.matches) setSize("40"); }; |
| 177 | update(); media.addEventListener("change", update); |
| 178 | return () => media.removeEventListener("change", update); |
| 179 | }, []); |
| 180 | const choose = (name: string) => { |
| 181 | if (detail) { |
| 182 | const params = tab === "preview" ? "" : `?tab=${tab}`; |
| 183 | router.push(`/${locale}/ratatui/${name}${params}`); |
| 184 | return; |
| 185 | } |
| 186 | setSelected(name); setTab("preview"); if (mobile) setBrowserExpanded(false); |
| 187 | }; |
| 188 | const helpers = data?.fixture.helpers?.map((helper) => helper.code).join("\n\n") ?? ""; |
| 189 | const example = data ? [data.fixture.imports, data.fixture.code, helpers].filter(Boolean).join("\n\n") : ""; |
| 190 | const clearFilters = () => { setQuery(""); setFamily("all"); setTask("all"); }; |
| 191 | return <section id="ratatui-explorer" className={`rat-explorer${detail ? " rat-detail" : ""}`} aria-label={copy.title}> |
| 192 | {!detail && <div className="rat-task-picker"> |
| 193 | <div><h2>{copy.tasksTitle}</h2><p>{copy.tasksDescription}</p></div> |
| 194 | <div className="rat-task-options" role="group" aria-label={copy.tasksTitle}> |
| 195 | <button type="button" className="rat-button" aria-pressed={task === "all"} onClick={clearFilters}>{copy.allComponents}</button> |
| 196 | {TASKS.map((item) => <button key={item.id} type="button" className="rat-button" aria-pressed={task === item.id} onClick={() => { |
| 197 | setTask(item.id); setFamily("all"); setQuery(""); |
| 198 | choose(item.recommended.find((name) => catalogue.entries.some((candidate) => candidate.name === name)) ?? first.name); |
| 199 | }}>{pickText(item.label, locale)}</button>)} |
| 200 | </div> |
| 201 | {task !== "all" && <p className="rat-task-description">{pickText(TASKS.find((item) => item.id === task)!.description, locale)}</p>} |
| 202 | </div>} |
| 203 | {detail ? <aside className="rat-browser rat-family-index" aria-label={collection?.title ?? copy.componentNavLabel}> |
| 204 | <h2>{collection?.title}</h2> |
| 205 | <p className="rat-count" role="status">{copy.familyPosition.replace("{index}", String(currentIndex + 1)).replace("{count}", String(filtered.length))}</p> |
| 206 | <nav className="rat-entry-list" aria-label={collection?.title}> |
| 207 | {filtered.map((candidate) => <Link key={candidate.name} href={`/${locale}/ratatui/${candidate.name}`} className="rat-entry" aria-current={candidate.name === entry.name ? "page" : undefined}><span>{candidate.title}</span><span className="rat-entry-width">{candidate.width}</span></Link>)} |
| 208 | </nav> |
| 209 | <Link href={`/${locale}/ratatui`} className="rat-link rat-index-back">{copy.back}<Icon name="arrow-right" className="rat-prev-icon" /></Link> |
| 210 | </aside> : <aside className="rat-browser" aria-label={copy.componentNavLabel}> |
| 211 | <label className="rat-search" htmlFor="rat-search"><span>{copy.searchLabel}</span><div><Icon name="search" /><input id="rat-search" ref={searchRef} type="search" value={query} placeholder={copy.searchPlaceholder} onChange={(event) => { setQuery(event.target.value); if (mobile) setBrowserExpanded(true); }} onKeyDown={(event) => { if (event.key === "Escape") setQuery(""); }} /><kbd>/</kbd></div></label> |
| 212 | <label className="rat-family-field" htmlFor="rat-family">{copy.families}<select id="rat-family" value={family} onChange={(event) => { setFamily(event.target.value); setTask("all"); if (mobile) setBrowserExpanded(true); }}><option value="all">{copy.allComponents}</option>{catalogue.families.map((item) => <option key={item.id} value={item.id}>{item.title} ({item.count})</option>)}</select></label> |
| 213 | <p className="rat-count" role="status">{copy.visibleCount.replace("{visible}", String(filtered.length)).replace("{total}", String(catalogue.entries.length))}</p> |
| 214 | <details className="rat-browser-results" open={browserExpanded} onToggle={(event) => setBrowserExpanded(event.currentTarget.open)}><summary>{copy.allComponents}<Icon name="chevron-down" /></summary> |
| 215 | <nav className="rat-entry-list" aria-label={copy.resultsLabel}> |
| 216 | {catalogue.families.map((item) => { |
| 217 | const entries = filtered.filter((candidate) => candidate.family === item.id); |
| 218 | return entries.length ? <div className="rat-family" key={item.id}><h3>{item.title} <span className="rat-family-count">{entries.length}</span></h3>{entries.map((candidate) => <button type="button" key={candidate.name} className="rat-entry" aria-current={candidate.name === entry.name ? "true" : undefined} onClick={() => choose(candidate.name)}><span>{candidate.title}</span><span className="rat-entry-width">{candidate.width}</span></button>)}</div> : null; |
| 219 | })} |
| 220 | </nav></details> |
| 221 | </aside>} |
| 222 | {filtered.length === 0 ? <div className="rat-study rat-empty-study"><h2>{copy.emptyTitle}</h2><p>{copy.emptyDescription}</p><button className="rat-button" onClick={clearFilters}>{copy.clearSearch}</button></div> : <div className="rat-study"> |
| 223 | {!detail && <><div className="rat-study-heading"><div><p>{collection?.title}</p><h2>{entry.title}</h2></div>{initialEntry !== entry.name && <Link href={`/${locale}/ratatui/${entry.name}`} className="rat-link" title={copy.share}>{copy.share}<Icon name="arrow-right" /></Link>}</div> |
| 224 | <p className="rat-description">{entry.description}</p></>} |
| 225 | <div className="rat-study-tabs" role="group" aria-label={copy.preview}> |
| 226 | <button type="button" aria-pressed={activeTab === "preview"} onClick={() => setTab("preview")}>{copy.preview}</button> |
| 227 | <button type="button" aria-pressed={activeTab === "use"} onClick={() => setTab("use")}>{copy.use}</button> |
| 228 | <button type="button" aria-pressed={activeTab === "code"} onClick={() => setTab("code")}>{copy.code}</button> |
| 229 | {animation && <button type="button" aria-pressed={activeTab === "motion"} onClick={() => { setTab("motion"); if (profile !== "light-truecolor") setProfile("dark-truecolor"); }}>{copy.motion}</button>} |
| 230 | <div className="rat-pager"><button type="button" aria-label={copy.previous} disabled={currentIndex <= 0} onClick={() => choose(filtered[currentIndex - 1].name)}><Icon name="arrow-right" className="rat-prev-icon" /></button><button type="button" aria-label={copy.next} disabled={currentIndex < 0 || currentIndex >= filtered.length - 1} onClick={() => choose(filtered[currentIndex + 1].name)}><Icon name="arrow-right" /></button></div> |
| 231 | </div> |
| 232 | {(activeTab === "preview" || activeTab === "motion") && <div className="rat-preview-controls">{(activeTab !== "motion" || !["whale", "habitat"].includes(animation ?? "")) && <label className="rat-field">{copy.profile}<select aria-label={copy.profile} value={profile} onChange={(event) => setProfile(event.target.value)}>{catalogue.profiles.filter((item) => activeTab !== "motion" || ["dark-truecolor", "light-truecolor"].includes(item.id)).map((item) => <option key={item.id} value={item.id}>{item.label}</option>)}</select></label>}{activeTab === "preview" && <label className="rat-field">{copy.size}<select aria-label={copy.size} value={size} onChange={(event) => setSize(event.target.value)}>{catalogue.sizes.map((item) => <option key={item.id} value={item.id}>{item.id === "native" ? copy.nativeSize : copy.columns.replace("{count}", item.id)}</option>)}</select></label>}<button type="button" className="rat-button rat-scale" onClick={() => setFit(!fit)} aria-pressed={!fit}>{fit ? copy.actualSize : copy.fit}</button></div>} |
| 233 | {activeTab === "use" ? <div className="rat-code-panel rat-usage-panel"> |
| 234 | <p>{guidance.description}</p><p className="rat-note">{guidance.hostNote}</p> |
| 235 | {recipe && <><div className="rat-code-heading"><h4>{copy.use}</h4><CopyButton text={recipe.code} copy={copy} /></div><CodeBlock code={recipe.code} language="rust" /><p className="rat-note">{copy.recipeNote} <a href={recipeSourceUrl(recipe.line)}>{copy.openSource}<Icon name="external" /></a></p></>} |
| 236 | <div className="rat-usage-links"><a className="rat-link" href={learningGuideUrl(guidance.guideAnchor)}>{copy.guide}<Icon name="external" /></a><Link className="rat-link" href={`/${locale}/ratatui#ratatui-install`}>{copy.gettingStarted}<Icon name="arrow-right" /></Link></div> |
| 237 | <p className="rat-note">{copy.runExample}</p><CodeBlock code={`cargo run --locked --example ${recipe ? "recipes" : guidance.example.replace(/^examples\//, "").replace(/\.rs$/, "")}`} language="sh" />{recipe && <p className="rat-note">{copy.recipeControls}</p>} |
| 238 | </div> : |
| 239 | activeTab === "motion" && animation ? <MotionPlayer key={animation} id={animation} copy={copy} fit={fit} /> : activeTab === "code" ? <div className="rat-code-panel"><div className="rat-code-heading"><h3>{copy.fixtureTitle}</h3><CopyButton text={example} copy={copy} /></div>{data ? <CodeBlock code={example} language="rust" /> : <div className="rat-load" role={error ? "alert" : "status"}><p>{error ? copy.previewError : copy.loading}</p>{error && <button className="rat-button" onClick={() => setAttempt((value) => value + 1)}>{copy.retry}</button>}</div>}<p className="rat-note">{copy.fixtureNote} <a href={sourceUrl}>{copy.openSource}<Icon name="external" /></a></p></div> : <> |
| 240 | {error ? <div className="rat-load" role="alert"><p>{copy.previewError}</p><button className="rat-button" type="button" onClick={() => setAttempt((value) => value + 1)}>{copy.retry}</button></div> : svg ? <TerminalImage svg={svg} label={label} fit={fit} width={columns} height={entry.height} /> : <div className="rat-load" role="status">{copy.previewLoading}</div>} |
| 241 | <div className="rat-preview-meta"><span>{columns} × {entry.height}</span><span>{profile}</span>{svg && <a className="rat-link" href={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`} download={`${entry.name}.${profile}.${size}.svg`}>{copy.downloadSvg}<Icon name="external" /></a>}</div> |
| 242 | <p className="rat-note">{copy.renderedPreviewNote}</p> |
| 243 | </>} |
| 244 | <div className="rat-api"><h3>{copy.api}</h3><div>{displayApi(entry).map((symbol) => <a key={symbol} href={sourceUrl}><code>{symbol}</code></a>)}</div><p>{copy.hostNote}</p></div> |
| 245 | </div>} |
| 246 | </section>; |
| 247 | } |
| 248 |