@streamoid/ui 0.6.17 → 0.6.19
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/README.md +35 -18
- package/dist/docs/AGENTS.md +325 -0
- package/dist/docs/CreditWarningBanner.md +305 -0
- package/dist/docs/InvoiceHistoryMobile.md +222 -0
- package/dist/docs/ScAccess.md +259 -0
- package/dist/docs/ScAppCard.md +244 -0
- package/dist/docs/ScAppCardForCopilot.md +230 -0
- package/dist/docs/ScAppCardV3.md +273 -0
- package/dist/docs/ScAppField.md +308 -0
- package/dist/docs/ScAppListingCard.md +271 -0
- package/dist/docs/ScAppSwitchPanel.md +286 -0
- package/dist/docs/ScAppcardLogos.md +226 -0
- package/dist/docs/ScArtifaxInvite.md +262 -0
- package/dist/docs/ScArtifaxSidebar.md +330 -0
- package/dist/docs/ScAskAgentButton.md +307 -0
- package/dist/docs/ScBadges.md +261 -0
- package/dist/docs/ScBeacon.md +244 -0
- package/dist/docs/ScBillingHistoryHeader.md +210 -0
- package/dist/docs/ScBillingHistoryTableList.md +243 -0
- package/dist/docs/ScBillingLogsTableHeader.md +212 -0
- package/dist/docs/ScBillingLogsTableList.md +251 -0
- package/dist/docs/ScBriefCard.md +255 -0
- package/dist/docs/ScButton.md +251 -0
- package/dist/docs/ScCalendar.md +268 -0
- package/dist/docs/ScCalendarDateComps.md +264 -0
- package/dist/docs/ScCatalogixInvite.md +345 -0
- package/dist/docs/ScCatalogixSidebar.md +337 -0
- package/dist/docs/ScCatalogixStoreHeader.md +246 -0
- package/dist/docs/ScCatalogixStoreTableList.md +316 -0
- package/dist/docs/ScCheckField.md +233 -0
- package/dist/docs/ScCheckbox.md +272 -0
- package/dist/docs/ScCounter.md +235 -0
- package/dist/docs/ScCreditsUsageCard.md +247 -0
- package/dist/docs/ScCreditsUsageCardMobile.md +224 -0
- package/dist/docs/ScDefaultCard.md +269 -0
- package/dist/docs/ScDp.md +245 -0
- package/dist/docs/ScDrawer.md +318 -0
- package/dist/docs/ScFieldButton.md +255 -0
- package/dist/docs/ScFileField.md +268 -0
- package/dist/docs/ScGoogleSignIn.md +250 -0
- package/dist/docs/ScGuide.md +278 -0
- package/dist/docs/ScHDivider.md +210 -0
- package/dist/docs/ScHeader.md +222 -0
- package/dist/docs/ScImageField.md +253 -0
- package/dist/docs/ScInChatList.md +277 -0
- package/dist/docs/ScInChatMessage.md +205 -0
- package/dist/docs/ScInfoPopup.md +248 -0
- package/dist/docs/ScIntialProfileCover.md +233 -0
- package/dist/docs/ScInvoiceHistoryMobile.md +187 -0
- package/dist/docs/ScLogoUnit.md +232 -0
- package/dist/docs/ScMappingCard.md +241 -0
- package/dist/docs/ScMediaApproval.md +301 -0
- package/dist/docs/ScMediaSelect.md +310 -0
- package/dist/docs/ScMenuOptions.md +308 -0
- package/dist/docs/ScMobileBottomAction.md +252 -0
- package/dist/docs/ScMobileTopNav.md +279 -0
- package/dist/docs/ScModal.md +291 -0
- package/dist/docs/ScOnlyField.md +302 -0
- package/dist/docs/ScOnlyIcon.md +213 -0
- package/dist/docs/ScPagination.md +284 -0
- package/dist/docs/ScPairtext.md +287 -0
- package/dist/docs/ScPendingAction.md +238 -0
- package/dist/docs/ScPhtogenixInvite.md +275 -0
- package/dist/docs/ScPlanCard.md +302 -0
- package/dist/docs/ScPlanComparison.md +264 -0
- package/dist/docs/ScPlanDetailsCard.md +246 -0
- package/dist/docs/ScPlanDetailsCardMobile.md +240 -0
- package/dist/docs/ScPopUpMenu.md +224 -0
- package/dist/docs/ScProfile.md +234 -0
- package/dist/docs/ScProfileImageUpdate.md +261 -0
- package/dist/docs/ScProfileOptions.md +245 -0
- package/dist/docs/ScProfilePopup.md +396 -0
- package/dist/docs/ScProfileSettingsComp.md +250 -0
- package/dist/docs/ScProfileV2Mobile.md +216 -0
- package/dist/docs/ScProgressBar.md +267 -0
- package/dist/docs/ScQuickPrompt.md +277 -0
- package/dist/docs/ScRadio.md +228 -0
- package/dist/docs/ScReferralCardMobile.md +226 -0
- package/dist/docs/ScReferralTableHeader.md +260 -0
- package/dist/docs/ScReferralTableList.md +293 -0
- package/dist/docs/ScRole.md +226 -0
- package/dist/docs/ScRoleMobile.md +199 -0
- package/dist/docs/ScSelect.md +270 -0
- package/dist/docs/ScSelection.md +256 -0
- package/dist/docs/ScSelectionList.md +272 -0
- package/dist/docs/ScSelectionPill.md +240 -0
- package/dist/docs/ScSelectionPillGroup.md +302 -0
- package/dist/docs/ScSettingsNav.md +212 -0
- package/dist/docs/ScSettingsTabComp.md +260 -0
- package/dist/docs/ScSideBarLogoUnit.md +340 -0
- package/dist/docs/ScSidebar.md +243 -0
- package/dist/docs/ScSidebarIcons.md +232 -0
- package/dist/docs/ScSidebarMenu.md +283 -0
- package/dist/docs/ScSidebarProfile.md +231 -0
- package/dist/docs/ScSidebarSwitchMenu.md +258 -0
- package/dist/docs/ScSlider.md +194 -0
- package/dist/docs/ScStoreCard.md +252 -0
- package/dist/docs/ScStrLogo.md +253 -0
- package/dist/docs/ScStreamoidWordmark.md +302 -0
- package/dist/docs/ScSubAgent.md +226 -0
- package/dist/docs/ScTabComp.md +308 -0
- package/dist/docs/ScTabField.md +258 -0
- package/dist/docs/ScTabSwitcher.md +307 -0
- package/dist/docs/ScTableHeader.md +261 -0
- package/dist/docs/ScTableList.md +301 -0
- package/dist/docs/ScTableListMobile.md +282 -0
- package/dist/docs/ScTabs.md +268 -0
- package/dist/docs/ScTaxonomyPill.md +263 -0
- package/dist/docs/ScTextArea.md +259 -0
- package/dist/docs/ScTextField.md +324 -0
- package/dist/docs/ScThinkingStepIcon.md +249 -0
- package/dist/docs/ScTodoList.md +288 -0
- package/dist/docs/ScToggleSwitch.md +229 -0
- package/dist/docs/ScUsageHistoryMobile.md +194 -0
- package/dist/docs/ScVDivider.md +215 -0
- package/dist/docs/ScValueMappingL1.md +256 -0
- package/dist/docs/ScVersion.md +251 -0
- package/dist/docs/ScWorkspace.md +233 -0
- package/dist/docs/ScWorkspaceAccountMenu.md +115 -0
- package/dist/docs/ScWorkspaceCard.md +234 -0
- package/dist/docs/ScWorkspaceSettingsMobile.md +265 -0
- package/dist/docs/ScWorkspaceSwitchCard.md +312 -0
- package/dist/docs/ScWorkspaceSwitchMobile.md +241 -0
- package/dist/docs/ScWorkspaceSwitchMobileV2.md +278 -0
- package/dist/docs/StreamoidSidebar.md +413 -0
- package/dist/docs/StreamoidWorkspaceSwitcher.md +307 -0
- package/dist/docs/UsageHistoryMobile.md +235 -0
- package/dist/docs/components.json +4931 -0
- package/dist/index.css +361 -36
- package/dist/index.d.mts +213 -88
- package/dist/index.d.ts +213 -88
- package/dist/index.js +2486 -1629
- package/dist/index.mjs +2487 -1620
- package/package.json +5 -3
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
---
|
|
2
|
+
component: ScMediaApproval
|
|
3
|
+
package: "@streamoid/ui"
|
|
4
|
+
category: chat-agent
|
|
5
|
+
status: stable
|
|
6
|
+
renders: div
|
|
7
|
+
tags: [chat, agent, media, image, video, carousel, approval, feedback, review, dynamicform]
|
|
8
|
+
related: [ScMediaSelect, ScSelectionList, ScTextArea, ScImageField, ScFileField]
|
|
9
|
+
do_not_confuse_with: [ScMediaSelect, ScSelectionList, ScImageField, ScFileField, ScTodoList]
|
|
10
|
+
used_by: [agent]
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ScMediaApproval
|
|
14
|
+
|
|
15
|
+
**A one-up media carousel with a feedback box.** A 208px-tall letterboxed frame showing
|
|
16
|
+
one image or video at a time (prev/next chevrons, an `n / m` counter badge and dot
|
|
17
|
+
pagination when there is more than one), and beneath it a labelled textarea for
|
|
18
|
+
"What should be changed?".
|
|
19
|
+
|
|
20
|
+
## TL;DR for agents
|
|
21
|
+
|
|
22
|
+
- **Reach for it when:** the agent generated media and the user must review it one item
|
|
23
|
+
at a time and optionally say what to change.
|
|
24
|
+
- **Don't reach for it when:** the user must **pick** several items from a set
|
|
25
|
+
(→ `ScMediaSelect`), the decision is a single accept/decline on a text suggestion
|
|
26
|
+
(→ `ScSelectionList`), or the user is **uploading** media
|
|
27
|
+
(→ `ScImageField` / `ScFileField`).
|
|
28
|
+
- **Four things that will bite you:**
|
|
29
|
+
1. Despite the name there are **no approve/reject buttons**. It is a viewer plus a
|
|
30
|
+
textarea; the decision buttons are yours.
|
|
31
|
+
2. `feedbackValue` is a **controlled** textarea. Without `onFeedbackChange` the user
|
|
32
|
+
cannot type.
|
|
33
|
+
3. On a video, `controls` is on **and** clicking calls `onPreview` — pressing play can
|
|
34
|
+
also open your lightbox.
|
|
35
|
+
4. Without `mediaType`, video detection is a **filename-extension regex**. Extensionless
|
|
36
|
+
CDN URLs render as `<img>` and show nothing.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 1. How to use it
|
|
41
|
+
|
|
42
|
+
### Import
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { ScMediaApproval } from "@streamoid/ui";
|
|
46
|
+
import "@streamoid/ui/dist/index.css"; // once, at your app root
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Minimal usage
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<ScMediaApproval
|
|
53
|
+
mediaUrls={urls}
|
|
54
|
+
mediaType="image"
|
|
55
|
+
feedbackValue={feedback}
|
|
56
|
+
onFeedbackChange={setFeedback}
|
|
57
|
+
/>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Props
|
|
61
|
+
|
|
62
|
+
| Prop | Type | Default | Notes |
|
|
63
|
+
|---|---|---|---|
|
|
64
|
+
| `mediaUrls` | `string[]` | `[]` | Empty ⇒ a 208px black placeholder block (not nothing). |
|
|
65
|
+
| `mediaType` | `"image"` \| `"video"` \| `"mixed"` | – (`undefined`) | `"video"` ⇒ every item is a `<video>`; `"image"` ⇒ every item is an `<img>`; `"mixed"` **or unset** ⇒ per-URL extension sniffing (`.mp4|.webm|.ogg|.mov|.m4v`, query string tolerated). |
|
|
66
|
+
| `activeIndex` | `number` | – | Pushes the internal index via `useEffect`. Semi-controlled — see Gotcha 6. |
|
|
67
|
+
| `onIndexChange` | `(i: number) => void` | – | Fires on every prev/next/dot click, with the **wrapped, clamped** index. |
|
|
68
|
+
| `onPreview` | `(url: string) => void` | – | Fires when the media element itself is clicked. Wire it to your lightbox. |
|
|
69
|
+
| `loading` | `boolean` | – | Forces the dark scrim + spinner overlay. ORed with the component's own load state. |
|
|
70
|
+
| `feedbackLabel` | `string` | `"What should be changed?"` | ⚠️ Real default, hardcoded English. Always rendered — there is no way to hide the feedback block. |
|
|
71
|
+
| `feedbackValue` | `string` | – | Controlled value (`feedbackValue ?? ""`). |
|
|
72
|
+
| `onFeedbackChange` | `(v: string) => void` | – | Required for the textarea to be usable at all. |
|
|
73
|
+
| `feedbackPlaceholder` | `string` | – | No default ⇒ no placeholder. |
|
|
74
|
+
| `className` | `string` | – | Appended after the internal class. |
|
|
75
|
+
|
|
76
|
+
⚠️ `IScMediaApprovalProps` does **not** extend `HTMLAttributes` — no `style`, `id`,
|
|
77
|
+
`data-*` or root `onClick`. Wrap it.
|
|
78
|
+
|
|
79
|
+
### What renders when
|
|
80
|
+
|
|
81
|
+
| Region | Condition |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `<img>` / `<video>` | `mediaUrls.length > 0` |
|
|
84
|
+
| Black placeholder | `mediaUrls.length === 0` |
|
|
85
|
+
| Spinner overlay | `loading` **or** the internal load state (set on navigation, cleared on `load`/`loadeddata`) |
|
|
86
|
+
| Prev/next chevrons, `n / m` badge, dots | `mediaUrls.length > 1` only |
|
|
87
|
+
| Feedback label + textarea | **always** |
|
|
88
|
+
|
|
89
|
+
### Recipes
|
|
90
|
+
|
|
91
|
+
```tsx
|
|
92
|
+
// The canonical DynamicForm wiring: viewer + feedback; the form supplies the buttons
|
|
93
|
+
<ScMediaApproval
|
|
94
|
+
mediaUrls={mediaUrls}
|
|
95
|
+
mediaType={field.media_type ?? "mixed"}
|
|
96
|
+
onPreview={(url) => setMediaPreview({ url, type: resolveMediaType(url) })}
|
|
97
|
+
feedbackLabel={field.feedback_label ?? "What should be changed?"}
|
|
98
|
+
feedbackValue={value.feedback || ""}
|
|
99
|
+
onFeedbackChange={(v) => setValue({ ...value, feedback: v })}
|
|
100
|
+
feedbackPlaceholder="Describe what needs to be modified."
|
|
101
|
+
/>
|
|
102
|
+
{/* the decision lives outside the component */}
|
|
103
|
+
<div style={{ display: "flex", gap: 8 }}>
|
|
104
|
+
<ScButton text="Approve" variant="mono" size="md" onClick={approve} />
|
|
105
|
+
<ScButton text="Request changes" variant="outline" size="md" onClick={requestChanges} />
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
// Tracking the visible item (e.g. to approve only the one on screen)
|
|
109
|
+
const [index, setIndex] = useState(0);
|
|
110
|
+
<ScMediaApproval
|
|
111
|
+
mediaUrls={urls}
|
|
112
|
+
mediaType="image"
|
|
113
|
+
activeIndex={index}
|
|
114
|
+
onIndexChange={setIndex}
|
|
115
|
+
feedbackValue={feedback}
|
|
116
|
+
onFeedbackChange={setFeedback}
|
|
117
|
+
/>
|
|
118
|
+
|
|
119
|
+
// Media that is still being generated server-side
|
|
120
|
+
<ScMediaApproval mediaUrls={urls} loading={isGenerating} feedbackValue={fb} onFeedbackChange={setFb} />
|
|
121
|
+
|
|
122
|
+
// Extensionless signed URLs — you MUST declare the type
|
|
123
|
+
<ScMediaApproval mediaUrls={signedUrls} mediaType="video" feedbackValue={fb} onFeedbackChange={setFb} />
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## 2. Where to use it
|
|
129
|
+
|
|
130
|
+
- **The agent's in-chat DynamicForm**, `media_approval` field type — the only real
|
|
131
|
+
consumer. Live at `stream-agent packages/chat-components/src/DynamicForm.tsx`, which
|
|
132
|
+
pairs it with its own lightbox (`onPreview`) and its own submit row for the actual
|
|
133
|
+
approve/reject decision.
|
|
134
|
+
- **A generation-review step in the chat transcript**, under the `ScInChatList` steps
|
|
135
|
+
that produced the media.
|
|
136
|
+
- It is a **chat-surface** component: the root is `width: 100%` with a fixed 208px frame
|
|
137
|
+
and a black letterbox, tuned to sit inside a narrow panel next to `ScInChatMessage`
|
|
138
|
+
(600px), not to fill a full-page gallery.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 3. When to use it
|
|
143
|
+
|
|
144
|
+
### Use it when
|
|
145
|
+
|
|
146
|
+
- The user reviews **one item at a time** and the items are alternatives, not a set to
|
|
147
|
+
choose from.
|
|
148
|
+
- Free-text feedback ("make the background warmer") is part of the response.
|
|
149
|
+
- The media may be a mix of images and video.
|
|
150
|
+
|
|
151
|
+
### Don't use it — reach for this instead
|
|
152
|
+
|
|
153
|
+
| Situation | Use instead |
|
|
154
|
+
|---|---|
|
|
155
|
+
| Pick **several** items from a grid of thumbnails | `ScMediaSelect` |
|
|
156
|
+
| Accept/decline one **text** suggestion | `ScSelectionList` |
|
|
157
|
+
| The user **uploads** an image | `ScImageField` |
|
|
158
|
+
| The user uploads an arbitrary file | `ScFileField` |
|
|
159
|
+
| A standalone feedback textarea with no media | `ScTextArea` |
|
|
160
|
+
| An editable task list from the agent | `ScTodoList` |
|
|
161
|
+
| A full-page asset gallery with filters and bulk actions | build it — this is a chat panel component |
|
|
162
|
+
|
|
163
|
+
### Don't confuse with
|
|
164
|
+
|
|
165
|
+
| You may actually want | Not this |
|
|
166
|
+
|---|---|
|
|
167
|
+
| `ScMediaSelect` — a 4-up multi-select **grid** with select-all | This is a one-up carousel |
|
|
168
|
+
| A component that renders Approve/Reject | It renders neither; the name describes the *flow*, not the UI |
|
|
169
|
+
| `ScImageField` — an upload/preview **form field** | This never uploads; it only displays URLs you pass |
|
|
170
|
+
| `ScSelectionList` — ✓/✕ toggles | No toggles here |
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## 4. Why to use it
|
|
175
|
+
|
|
176
|
+
- **On-media controls that survive any image, in any theme.** The chevrons, counter and
|
|
177
|
+
dot pill deliberately use a fixed dark scrim + fixed white glyph + a subtle light
|
|
178
|
+
border and blur, rather than theme tokens, so they stay legible over a white product
|
|
179
|
+
shot, a black letterbox, and in light mode. This is the one place in the DS where
|
|
180
|
+
ignoring the tokens is correct — and it is already decided for you.
|
|
181
|
+
- **Letterboxing is handled.** `object-fit: contain` on a black background at a fixed
|
|
182
|
+
208px height means a portrait and a landscape render at the same frame size, so the
|
|
183
|
+
layout doesn't jump as the user navigates.
|
|
184
|
+
- **The spinner is self-managing.** Navigating sets the internal load state and `onLoad`
|
|
185
|
+
/ `onLoadedData` clears it, so slow CDN fetches show a scrim without you tracking
|
|
186
|
+
per-URL load state.
|
|
187
|
+
- **Index wrapping is already modulo-safe** (`((next % total) + total) % total`), and the
|
|
188
|
+
render index is clamped to `total - 1`, so a shrinking `mediaUrls` array can't produce
|
|
189
|
+
an out-of-range read.
|
|
190
|
+
- **The mixed-media branch is one prop.** Sniffing per URL vs forcing a type is a
|
|
191
|
+
one-line decision instead of a per-call-site helper.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## Gotchas
|
|
196
|
+
|
|
197
|
+
**1. There are no approve/reject buttons.** The component is a viewer + a feedback
|
|
198
|
+
textarea. Every consumer must supply the decision affordance itself.
|
|
199
|
+
|
|
200
|
+
**2. The textarea is inert without `onFeedbackChange`.** It is `value={feedbackValue ?? ""}`
|
|
201
|
+
with an `onChange` that no-ops when the handler is missing — so the user types and
|
|
202
|
+
nothing appears.
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
// WRONG — read-only textarea that looks editable
|
|
206
|
+
<ScMediaApproval mediaUrls={urls} feedbackValue={feedback} />
|
|
207
|
+
|
|
208
|
+
// RIGHT
|
|
209
|
+
<ScMediaApproval mediaUrls={urls} feedbackValue={feedback} onFeedbackChange={setFeedback} />
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**3. Video: play and preview collide.** The `<video>` has `controls` **and**
|
|
213
|
+
`onClick={handlePreview}`. A click on the play button also fires `onPreview`, so your
|
|
214
|
+
lightbox opens over the video the user just started. Either omit `onPreview` for video,
|
|
215
|
+
or have your lightbox continue playback.
|
|
216
|
+
|
|
217
|
+
**4. Extension sniffing fails on extensionless URLs.** With `mediaType` unset or
|
|
218
|
+
`"mixed"`, `isVideoUrl` tests `/\.(mp4|webm|ogg|mov|m4v)(\?.*)?$/i`. A signed URL like
|
|
219
|
+
`…/asset/9f3c1?sig=…` renders as `<img>` → broken image, no error. If your URLs have no
|
|
220
|
+
extension, pass `mediaType` explicitly.
|
|
221
|
+
|
|
222
|
+
**5. `cursor: zoom-in` is unconditional.** The media always looks clickable, even when
|
|
223
|
+
you didn't pass `onPreview`. There is no way to turn that off short of a `className`
|
|
224
|
+
override.
|
|
225
|
+
|
|
226
|
+
**6. `activeIndex` is a *push*, not a binding.** The `useEffect` only runs when
|
|
227
|
+
`activeIndex` changes; internal prev/next still moves the internal index. If you pass a
|
|
228
|
+
constant `activeIndex` and ignore `onIndexChange`, the user can navigate away and your
|
|
229
|
+
state will be wrong.
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
// WRONG — thinks it pinned the view to item 0
|
|
233
|
+
<ScMediaApproval mediaUrls={urls} activeIndex={0} />
|
|
234
|
+
|
|
235
|
+
// RIGHT — controlled round-trip
|
|
236
|
+
<ScMediaApproval mediaUrls={urls} activeIndex={index} onIndexChange={setIndex} />
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
**7. No spinner on first paint.** The internal load state starts `false` and is only set
|
|
240
|
+
to `true` on navigation. For the initial image you must pass `loading` yourself if you
|
|
241
|
+
want a scrim.
|
|
242
|
+
|
|
243
|
+
**8. Navigation wraps silently.** "Next" on the last item goes to the first. There is no
|
|
244
|
+
disabled end-state, so users can loop without noticing they've been round.
|
|
245
|
+
|
|
246
|
+
**9. `alt=""` is hardcoded.** Images are announced as decorative. If the media *is* the
|
|
247
|
+
content — which it is here — a screen-reader user gets nothing but the feedback label.
|
|
248
|
+
|
|
249
|
+
**10. The feedback block cannot be hidden.** No prop suppresses it; passing
|
|
250
|
+
`feedbackLabel=""` leaves an empty label div and the textarea. If you only want a
|
|
251
|
+
carousel, this is the wrong component.
|
|
252
|
+
|
|
253
|
+
**11. Fixed 13rem (208px) media height and hardcoded `#000` backgrounds.** Not
|
|
254
|
+
tokenised (deliberate, for letterboxing), and not overridable via props.
|
|
255
|
+
|
|
256
|
+
**12. The textarea's resize grip is clipped.** `.textArea` is `resize: vertical` but
|
|
257
|
+
`.inputContainer` is `overflow: hidden`, so the drag handle is cut off — users get a
|
|
258
|
+
`min-height: 4.5rem` box that scrolls rather than one they can obviously grow.
|
|
259
|
+
|
|
260
|
+
**13. No root-level props.** `IScMediaApprovalProps` doesn't extend `HTMLAttributes`, so
|
|
261
|
+
`style`/`id`/`data-testid` are type errors. Wrap it.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## In the wild
|
|
266
|
+
|
|
267
|
+
Rendered by the **agent runtime**, not by any of the four dashboards — the live call
|
|
268
|
+
site is the chat DynamicForm's `media_approval` field in the `stream-agent` repo
|
|
269
|
+
(`@streamoid/chat-components`):
|
|
270
|
+
|
|
271
|
+
```tsx
|
|
272
|
+
// stream-agent packages/chat-components/src/DynamicForm.tsx:871
|
|
273
|
+
<ScMediaApproval
|
|
274
|
+
mediaUrls={mediaUrls}
|
|
275
|
+
mediaType={field.media_type ?? "mixed"}
|
|
276
|
+
onPreview={(url) => setMediaPreview({ url, type: resolveMediaType(url) })}
|
|
277
|
+
feedbackLabel={field.feedback_label ?? "What should be changed?"}
|
|
278
|
+
feedbackValue={mediaValue.feedback || ""}
|
|
279
|
+
onFeedbackChange={(v) =>
|
|
280
|
+
handleFieldChange(field.id, {
|
|
281
|
+
...mediaValue,
|
|
282
|
+
feedback: v,
|
|
283
|
+
media_urls: normalizeMediaUrls(mediaValue.media_urls ?? mediaUrls),
|
|
284
|
+
media_type: mediaValue.media_type ?? field.media_type ?? "mixed",
|
|
285
|
+
} as MediaApprovalValue)
|
|
286
|
+
}
|
|
287
|
+
feedbackPlaceholder={
|
|
288
|
+
field.feedback_placeholder ?? "Describe what needs to be modified."
|
|
289
|
+
}
|
|
290
|
+
/>
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
---
|
|
294
|
+
|
|
295
|
+
## Related
|
|
296
|
+
|
|
297
|
+
- `ScMediaSelect` — the multi-select grid sibling; same media vocabulary, different job.
|
|
298
|
+
- `ScSelectionList` — accept/decline for text suggestions.
|
|
299
|
+
- `ScTextArea` — the standalone DS textarea if you need feedback without media.
|
|
300
|
+
- `ScImageField` / `ScFileField` — the upload-side fields.
|
|
301
|
+
- `ScButton` — what you'll use for the Approve / Request-changes pair this component omits.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
---
|
|
2
|
+
component: ScMediaSelect
|
|
3
|
+
package: "@streamoid/ui"
|
|
4
|
+
category: chat-agent
|
|
5
|
+
status: stable
|
|
6
|
+
renders: div
|
|
7
|
+
tags: [chat, agent, media, image, video, grid, multi-select, thumbnails, gallery, dynamicform]
|
|
8
|
+
related: [ScMediaApproval, ScCheckField, ScImageField, ScTodoList, ScSelectionList]
|
|
9
|
+
do_not_confuse_with: [ScMediaApproval, ScImageField, ScSelectionList, ScCheckField, ScStoreCard]
|
|
10
|
+
used_by: [agent]
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ScMediaSelect
|
|
14
|
+
|
|
15
|
+
**A 4-up thumbnail grid for picking several generated assets.** A count line
|
|
16
|
+
("3 of 12 selected (min 1)") with "Select all | Deselect all" above a bordered,
|
|
17
|
+
internally-scrolling square grid; selected tiles get a strong border and a white check
|
|
18
|
+
badge, and a hover-only maximize button opens your lightbox.
|
|
19
|
+
|
|
20
|
+
## TL;DR for agents
|
|
21
|
+
|
|
22
|
+
- **Reach for it when:** the agent produced a batch of images/videos and the user must
|
|
23
|
+
choose a subset.
|
|
24
|
+
- **Don't reach for it when:** the user reviews one item at a time with free-text
|
|
25
|
+
feedback (→ `ScMediaApproval`), the choice is textual (→ `ScCheckField` /
|
|
26
|
+
`ScSelectionList`), or the user is **uploading** (→ `ScImageField`).
|
|
27
|
+
- **Four things that will bite you:**
|
|
28
|
+
1. `min` and `max` are **labels only**. The component does not enforce them —
|
|
29
|
+
`onToggle` fires regardless.
|
|
30
|
+
2. Selection identity is the **URL string**. Duplicate URLs in `items` select and
|
|
31
|
+
deselect together.
|
|
32
|
+
3. `mediaType` defaults to `"image"`, and only the exact value `"video"` renders
|
|
33
|
+
`<video>`. **`"mixed"` renders `<img>` for everything**, including your `.mp4`s.
|
|
34
|
+
4. Tiles are `div[role="button"] tabIndex={0}` with **no key handler**, and the preview
|
|
35
|
+
button is `display: none` until `:hover` — focusable but not operable, and invisible
|
|
36
|
+
on touch.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 1. How to use it
|
|
41
|
+
|
|
42
|
+
### Import
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import { ScMediaSelect } from "@streamoid/ui";
|
|
46
|
+
import "@streamoid/ui/dist/index.css"; // once, at your app root
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Minimal usage
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
<ScMediaSelect items={urls} selected={picked} onToggle={toggle} />
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Props
|
|
56
|
+
|
|
57
|
+
| Prop | Type | Default | Notes |
|
|
58
|
+
|---|---|---|---|
|
|
59
|
+
| `items` | `string[]` | `[]` | Media URLs, in grid order. Empty ⇒ an empty bordered box, but the header and both link buttons still render. |
|
|
60
|
+
| `selected` | `string[]` | `[]` | Controlled. Compared by URL via a `Set` (memoised on the array identity — pass a new array, not a mutated one). |
|
|
61
|
+
| `onToggle` | `(url: string) => void` | – | Fires on any tile click. **You** add/remove and **you** enforce `max`. |
|
|
62
|
+
| `onSelectAll` | `() => void` | – | The "Select all" link. The component does not compute the selection for you. |
|
|
63
|
+
| `onDeselectAll` | `() => void` | – | The "Deselect all" link. |
|
|
64
|
+
| `min` | `number` | `0` | **Display only.** `> 0` appends `" (min N)"` to the count label. |
|
|
65
|
+
| `max` | `number` | – (⇒ `items.length`) | **Display only.** Appends `" (max N)"` **only when** the resolved max is less than `items.length`. |
|
|
66
|
+
| `mediaType` | `"image"` \| `"video"` \| `"mixed"` | `"image"` | ⚠️ Only `"video"` renders `<video muted playsInline>`. `"image"` **and** `"mixed"` both render `<img>`. |
|
|
67
|
+
| `onPreview` | `(url: string) => void` | – | **Gates the maximize button** — omit it and no preview affordance renders at all. |
|
|
68
|
+
| `className` | `string` | – | Appended after the internal class. |
|
|
69
|
+
|
|
70
|
+
⚠️ `IScMediaSelectProps` does **not** extend `HTMLAttributes` — no `style`, `id`,
|
|
71
|
+
`data-*` or root `onClick`. Wrap it.
|
|
72
|
+
|
|
73
|
+
### The count label, exactly
|
|
74
|
+
|
|
75
|
+
`` `${selected.size} of ${items.length} selected` `` then `" (min N)"` if `min > 0`,
|
|
76
|
+
then `" (max N)"` if `resolvedMax < items.length`. All hardcoded English.
|
|
77
|
+
|
|
78
|
+
### Recipes
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
// The canonical DynamicForm wiring — note that MAX IS ENFORCED HERE, not in the DS
|
|
82
|
+
const toggle = (url: string) => {
|
|
83
|
+
const next = new Set(selectedUrls);
|
|
84
|
+
if (next.has(url)) next.delete(url);
|
|
85
|
+
else {
|
|
86
|
+
if (next.size >= maxSelect) return; // <- the component will not do this
|
|
87
|
+
next.add(url);
|
|
88
|
+
}
|
|
89
|
+
setSelectedUrls(Array.from(next));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
<ScMediaSelect
|
|
93
|
+
items={allMediaUrls}
|
|
94
|
+
selected={selectedUrls}
|
|
95
|
+
min={minSelect}
|
|
96
|
+
max={maxSelect}
|
|
97
|
+
mediaType="image"
|
|
98
|
+
onToggle={toggle}
|
|
99
|
+
onSelectAll={() => setSelectedUrls(allMediaUrls.slice(0, maxSelect))}
|
|
100
|
+
onDeselectAll={() => setSelectedUrls([])}
|
|
101
|
+
onPreview={(url) => setLightbox(url)}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
// Video thumbnails — "mixed" would render <img> and show broken tiles
|
|
105
|
+
<ScMediaSelect items={clipUrls} selected={picked} mediaType="video" onToggle={toggle} />
|
|
106
|
+
|
|
107
|
+
// De-duplicate before rendering: selection is keyed on the URL
|
|
108
|
+
<ScMediaSelect items={Array.from(new Set(urls))} selected={picked} onToggle={toggle} />
|
|
109
|
+
|
|
110
|
+
// Gate a submit on the min the component only *displays*
|
|
111
|
+
<ScButton
|
|
112
|
+
text="Use selected"
|
|
113
|
+
variant="mono"
|
|
114
|
+
size="md"
|
|
115
|
+
state={picked.length >= minSelect ? "default" : "disabled"}
|
|
116
|
+
onClick={submit}
|
|
117
|
+
/>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## 2. Where to use it
|
|
123
|
+
|
|
124
|
+
- **The agent's in-chat DynamicForm**, `media_select` field type — the only real
|
|
125
|
+
consumer. Live at `stream-agent packages/chat-components/src/DynamicForm.tsx`, which
|
|
126
|
+
owns the max enforcement, the select-all slice and the lightbox.
|
|
127
|
+
- **A batch-review step in the chat transcript**, after a generation run — "here are 12,
|
|
128
|
+
which do you want?".
|
|
129
|
+
- Tuned for a **narrow chat panel**: 4 columns (3 below 360px), square tiles, and an
|
|
130
|
+
internal `max-height: 18.75rem` scroll region rather than growing the transcript.
|
|
131
|
+
|
|
132
|
+
---
|
|
133
|
+
|
|
134
|
+
## 3. When to use it
|
|
135
|
+
|
|
136
|
+
### Use it when
|
|
137
|
+
|
|
138
|
+
- The user picks **a subset** of a batch, and seeing all candidates at once matters.
|
|
139
|
+
- The tiles are square-croppable and the count is the feedback the user needs.
|
|
140
|
+
- The set is large enough that an internal scroll region is preferable to a growing panel.
|
|
141
|
+
|
|
142
|
+
### Don't use it — reach for this instead
|
|
143
|
+
|
|
144
|
+
| Situation | Use instead |
|
|
145
|
+
|---|---|
|
|
146
|
+
| Review one item at a time, with free-text feedback | `ScMediaApproval` |
|
|
147
|
+
| Accept/decline one **text** suggestion | `ScSelectionList` |
|
|
148
|
+
| Multi-select over **labels**, not media | `ScCheckField` (or `ScCheckbox` for one) |
|
|
149
|
+
| The user **uploads** an image | `ScImageField` |
|
|
150
|
+
| Pick exactly one media item | `ScMediaApproval` (carousel + your own confirm), or this with your own max=1 |
|
|
151
|
+
| An editable task list | `ScTodoList` |
|
|
152
|
+
| A full-page asset library with filters, pagination, bulk ops | build it — this is a chat panel component |
|
|
153
|
+
| Cards representing entities rather than media | `ScStoreCard` / `ScDefaultCard` |
|
|
154
|
+
|
|
155
|
+
### Don't confuse with
|
|
156
|
+
|
|
157
|
+
| You may actually want | Not this |
|
|
158
|
+
|---|---|
|
|
159
|
+
| `ScMediaApproval` — one-up carousel + feedback textarea | This is a multi-select grid with no feedback field |
|
|
160
|
+
| `ScImageField` — an upload/preview **form field** | This never uploads |
|
|
161
|
+
| `ScCheckField` — checkbox group with a field label | No labels here, only thumbnails |
|
|
162
|
+
| A component that enforces min/max | It only prints them |
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
## 4. Why to use it
|
|
167
|
+
|
|
168
|
+
- **The selected affordance is doubled.** A 2px `--alias-border-strong` frame *and* a
|
|
169
|
+
filled check badge, so selection survives greyscale and doesn't depend on a colour the
|
|
170
|
+
thumbnail might already contain.
|
|
171
|
+
- **The check badge is token-paired** (`--alias-fill-base-base` circle,
|
|
172
|
+
`--alias-text-and-icons-inverse` tick), so it inverts correctly in light mode instead
|
|
173
|
+
of becoming a white blob on a bright product shot.
|
|
174
|
+
- **The grid can't blow out the panel.** `max-height: 18.75rem` + `overflow-y: auto`
|
|
175
|
+
means a 200-item batch scrolls inside its own box rather than pushing the chat
|
|
176
|
+
transcript around, and the 4→3 column breakpoint at 360px keeps tiles tappable on a
|
|
177
|
+
phone.
|
|
178
|
+
- **Square-cropped tiles scan as a set.** `aspect-ratio: 1/1` + `object-fit: cover` on a
|
|
179
|
+
black background gives a uniform grid whatever the source aspect ratios are.
|
|
180
|
+
- **Hover-revealed preview keeps the tile clean** — the maximize button `stopPropagation`s
|
|
181
|
+
so previewing never toggles selection, which is the single most common bug when this is
|
|
182
|
+
hand-rolled.
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Gotchas
|
|
187
|
+
|
|
188
|
+
**1. `min`/`max` are not enforced.** They only decorate the count label. `onToggle` fires
|
|
189
|
+
on every click and the component will happily let the user select all 50 when `max={3}`.
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
// WRONG — believes max is a constraint
|
|
193
|
+
<ScMediaSelect items={urls} selected={picked} max={3} onToggle={(u) => setPicked([...picked, u])} />
|
|
194
|
+
|
|
195
|
+
// RIGHT — enforce it in your handler (see the first recipe)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
**2. `"mixed"` renders `<img>`, not a per-URL guess.** Unlike `ScMediaApproval` — which
|
|
199
|
+
sniffs the extension for `"mixed"` — this component branches on `mediaType === "video"`
|
|
200
|
+
only. Passing `field.media_type ?? "mixed"` for a mixed batch gives you broken `<img>`
|
|
201
|
+
tiles for the videos.
|
|
202
|
+
|
|
203
|
+
```tsx
|
|
204
|
+
// WRONG for a mixed batch — videos render as broken images
|
|
205
|
+
<ScMediaSelect items={urls} mediaType="mixed" selected={picked} onToggle={toggle} />
|
|
206
|
+
|
|
207
|
+
// RIGHT — split the batch, or render two grids
|
|
208
|
+
<ScMediaSelect items={images} mediaType="image" … />
|
|
209
|
+
<ScMediaSelect items={clips} mediaType="video" … />
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**3. Selection is keyed on the URL string.** Duplicate URLs in `items` are visually
|
|
213
|
+
distinct tiles that select and deselect **together**, and `onToggle` cannot tell them
|
|
214
|
+
apart. De-duplicate before rendering. (React keys use `url + "-" + index`, so keys are
|
|
215
|
+
fine — the selection model is what breaks.)
|
|
216
|
+
|
|
217
|
+
**4. `selected` is memoised on array identity.** The internal `Set` is
|
|
218
|
+
`useMemo(..., [selected])`. Mutating the same array in place (`picked.push(url)`) will
|
|
219
|
+
not refresh the highlight; always pass a new array.
|
|
220
|
+
|
|
221
|
+
**5. Tiles are keyboard-focusable but not keyboard-operable.** They are
|
|
222
|
+
`div[role="button"] tabIndex={0} aria-pressed` with **no `onKeyDown`**. Tab lands on
|
|
223
|
+
them, Enter and Space do nothing. There is no way to fix this from the outside — treat it
|
|
224
|
+
as a DS bug for accessible surfaces.
|
|
225
|
+
|
|
226
|
+
**6. The preview button is hover-only.** `.previewButton { display: none }` → `flex` on
|
|
227
|
+
`.item:hover`. There is no hover on touch, so preview is unreachable on mobile.
|
|
228
|
+
|
|
229
|
+
**7. No `onPreview` ⇒ no preview button at all.** The button is conditionally rendered on
|
|
230
|
+
the handler's presence, which is good — but means "the maximize icon is missing" is
|
|
231
|
+
usually a missing prop, not a CSS problem.
|
|
232
|
+
|
|
233
|
+
**8. "Select all" / "Deselect all" compute nothing.** They just call your handlers. If
|
|
234
|
+
you wire `onSelectAll` to `setSelected(items)` while `max` is smaller, you have silently
|
|
235
|
+
broken your own limit — slice it (`items.slice(0, max)`).
|
|
236
|
+
|
|
237
|
+
**9. The header renders even with zero items.** "0 of 0 selected" plus two live link
|
|
238
|
+
buttons over an empty box. Guard on `items.length` and render your own empty state.
|
|
239
|
+
|
|
240
|
+
**10. Thumbnails are cropped.** `object-fit: cover` on a square tile — a tall portrait
|
|
241
|
+
loses its top and bottom. If the composition matters for the choice, `ScMediaApproval`
|
|
242
|
+
(which uses `contain`) is the better fit.
|
|
243
|
+
|
|
244
|
+
**11. Video tiles have no poster and no controls.** `<video muted playsInline>` with no
|
|
245
|
+
`poster` shows a black frame until metadata loads and never plays inline. Provide poster
|
|
246
|
+
images and use `mediaType="image"` if the first frame matters.
|
|
247
|
+
|
|
248
|
+
**12. `alt=""` is hardcoded.** Tiles are announced as decorative; a screen-reader user
|
|
249
|
+
gets a run of unlabelled pressed/unpressed buttons.
|
|
250
|
+
|
|
251
|
+
**13. Hardcoded English.** `"N of M selected"`, `" (min N)"`, `" (max N)"`,
|
|
252
|
+
`"Select all"`, `"Deselect all"`, and the `"Preview"` aria-label. No label props.
|
|
253
|
+
|
|
254
|
+
**14. No root-level props.** `IScMediaSelectProps` doesn't extend `HTMLAttributes`, so
|
|
255
|
+
`style`/`id`/`data-testid` are type errors. Wrap it.
|
|
256
|
+
|
|
257
|
+
---
|
|
258
|
+
|
|
259
|
+
## In the wild
|
|
260
|
+
|
|
261
|
+
Rendered by the **agent runtime**, not by any of the four dashboards — the live call
|
|
262
|
+
site is the chat DynamicForm's `media_select` field in the `stream-agent` repo
|
|
263
|
+
(`@streamoid/chat-components`):
|
|
264
|
+
|
|
265
|
+
```tsx
|
|
266
|
+
// stream-agent packages/chat-components/src/DynamicForm.tsx:916
|
|
267
|
+
<ScMediaSelect
|
|
268
|
+
items={allMediaUrls}
|
|
269
|
+
selected={selectValue.selected_urls}
|
|
270
|
+
min={field.min_select ?? 0}
|
|
271
|
+
max={maxSelect}
|
|
272
|
+
mediaType={field.media_type ?? "mixed"}
|
|
273
|
+
onToggle={toggleSelect}
|
|
274
|
+
onSelectAll={() =>
|
|
275
|
+
handleFieldChange(field.id, {
|
|
276
|
+
...selectValue,
|
|
277
|
+
selected_urls: allMediaUrls.slice(0, maxSelect),
|
|
278
|
+
} as MediaSelectValue)
|
|
279
|
+
}
|
|
280
|
+
onDeselectAll={() =>
|
|
281
|
+
handleFieldChange(field.id, {
|
|
282
|
+
...selectValue,
|
|
283
|
+
selected_urls: [],
|
|
284
|
+
} as MediaSelectValue)
|
|
285
|
+
}
|
|
286
|
+
onPreview={(url) =>
|
|
287
|
+
setMediaPreview({
|
|
288
|
+
url,
|
|
289
|
+
type:
|
|
290
|
+
field.media_type && field.media_type !== "mixed"
|
|
291
|
+
? field.media_type
|
|
292
|
+
: inferMediaTypeFromUrl(url),
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
/>
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Note that this call site passes `mediaType="mixed"` — which, per Gotcha 2, renders
|
|
299
|
+
`<img>` for every item. That is a live bug worth fixing in the runtime (or by teaching
|
|
300
|
+
this component to sniff like `ScMediaApproval` does).
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Related
|
|
305
|
+
|
|
306
|
+
- `ScMediaApproval` — the one-up carousel sibling, with a feedback textarea and `contain` fit.
|
|
307
|
+
- `ScCheckField` / `ScCheckbox` — multi-select over labels rather than thumbnails.
|
|
308
|
+
- `ScSelectionList` — accept/decline one text suggestion.
|
|
309
|
+
- `ScImageField` — the upload-side field.
|
|
310
|
+
- `ScTodoList` — the other "user edits what the agent produced" in-chat component.
|