@chai-ui/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +207 -0
- package/LICENSE +21 -0
- package/README.md +265 -0
- package/dist/ChaiProvider.d.ts +49 -0
- package/dist/ChaiProvider.d.ts.map +1 -0
- package/dist/ChaiProvider.js +54 -0
- package/dist/ChaiProvider.js.map +1 -0
- package/dist/Composer.d.ts +169 -0
- package/dist/Composer.d.ts.map +1 -0
- package/dist/Composer.js +389 -0
- package/dist/Composer.js.map +1 -0
- package/dist/MediaAnalyzer.d.ts +102 -0
- package/dist/MediaAnalyzer.d.ts.map +1 -0
- package/dist/MediaAnalyzer.js +250 -0
- package/dist/MediaAnalyzer.js.map +1 -0
- package/dist/ResultCard.d.ts +55 -0
- package/dist/ResultCard.d.ts.map +1 -0
- package/dist/ResultCard.js +409 -0
- package/dist/ResultCard.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/primitives/Pagination.d.ts +19 -0
- package/dist/primitives/Pagination.d.ts.map +1 -0
- package/dist/primitives/Pagination.js +13 -0
- package/dist/primitives/Pagination.js.map +1 -0
- package/dist/primitives/SearchMenu.d.ts +69 -0
- package/dist/primitives/SearchMenu.d.ts.map +1 -0
- package/dist/primitives/SearchMenu.js +88 -0
- package/dist/primitives/SearchMenu.js.map +1 -0
- package/dist/primitives/Toggle.d.ts +9 -0
- package/dist/primitives/Toggle.d.ts.map +1 -0
- package/dist/primitives/Toggle.js +28 -0
- package/dist/primitives/Toggle.js.map +1 -0
- package/dist/primitives/index.d.ts +4 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/index.js +4 -0
- package/dist/primitives/index.js.map +1 -0
- package/dist/style.css +1860 -0
- package/dist/use-latest.d.ts +7 -0
- package/dist/use-latest.d.ts.map +1 -0
- package/dist/use-latest.js +17 -0
- package/dist/use-latest.js.map +1 -0
- package/dist/useComposer.d.ts +75 -0
- package/dist/useComposer.d.ts.map +1 -0
- package/dist/useComposer.js +191 -0
- package/dist/useComposer.js.map +1 -0
- package/dist/useMediaAnalyzer.d.ts +37 -0
- package/dist/useMediaAnalyzer.d.ts.map +1 -0
- package/dist/useMediaAnalyzer.js +114 -0
- package/dist/useMediaAnalyzer.js.map +1 -0
- package/package.json +88 -0
- package/src/ChaiProvider.tsx +88 -0
- package/src/Composer.tsx +961 -0
- package/src/MediaAnalyzer.tsx +527 -0
- package/src/ResultCard.tsx +980 -0
- package/src/index.ts +36 -0
- package/src/primitives/Pagination.tsx +41 -0
- package/src/primitives/SearchMenu.tsx +284 -0
- package/src/primitives/Toggle.tsx +43 -0
- package/src/primitives/index.ts +3 -0
- package/src/style.css +1860 -0
- package/src/use-latest.ts +18 -0
- package/src/useComposer.ts +289 -0
- package/src/useMediaAnalyzer.ts +157 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
+
import type { ModelOption, Result } from "@chai-ui/core";
|
|
3
|
+
import { Pagination } from "./primitives/Pagination.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ResultCard's complement to Composer bar, and the last stop in the
|
|
7
|
+
* pipeline: by design there's no "use as input" action feeding a result
|
|
8
|
+
* back into a Composer.
|
|
9
|
+
*
|
|
10
|
+
* Renders the results from one prompt (reference: Figma "08 Result from
|
|
11
|
+
* Composer Bar") — a single result, or paginated through several (the
|
|
12
|
+
* "Image - Paginated" state) when more than one is handed in. That's a
|
|
13
|
+
* different thing from the still-unscoped plural `Results` grid/stack/
|
|
14
|
+
* carousel container — this card
|
|
15
|
+
* only ever shows what came back from one prompt, one candidate at a time,
|
|
16
|
+
* never a grid of separate prompts/history.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// --- Formatting ------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
function formatDuration(ms: number | undefined): string | undefined {
|
|
22
|
+
if (ms == null) return undefined;
|
|
23
|
+
return `${(ms / 1000).toFixed(2)}s`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function formatCost(usd: number | undefined): string | undefined {
|
|
27
|
+
if (usd == null) return undefined;
|
|
28
|
+
return `$${usd.toFixed(2)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatTokens(total: number | undefined): string | undefined {
|
|
32
|
+
if (total == null) return undefined;
|
|
33
|
+
return `${total.toLocaleString()} tokens`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function gcd(a: number, b: number): number {
|
|
37
|
+
return b === 0 ? a : gcd(b, a % b);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A real, exactly-reduced ratio (e.g. 1024×768 → "4:3", 1920×1080 → "16:9") from the media's own measured dimensions — not a guess at the nearest "named" ratio, so an unusual crop just shows its own honest (if less familiar) reduced fraction instead of a misleading rounded one. */
|
|
41
|
+
function formatAspectRatio(width: number, height: number): string | undefined {
|
|
42
|
+
if (!width || !height) return undefined;
|
|
43
|
+
const w = Math.round(width);
|
|
44
|
+
const h = Math.round(height);
|
|
45
|
+
const divisor = gcd(w, h);
|
|
46
|
+
if (!divisor) return undefined;
|
|
47
|
+
return `${w / divisor}:${h / divisor}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A real extension from the URL itself when there is one, otherwise a sensible guess by kind — used only for the downloaded file's suggested name, not anything that affects the actual bytes. */
|
|
51
|
+
function guessDownloadFilename(result: Result): string {
|
|
52
|
+
const kind = result.output?.kind ?? "file";
|
|
53
|
+
const src = result.output?.src ?? "";
|
|
54
|
+
const match = /\.([a-zA-Z0-9]{2,5})(?:[?#]|$)/.exec(src);
|
|
55
|
+
const ext = match?.[1] ?? (kind === "video" ? "mp4" : kind === "audio" ? "mp3" : kind === "text" ? "txt" : "png");
|
|
56
|
+
return `${kind}-${result.id}.${ext}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// --- Public types ------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/** One row in the "more" (⋯) menu, for app-specific actions (e.g. "Flag content"). ResultCard can't implement any of them — every one of these just fires `onAction` with this `id`. */
|
|
62
|
+
export interface ResultCardMoreAction {
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
icon?: ReactNode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ResultCardVote = "like" | "dislike" | null;
|
|
69
|
+
|
|
70
|
+
export interface ResultCardProps {
|
|
71
|
+
/** The results from one prompt — `results[0]` if there's only one, paginated internally if more. */
|
|
72
|
+
results: Result[];
|
|
73
|
+
/**
|
|
74
|
+
* Fixed width in px — defaults to 375 (the Figma mock's own width), not
|
|
75
|
+
* to the displayed media's actual pixel dimensions. The card's own
|
|
76
|
+
* footprint has to stay predictable for whatever app it's embedded in;
|
|
77
|
+
* a real generated image could be anywhere from a few hundred to a few
|
|
78
|
+
* thousand pixels wide, and this card was never meant to render at that
|
|
79
|
+
* native size — that's what the "Expand" button is for. Height still
|
|
80
|
+
* follows the media's own aspect ratio at this width (see
|
|
81
|
+
* `.chai-result-card`'s own CSS comment) — only the width is fixed.
|
|
82
|
+
*/
|
|
83
|
+
width?: number;
|
|
84
|
+
/** Max height in px of a text result before it scrolls — defaults to 400. Text results are often long; the card stays a predictable size and the text scrolls inside it. */
|
|
85
|
+
textMaxHeight?: number;
|
|
86
|
+
/** The request's source prompt — shown read-only (copiable, not editable) on the flipped info side. */
|
|
87
|
+
prompt: string;
|
|
88
|
+
/** Alt text for a generated image. Defaults to `prompt`, which describes what was asked for. */
|
|
89
|
+
altText?: (result: Result) => string;
|
|
90
|
+
/** Resolves `Result.modelId` to a human-readable label (reference: "GPT-4o Image Pro", not a raw id) — falls back to the raw id when not found. */
|
|
91
|
+
models?: ModelOption[];
|
|
92
|
+
/**
|
|
93
|
+
* Resolves a result's vote, if any — controlled, not tracked by
|
|
94
|
+
* ResultCard itself: what a vote means is the app's policy. A function, not a single flat value: pagination
|
|
95
|
+
* is internal to this component (not a prop the host controls — see
|
|
96
|
+
* `results`'s own doc comment), so a host can't know in advance which
|
|
97
|
+
* result is currently paginated-to in order to hand back the right one.
|
|
98
|
+
*/
|
|
99
|
+
getVote?: (result: Result) => ResultCardVote;
|
|
100
|
+
/**
|
|
101
|
+
* Fires for every action this card can trigger: `"like"` / `"dislike"`
|
|
102
|
+
* (always fires the plain vote name, even to toggle one back off — the
|
|
103
|
+
* app decides what re-clicking an already-active vote means, same as it
|
|
104
|
+
* decides what the vote itself means), `"download"`, `"share"`,
|
|
105
|
+
* `"copy-prompt"`, `"retry"` (expected to re-run the exact same model
|
|
106
|
+
* call), or one of
|
|
107
|
+
* `moreActions`' own ids.
|
|
108
|
+
*/
|
|
109
|
+
onAction: (action: string, result: Result) => void;
|
|
110
|
+
/** Configurable extra items in the "more" menu (reference: Figma "Result Card - More menu expanded") — entirely up to the host. Omit to hide the "more" button entirely. */
|
|
111
|
+
moreActions?: ResultCardMoreAction[];
|
|
112
|
+
disabled?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- Component ------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
export function ResultCard({
|
|
118
|
+
results,
|
|
119
|
+
width,
|
|
120
|
+
textMaxHeight,
|
|
121
|
+
prompt,
|
|
122
|
+
altText,
|
|
123
|
+
models,
|
|
124
|
+
getVote,
|
|
125
|
+
onAction,
|
|
126
|
+
moreActions,
|
|
127
|
+
disabled,
|
|
128
|
+
}: ResultCardProps) {
|
|
129
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
130
|
+
const [flipped, setFlipped] = useState(false);
|
|
131
|
+
const [moreOpen, setMoreOpen] = useState(false);
|
|
132
|
+
const [copied, setCopied] = useState(false);
|
|
133
|
+
const [mediaExpanded, setMediaExpanded] = useState(false);
|
|
134
|
+
// Not part of `Result` (no engine reports generated-media dimensions
|
|
135
|
+
// today) — measured client-side off the loaded `<img>`/`<video>` itself
|
|
136
|
+
// (`naturalWidth`/`naturalHeight`, `videoWidth`/`videoHeight`) purely to
|
|
137
|
+
// back the info side's aspect-ratio chip. Kept with the id of the result
|
|
138
|
+
// it was measured from, so after pagination a stale ratio from the
|
|
139
|
+
// *previous* result can't flash before the new media finishes loading.
|
|
140
|
+
const [measured, setMeasured] = useState<{ id: string; width: number; height: number } | null>(null);
|
|
141
|
+
const videoTimeRef = useRef(0);
|
|
142
|
+
// The face that's turned away is `inert`, so its buttons can't be tabbed
|
|
143
|
+
// to. Set as a DOM property: React 18 and 19 treat the `inert` attribute
|
|
144
|
+
// differently.
|
|
145
|
+
const frontRef = useRef<HTMLDivElement>(null);
|
|
146
|
+
const backRef = useRef<HTMLDivElement>(null);
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (frontRef.current) frontRef.current.inert = flipped;
|
|
149
|
+
if (backRef.current) backRef.current.inert = !flipped;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Flipping moves focus to the side now showing, so a keyboard user isn't
|
|
153
|
+
// left on a button that just turned away. Skipped on first render.
|
|
154
|
+
const wasFlipped = useRef(flipped);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (wasFlipped.current === flipped) return;
|
|
157
|
+
wasFlipped.current = flipped;
|
|
158
|
+
const target = flipped
|
|
159
|
+
? backRef.current?.querySelector<HTMLElement>('[aria-label="Close details"]')
|
|
160
|
+
: frontRef.current?.querySelector<HTMLElement>('[aria-label="Show details"]');
|
|
161
|
+
target?.focus();
|
|
162
|
+
}, [flipped]);
|
|
163
|
+
|
|
164
|
+
// The expanded view is a modal dialog: focus moves in, Tab stays inside,
|
|
165
|
+
// Escape closes, and focus returns to what opened it.
|
|
166
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (!mediaExpanded) return;
|
|
169
|
+
overlayRef.current?.querySelector<HTMLElement>(".chai-result-card__collapse")?.focus();
|
|
170
|
+
// On close, back to Expand, the only way in. Looked up at close time:
|
|
171
|
+
// the card re-renders its media buttons while expanded.
|
|
172
|
+
const front = frontRef.current;
|
|
173
|
+
return () => front?.querySelector<HTMLElement>('[aria-label="Expand"]')?.focus();
|
|
174
|
+
}, [mediaExpanded]);
|
|
175
|
+
|
|
176
|
+
function handleOverlayKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
177
|
+
if (e.key === "Escape") {
|
|
178
|
+
e.stopPropagation();
|
|
179
|
+
setMediaExpanded(false);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (e.key !== "Tab") return;
|
|
183
|
+
const focusable = [
|
|
184
|
+
...e.currentTarget.querySelectorAll<HTMLElement>('button, [tabindex="0"], a[href], input, video[controls]'),
|
|
185
|
+
].filter((el) => !el.hasAttribute("disabled"));
|
|
186
|
+
if (focusable.length === 0) return;
|
|
187
|
+
const first = focusable[0]!;
|
|
188
|
+
const last = focusable[focusable.length - 1]!;
|
|
189
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
190
|
+
e.preventDefault();
|
|
191
|
+
last.focus();
|
|
192
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
first.focus();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// State, not a ref: the player reads it while rendering, and it only
|
|
198
|
+
// changes on play/pause.
|
|
199
|
+
const [videoWasPlaying, setVideoWasPlaying] = useState(false);
|
|
200
|
+
const clampedIndex = Math.min(activeIndex, Math.max(0, results.length - 1));
|
|
201
|
+
const result = results[clampedIndex];
|
|
202
|
+
|
|
203
|
+
const mediaDims = measured && measured.id === result?.id ? measured : null;
|
|
204
|
+
|
|
205
|
+
if (!result) return null;
|
|
206
|
+
|
|
207
|
+
const vote = getVote?.(result) ?? null;
|
|
208
|
+
const modelLabel = models?.find((m) => m.id === result.modelId)?.label ?? result.modelId;
|
|
209
|
+
const duration = formatDuration(result.durationMs);
|
|
210
|
+
const cost = formatCost(result.usage?.costUsd);
|
|
211
|
+
const tokens = formatTokens(result.usage?.totalTokens);
|
|
212
|
+
const aspectRatio = mediaDims ? formatAspectRatio(mediaDims.width, mediaDims.height) : undefined;
|
|
213
|
+
|
|
214
|
+
// Arrow expressions, not function declarations — a hoisted declaration
|
|
215
|
+
// doesn't inherit the `if (!result) return null;` narrowing above (a
|
|
216
|
+
// known TS limitation, not stylistic), so every one of these closing
|
|
217
|
+
// over `result` needs to be an expression instead.
|
|
218
|
+
const goTo = (index: number) => {
|
|
219
|
+
setActiveIndex(Math.max(0, Math.min(results.length - 1, index)));
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const handleCopyPrompt = () => {
|
|
223
|
+
navigator.clipboard?.writeText(prompt).catch(() => {});
|
|
224
|
+
setCopied(true);
|
|
225
|
+
setTimeout(() => setCopied(false), 1500);
|
|
226
|
+
onAction("copy-prompt", result);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const handleDownload = async () => {
|
|
230
|
+
if (result.output) {
|
|
231
|
+
// A plain `<a download href={remoteUrl}>` only forces a real download
|
|
232
|
+
// for a same-origin or `blob:`/`data:` URL — for anything else
|
|
233
|
+
// (a normal cross-origin CDN url, which is what a real generated
|
|
234
|
+
// result's `src` almost always is) browsers ignore `download` and
|
|
235
|
+
// just navigate/open it instead, which is exactly the "pops a tab"
|
|
236
|
+
// behavior this replaces. Fetching it into a blob first sidesteps
|
|
237
|
+
// that: a blob: URL is always same-origin, so `download` works
|
|
238
|
+
// reliably regardless of where the media actually lives.
|
|
239
|
+
try {
|
|
240
|
+
const response = await fetch(result.output.src);
|
|
241
|
+
const blob = await response.blob();
|
|
242
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
243
|
+
const a = document.createElement("a");
|
|
244
|
+
a.href = blobUrl;
|
|
245
|
+
a.download = guessDownloadFilename(result);
|
|
246
|
+
document.body.appendChild(a);
|
|
247
|
+
a.click();
|
|
248
|
+
a.remove();
|
|
249
|
+
URL.revokeObjectURL(blobUrl);
|
|
250
|
+
} catch {
|
|
251
|
+
// The one case this can't force: the source not allowing
|
|
252
|
+
// cross-origin fetches (no CORS headers) or a real network
|
|
253
|
+
// failure. Opening it directly is the best fallback available —
|
|
254
|
+
// still better than silently doing nothing.
|
|
255
|
+
window.open(result.output.src, "_blank", "noopener");
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
onAction("download", result);
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const handleShare = async () => {
|
|
262
|
+
if (result.output && navigator.share) {
|
|
263
|
+
try {
|
|
264
|
+
await navigator.share({ url: result.output.src });
|
|
265
|
+
} catch {
|
|
266
|
+
// Cancelled or unsupported for this src — fall through silently, same as a native share sheet dismissal.
|
|
267
|
+
}
|
|
268
|
+
} else if (result.output) {
|
|
269
|
+
navigator.clipboard?.writeText(result.output.src).catch(() => {});
|
|
270
|
+
}
|
|
271
|
+
onAction("share", result);
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const isVideo = result.output?.kind === "video";
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<>
|
|
278
|
+
<div
|
|
279
|
+
className={`chai-result-card${flipped ? " chai-result-card--flipped" : ""}`}
|
|
280
|
+
style={
|
|
281
|
+
{
|
|
282
|
+
...(width != null && { "--chai-result-card-width": `${width}px` }),
|
|
283
|
+
...(textMaxHeight != null && { "--chai-result-card-text-max-height": `${textMaxHeight}px` }),
|
|
284
|
+
} as CSSProperties
|
|
285
|
+
}
|
|
286
|
+
>
|
|
287
|
+
<div className="chai-result-card__inner">
|
|
288
|
+
<div ref={frontRef} className="chai-result-card__face chai-result-card__front" aria-hidden={flipped}>
|
|
289
|
+
<ResultMedia
|
|
290
|
+
result={result}
|
|
291
|
+
alt={altText?.(result) ?? prompt}
|
|
292
|
+
disabled={disabled}
|
|
293
|
+
mediaExpanded={mediaExpanded}
|
|
294
|
+
onDownload={handleDownload}
|
|
295
|
+
onShare={handleShare}
|
|
296
|
+
onExpand={() => setMediaExpanded(true)}
|
|
297
|
+
onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
|
|
298
|
+
videoTimeRef={videoTimeRef}
|
|
299
|
+
videoWasPlaying={videoWasPlaying}
|
|
300
|
+
onVideoPlayingChange={setVideoWasPlaying}
|
|
301
|
+
/>
|
|
302
|
+
|
|
303
|
+
<div className="chai-result-card__bar">
|
|
304
|
+
<div className="chai-result-card__bar-row">
|
|
305
|
+
<div className="chai-result-card__bar-start">
|
|
306
|
+
<ResultIconButton
|
|
307
|
+
label="Like"
|
|
308
|
+
active={vote === "like"}
|
|
309
|
+
disabled={disabled}
|
|
310
|
+
onClick={() => onAction("like", result)}
|
|
311
|
+
>
|
|
312
|
+
<ThumbUpIcon />
|
|
313
|
+
</ResultIconButton>
|
|
314
|
+
<ResultIconButton
|
|
315
|
+
label="Dislike"
|
|
316
|
+
active={vote === "dislike"}
|
|
317
|
+
disabled={disabled}
|
|
318
|
+
onClick={() => onAction("dislike", result)}
|
|
319
|
+
>
|
|
320
|
+
<ThumbDownIcon />
|
|
321
|
+
</ResultIconButton>
|
|
322
|
+
<ResultIconButton
|
|
323
|
+
label="Retry"
|
|
324
|
+
disabled={disabled}
|
|
325
|
+
onClick={() => onAction("retry", result)}
|
|
326
|
+
>
|
|
327
|
+
<RefreshIcon />
|
|
328
|
+
</ResultIconButton>
|
|
329
|
+
{moreActions && moreActions.length > 0 && (
|
|
330
|
+
<MoreMenu
|
|
331
|
+
actions={moreActions}
|
|
332
|
+
open={moreOpen}
|
|
333
|
+
onOpenChange={setMoreOpen}
|
|
334
|
+
disabled={disabled}
|
|
335
|
+
onSelect={(action) => onAction(action.id, result)}
|
|
336
|
+
/>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<ResultIconButton
|
|
341
|
+
label="Show details"
|
|
342
|
+
disabled={disabled}
|
|
343
|
+
onClick={() => setFlipped(true)}
|
|
344
|
+
>
|
|
345
|
+
<InfoIcon />
|
|
346
|
+
</ResultIconButton>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
{results.length > 1 && (
|
|
350
|
+
<Pagination
|
|
351
|
+
count={results.length}
|
|
352
|
+
index={clampedIndex}
|
|
353
|
+
onChange={goTo}
|
|
354
|
+
label="Results"
|
|
355
|
+
disabled={disabled}
|
|
356
|
+
/>
|
|
357
|
+
)}
|
|
358
|
+
</div>
|
|
359
|
+
</div>
|
|
360
|
+
|
|
361
|
+
<div ref={backRef} className="chai-result-card__face chai-result-card__back" aria-hidden={!flipped}>
|
|
362
|
+
<button
|
|
363
|
+
type="button"
|
|
364
|
+
className="chai-result-card__close"
|
|
365
|
+
aria-label="Close details"
|
|
366
|
+
onClick={() => setFlipped(false)}
|
|
367
|
+
>
|
|
368
|
+
<CloseIcon />
|
|
369
|
+
</button>
|
|
370
|
+
|
|
371
|
+
<div className="chai-result-card__prompt-box">
|
|
372
|
+
<p className="chai-result-card__prompt-text">{prompt}</p>
|
|
373
|
+
<button
|
|
374
|
+
type="button"
|
|
375
|
+
className="chai-result-card__copy"
|
|
376
|
+
aria-label={copied ? "Copied" : "Copy prompt"}
|
|
377
|
+
title={copied ? "Copied" : "Copy prompt"}
|
|
378
|
+
onClick={handleCopyPrompt}
|
|
379
|
+
>
|
|
380
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
381
|
+
</button>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
<div className="chai-result-card__chips">
|
|
385
|
+
<span className="chai-result-card__chip">{modelLabel}</span>
|
|
386
|
+
{tokens && <span className="chai-result-card__chip">{tokens}</span>}
|
|
387
|
+
{cost && <span className="chai-result-card__chip">{cost}</span>}
|
|
388
|
+
{duration && <span className="chai-result-card__chip">{duration}</span>}
|
|
389
|
+
{aspectRatio && <span className="chai-result-card__chip">{aspectRatio}</span>}
|
|
390
|
+
{/* The builder's own backend checks (reference: the mock's green
|
|
391
|
+
"Evaluation" chip) — pass green with a check, fail red with a
|
|
392
|
+
cross, so the verdict never relies on color alone. */}
|
|
393
|
+
{result.evaluations?.map((evaluation) => (
|
|
394
|
+
<span
|
|
395
|
+
key={evaluation.id}
|
|
396
|
+
className={`chai-result-card__chip chai-result-card__chip--evaluation chai-result-card__chip--${
|
|
397
|
+
evaluation.passed ? "passed" : "failed"
|
|
398
|
+
}`}
|
|
399
|
+
title={`${evaluation.label}: ${evaluation.passed ? "passed" : "failed"}`}
|
|
400
|
+
>
|
|
401
|
+
{evaluation.passed ? <CheckIcon /> : <CloseIcon />}
|
|
402
|
+
{evaluation.label}
|
|
403
|
+
<span className="chai-visually-hidden">{evaluation.passed ? ", passed" : ", failed"}</span>
|
|
404
|
+
</span>
|
|
405
|
+
))}
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
{/*
|
|
412
|
+
* The expanded-media overlay (image or video — by design, keep the same explicit "expand" affordance for
|
|
413
|
+
* images too, rather than only video, since an image's own surface
|
|
414
|
+
* isn't a reliable/universal "click to enlarge" gesture on its own)
|
|
415
|
+
* is a sibling of `.chai-result-card` itself, not a descendant —
|
|
416
|
+
* `.chai-result-card` sets `perspective` for the flip animation, and
|
|
417
|
+
* *any* element with `perspective` (like `transform`) becomes a new
|
|
418
|
+
* containing block for its `position: fixed` descendants. An overlay
|
|
419
|
+
* nested inside it would be "fixed" relative to the card's own box,
|
|
420
|
+
* not the viewport — confirmed via a real screenshot during this
|
|
421
|
+
* build (it covered only the card's own area, sidebar and page
|
|
422
|
+
* content still visible around it). Escaping the subtree entirely is
|
|
423
|
+
* what actually fixes it, not a z-index or position tweak.
|
|
424
|
+
*
|
|
425
|
+
* This does mean the expanded `<video>` is a different DOM element
|
|
426
|
+
* from the inline one (remounts, doesn't just get repositioned) —
|
|
427
|
+
* `videoTimeRef`/`videoWasPlaying` are what carry current time and
|
|
428
|
+
* play state across that swap so it doesn't visibly reset. An image
|
|
429
|
+
* has no such state to carry, so it's just rendered twice (inline +
|
|
430
|
+
* overlay) with no special handling — cheap, no decode/playback cost
|
|
431
|
+
* the way a second `<video>` would have.
|
|
432
|
+
*/}
|
|
433
|
+
{mediaExpanded && result.output && (result.output.kind === "image" || isVideo) && (
|
|
434
|
+
<div
|
|
435
|
+
ref={overlayRef}
|
|
436
|
+
className="chai-result-card__expand-overlay"
|
|
437
|
+
role="dialog"
|
|
438
|
+
aria-modal="true"
|
|
439
|
+
aria-label={isVideo ? "Expanded video" : "Expanded image"}
|
|
440
|
+
onKeyDown={handleOverlayKeyDown}
|
|
441
|
+
>
|
|
442
|
+
<div
|
|
443
|
+
className="chai-result-card__expand-scrim"
|
|
444
|
+
role="presentation"
|
|
445
|
+
onClick={() => setMediaExpanded(false)}
|
|
446
|
+
/>
|
|
447
|
+
<div className="chai-result-card__expand-frame">
|
|
448
|
+
{isVideo ? (
|
|
449
|
+
<ResultVideoPlayer
|
|
450
|
+
src={result.output.src}
|
|
451
|
+
expanded
|
|
452
|
+
timeRef={videoTimeRef}
|
|
453
|
+
wasPlaying={videoWasPlaying}
|
|
454
|
+
onPlayingChange={setVideoWasPlaying}
|
|
455
|
+
/>
|
|
456
|
+
) : (
|
|
457
|
+
<img className="chai-result-card__image" src={result.output.src} alt={altText?.(result) ?? prompt} />
|
|
458
|
+
)}
|
|
459
|
+
<button
|
|
460
|
+
type="button"
|
|
461
|
+
className="chai-result-card__collapse"
|
|
462
|
+
aria-label="Collapse"
|
|
463
|
+
onClick={() => setMediaExpanded(false)}
|
|
464
|
+
>
|
|
465
|
+
<CloseIcon />
|
|
466
|
+
</button>
|
|
467
|
+
</div>
|
|
468
|
+
</div>
|
|
469
|
+
)}
|
|
470
|
+
</>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// --- Media (image / video / audio / text, by status) ------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
function ResultMedia({
|
|
477
|
+
result,
|
|
478
|
+
alt,
|
|
479
|
+
disabled,
|
|
480
|
+
mediaExpanded,
|
|
481
|
+
onDownload,
|
|
482
|
+
onShare,
|
|
483
|
+
onExpand,
|
|
484
|
+
onDimensions,
|
|
485
|
+
videoTimeRef,
|
|
486
|
+
videoWasPlaying,
|
|
487
|
+
onVideoPlayingChange,
|
|
488
|
+
}: {
|
|
489
|
+
result: Result;
|
|
490
|
+
alt: string;
|
|
491
|
+
disabled?: boolean;
|
|
492
|
+
mediaExpanded: boolean;
|
|
493
|
+
onDownload: () => void;
|
|
494
|
+
onShare: () => void;
|
|
495
|
+
onExpand: () => void;
|
|
496
|
+
onDimensions: (width: number, height: number) => void;
|
|
497
|
+
videoTimeRef: React.MutableRefObject<number>;
|
|
498
|
+
videoWasPlaying: boolean;
|
|
499
|
+
onVideoPlayingChange: (playing: boolean) => void;
|
|
500
|
+
}) {
|
|
501
|
+
const isVideo = result.output?.kind === "video";
|
|
502
|
+
const isImage = result.output?.kind === "image";
|
|
503
|
+
// "Running with output" is a text result streaming in (see `streamResult`
|
|
504
|
+
// in @chai-ui/core) — show the partial text, not the spinner.
|
|
505
|
+
const streaming = result.status === "running" && result.output?.kind === "text";
|
|
506
|
+
|
|
507
|
+
if ((result.status === "queued" || result.status === "running") && !streaming) {
|
|
508
|
+
return (
|
|
509
|
+
<div className="chai-result-card__media chai-result-card__media--pending">
|
|
510
|
+
<span className="chai-result-card__spinner" aria-label={result.status === "running" ? "Running" : "Queued"} />
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Stopped before any output: say so. A streamed text result that was
|
|
516
|
+
// stopped keeps its partial text and renders below like any other.
|
|
517
|
+
if (result.status === "cancelled" && !result.output) {
|
|
518
|
+
return (
|
|
519
|
+
<div className="chai-result-card__media chai-result-card__media--cancelled">
|
|
520
|
+
<p className="chai-result-card__cancelled-text">Stopped</p>
|
|
521
|
+
</div>
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (result.status === "error") {
|
|
526
|
+
return (
|
|
527
|
+
<div className="chai-result-card__media chai-result-card__media--error">
|
|
528
|
+
<p className="chai-result-card__error-text">{result.error?.message ?? "Something went wrong."}</p>
|
|
529
|
+
</div>
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (!result.output) return <div className="chai-result-card__media" />;
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<div
|
|
537
|
+
className={`chai-result-card__media${
|
|
538
|
+
result.output.kind === "audio" || result.output.kind === "text"
|
|
539
|
+
? " chai-result-card__media--fixed-height"
|
|
540
|
+
: ""
|
|
541
|
+
}`}
|
|
542
|
+
>
|
|
543
|
+
{result.output.kind === "video" ? (
|
|
544
|
+
mediaExpanded ? (
|
|
545
|
+
// The real, playing `<video>` lives in the expand overlay (a
|
|
546
|
+
// sibling of the whole card — see that overlay's own comment for
|
|
547
|
+
// why) while it's open; this card face just shows a plain
|
|
548
|
+
// frozen frame in its place so the layout doesn't collapse.
|
|
549
|
+
<img className="chai-result-card__image" src={result.output.src} alt="" />
|
|
550
|
+
) : (
|
|
551
|
+
<ResultVideoPlayer
|
|
552
|
+
src={result.output.src}
|
|
553
|
+
timeRef={videoTimeRef}
|
|
554
|
+
wasPlaying={videoWasPlaying}
|
|
555
|
+
onPlayingChange={onVideoPlayingChange}
|
|
556
|
+
onDimensions={onDimensions}
|
|
557
|
+
/>
|
|
558
|
+
)
|
|
559
|
+
) : result.output.kind === "audio" ? (
|
|
560
|
+
<audio className="chai-result-card__audio" src={result.output.src} controls />
|
|
561
|
+
) : result.output.kind === "text" ? (
|
|
562
|
+
<ResultText text={result.output.src} streaming={streaming} />
|
|
563
|
+
) : (
|
|
564
|
+
<img
|
|
565
|
+
className="chai-result-card__image"
|
|
566
|
+
src={result.output.src}
|
|
567
|
+
alt={alt}
|
|
568
|
+
onLoad={(e) => {
|
|
569
|
+
const el = e.currentTarget;
|
|
570
|
+
onDimensions(el.naturalWidth, el.naturalHeight);
|
|
571
|
+
}}
|
|
572
|
+
/>
|
|
573
|
+
)}
|
|
574
|
+
|
|
575
|
+
{!mediaExpanded && !streaming && (
|
|
576
|
+
<div className="chai-result-card__media-actions">
|
|
577
|
+
<ResultIconButton label="Download" disabled={disabled} onClick={onDownload}>
|
|
578
|
+
<DownloadIcon />
|
|
579
|
+
</ResultIconButton>
|
|
580
|
+
<ResultIconButton label="Share" disabled={disabled} onClick={onShare}>
|
|
581
|
+
<ShareIcon />
|
|
582
|
+
</ResultIconButton>
|
|
583
|
+
{(isVideo || isImage) && (
|
|
584
|
+
<ResultIconButton label="Expand" disabled={disabled} onClick={onExpand}>
|
|
585
|
+
<ExpandIcon />
|
|
586
|
+
</ResultIconButton>
|
|
587
|
+
)}
|
|
588
|
+
</div>
|
|
589
|
+
)}
|
|
590
|
+
</div>
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A text result, streaming or finished. One element for both states, so the
|
|
596
|
+
* scroll position survives the moment a stream completes. Past its max
|
|
597
|
+
* height it scrolls; while streaming it follows the newest text, unless the
|
|
598
|
+
* person has scrolled up to read, in which case it stays where they are.
|
|
599
|
+
*/
|
|
600
|
+
function ResultText({ text, streaming }: { text: string; streaming: boolean }) {
|
|
601
|
+
const ref = useRef<HTMLParagraphElement>(null);
|
|
602
|
+
const followRef = useRef(true);
|
|
603
|
+
|
|
604
|
+
useEffect(() => {
|
|
605
|
+
const el = ref.current;
|
|
606
|
+
if (el && streaming && followRef.current) el.scrollTop = el.scrollHeight;
|
|
607
|
+
}, [text, streaming]);
|
|
608
|
+
|
|
609
|
+
return (
|
|
610
|
+
<p
|
|
611
|
+
ref={ref}
|
|
612
|
+
className={`chai-result-card__text-output${streaming ? " chai-result-card__text-output--streaming" : ""}`}
|
|
613
|
+
// Screen readers wait for the finished text instead of announcing every chunk.
|
|
614
|
+
aria-busy={streaming}
|
|
615
|
+
// Keyboard users can scroll a long answer.
|
|
616
|
+
tabIndex={0}
|
|
617
|
+
onScroll={(e) => {
|
|
618
|
+
const el = e.currentTarget;
|
|
619
|
+
followRef.current = el.scrollTop + el.clientHeight >= el.scrollHeight - 24;
|
|
620
|
+
}}
|
|
621
|
+
>
|
|
622
|
+
{text}
|
|
623
|
+
</p>
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* A real `<video>` element plus custom play/pause + scrubber chrome built
|
|
629
|
+
* on its own playback API (`currentTime`, `duration`, the `timeupdate`
|
|
630
|
+
* event) — not the browser's native `controls` UI (reads as a different,
|
|
631
|
+
* heavier chrome than the mock's slim in-card scrubber) and not a
|
|
632
|
+
* from-scratch player (nothing here decodes or renders video itself, the
|
|
633
|
+
* real `<video>` element still does all of that).
|
|
634
|
+
*
|
|
635
|
+
* Used in two different mount points (inline in the card, and again in the
|
|
636
|
+
* expand overlay — see that overlay's own comment on why it can't just be
|
|
637
|
+
* the same element repositioned) — `timeRef`/`wasPlaying` are what let
|
|
638
|
+
* whichever one mounts pick up exactly where the other left off: seeded on
|
|
639
|
+
* mount, kept live on every timeupdate/play/pause so the *other* instance
|
|
640
|
+
* has an up-to-date value whenever it mounts next.
|
|
641
|
+
*/
|
|
642
|
+
function ResultVideoPlayer({
|
|
643
|
+
src,
|
|
644
|
+
expanded,
|
|
645
|
+
timeRef,
|
|
646
|
+
wasPlaying,
|
|
647
|
+
onPlayingChange,
|
|
648
|
+
onDimensions,
|
|
649
|
+
}: {
|
|
650
|
+
src: string;
|
|
651
|
+
expanded?: boolean;
|
|
652
|
+
timeRef: React.MutableRefObject<number>;
|
|
653
|
+
wasPlaying: boolean;
|
|
654
|
+
onPlayingChange: (playing: boolean) => void;
|
|
655
|
+
onDimensions?: (width: number, height: number) => void;
|
|
656
|
+
}) {
|
|
657
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
658
|
+
const [playing, setPlaying] = useState(wasPlaying);
|
|
659
|
+
const [progress, setProgress] = useState(0);
|
|
660
|
+
|
|
661
|
+
function togglePlay() {
|
|
662
|
+
const el = videoRef.current;
|
|
663
|
+
if (!el) return;
|
|
664
|
+
if (el.paused) el.play();
|
|
665
|
+
else el.pause();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function handleScrub(e: React.MouseEvent<HTMLDivElement>) {
|
|
669
|
+
const el = videoRef.current;
|
|
670
|
+
if (!el || !el.duration) return;
|
|
671
|
+
const rect = e.currentTarget.getBoundingClientRect();
|
|
672
|
+
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
|
|
673
|
+
el.currentTime = ratio * el.duration;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Keyboard seeking, the slider pattern: arrows move 5s, Home/End jump.
|
|
677
|
+
function handleScrubKey(e: React.KeyboardEvent<HTMLDivElement>) {
|
|
678
|
+
const el = videoRef.current;
|
|
679
|
+
if (!el || !el.duration) return;
|
|
680
|
+
const step = { ArrowRight: 5, ArrowUp: 5, ArrowLeft: -5, ArrowDown: -5 }[e.key];
|
|
681
|
+
if (step != null) el.currentTime = Math.min(el.duration, Math.max(0, el.currentTime + step));
|
|
682
|
+
else if (e.key === "Home") el.currentTime = 0;
|
|
683
|
+
else if (e.key === "End") el.currentTime = el.duration;
|
|
684
|
+
else return;
|
|
685
|
+
e.preventDefault();
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return (
|
|
689
|
+
<>
|
|
690
|
+
<video
|
|
691
|
+
ref={videoRef}
|
|
692
|
+
className="chai-result-card__video"
|
|
693
|
+
src={src}
|
|
694
|
+
onLoadedMetadata={(e) => {
|
|
695
|
+
const el = e.currentTarget;
|
|
696
|
+
el.currentTime = timeRef.current;
|
|
697
|
+
if (wasPlaying) el.play().catch(() => {});
|
|
698
|
+
onDimensions?.(el.videoWidth, el.videoHeight);
|
|
699
|
+
}}
|
|
700
|
+
onPlay={() => {
|
|
701
|
+
setPlaying(true);
|
|
702
|
+
onPlayingChange(true);
|
|
703
|
+
}}
|
|
704
|
+
onPause={() => {
|
|
705
|
+
setPlaying(false);
|
|
706
|
+
onPlayingChange(false);
|
|
707
|
+
}}
|
|
708
|
+
onTimeUpdate={(e) => {
|
|
709
|
+
const el = e.currentTarget;
|
|
710
|
+
timeRef.current = el.currentTime;
|
|
711
|
+
if (el.duration) setProgress(el.currentTime / el.duration);
|
|
712
|
+
}}
|
|
713
|
+
/>
|
|
714
|
+
{/* One control over the whole video: click, or Tab then Space/Enter, to
|
|
715
|
+
play or pause. The round Play badge is just its paused look. */}
|
|
716
|
+
<button
|
|
717
|
+
type="button"
|
|
718
|
+
className="chai-result-card__video-toggle"
|
|
719
|
+
aria-label={playing ? "Pause" : "Play"}
|
|
720
|
+
onClick={togglePlay}
|
|
721
|
+
>
|
|
722
|
+
{!playing && (
|
|
723
|
+
<span className="chai-result-card__play">
|
|
724
|
+
<PlayIcon />
|
|
725
|
+
</span>
|
|
726
|
+
)}
|
|
727
|
+
</button>
|
|
728
|
+
<div
|
|
729
|
+
className={`chai-result-card__scrubber${expanded ? " chai-result-card__scrubber--expanded" : ""}`}
|
|
730
|
+
role="slider"
|
|
731
|
+
tabIndex={0}
|
|
732
|
+
aria-label="Seek"
|
|
733
|
+
aria-valuemin={0}
|
|
734
|
+
aria-valuemax={100}
|
|
735
|
+
aria-valuenow={Math.round(progress * 100)}
|
|
736
|
+
aria-valuetext={`${Math.round(progress * 100)}%`}
|
|
737
|
+
onClick={handleScrub}
|
|
738
|
+
onKeyDown={handleScrubKey}
|
|
739
|
+
>
|
|
740
|
+
<div className="chai-result-card__scrubber-fill" style={{ width: `${progress * 100}%` }} />
|
|
741
|
+
</div>
|
|
742
|
+
</>
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// --- Shared icon button (the hover circle — reference: the design note that the "like" button's hover treatment in the mock applies to every small icon button on the card) ------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
function ResultIconButton({
|
|
749
|
+
children,
|
|
750
|
+
label,
|
|
751
|
+
active,
|
|
752
|
+
disabled,
|
|
753
|
+
onClick,
|
|
754
|
+
}: {
|
|
755
|
+
children: ReactNode;
|
|
756
|
+
label: string;
|
|
757
|
+
active?: boolean;
|
|
758
|
+
disabled?: boolean;
|
|
759
|
+
onClick: () => void;
|
|
760
|
+
}) {
|
|
761
|
+
return (
|
|
762
|
+
<button
|
|
763
|
+
type="button"
|
|
764
|
+
className={`chai-result-card__icon-btn${active ? " chai-result-card__icon-btn--active" : ""}`}
|
|
765
|
+
aria-label={label}
|
|
766
|
+
aria-pressed={active}
|
|
767
|
+
title={label}
|
|
768
|
+
disabled={disabled}
|
|
769
|
+
onClick={onClick}
|
|
770
|
+
>
|
|
771
|
+
{children}
|
|
772
|
+
</button>
|
|
773
|
+
);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// --- More menu (reference: Figma "Result Card - More menu expanded") — same hand-rolled positioned-popover pattern Composer.tsx's own ComposerAttachMenu documents, for the same reason (needs to live inline under its own trigger). ------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
function MoreMenu({
|
|
779
|
+
actions,
|
|
780
|
+
open,
|
|
781
|
+
onOpenChange,
|
|
782
|
+
disabled,
|
|
783
|
+
onSelect,
|
|
784
|
+
}: {
|
|
785
|
+
actions: ResultCardMoreAction[];
|
|
786
|
+
open: boolean;
|
|
787
|
+
onOpenChange: (open: boolean) => void;
|
|
788
|
+
disabled?: boolean;
|
|
789
|
+
onSelect: (action: ResultCardMoreAction) => void;
|
|
790
|
+
}) {
|
|
791
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
792
|
+
|
|
793
|
+
useEffect(() => {
|
|
794
|
+
if (!open) return;
|
|
795
|
+
function handlePointerDown(e: PointerEvent) {
|
|
796
|
+
if (!rootRef.current?.contains(e.target as Node)) onOpenChange(false);
|
|
797
|
+
}
|
|
798
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
799
|
+
if (e.key === "Escape") onOpenChange(false);
|
|
800
|
+
}
|
|
801
|
+
document.addEventListener("pointerdown", handlePointerDown);
|
|
802
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
803
|
+
return () => {
|
|
804
|
+
document.removeEventListener("pointerdown", handlePointerDown);
|
|
805
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
806
|
+
};
|
|
807
|
+
}, [open, onOpenChange]);
|
|
808
|
+
|
|
809
|
+
return (
|
|
810
|
+
<div className="chai-result-card__more" ref={rootRef}>
|
|
811
|
+
<ResultIconButton label="More" active={open} disabled={disabled} onClick={() => onOpenChange(!open)}>
|
|
812
|
+
<MoreHorizIcon />
|
|
813
|
+
</ResultIconButton>
|
|
814
|
+
{open && (
|
|
815
|
+
<ul className="chai-result-card__more-menu" role="menu">
|
|
816
|
+
{actions.map((action) => (
|
|
817
|
+
<li key={action.id} role="none">
|
|
818
|
+
<button
|
|
819
|
+
type="button"
|
|
820
|
+
role="menuitem"
|
|
821
|
+
className="chai-result-card__more-menu-item"
|
|
822
|
+
onClick={() => {
|
|
823
|
+
onOpenChange(false);
|
|
824
|
+
onSelect(action);
|
|
825
|
+
}}
|
|
826
|
+
>
|
|
827
|
+
{action.icon && <span className="chai-result-card__more-menu-icon">{action.icon}</span>}
|
|
828
|
+
{action.label}
|
|
829
|
+
</button>
|
|
830
|
+
</li>
|
|
831
|
+
))}
|
|
832
|
+
</ul>
|
|
833
|
+
)}
|
|
834
|
+
</div>
|
|
835
|
+
);
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// --- Icons ------------------------------------------------------------
|
|
839
|
+
// Traced from the design's exported assets ("Result from Composer Bar") — same "trace and inline as a
|
|
840
|
+
// currentColor component" convention Composer.tsx documents, icons aren't
|
|
841
|
+
// shared across files yet in this codebase. `PlayIcon`/`PauseIcon`/
|
|
842
|
+
// `ExpandIcon` aren't in the mock itself (its video state has no separate
|
|
843
|
+
// play-button layer — the triangle in the reference screenshot is baked
|
|
844
|
+
// into that frame's placeholder photo, not a real asset) — real Material
|
|
845
|
+
// Symbols glyphs / a simple hand-drawn corner-bracket mark instead.
|
|
846
|
+
|
|
847
|
+
function ThumbUpIcon() {
|
|
848
|
+
return (
|
|
849
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
850
|
+
<path
|
|
851
|
+
d="M5.25039 15.7502V6.00019L10.5004 0.750193L11.8879 2.13769L10.9129 6.00019H17.2504V9.30019L14.5129 15.7502H5.25039ZM6.75039 14.2502H13.5004L15.7504 9.00019V7.50019H9.00039L10.0129 3.37519L6.75039 6.63769V14.2502ZM1.50039 15.7502V6.00019H5.25039V7.50019H3.00039V14.2502H5.25039V15.7502H1.50039Z"
|
|
852
|
+
fill="currentColor"
|
|
853
|
+
/>
|
|
854
|
+
</svg>
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function ThumbDownIcon() {
|
|
859
|
+
return (
|
|
860
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
861
|
+
<path
|
|
862
|
+
d="M2.24961 12C1.84961 12 1.49961 11.85 1.19961 11.55C0.899615 11.25 0.749615 10.9 0.749615 10.5V9C0.749615 8.9125 0.762115 8.81875 0.787115 8.71875C0.812115 8.61875 0.837115 8.525 0.862115 8.4375L3.11211 3.15C3.22461 2.9 3.41211 2.6875 3.67461 2.5125C3.93711 2.3375 4.21211 2.25 4.49961 2.25H12.7496V12L8.24961 16.4625C8.06211 16.65 7.84024 16.7594 7.58399 16.7906C7.32774 16.8219 7.08086 16.775 6.84336 16.65C6.60586 16.525 6.43086 16.35 6.31836 16.125C6.20586 15.9 6.18086 15.6688 6.24336 15.4313L7.08711 12H2.24961ZM11.2496 11.3625V3.75H4.49961L2.24961 9V10.5H8.99961L7.98711 14.625L11.2496 11.3625ZM14.9996 2.25C15.4121 2.25 15.7652 2.39688 16.059 2.69063C16.3527 2.98438 16.4996 3.3375 16.4996 3.75V10.5C16.4996 10.9125 16.3527 11.2656 16.059 11.5594C15.7652 11.8531 15.4121 12 14.9996 12H12.7496V10.5H14.9996V3.75H12.7496V2.25H14.9996Z"
|
|
863
|
+
fill="currentColor"
|
|
864
|
+
/>
|
|
865
|
+
</svg>
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function RefreshIcon() {
|
|
870
|
+
return (
|
|
871
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
872
|
+
<path
|
|
873
|
+
d="M8.99961 15.0002C7.32461 15.0002 5.90586 14.4189 4.74336 13.2564C3.58086 12.0939 2.99961 10.6752 2.99961 9.00019C2.99961 7.32519 3.58086 5.90644 4.74336 4.74394C5.90586 3.58144 7.32461 3.00019 8.99961 3.00019C9.86211 3.00019 10.6871 3.17832 11.4746 3.53457C12.2621 3.89082 12.9371 4.40019 13.4996 5.06269V3.00019H14.9996V8.25019H9.74961V6.75019H12.8996C12.4996 6.05019 11.9527 5.50019 11.259 5.10019C10.5652 4.70019 9.81211 4.50019 8.99961 4.50019C7.74961 4.50019 6.68711 4.93769 5.81211 5.81269C4.93711 6.68769 4.49961 7.75019 4.49961 9.00019C4.49961 10.2502 4.93711 11.3127 5.81211 12.1877C6.68711 13.0627 7.74961 13.5002 8.99961 13.5002C9.96211 13.5002 10.8309 13.2252 11.6059 12.6752C12.3809 12.1252 12.9246 11.4002 13.2371 10.5002H14.8121C14.4621 11.8252 13.7496 12.9064 12.6746 13.7439C11.5996 14.5814 10.3746 15.0002 8.99961 15.0002Z"
|
|
874
|
+
fill="currentColor"
|
|
875
|
+
/>
|
|
876
|
+
</svg>
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function MoreHorizIcon() {
|
|
881
|
+
return (
|
|
882
|
+
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
883
|
+
<path
|
|
884
|
+
d="M6.41473 14C5.86473 14 5.3939 13.8042 5.00223 13.4125C4.61056 13.0208 4.41473 12.55 4.41473 12C4.41473 11.45 4.61056 10.9792 5.00223 10.5875C5.3939 10.1958 5.86473 10 6.41473 10C6.96473 10 7.43556 10.1958 7.82723 10.5875C8.2189 10.9792 8.41473 11.45 8.41473 12C8.41473 12.55 8.2189 13.0208 7.82723 13.4125C7.43556 13.8042 6.96473 14 6.41473 14ZM12.4147 14C11.8647 14 11.3939 13.8042 11.0022 13.4125C10.6106 13.0208 10.4147 12.55 10.4147 12C10.4147 11.45 10.6106 10.9792 11.0022 10.5875C11.3939 10.1958 11.8647 10 12.4147 10C12.9647 10 13.4356 10.1958 13.8272 10.5875C14.2189 10.9792 14.4147 11.45 14.4147 12C14.4147 12.55 14.2189 13.0208 13.8272 13.4125C13.4356 13.8042 12.9647 14 12.4147 14ZM18.4147 14C17.8647 14 17.3939 13.8042 17.0022 13.4125C16.6106 13.0208 16.4147 12.55 16.4147 12C16.4147 11.45 16.6106 10.9792 17.0022 10.5875C17.3939 10.1958 17.8647 10 18.4147 10C18.9647 10 19.4356 10.1958 19.8272 10.5875C20.2189 10.9792 20.4147 11.45 20.4147 12C20.4147 12.55 20.2189 13.0208 19.8272 13.4125C19.4356 13.8042 18.9647 14 18.4147 14Z"
|
|
885
|
+
fill="currentColor"
|
|
886
|
+
/>
|
|
887
|
+
</svg>
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function ShareIcon() {
|
|
892
|
+
return (
|
|
893
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
894
|
+
<path
|
|
895
|
+
d="M2.99961 16.4999V5.9999H6.74961V7.4999H4.49961V14.9999H13.4996V7.4999H11.2496V5.9999H14.9996V16.4999H2.99961ZM8.24961 11.9999V3.61865L7.04961 4.81865L5.99961 3.7499L8.99961 0.749904L11.9996 3.7499L10.9496 4.81865L9.74961 3.61865V11.9999H8.24961Z"
|
|
896
|
+
fill="currentColor"
|
|
897
|
+
/>
|
|
898
|
+
</svg>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function DownloadIcon() {
|
|
903
|
+
return (
|
|
904
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
905
|
+
<path
|
|
906
|
+
d="M8.99961 11.9999L5.24961 8.2499L6.29961 7.1624L8.24961 9.1124V2.9999H9.74961V9.1124L11.6996 7.1624L12.7496 8.2499L8.99961 11.9999ZM2.99961 14.9999V11.2499H4.49961V13.4999H13.4996V11.2499H14.9996V14.9999H2.99961Z"
|
|
907
|
+
fill="currentColor"
|
|
908
|
+
/>
|
|
909
|
+
</svg>
|
|
910
|
+
);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function InfoIcon() {
|
|
914
|
+
return (
|
|
915
|
+
<svg viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
|
916
|
+
<path
|
|
917
|
+
d="M10.0833 6.41667H11.9167V8.25H10.0833V6.41667ZM10.0833 10.0833H11.9167V15.5833H10.0833V10.0833ZM11 1.83333C5.94 1.83333 1.83333 5.94 1.83333 11C1.83333 16.06 5.94 20.1667 11 20.1667C16.06 20.1667 20.1667 16.06 20.1667 11C20.1667 5.94 16.06 1.83333 11 1.83333ZM11 18.3333C6.9575 18.3333 3.66667 15.0425 3.66667 11C3.66667 6.9575 6.9575 3.66667 11 3.66667C15.0425 3.66667 18.3333 6.9575 18.3333 11C18.3333 15.0425 15.0425 18.3333 11 18.3333Z"
|
|
918
|
+
fill="currentColor"
|
|
919
|
+
/>
|
|
920
|
+
</svg>
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function CloseIcon() {
|
|
925
|
+
return (
|
|
926
|
+
<svg viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
|
927
|
+
<path
|
|
928
|
+
d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z"
|
|
929
|
+
fill="currentColor"
|
|
930
|
+
/>
|
|
931
|
+
</svg>
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function CopyIcon() {
|
|
936
|
+
return (
|
|
937
|
+
<svg viewBox="0 0 8.60253 11.6462" fill="none" aria-hidden="true">
|
|
938
|
+
<path
|
|
939
|
+
d="M1.01206 11.6462C0.733745 11.6462 0.495405 11.5323 0.29704 11.3044C0.0990134 11.0761 0 10.8019 0 10.4816V2.32924H1.01206V10.4816H6.57841V11.6462H1.01206ZM3.03619 9.31697C2.75787 9.31697 2.5197 9.20303 2.32167 8.97515C2.12331 8.74689 2.02413 8.47262 2.02413 8.15235V1.16462C2.02413 0.84435 2.12331 0.570082 2.32167 0.341816C2.5197 0.113939 2.75787 0 3.03619 0H7.59047C7.86879 0 8.10713 0.113939 8.30549 0.341816C8.50352 0.570082 8.60253 0.84435 8.60253 1.16462V8.15235C8.60253 8.47262 8.50352 8.74689 8.30549 8.97515C8.10713 9.20303 7.86879 9.31697 7.59047 9.31697H3.03619ZM3.03619 8.15235H7.59047V1.16462H3.03619V8.15235Z"
|
|
940
|
+
fill="currentColor"
|
|
941
|
+
/>
|
|
942
|
+
</svg>
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
/** MD3 "check" symbol — same traced glyph SearchMenu.tsx uses, marking a passed evaluation. */
|
|
947
|
+
function CheckIcon() {
|
|
948
|
+
return (
|
|
949
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
950
|
+
<path
|
|
951
|
+
d="M7.1625 13.5L2.8875 9.225L3.95625 8.15625L7.1625 11.3625L14.0438 4.48125L15.1125 5.55L7.1625 13.5Z"
|
|
952
|
+
fill="currentColor"
|
|
953
|
+
/>
|
|
954
|
+
</svg>
|
|
955
|
+
);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/** Real Material Symbols "play_arrow" (viewBox `0 -960 960 960`) — not in the mock itself, see this section's own header comment. */
|
|
959
|
+
function PlayIcon() {
|
|
960
|
+
return (
|
|
961
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
962
|
+
<path d="M320-200v-560l440 280-440 280Z" fill="currentColor" />
|
|
963
|
+
</svg>
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/** Self-drawn corner-bracket mark, not a recalled/traced icon-set glyph (the mock has no "expand" asset to trace — see this section's own header comment) — four independent strokes, deliberately simple so its geometry is obviously correct rather than a compound path that's hard to eyeball. */
|
|
968
|
+
function ExpandIcon() {
|
|
969
|
+
return (
|
|
970
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
971
|
+
<path
|
|
972
|
+
d="M2 6V2h4M12 2h4v4M16 12v4h-4M6 16H2v-4"
|
|
973
|
+
stroke="currentColor"
|
|
974
|
+
strokeWidth="1.6"
|
|
975
|
+
strokeLinecap="round"
|
|
976
|
+
strokeLinejoin="round"
|
|
977
|
+
/>
|
|
978
|
+
</svg>
|
|
979
|
+
);
|
|
980
|
+
}
|