@chai-ui/react 0.1.0 → 0.2.1
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 +6 -1
- package/README.md +69 -6
- package/dist/Composer.d.ts +19 -2
- package/dist/Composer.d.ts.map +1 -1
- package/dist/Composer.js +36 -10
- package/dist/Composer.js.map +1 -1
- package/dist/EditCard.d.ts +52 -0
- package/dist/EditCard.d.ts.map +1 -0
- package/dist/EditCard.js +360 -0
- package/dist/EditCard.js.map +1 -0
- package/dist/ResultCard.d.ts +2 -1
- package/dist/ResultCard.d.ts.map +1 -1
- package/dist/ResultCard.js +19 -63
- package/dist/ResultCard.js.map +1 -1
- package/dist/icons.d.ts +27 -0
- package/dist/icons.d.ts.map +1 -0
- package/dist/icons.js +50 -0
- package/dist/icons.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/media-actions.d.ts +15 -0
- package/dist/media-actions.d.ts.map +1 -0
- package/dist/media-actions.js +47 -0
- package/dist/media-actions.js.map +1 -0
- package/dist/page-slide.d.ts +32 -0
- package/dist/page-slide.d.ts.map +1 -0
- package/dist/page-slide.js +43 -0
- package/dist/page-slide.js.map +1 -0
- package/dist/primitives/Pagination.d.ts +3 -1
- package/dist/primitives/Pagination.d.ts.map +1 -1
- package/dist/primitives/Pagination.js +2 -2
- package/dist/primitives/Pagination.js.map +1 -1
- package/dist/regions.d.ts +3 -0
- package/dist/regions.d.ts.map +1 -0
- package/dist/regions.js +7 -0
- package/dist/regions.js.map +1 -0
- package/dist/style.css +508 -82
- package/dist/useComposer.d.ts +22 -1
- package/dist/useComposer.d.ts.map +1 -1
- package/dist/useComposer.js +126 -24
- package/dist/useComposer.js.map +1 -1
- package/package.json +3 -3
- package/src/Composer.tsx +101 -13
- package/src/EditCard.tsx +688 -0
- package/src/ResultCard.tsx +60 -125
- package/src/icons.tsx +115 -0
- package/src/index.ts +8 -0
- package/src/media-actions.ts +45 -0
- package/src/page-slide.tsx +82 -0
- package/src/primitives/Pagination.tsx +4 -2
- package/src/regions.ts +7 -0
- package/src/style.css +508 -82
- package/src/useComposer.ts +147 -18
package/src/ResultCard.tsx
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { ROUTING_MODEL_ID, type ModelOption, type Result } from "@chai-ui/core";
|
|
3
3
|
import { Pagination } from "./primitives/Pagination.js";
|
|
4
|
+
import { DownloadIcon, ExpandIcon, ShareIcon } from "./icons.js";
|
|
5
|
+
import { downloadFilename, downloadMedia, shareMedia } from "./media-actions.js";
|
|
6
|
+
import { PageSlide, usePageSlide } from "./page-slide.js";
|
|
4
7
|
|
|
5
8
|
/**
|
|
6
9
|
* ResultCard's complement to Composer bar, and the last stop in the
|
|
@@ -47,14 +50,6 @@ function formatAspectRatio(width: number, height: number): string | undefined {
|
|
|
47
50
|
return `${w / divisor}:${h / divisor}`;
|
|
48
51
|
}
|
|
49
52
|
|
|
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
53
|
|
|
59
54
|
// --- Public types ------------------------------------------------------------
|
|
60
55
|
|
|
@@ -201,11 +196,20 @@ export function ResultCard({
|
|
|
201
196
|
const result = results[clampedIndex];
|
|
202
197
|
|
|
203
198
|
const mediaDims = measured && measured.id === result?.id ? measured : null;
|
|
199
|
+
// Paging slides, with the last image sliding out (other kinds just slide in).
|
|
200
|
+
const { slide, endSlide } = usePageSlide(
|
|
201
|
+
result?.id,
|
|
202
|
+
clampedIndex,
|
|
203
|
+
result?.output?.kind === "image" ? result.output.src : undefined
|
|
204
|
+
);
|
|
204
205
|
|
|
205
206
|
if (!result) return null;
|
|
206
207
|
|
|
207
208
|
const vote = getVote?.(result) ?? null;
|
|
208
|
-
const modelLabel =
|
|
209
|
+
const modelLabel =
|
|
210
|
+
result.modelId === ROUTING_MODEL_ID
|
|
211
|
+
? "Choosing model…"
|
|
212
|
+
: (models?.find((m) => m.id === result.modelId)?.label ?? result.modelId);
|
|
209
213
|
const duration = formatDuration(result.durationMs);
|
|
210
214
|
const cost = formatCost(result.usage?.costUsd);
|
|
211
215
|
const tokens = formatTokens(result.usage?.totalTokens);
|
|
@@ -228,46 +232,13 @@ export function ResultCard({
|
|
|
228
232
|
|
|
229
233
|
const handleDownload = async () => {
|
|
230
234
|
if (result.output) {
|
|
231
|
-
|
|
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
|
-
}
|
|
235
|
+
await downloadMedia(result.output.src, downloadFilename(result.id, result.output.kind, result.output.src));
|
|
257
236
|
}
|
|
258
237
|
onAction("download", result);
|
|
259
238
|
};
|
|
260
239
|
|
|
261
240
|
const handleShare = async () => {
|
|
262
|
-
if (result.output
|
|
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
|
-
}
|
|
241
|
+
if (result.output) await shareMedia(result.output.src);
|
|
271
242
|
onAction("share", result);
|
|
272
243
|
};
|
|
273
244
|
|
|
@@ -286,24 +257,36 @@ export function ResultCard({
|
|
|
286
257
|
>
|
|
287
258
|
<div className="chai-result-card__inner">
|
|
288
259
|
<div ref={frontRef} className="chai-result-card__face chai-result-card__front" aria-hidden={flipped}>
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
260
|
+
<div className="chai-result-card__media-frame">
|
|
261
|
+
<PageSlide slide={slide} onEnd={endSlide} className="chai-result-card__media-page">
|
|
262
|
+
<ResultMedia
|
|
263
|
+
result={result}
|
|
264
|
+
alt={altText?.(result) ?? prompt}
|
|
265
|
+
disabled={disabled}
|
|
266
|
+
mediaExpanded={mediaExpanded}
|
|
267
|
+
onDownload={handleDownload}
|
|
268
|
+
onShare={handleShare}
|
|
269
|
+
onExpand={() => setMediaExpanded(true)}
|
|
270
|
+
onDimensions={(w, h) => setMeasured({ id: result.id, width: w, height: h })}
|
|
271
|
+
videoTimeRef={videoTimeRef}
|
|
272
|
+
videoWasPlaying={videoWasPlaying}
|
|
273
|
+
onVideoPlayingChange={setVideoWasPlaying}
|
|
274
|
+
/>
|
|
275
|
+
</PageSlide>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
278
|
+
{/* Floating over the bottom of the media, as on the edit card: the
|
|
279
|
+
page dots above, votes and retry in one pill, details on the right. */}
|
|
280
|
+
<div className="chai-card-bottom">
|
|
281
|
+
{results.length > 1 && (
|
|
282
|
+
<div className="chai-card-pager">
|
|
283
|
+
<Pagination count={results.length} index={clampedIndex} onChange={goTo} label="Results" disabled={disabled} />
|
|
284
|
+
</div>
|
|
285
|
+
)}
|
|
286
|
+
<div className="chai-card-actions">
|
|
287
|
+
<div className="chai-card-pill">
|
|
306
288
|
<ResultIconButton
|
|
289
|
+
variant="pill"
|
|
307
290
|
label="Like"
|
|
308
291
|
active={vote === "like"}
|
|
309
292
|
disabled={disabled}
|
|
@@ -312,6 +295,7 @@ export function ResultCard({
|
|
|
312
295
|
<ThumbUpIcon />
|
|
313
296
|
</ResultIconButton>
|
|
314
297
|
<ResultIconButton
|
|
298
|
+
variant="pill"
|
|
315
299
|
label="Dislike"
|
|
316
300
|
active={vote === "dislike"}
|
|
317
301
|
disabled={disabled}
|
|
@@ -319,11 +303,7 @@ export function ResultCard({
|
|
|
319
303
|
>
|
|
320
304
|
<ThumbDownIcon />
|
|
321
305
|
</ResultIconButton>
|
|
322
|
-
<ResultIconButton
|
|
323
|
-
label="Retry"
|
|
324
|
-
disabled={disabled}
|
|
325
|
-
onClick={() => onAction("retry", result)}
|
|
326
|
-
>
|
|
306
|
+
<ResultIconButton variant="pill" label="Retry" disabled={disabled} onClick={() => onAction("retry", result)}>
|
|
327
307
|
<RefreshIcon />
|
|
328
308
|
</ResultIconButton>
|
|
329
309
|
{moreActions && moreActions.length > 0 && (
|
|
@@ -336,25 +316,10 @@ export function ResultCard({
|
|
|
336
316
|
/>
|
|
337
317
|
)}
|
|
338
318
|
</div>
|
|
339
|
-
|
|
340
|
-
<ResultIconButton
|
|
341
|
-
label="Show details"
|
|
342
|
-
disabled={disabled}
|
|
343
|
-
onClick={() => setFlipped(true)}
|
|
344
|
-
>
|
|
319
|
+
<ResultIconButton variant="round" label="Show details" disabled={disabled} onClick={() => setFlipped(true)}>
|
|
345
320
|
<InfoIcon />
|
|
346
321
|
</ResultIconButton>
|
|
347
322
|
</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
323
|
</div>
|
|
359
324
|
</div>
|
|
360
325
|
|
|
@@ -507,7 +472,7 @@ function ResultMedia({
|
|
|
507
472
|
if ((result.status === "queued" || result.status === "running") && !streaming) {
|
|
508
473
|
return (
|
|
509
474
|
<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"} />
|
|
475
|
+
<span className="chai-result-card__spinner" role="img" aria-label={result.status === "running" ? "Running" : "Queued"} />
|
|
511
476
|
</div>
|
|
512
477
|
);
|
|
513
478
|
}
|
|
@@ -573,15 +538,15 @@ function ResultMedia({
|
|
|
573
538
|
)}
|
|
574
539
|
|
|
575
540
|
{!mediaExpanded && !streaming && (
|
|
576
|
-
<div className="chai-
|
|
577
|
-
<ResultIconButton label="Download" disabled={disabled} onClick={onDownload}>
|
|
541
|
+
<div className="chai-card-top chai-card-top--end">
|
|
542
|
+
<ResultIconButton variant="top" label="Download" disabled={disabled} onClick={onDownload}>
|
|
578
543
|
<DownloadIcon />
|
|
579
544
|
</ResultIconButton>
|
|
580
|
-
<ResultIconButton label="Share" disabled={disabled} onClick={onShare}>
|
|
545
|
+
<ResultIconButton variant="top" label="Share" disabled={disabled} onClick={onShare}>
|
|
581
546
|
<ShareIcon />
|
|
582
547
|
</ResultIconButton>
|
|
583
548
|
{(isVideo || isImage) && (
|
|
584
|
-
<ResultIconButton label="Expand" disabled={disabled} onClick={onExpand}>
|
|
549
|
+
<ResultIconButton variant="top" label="Expand" disabled={disabled} onClick={onExpand}>
|
|
585
550
|
<ExpandIcon />
|
|
586
551
|
</ResultIconButton>
|
|
587
552
|
)}
|
|
@@ -745,14 +710,19 @@ function ResultVideoPlayer({
|
|
|
745
710
|
|
|
746
711
|
// --- 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
712
|
|
|
713
|
+
const ICON_BUTTON_CLASS = { top: "chai-card-top-btn", pill: "chai-card-pill-btn", round: "chai-card-round" } as const;
|
|
714
|
+
|
|
748
715
|
function ResultIconButton({
|
|
749
716
|
children,
|
|
717
|
+
variant,
|
|
750
718
|
label,
|
|
751
719
|
active,
|
|
752
720
|
disabled,
|
|
753
721
|
onClick,
|
|
754
722
|
}: {
|
|
755
723
|
children: ReactNode;
|
|
724
|
+
/** Where it floats: in a dark group along the top, in the light pill, or the round button on the right. */
|
|
725
|
+
variant: "top" | "pill" | "round";
|
|
756
726
|
label: string;
|
|
757
727
|
active?: boolean;
|
|
758
728
|
disabled?: boolean;
|
|
@@ -761,7 +731,7 @@ function ResultIconButton({
|
|
|
761
731
|
return (
|
|
762
732
|
<button
|
|
763
733
|
type="button"
|
|
764
|
-
className={
|
|
734
|
+
className={`${ICON_BUTTON_CLASS[variant]}${active ? " chai-card-pill-btn--active" : ""}`}
|
|
765
735
|
aria-label={label}
|
|
766
736
|
aria-pressed={active}
|
|
767
737
|
title={label}
|
|
@@ -808,7 +778,7 @@ function MoreMenu({
|
|
|
808
778
|
|
|
809
779
|
return (
|
|
810
780
|
<div className="chai-result-card__more" ref={rootRef}>
|
|
811
|
-
<ResultIconButton label="More" active={open} disabled={disabled} onClick={() => onOpenChange(!open)}>
|
|
781
|
+
<ResultIconButton variant="pill" label="More" active={open} disabled={disabled} onClick={() => onOpenChange(!open)}>
|
|
812
782
|
<MoreHorizIcon />
|
|
813
783
|
</ResultIconButton>
|
|
814
784
|
{open && (
|
|
@@ -888,28 +858,6 @@ function MoreHorizIcon() {
|
|
|
888
858
|
);
|
|
889
859
|
}
|
|
890
860
|
|
|
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
861
|
function InfoIcon() {
|
|
914
862
|
return (
|
|
915
863
|
<svg viewBox="0 0 22 22" fill="none" aria-hidden="true">
|
|
@@ -965,16 +913,3 @@ function PlayIcon() {
|
|
|
965
913
|
}
|
|
966
914
|
|
|
967
915
|
/** 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
|
-
}
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Icons shared by more than one component. Each is an inline
|
|
3
|
+
* `currentColor` SVG, the convention every component file follows.
|
|
4
|
+
* The media-action icons are traced from the result card design; the rest
|
|
5
|
+
* are real Material Symbols paths (viewBox `0 -960 960 960`) copied from
|
|
6
|
+
* `@material-symbols/svg-400`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function DownloadIcon() {
|
|
10
|
+
return (
|
|
11
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
12
|
+
<path
|
|
13
|
+
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"
|
|
14
|
+
fill="currentColor"
|
|
15
|
+
/>
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function ShareIcon() {
|
|
21
|
+
return (
|
|
22
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
23
|
+
<path
|
|
24
|
+
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"
|
|
25
|
+
fill="currentColor"
|
|
26
|
+
/>
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function ExpandIcon() {
|
|
32
|
+
return (
|
|
33
|
+
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
34
|
+
<path
|
|
35
|
+
d="M2 6V2h4M12 2h4v4M16 12v4h-4M6 16H2v-4"
|
|
36
|
+
stroke="currentColor"
|
|
37
|
+
strokeWidth="1.6"
|
|
38
|
+
strokeLinecap="round"
|
|
39
|
+
strokeLinejoin="round"
|
|
40
|
+
/>
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Material Symbols "zoom_in". */
|
|
46
|
+
export function ZoomInIcon() {
|
|
47
|
+
return (
|
|
48
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
49
|
+
<path d="M796-121 533-384q-30 26-69.96 40.5Q423.08-329 378-329q-108.16 0-183.08-75Q120-479 120-585t75-181q75-75 181.5-75t181 75Q632-691 632-584.85 632-542 618-502q-14 40-42 75l264 262-44 44ZM377-389q81.25 0 138.13-57.5Q572-504 572-585t-56.87-138.5Q458.25-781 377-781q-82.08 0-139.54 57.5Q180-666 180-585t57.46 138.5Q294.92-389 377-389Zm-31-85v-82h-82v-60h82v-81h60v81h81v60h-81v82h-60Z" fill="currentColor" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Material Symbols "zoom_out". */
|
|
55
|
+
export function ZoomOutIcon() {
|
|
56
|
+
return (
|
|
57
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
58
|
+
<path d="M796-121 533-384q-30 26-70 40.5T378-329q-108 0-183-75t-75-181q0-106 75-181t182-75q106 0 180.5 75T632-585q0 43-14 83t-42 75l264 262-44 44ZM377-389q81 0 138-57.5T572-585q0-81-57-138.5T377-781q-82 0-139.5 57.5T180-585q0 81 57.5 138.5T377-389ZM275-556v-60h201v60H275Z" fill="currentColor" />
|
|
59
|
+
</svg>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Material Symbols "add". */
|
|
64
|
+
export function AddIcon() {
|
|
65
|
+
return (
|
|
66
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
67
|
+
<path d="M450-450H200v-60h250v-250h60v250h250v60H510v250h-60v-250Z" fill="currentColor" />
|
|
68
|
+
</svg>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Material Symbols "delete". */
|
|
73
|
+
export function DeleteIcon() {
|
|
74
|
+
return (
|
|
75
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
76
|
+
<path d="M261-120q-24.75 0-42.37-17.63Q201-155.25 201-180v-570h-41v-60h188v-30h264v30h188v60h-41v570q0 24-18 42t-42 18H261Zm438-630H261v570h438v-570ZM367-266h60v-399h-60v399Zm166 0h60v-399h-60v399ZM261-750v570-570Z" fill="currentColor" />
|
|
77
|
+
</svg>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Material Symbols "visibility". */
|
|
82
|
+
export function VisibilityIcon() {
|
|
83
|
+
return (
|
|
84
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
85
|
+
<path d="M600.5-379.5Q650-429 650-500t-49.5-120.5Q551-670 480-670t-120.5 49.5Q310-571 310-500t49.5 120.5Q409-330 480-330t120.5-49.5Zm-200-41Q368-453 368-500t32.5-79.5Q433-612 480-612t79.5 32.5Q592-547 592-500t-32.5 79.5Q527-388 480-388t-79.5-32.5ZM216-283Q98-366 40-500q58-134 176-217t264-83q146 0 264 83t176 217q-58 134-176 217t-264 83q-146 0-264-83Zm264-217Zm222.5 174.5Q804-391 857-500q-53-109-154.5-174.5T480-740q-121 0-222.5 65.5T102-500q54 109 155.5 174.5T480-260q121 0 222.5-65.5Z" fill="currentColor" />
|
|
86
|
+
</svg>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Material Symbols "visibility_off". */
|
|
91
|
+
export function VisibilityOffIcon() {
|
|
92
|
+
return (
|
|
93
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
94
|
+
<path d="m629-419-44-44q26-71-27-118t-115-24l-44-44q17-11 38-16t43-5q71 0 120.5 49.5T650-500q0 22-5.5 43.5T629-419Zm129 129-40-40q49-36 85.5-80.5T857-500q-50-111-150-175.5T490-740q-42 0-86 8t-69 19l-46-47q35-16 89.5-28T485-800q143 0 261.5 81.5T920-500q-26 64-67 117t-95 93Zm58 226L648-229q-35 14-79 21.5t-89 7.5q-146 0-265-81.5T40-500q20-52 55.5-101.5T182-696L56-822l42-43 757 757-39 44ZM223-654q-37 27-71.5 71T102-500q51 111 153.5 175.5T488-260q33 0 65-4t48-12l-64-64q-11 5-27 7.5t-30 2.5q-70 0-120-49t-50-121q0-15 2.5-30t7.5-27l-97-97Zm305 142Zm-116 58Z" fill="currentColor" />
|
|
95
|
+
</svg>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Material Symbols "check". */
|
|
100
|
+
export function CheckSymbolIcon() {
|
|
101
|
+
return (
|
|
102
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
103
|
+
<path d="M378-246 154-470l43-43 181 181 384-384 43 43-427 427Z" fill="currentColor" />
|
|
104
|
+
</svg>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Material Symbols "close". */
|
|
109
|
+
export function CloseSymbolIcon() {
|
|
110
|
+
return (
|
|
111
|
+
<svg viewBox="0 -960 960 960" aria-hidden="true">
|
|
112
|
+
<path d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" fill="currentColor" />
|
|
113
|
+
</svg>
|
|
114
|
+
);
|
|
115
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ export * from "./primitives/index.js";
|
|
|
2
2
|
export * from "./Composer.js";
|
|
3
3
|
export * from "./MediaAnalyzer.js";
|
|
4
4
|
export * from "./ResultCard.js";
|
|
5
|
+
export * from "./EditCard.js";
|
|
5
6
|
export * from "./useComposer.js";
|
|
6
7
|
export * from "./ChaiProvider.js";
|
|
7
8
|
export * from "./useMediaAnalyzer.js";
|
|
@@ -13,6 +14,10 @@ export {
|
|
|
13
14
|
createOpenRouterEngine,
|
|
14
15
|
createMockEngine,
|
|
15
16
|
mockEngine,
|
|
17
|
+
buildRegionEditPrompt,
|
|
18
|
+
DEFAULT_MAX_REGIONS,
|
|
19
|
+
PRECISE_EDIT_MODELS,
|
|
20
|
+
ROUTING_MODEL_ID,
|
|
16
21
|
GenerationError,
|
|
17
22
|
DEFAULT_MEDIA_ANALYSIS_PROMPT_LENGTH,
|
|
18
23
|
MEDIA_ANALYSIS_PROMPTS,
|
|
@@ -20,6 +25,7 @@ export {
|
|
|
20
25
|
} from "@chai-ui/core";
|
|
21
26
|
export type {
|
|
22
27
|
DroppedMedia,
|
|
28
|
+
EditRegion,
|
|
23
29
|
Evaluation,
|
|
24
30
|
GenerationEngine,
|
|
25
31
|
GenerationUseCase,
|
|
@@ -28,6 +34,8 @@ export type {
|
|
|
28
34
|
MediaKind,
|
|
29
35
|
ModelOption,
|
|
30
36
|
ParameterOption,
|
|
37
|
+
RegionBox,
|
|
38
|
+
RegionFormat,
|
|
31
39
|
Request,
|
|
32
40
|
Result,
|
|
33
41
|
ResultStatus,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Download and share for a piece of generated media, shared by the cards.
|
|
3
|
+
*
|
|
4
|
+
* A plain `<a download href={remoteUrl}>` only forces a download for a
|
|
5
|
+
* same-origin or `blob:`/`data:` URL; for a cross-origin CDN URL (what a
|
|
6
|
+
* generated result usually is) browsers ignore `download` and open it
|
|
7
|
+
* instead. Fetching it into a blob first works wherever the source allows
|
|
8
|
+
* cross-origin fetches. When it doesn't, opening the media is the fallback.
|
|
9
|
+
*/
|
|
10
|
+
export async function downloadMedia(src: string, filename: string): Promise<void> {
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetch(src);
|
|
13
|
+
const blob = await response.blob();
|
|
14
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
15
|
+
const a = document.createElement("a");
|
|
16
|
+
a.href = blobUrl;
|
|
17
|
+
a.download = filename;
|
|
18
|
+
document.body.appendChild(a);
|
|
19
|
+
a.click();
|
|
20
|
+
a.remove();
|
|
21
|
+
URL.revokeObjectURL(blobUrl);
|
|
22
|
+
} catch {
|
|
23
|
+
window.open(src, "_blank", "noopener");
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The native share sheet where there is one, otherwise the media's URL copied to the clipboard. */
|
|
28
|
+
export async function shareMedia(src: string): Promise<void> {
|
|
29
|
+
if (navigator.share) {
|
|
30
|
+
try {
|
|
31
|
+
await navigator.share({ url: src });
|
|
32
|
+
} catch {
|
|
33
|
+
// Dismissed, or not shareable: same as closing a native share sheet.
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
navigator.clipboard?.writeText(src).catch(() => {});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A file name for a download: the URL's own extension when it has one, otherwise a guess by kind. */
|
|
41
|
+
export function downloadFilename(id: string, kind: string, src: string): string {
|
|
42
|
+
const match = /\.([a-zA-Z0-9]{2,5})(?:[?#]|$)/.exec(src);
|
|
43
|
+
const ext = match?.[1] ?? (kind === "video" ? "mp4" : kind === "audio" ? "mp3" : kind === "text" ? "txt" : "png");
|
|
44
|
+
return `${kind}-${id}.${ext}`;
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
interface Slide {
|
|
4
|
+
/** The image paged away from, shown sliding out. Absent when it wasn't an image. */
|
|
5
|
+
from?: string;
|
|
6
|
+
forward: boolean;
|
|
7
|
+
key: number;
|
|
8
|
+
active: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The slide between pages of a card: the new page comes in from the side it
|
|
13
|
+
* was paged from while the last one leaves the other way. `id` is the page
|
|
14
|
+
* showing and `image` its image, if it has one (what slides out next time).
|
|
15
|
+
*/
|
|
16
|
+
export function usePageSlide(id: string | undefined, index: number, image: string | undefined) {
|
|
17
|
+
const [shown, setShown] = useState({ id, index, image });
|
|
18
|
+
const [slide, setSlide] = useState<Slide | null>(null);
|
|
19
|
+
// Tracked while rendering, not in an effect (the rules of React).
|
|
20
|
+
if (id !== shown.id) {
|
|
21
|
+
setShown({ id, index, image });
|
|
22
|
+
// No slide for someone who has asked their system for less motion.
|
|
23
|
+
const reduceMotion =
|
|
24
|
+
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
25
|
+
if (shown.id !== undefined && id !== undefined && !reduceMotion) {
|
|
26
|
+
setSlide({ from: shown.image, forward: index > shown.index, key: (slide?.key ?? 0) + 1, active: true });
|
|
27
|
+
}
|
|
28
|
+
} else if (image !== shown.image) {
|
|
29
|
+
// The same page getting its image (a result arriving) doesn't slide.
|
|
30
|
+
setShown({ ...shown, image });
|
|
31
|
+
}
|
|
32
|
+
return { slide, endSlide: () => setSlide((s) => s && { ...s, active: false, from: undefined }) };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Renders a page with its slide. The outgoing image stays in the layout
|
|
37
|
+
* while it slides away, so the card holds its height even while the new
|
|
38
|
+
* image is still loading, and the new page slides in over that space. The
|
|
39
|
+
* page keeps its key once the slide ends, so it isn't remounted (no
|
|
40
|
+
* reloaded image, no restarted video).
|
|
41
|
+
*/
|
|
42
|
+
export function PageSlide({
|
|
43
|
+
slide,
|
|
44
|
+
onEnd,
|
|
45
|
+
className,
|
|
46
|
+
children,
|
|
47
|
+
}: {
|
|
48
|
+
slide: Slide | null;
|
|
49
|
+
onEnd: () => void;
|
|
50
|
+
className?: string;
|
|
51
|
+
children: ReactNode;
|
|
52
|
+
}) {
|
|
53
|
+
const side = slide?.forward ? "left" : "right";
|
|
54
|
+
const outgoing = slide?.active && slide.from;
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
{outgoing && (
|
|
58
|
+
<img
|
|
59
|
+
key={`out-${slide.key}`}
|
|
60
|
+
className={`chai-slide-out chai-slide-out--${side}`}
|
|
61
|
+
src={slide.from}
|
|
62
|
+
alt=""
|
|
63
|
+
aria-hidden="true"
|
|
64
|
+
draggable={false}
|
|
65
|
+
/>
|
|
66
|
+
)}
|
|
67
|
+
<div
|
|
68
|
+
key={`in-${slide?.key ?? 0}`}
|
|
69
|
+
className={
|
|
70
|
+
[className, slide?.active && `chai-slide-in chai-slide-in--${side}`, outgoing && "chai-slide-in--over"]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(" ") || undefined
|
|
73
|
+
}
|
|
74
|
+
onAnimationEnd={(e) => {
|
|
75
|
+
if (e.target === e.currentTarget) onEnd();
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
{children}
|
|
79
|
+
</div>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -6,6 +6,8 @@ interface PaginationProps {
|
|
|
6
6
|
onChange: (index: number) => void;
|
|
7
7
|
/** Accessible name for the nav landmark, e.g. "Results". */
|
|
8
8
|
label: string;
|
|
9
|
+
/** What one page is, for each dot's name ("Result 2 of 3"). Defaults to "Result". */
|
|
10
|
+
itemLabel?: string;
|
|
9
11
|
disabled?: boolean;
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -17,7 +19,7 @@ interface PaginationProps {
|
|
|
17
19
|
* any one component, so every paginated surface renders the same pager
|
|
18
20
|
* rather than each growing its own.
|
|
19
21
|
*/
|
|
20
|
-
export function Pagination({ count, index, onChange, label, disabled }: PaginationProps) {
|
|
22
|
+
export function Pagination({ count, index, onChange, label, itemLabel = "Result", disabled }: PaginationProps) {
|
|
21
23
|
return (
|
|
22
24
|
<nav
|
|
23
25
|
className="chai-pagination"
|
|
@@ -31,7 +33,7 @@ export function Pagination({ count, index, onChange, label, disabled }: Paginati
|
|
|
31
33
|
type="button"
|
|
32
34
|
className={`chai-pagination__dot${i <= index ? " chai-pagination__dot--filled" : ""}`}
|
|
33
35
|
aria-current={i === index}
|
|
34
|
-
aria-label={
|
|
36
|
+
aria-label={`${itemLabel} ${i + 1} of ${count}`}
|
|
35
37
|
disabled={disabled}
|
|
36
38
|
onClick={() => onChange(i)}
|
|
37
39
|
/>
|
package/src/regions.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** How many region colors the tokens define (`--chai-color-semantic-region-1` to `-6`). */
|
|
2
|
+
const REGION_COLOR_COUNT = 6;
|
|
3
|
+
|
|
4
|
+
/** The color token for a region number. Numbers past the last color start over at the first. */
|
|
5
|
+
export function regionColor(number: number): string {
|
|
6
|
+
return `var(--chai-color-semantic-region-${((Math.max(1, number) - 1) % REGION_COLOR_COUNT) + 1})`;
|
|
7
|
+
}
|