返回 CodeWhale
explorer.tsx
根目录 / web / components / ratatui / explorer.tsx
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
248 lines Plain Text