@flamingo-stack/openframe-frontend-core 0.0.445 → 0.0.446-snapshot.20260717214810
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/dist/{chunk-R6WGCJBB.cjs → chunk-2PNWAAU4.cjs} +73 -73
- package/dist/{chunk-R6WGCJBB.cjs.map → chunk-2PNWAAU4.cjs.map} +1 -1
- package/dist/{chunk-H352AZJN.js → chunk-3GBLPPSW.js} +6 -6
- package/dist/{chunk-TCEBFMLQ.js → chunk-4ONE3HQP.js} +2 -2
- package/dist/{chunk-K4RASNQM.js → chunk-5BJJRPDH.js} +3 -3
- package/dist/{chunk-H2DYCT6M.js → chunk-6TAIXSF5.js} +2 -2
- package/dist/{chunk-VIJARDBM.cjs → chunk-AN7MCJTV.cjs} +5 -3
- package/dist/chunk-AN7MCJTV.cjs.map +1 -0
- package/dist/{chunk-7R256DKK.js → chunk-D2NLNBLH.js} +2 -2
- package/dist/{chunk-N64VTHZ5.cjs → chunk-E6HKLQ3Q.cjs} +7 -7
- package/dist/{chunk-N64VTHZ5.cjs.map → chunk-E6HKLQ3Q.cjs.map} +1 -1
- package/dist/{chunk-DTLNCOSG.cjs → chunk-EZMM2OMP.cjs} +32 -32
- package/dist/{chunk-DTLNCOSG.cjs.map → chunk-EZMM2OMP.cjs.map} +1 -1
- package/dist/{chunk-5FUQTSTT.cjs → chunk-FR6NVZVM.cjs} +18 -18
- package/dist/{chunk-5FUQTSTT.cjs.map → chunk-FR6NVZVM.cjs.map} +1 -1
- package/dist/{chunk-Z4N7XLFG.js → chunk-G7QSLGMI.js} +3 -3
- package/dist/{chunk-5DWPXK37.cjs → chunk-ITJMUH4Q.cjs} +40 -40
- package/dist/{chunk-5DWPXK37.cjs.map → chunk-ITJMUH4Q.cjs.map} +1 -1
- package/dist/{chunk-P3FN4NAE.cjs → chunk-KYLOPUIH.cjs} +29 -29
- package/dist/{chunk-P3FN4NAE.cjs.map → chunk-KYLOPUIH.cjs.map} +1 -1
- package/dist/{chunk-YCLSV2H6.cjs → chunk-PF6CKZIH.cjs} +5 -5
- package/dist/{chunk-YCLSV2H6.cjs.map → chunk-PF6CKZIH.cjs.map} +1 -1
- package/dist/{chunk-QL2EI56X.cjs → chunk-RNXMP7GB.cjs} +1014 -988
- package/dist/chunk-RNXMP7GB.cjs.map +1 -0
- package/dist/{chunk-PTT4J5GF.cjs → chunk-ROCJMSJI.cjs} +3 -3
- package/dist/{chunk-PTT4J5GF.cjs.map → chunk-ROCJMSJI.cjs.map} +1 -1
- package/dist/{chunk-RXLMN6B5.cjs → chunk-S7VMUDUB.cjs} +12 -12
- package/dist/{chunk-RXLMN6B5.cjs.map → chunk-S7VMUDUB.cjs.map} +1 -1
- package/dist/{chunk-22NSWMI6.js → chunk-SHC5NFOH.js} +3 -3
- package/dist/{chunk-5NYXGWBN.js → chunk-TKGCTDQY.js} +4 -2
- package/dist/{chunk-5NYXGWBN.js.map → chunk-TKGCTDQY.js.map} +1 -1
- package/dist/{chunk-3DQ5BFPM.js → chunk-TMZYFYSS.js} +5 -5
- package/dist/{chunk-KISQUH33.cjs → chunk-WLU37UZO.cjs} +73 -73
- package/dist/{chunk-KISQUH33.cjs.map → chunk-WLU37UZO.cjs.map} +1 -1
- package/dist/{chunk-FIAXFNAA.js → chunk-WMOF3WQT.js} +5 -5
- package/dist/{chunk-MPSA6YAM.cjs → chunk-X4XHW3OV.cjs} +40 -40
- package/dist/{chunk-MPSA6YAM.cjs.map → chunk-X4XHW3OV.cjs.map} +1 -1
- package/dist/{chunk-3HYSD7NR.cjs → chunk-XUS75SQW.cjs} +15 -15
- package/dist/{chunk-3HYSD7NR.cjs.map → chunk-XUS75SQW.cjs.map} +1 -1
- package/dist/{chunk-3BHV2QAH.js → chunk-YCSNDUDE.js} +510 -484
- package/dist/chunk-YCSNDUDE.js.map +1 -0
- package/dist/{chunk-G2KXNMFQ.js → chunk-YHNBJ3F3.js} +2 -2
- package/dist/{chunk-MQUS6BFR.js → chunk-ZQVEB6KT.js} +6 -6
- package/dist/{chunk-XPZLIONZ.js → chunk-ZYB3BYBA.js} +4 -4
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/highlight-video-section.d.ts.map +1 -1
- package/dist/components/features/index.cjs +5 -3
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +4 -2
- package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
- package/dist/components/features/mux-origins.cjs +14 -0
- package/dist/components/features/mux-origins.cjs.map +1 -1
- package/dist/components/features/mux-origins.d.ts +26 -2
- package/dist/components/features/mux-origins.d.ts.map +1 -1
- package/dist/components/features/mux-origins.js +14 -1
- package/dist/components/features/mux-origins.js.map +1 -1
- package/dist/components/features/policy-configuration-panel.d.ts.map +1 -1
- package/dist/components/features/release-media-manager.d.ts.map +1 -1
- package/dist/components/features/use-video-warmup.d.ts +12 -0
- package/dist/components/features/use-video-warmup.d.ts.map +1 -1
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video-source-selector.d.ts +1 -11
- package/dist/components/features/video-source-selector.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +14 -1
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +137 -135
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +13 -11
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/simple-markdown-renderer.d.ts.map +1 -1
- package/dist/hooks/index.cjs +4 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +3 -1
- package/dist/hooks/use-near-viewport.d.ts +7 -0
- package/dist/hooks/use-near-viewport.d.ts.map +1 -1
- package/dist/index.cjs +7 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +6 -2
- package/package.json +1 -1
- package/src/components/features/cards-strip.tsx +2 -1
- package/src/components/features/highlight-video-section.tsx +10 -7
- package/src/components/features/media-gallery-manager.tsx +6 -8
- package/src/components/features/mux-origins.ts +40 -2
- package/src/components/features/policy-configuration-panel.tsx +34 -49
- package/src/components/features/release-media-manager.tsx +6 -8
- package/src/components/features/use-video-warmup.ts +19 -3
- package/src/components/features/video-bites-strip.tsx +24 -4
- package/src/components/features/video-source-selector.tsx +16 -56
- package/src/components/features/video.tsx +103 -8
- package/src/components/ui/rich-markdown-renderer.tsx +20 -0
- package/src/components/ui/simple-markdown-renderer.tsx +9 -5
- package/src/hooks/use-near-viewport.ts +9 -1
- package/src/stories/PolicyConfigurationPanel.stories.tsx +8 -6
- package/dist/chunk-3BHV2QAH.js.map +0 -1
- package/dist/chunk-QL2EI56X.cjs.map +0 -1
- package/dist/chunk-VIJARDBM.cjs.map +0 -1
- /package/dist/{chunk-H352AZJN.js.map → chunk-3GBLPPSW.js.map} +0 -0
- /package/dist/{chunk-TCEBFMLQ.js.map → chunk-4ONE3HQP.js.map} +0 -0
- /package/dist/{chunk-K4RASNQM.js.map → chunk-5BJJRPDH.js.map} +0 -0
- /package/dist/{chunk-H2DYCT6M.js.map → chunk-6TAIXSF5.js.map} +0 -0
- /package/dist/{chunk-7R256DKK.js.map → chunk-D2NLNBLH.js.map} +0 -0
- /package/dist/{chunk-Z4N7XLFG.js.map → chunk-G7QSLGMI.js.map} +0 -0
- /package/dist/{chunk-22NSWMI6.js.map → chunk-SHC5NFOH.js.map} +0 -0
- /package/dist/{chunk-3DQ5BFPM.js.map → chunk-TMZYFYSS.js.map} +0 -0
- /package/dist/{chunk-FIAXFNAA.js.map → chunk-WMOF3WQT.js.map} +0 -0
- /package/dist/{chunk-G2KXNMFQ.js.map → chunk-YHNBJ3F3.js.map} +0 -0
- /package/dist/{chunk-MQUS6BFR.js.map → chunk-ZQVEB6KT.js.map} +0 -0
- /package/dist/{chunk-XPZLIONZ.js.map → chunk-ZYB3BYBA.js.map} +0 -0
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mux CDN
|
|
2
|
+
* Mux CDN origins + playback-URL helpers — single source of truth, server-safe.
|
|
3
3
|
*
|
|
4
4
|
* Lives in its own NON-`'use client'` module so server-side hub
|
|
5
5
|
* modules (webhook handlers, URL builders, hostname comparisons in
|
|
6
|
-
* `lib/config/mux-config.ts`) can import these
|
|
6
|
+
* `lib/config/mux-config.ts`) can import these without
|
|
7
7
|
* tripping Next.js's client-reference poisoning. Re-exported from
|
|
8
8
|
* `use-video-warmup.ts` for backward-compat with client-side callers.
|
|
9
9
|
*
|
|
10
|
+
* The Mux-HLS detection in `toMuxPreviewUrl` intentionally overlaps the
|
|
11
|
+
* hub's `lib/config/mux-config.ts` `isMuxHlsUrl` — same documented
|
|
12
|
+
* cross-repo duplication umbrella as the origin constants (the hub's
|
|
13
|
+
* server code cannot depend on this package's client-marked siblings).
|
|
14
|
+
*
|
|
10
15
|
* Bug history (2026-05-29): when these constants lived in
|
|
11
16
|
* `use-video-warmup.ts` (which is `'use client'`), the hub's
|
|
12
17
|
* server-side `new URL(MUX_STREAM_ORIGIN).hostname` evaluation crashed
|
|
@@ -25,3 +30,36 @@ export const MUX_STREAM_ORIGIN = 'https://stream.mux.com'
|
|
|
25
30
|
|
|
26
31
|
/** Server-generated thumbnails (`/{playback_id}/thumbnail.jpg`). */
|
|
27
32
|
export const MUX_IMAGE_ORIGIN = 'https://image.mux.com'
|
|
33
|
+
|
|
34
|
+
/** Valid values for Mux's `max_resolution` playback modifier (720p is the floor). */
|
|
35
|
+
export type MuxMaxResolution = '720p' | '1080p' | '1440p' | '2160p'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Cap the renditions offered by a public Mux HLS manifest for small
|
|
39
|
+
* preview players (e.g. the ~234px video-bite hover cards) by appending
|
|
40
|
+
* Mux's `max_resolution` playback modifier.
|
|
41
|
+
*
|
|
42
|
+
* Why a URL param and not a player prop: MuxPlayer's `maxResolution`
|
|
43
|
+
* prop only applies when playing via `playbackId` — our SSOT passes
|
|
44
|
+
* `src` URLs. And on Safari (native HLS, no hls.js) manifest-level
|
|
45
|
+
* filtering is the ONLY rendition cap; hls.js's `capLevelToPlayerSize`
|
|
46
|
+
* doesn't exist there.
|
|
47
|
+
*
|
|
48
|
+
* Pass-through (returns `url` unchanged) for: non-Mux hosts (Supabase
|
|
49
|
+
* MP4s, YouTube), non-`.m3u8` paths (MP4 renditions), signed URLs
|
|
50
|
+
* (`?token=` — Mux forbids loose modifiers alongside a token; they must
|
|
51
|
+
* live in the JWT), and unparseable strings.
|
|
52
|
+
*/
|
|
53
|
+
export function toMuxPreviewUrl(url: string, maxResolution: MuxMaxResolution = '720p'): string {
|
|
54
|
+
let parsed: URL
|
|
55
|
+
try {
|
|
56
|
+
parsed = new URL(url)
|
|
57
|
+
} catch {
|
|
58
|
+
return url
|
|
59
|
+
}
|
|
60
|
+
if (parsed.origin !== MUX_STREAM_ORIGIN) return url
|
|
61
|
+
if (!parsed.pathname.endsWith('.m3u8')) return url
|
|
62
|
+
if (parsed.searchParams.has('token')) return url
|
|
63
|
+
parsed.searchParams.set('max_resolution', maxResolution)
|
|
64
|
+
return parsed.toString()
|
|
65
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from 'react'
|
|
4
|
-
import {
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
5
|
import { ChevronDown, Shield } from 'lucide-react'
|
|
6
6
|
import { cn } from '../../utils/cn'
|
|
7
|
+
import { useCollapsible } from '../chat/hooks/use-collapsible'
|
|
7
8
|
import { ToolIcon } from '../tool-icon'
|
|
8
9
|
import { Button } from '../ui/button'
|
|
9
10
|
import {
|
|
@@ -19,21 +20,22 @@ import {
|
|
|
19
20
|
PolicyConfigurationPanelProps
|
|
20
21
|
} from '../../types/permissions'
|
|
21
22
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
const getApprovalLevelLabel = (level: ApprovalLevel | undefined, editMode: boolean = false): string => {
|
|
30
|
-
if (!level) {
|
|
31
|
-
return editMode ? 'Set Global Permission' : ''
|
|
32
|
-
}
|
|
33
|
-
const option = approvalLevelOptions.find(opt => opt.value === level)
|
|
34
|
-
return option?.label || level
|
|
23
|
+
const approvalLevelLabels: Record<ApprovalLevel, string> = {
|
|
24
|
+
ALLOW: 'Allow',
|
|
25
|
+
ASK_USER: 'Ask User',
|
|
26
|
+
ASK_TECHNICIAN: 'Ask Technician',
|
|
27
|
+
DENY: 'Restrict',
|
|
35
28
|
}
|
|
36
29
|
|
|
30
|
+
const approvalLevelOptions = (Object.keys(approvalLevelLabels) as ApprovalLevel[]).map(
|
|
31
|
+
value => ({ value, label: approvalLevelLabels[value] })
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const POLICY_LEVEL_WIDTH = 'w-[180px]'
|
|
35
|
+
const CATEGORY_LEVEL_WIDTH = 'w-[256px]'
|
|
36
|
+
|
|
37
|
+
const getApprovalLevelLabel = (level: ApprovalLevel): string => approvalLevelLabels[level] ?? level
|
|
38
|
+
|
|
37
39
|
const ApprovalLevelDropdown: React.FC<{
|
|
38
40
|
value: ApprovalLevel | undefined;
|
|
39
41
|
onChange: (level: ApprovalLevel | undefined) => void;
|
|
@@ -53,7 +55,7 @@ const ApprovalLevelDropdown: React.FC<{
|
|
|
53
55
|
triggerClassName
|
|
54
56
|
)}
|
|
55
57
|
>
|
|
56
|
-
{getApprovalLevelLabel(value
|
|
58
|
+
{value ? getApprovalLevelLabel(value) : 'Set Global Permission'}
|
|
57
59
|
</Button>
|
|
58
60
|
</DropdownMenuTrigger>
|
|
59
61
|
<DropdownMenuContent align="end" className={contentClassName}>
|
|
@@ -106,7 +108,7 @@ const PolicyRow: React.FC<{
|
|
|
106
108
|
value={policy.approvalLevel}
|
|
107
109
|
onChange={handleChange}
|
|
108
110
|
triggerClassName="w-full"
|
|
109
|
-
contentClassName=
|
|
111
|
+
contentClassName={POLICY_LEVEL_WIDTH}
|
|
110
112
|
/>
|
|
111
113
|
</div>
|
|
112
114
|
) : policy.approvalLevel ? (
|
|
@@ -122,11 +124,11 @@ const PolicyRow: React.FC<{
|
|
|
122
124
|
<ApprovalLevelDropdown
|
|
123
125
|
value={policy.approvalLevel}
|
|
124
126
|
onChange={handleChange}
|
|
125
|
-
triggerClassName=
|
|
126
|
-
contentClassName=
|
|
127
|
+
triggerClassName={POLICY_LEVEL_WIDTH}
|
|
128
|
+
contentClassName={POLICY_LEVEL_WIDTH}
|
|
127
129
|
/>
|
|
128
130
|
) : (
|
|
129
|
-
<div className="px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)]
|
|
131
|
+
<div className={cn("px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)]", POLICY_LEVEL_WIDTH)}>
|
|
130
132
|
{policy.approvalLevel && (
|
|
131
133
|
<span className="text-h4 text-ods-text-primary">
|
|
132
134
|
{getApprovalLevelLabel(policy.approvalLevel)}
|
|
@@ -139,20 +141,6 @@ const PolicyRow: React.FC<{
|
|
|
139
141
|
)
|
|
140
142
|
}
|
|
141
143
|
|
|
142
|
-
const useAnimatedHeight = (isExpanded: boolean) => {
|
|
143
|
-
const [height, setHeight] = useState<number | 'auto'>(0);
|
|
144
|
-
const contentRef = useRef<HTMLDivElement>(null);
|
|
145
|
-
|
|
146
|
-
useEffect(() => {
|
|
147
|
-
if (contentRef.current) {
|
|
148
|
-
const contentHeight = contentRef.current.scrollHeight;
|
|
149
|
-
setHeight(isExpanded ? contentHeight : 0);
|
|
150
|
-
}
|
|
151
|
-
}, [isExpanded]);
|
|
152
|
-
|
|
153
|
-
return { contentRef, height };
|
|
154
|
-
};
|
|
155
|
-
|
|
156
144
|
const CategorySection: React.FC<{
|
|
157
145
|
category: PermissionCategory;
|
|
158
146
|
editMode: boolean;
|
|
@@ -171,7 +159,7 @@ const CategorySection: React.FC<{
|
|
|
171
159
|
onBulkLevelChange,
|
|
172
160
|
onPolicyPermissionChange
|
|
173
161
|
}) => {
|
|
174
|
-
const {
|
|
162
|
+
const { innerRef, containerStyle } = useCollapsible({ expanded: isExpanded, durationMs: 300 })
|
|
175
163
|
const handleBulkChange = (level: ApprovalLevel | undefined) => onBulkLevelChange(category.id, level)
|
|
176
164
|
|
|
177
165
|
return (
|
|
@@ -203,7 +191,7 @@ const CategorySection: React.FC<{
|
|
|
203
191
|
onChange={handleBulkChange}
|
|
204
192
|
allowClear
|
|
205
193
|
triggerClassName="w-full"
|
|
206
|
-
contentClassName=
|
|
194
|
+
contentClassName={CATEGORY_LEVEL_WIDTH}
|
|
207
195
|
/>
|
|
208
196
|
</div>
|
|
209
197
|
)}
|
|
@@ -216,15 +204,12 @@ const CategorySection: React.FC<{
|
|
|
216
204
|
value={bulkLevel}
|
|
217
205
|
onChange={handleBulkChange}
|
|
218
206
|
allowClear
|
|
219
|
-
triggerClassName=
|
|
220
|
-
contentClassName=
|
|
207
|
+
triggerClassName={CATEGORY_LEVEL_WIDTH}
|
|
208
|
+
contentClassName={CATEGORY_LEVEL_WIDTH}
|
|
221
209
|
/>
|
|
222
210
|
</div>
|
|
223
211
|
) : (
|
|
224
|
-
<div
|
|
225
|
-
className="hidden md:block px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] w-[256px] shrink-0"
|
|
226
|
-
onClick={(e) => e.stopPropagation()}
|
|
227
|
-
/>
|
|
212
|
+
<div className={cn("hidden md:block px-[var(--spacing-system-sf)] py-[var(--spacing-system-xsf)] shrink-0", CATEGORY_LEVEL_WIDTH)} />
|
|
228
213
|
)}
|
|
229
214
|
|
|
230
215
|
{/* Expand/Collapse Button */}
|
|
@@ -248,17 +233,16 @@ const CategorySection: React.FC<{
|
|
|
248
233
|
|
|
249
234
|
{/* Policies List */}
|
|
250
235
|
<div
|
|
251
|
-
ref={contentRef}
|
|
252
236
|
style={{
|
|
253
|
-
|
|
254
|
-
transition:
|
|
237
|
+
...containerStyle,
|
|
238
|
+
transition: `${containerStyle.transition}, opacity 300ms ease-in-out, visibility 300ms`,
|
|
239
|
+
// visibility drops collapsed content from the tab order and a11y
|
|
240
|
+
// tree; its discrete transition delays hiding until the fade ends.
|
|
241
|
+
visibility: isExpanded ? 'visible' : 'hidden',
|
|
255
242
|
}}
|
|
256
|
-
className={
|
|
257
|
-
"overflow-hidden",
|
|
258
|
-
isExpanded ? "opacity-100" : "opacity-0"
|
|
259
|
-
)}
|
|
243
|
+
className={isExpanded ? "opacity-100" : "opacity-0"}
|
|
260
244
|
>
|
|
261
|
-
<div className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
|
|
245
|
+
<div ref={innerRef} className="px-[var(--spacing-system-mf)] pb-[var(--spacing-system-mf)] pt-0">
|
|
262
246
|
<div className="border border-ods-border rounded-md overflow-hidden">
|
|
263
247
|
{category.policies.map((policy) => (
|
|
264
248
|
<PolicyRow
|
|
@@ -297,6 +281,7 @@ export const PolicyConfigurationPanel: React.FC<PolicyConfigurationPanelProps> =
|
|
|
297
281
|
const [expandedIds, setExpandedIds] = useState<ReadonlySet<string>>(new Set())
|
|
298
282
|
const [bulkLevels, setBulkLevels] = useState<Record<string, ApprovalLevel | undefined>>({})
|
|
299
283
|
|
|
284
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: editMode is the reset trigger, not read in the body
|
|
300
285
|
useEffect(() => {
|
|
301
286
|
setBulkLevels({})
|
|
302
287
|
}, [editMode])
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef } from 'react';
|
|
4
4
|
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Textarea } from '../ui';
|
|
5
|
-
import { Trash2, Plus, Image as ImageIcon, Video, Upload, Loader2, GripVertical } from 'lucide-react';
|
|
5
|
+
import { Trash2, Plus, Image as ImageIcon, Video as VideoIcon, Upload, Loader2, GripVertical } from 'lucide-react';
|
|
6
6
|
import Image from '../../embed-shims/next-image';
|
|
7
|
+
import { Video } from './video';
|
|
7
8
|
|
|
8
9
|
export interface ReleaseMediaItem {
|
|
9
10
|
media_type: 'image' | 'video' | 'screenshot' | 'demo';
|
|
@@ -113,7 +114,7 @@ export function ReleaseMediaManager({
|
|
|
113
114
|
switch (type) {
|
|
114
115
|
case 'video':
|
|
115
116
|
case 'demo':
|
|
116
|
-
return <
|
|
117
|
+
return <VideoIcon className="w-5 h-5 text-ods-text-secondary" />;
|
|
117
118
|
default:
|
|
118
119
|
return <ImageIcon className="w-5 h-5 text-ods-text-secondary" />;
|
|
119
120
|
}
|
|
@@ -205,12 +206,9 @@ export function ReleaseMediaManager({
|
|
|
205
206
|
{item.media_url && (
|
|
206
207
|
<div className="aspect-video relative bg-ods-bg">
|
|
207
208
|
{item.media_type === 'video' || item.media_type === 'demo' ? (
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
controls
|
|
212
|
-
preload="metadata"
|
|
213
|
-
/>
|
|
209
|
+
// <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
|
|
210
|
+
// fit="cover" crops to the aspect-video cell.
|
|
211
|
+
<Video kind="file" url={item.media_url} fit="cover" className="w-full h-full" />
|
|
214
212
|
) : (
|
|
215
213
|
<Image
|
|
216
214
|
src={item.media_url}
|
|
@@ -48,6 +48,18 @@ import { useChatRuntime } from '../../contexts/chat-runtime-context'
|
|
|
48
48
|
export { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
|
|
49
49
|
import { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Save-Data detection — the ONE source of truth for "is this a metered
|
|
53
|
+
* connection". Consumed by the preload gate below and by `video.tsx`'s
|
|
54
|
+
* default `preload` policy. SSR-safe (returns false on the server).
|
|
55
|
+
*/
|
|
56
|
+
export function saveDataEnabled(): boolean {
|
|
57
|
+
if (typeof navigator === 'undefined') return false
|
|
58
|
+
type Connection = { saveData?: boolean }
|
|
59
|
+
const conn = (navigator as Navigator & { connection?: Connection }).connection
|
|
60
|
+
return conn?.saveData === true
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/**
|
|
52
64
|
* Preconnect-only variant — fires the three video-bearing origin
|
|
53
65
|
* preconnects (Supabase Storage + Mux stream + Mux image) without
|
|
@@ -68,6 +80,12 @@ import { MUX_STREAM_ORIGIN, MUX_IMAGE_ORIGIN } from './mux-origins'
|
|
|
68
80
|
* any equivalent provider that wires the field) get the origin
|
|
69
81
|
* automatically. The explicit `supabaseStorageOrigin` argument
|
|
70
82
|
* overrides the runtime value when set.
|
|
83
|
+
*
|
|
84
|
+
* The hub's `app/layout.tsx` also emits STATIC `<link rel="preconnect">`
|
|
85
|
+
* tags for the same Mux origins (document-parse-time warmup; React
|
|
86
|
+
* dedupes against these runtime calls via the matching
|
|
87
|
+
* crossOrigin="anonymous"). A change to the origin list here must be
|
|
88
|
+
* mirrored there.
|
|
71
89
|
*/
|
|
72
90
|
export function useVideoOriginPreconnect({
|
|
73
91
|
supabaseStorageOrigin,
|
|
@@ -139,9 +157,7 @@ export function useVideoWarmup<T extends Element = HTMLDivElement>({
|
|
|
139
157
|
if (!isNear || !videoUrl || !resolvedOrigin) return
|
|
140
158
|
|
|
141
159
|
// Save-Data gate — metered connections skip preload.
|
|
142
|
-
|
|
143
|
-
const conn = (navigator as Navigator & { connection?: Connection }).connection
|
|
144
|
-
if (conn?.saveData === true) return
|
|
160
|
+
if (saveDataEnabled()) return
|
|
145
161
|
|
|
146
162
|
// Origin gate: only preload Supabase-hosted MP4s. Mux HLS warms
|
|
147
163
|
// via the manifest fetch when MuxPlayer mounts; YouTube has no
|
|
@@ -29,6 +29,8 @@ import React, { useMemo, useRef, useState, useEffect } from 'react';
|
|
|
29
29
|
import { cn } from '../../utils/cn';
|
|
30
30
|
import { Video } from './video';
|
|
31
31
|
import { useVideoWarmup } from './use-video-warmup';
|
|
32
|
+
import { toMuxPreviewUrl } from './mux-origins';
|
|
33
|
+
import { NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
|
|
32
34
|
import { detectAspectRatio, RATIO_TO_CSS_ASPECT, ratioToCategory } from './video-ratio-tabs';
|
|
33
35
|
import type { VideoTeaserWithRatio } from './video-ratio-tabs';
|
|
34
36
|
import {
|
|
@@ -227,7 +229,16 @@ export function VideoBiteCard({
|
|
|
227
229
|
const cssAspect = RATIO_TO_CSS_ASPECT[ratioToCategory(detectAspectRatio(bite.aspect_ratio))];
|
|
228
230
|
const targetHref = bite.href ?? sectionHref;
|
|
229
231
|
const hasTarget = !!(targetHref || bite.onNavigate || onBiteNavigate);
|
|
232
|
+
// Strip keys and navigation stay on the RAW bite.url — only playback gets
|
|
233
|
+
// the rendition-capped preview URL below, so keys are stable regardless of
|
|
234
|
+
// the cap and original + clone share one HTTP cache entry per URL.
|
|
230
235
|
const key = cardKey ?? `${bite.url}__${index}`;
|
|
236
|
+
// Rendition-capped playback URL for this ~234px preview card: public Mux
|
|
237
|
+
// HLS manifests get `?max_resolution=720p` (the only Safari-side cap —
|
|
238
|
+
// native HLS ignores hls.js's player-size capping); everything else passes
|
|
239
|
+
// through unchanged. Used by BOTH the first-frame facade and the hover
|
|
240
|
+
// player so the two layers share the manifest fetch.
|
|
241
|
+
const previewUrl = toMuxPreviewUrl(bite.url);
|
|
231
242
|
|
|
232
243
|
// Hover activation: controlled by the strip (activeKey) or self-managed
|
|
233
244
|
// when rendered standalone (admin editor).
|
|
@@ -250,7 +261,7 @@ export function VideoBiteCard({
|
|
|
250
261
|
if (!el || typeof IntersectionObserver === 'undefined') return;
|
|
251
262
|
const io = new IntersectionObserver(
|
|
252
263
|
entries => setIsNear(entries[0]?.isIntersecting ?? false),
|
|
253
|
-
{ rootMargin:
|
|
264
|
+
{ rootMargin: NEAR_VIEWPORT_ROOT_MARGIN },
|
|
254
265
|
);
|
|
255
266
|
io.observe(el);
|
|
256
267
|
return () => io.disconnect();
|
|
@@ -392,7 +403,7 @@ export function VideoBiteCard({
|
|
|
392
403
|
className="object-cover"
|
|
393
404
|
/>
|
|
394
405
|
) : (
|
|
395
|
-
<Video kind="file" url={
|
|
406
|
+
<Video kind="file" url={previewUrl} firstFrameOnly layout="fill" />
|
|
396
407
|
)}
|
|
397
408
|
|
|
398
409
|
{/* Play affordance for every non-playing state (resting poster,
|
|
@@ -415,10 +426,19 @@ export function VideoBiteCard({
|
|
|
415
426
|
it keeps playing. Sound at 50% (pre-activation: muted start +
|
|
416
427
|
live unmute on the user's first gesture); CHROMELESS — the
|
|
417
428
|
card's mux-replica badge above is the play affordance for
|
|
418
|
-
every non-playing state.
|
|
429
|
+
every non-playing state.
|
|
430
|
+
|
|
431
|
+
INVARIANTS (do not regress):
|
|
432
|
+
- No explicit `preload` — the SSOT default ('metadata',
|
|
433
|
+
'none' under Save-Data) buffers manifest + ~1 segment on
|
|
434
|
+
mount, which is exactly what makes hover start instant.
|
|
435
|
+
- The poster <Image> layer above stays mounted UNDER this
|
|
436
|
+
player permanently, and play() is imperative — never gated
|
|
437
|
+
on poster load — so there is no black flash and no
|
|
438
|
+
loading state between hover and first frame. */}
|
|
419
439
|
<Video
|
|
420
440
|
kind="file"
|
|
421
|
-
url={
|
|
441
|
+
url={previewUrl}
|
|
422
442
|
poster={bite.thumbnail_url}
|
|
423
443
|
playWhenHovered={isActive}
|
|
424
444
|
chromeless
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import React, { useState, useCallback
|
|
4
|
-
import { Upload,
|
|
3
|
+
import React, { useState, useCallback } from 'react';
|
|
4
|
+
import { Upload, X, Video as VideoIcon } from 'lucide-react';
|
|
5
5
|
import { AIGeneratedBadge } from '../ui/ai-generated-badge';
|
|
6
6
|
import { Button } from '../ui/button';
|
|
7
7
|
import { Input } from '../ui/input';
|
|
8
8
|
import { Label } from '../ui/label';
|
|
9
9
|
import { Badge } from '../ui/badge';
|
|
10
10
|
import { YouTubeIcon } from '../icons/youtube-icon';
|
|
11
|
+
import { Video } from './video';
|
|
11
12
|
|
|
12
13
|
export type VideoSourceType = 'youtube' | 'uploaded';
|
|
13
14
|
|
|
@@ -48,16 +49,6 @@ export interface VideoSourceSelectorProps {
|
|
|
48
49
|
onDelete: () => void;
|
|
49
50
|
isAIGenerated?: boolean;
|
|
50
51
|
}>;
|
|
51
|
-
/**
|
|
52
|
-
* Optional: resolve the uploaded video URL before it's handed to the DEFAULT
|
|
53
|
-
* `<video>` preview (no-op when a `VideoPreviewComponent` is supplied — that
|
|
54
|
-
* component owns its own rendering). Lets a consumer swap a URL that a native
|
|
55
|
-
* `<video>` can't play — e.g. a Mux HLS manifest (`stream.mux.com/{id}.m3u8`),
|
|
56
|
-
* which Chrome/Firefox/Edge can't play natively — for a playable MP4. Without
|
|
57
|
-
* it the URL is used as-is (backward compatible). A rejected resolve degrades
|
|
58
|
-
* gracefully to the original URL.
|
|
59
|
-
*/
|
|
60
|
-
resolveVideoUrl?: (url: string) => Promise<string>;
|
|
61
52
|
/** Optional: Upload progress bar component */
|
|
62
53
|
UploadProgressComponent?: React.ComponentType<{
|
|
63
54
|
progress: number;
|
|
@@ -95,7 +86,6 @@ export function VideoSourceSelector({
|
|
|
95
86
|
uploadEmptyText = 'No video uploaded yet. Click "Upload Video" to add one.',
|
|
96
87
|
disabled = false,
|
|
97
88
|
VideoPreviewComponent,
|
|
98
|
-
resolveVideoUrl,
|
|
99
89
|
UploadProgressComponent,
|
|
100
90
|
title = 'Video',
|
|
101
91
|
showTitle = true,
|
|
@@ -149,7 +139,7 @@ export function VideoSourceSelector({
|
|
|
149
139
|
{/* Section Title */}
|
|
150
140
|
{showTitle && (
|
|
151
141
|
<h3 className="text-h5 text-ods-text-primary flex items-center gap-2">
|
|
152
|
-
<
|
|
142
|
+
<VideoIcon className="h-5 w-5" />
|
|
153
143
|
{title}
|
|
154
144
|
</h3>
|
|
155
145
|
)}
|
|
@@ -256,10 +246,8 @@ export function VideoSourceSelector({
|
|
|
256
246
|
isAIGenerated={isAIGenerated}
|
|
257
247
|
/>
|
|
258
248
|
) : (
|
|
259
|
-
// Default simple preview (resolver-aware — see DefaultVideoPreview)
|
|
260
249
|
<DefaultVideoPreview
|
|
261
250
|
videoUrl={mainVideoUrl}
|
|
262
|
-
resolveVideoUrl={resolveVideoUrl}
|
|
263
251
|
onDelete={handleDeleteVideo}
|
|
264
252
|
disabled={disabled}
|
|
265
253
|
/>
|
|
@@ -276,55 +264,27 @@ export function VideoSourceSelector({
|
|
|
276
264
|
}
|
|
277
265
|
|
|
278
266
|
interface DefaultVideoPreviewProps {
|
|
279
|
-
/** Uploaded video URL
|
|
267
|
+
/** Uploaded video URL — MP4 or Mux HLS manifest, both playable via <Video>. */
|
|
280
268
|
videoUrl: string;
|
|
281
|
-
/** Optional resolver → playable MP4 URL; omit for as-is (backward-compatible) behaviour. */
|
|
282
|
-
resolveVideoUrl?: (url: string) => Promise<string>;
|
|
283
269
|
onDelete: () => void;
|
|
284
270
|
disabled?: boolean;
|
|
285
271
|
}
|
|
286
272
|
|
|
287
273
|
/**
|
|
288
|
-
* Default
|
|
289
|
-
* `VideoPreviewComponent` is supplied.
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
* original URL. Without a resolver the URL is used synchronously, unchanged.
|
|
274
|
+
* Default preview for {@link VideoSourceSelector} when no
|
|
275
|
+
* `VideoPreviewComponent` is supplied. Renders through the `<Video>` SSOT
|
|
276
|
+
* (MuxPlayer), which plays Mux HLS manifests directly on every browser —
|
|
277
|
+
* no HLS→MP4 resolve step needed for playback.
|
|
293
278
|
*/
|
|
294
|
-
function DefaultVideoPreview({ videoUrl,
|
|
295
|
-
// `undefined` only while an async resolve is in flight (first paint); otherwise
|
|
296
|
-
// the (possibly resolved) URL. Synchronous passthrough when there's no resolver.
|
|
297
|
-
const [resolvedUrl, setResolvedUrl] = useState<string | undefined>(
|
|
298
|
-
resolveVideoUrl ? undefined : videoUrl,
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
useEffect(() => {
|
|
302
|
-
if (!resolveVideoUrl) {
|
|
303
|
-
setResolvedUrl(videoUrl);
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
// Guard against a late resolve overwriting a newer URL / unmounted preview.
|
|
307
|
-
let active = true;
|
|
308
|
-
setResolvedUrl(undefined);
|
|
309
|
-
resolveVideoUrl(videoUrl)
|
|
310
|
-
.then((url) => { if (active) setResolvedUrl(url); })
|
|
311
|
-
.catch(() => { if (active) setResolvedUrl(videoUrl); });
|
|
312
|
-
return () => { active = false; };
|
|
313
|
-
}, [videoUrl, resolveVideoUrl]);
|
|
314
|
-
|
|
279
|
+
function DefaultVideoPreview({ videoUrl, onDelete, disabled }: DefaultVideoPreviewProps) {
|
|
315
280
|
return (
|
|
316
281
|
<div className="relative rounded-lg border border-ods-border overflow-hidden">
|
|
317
|
-
{
|
|
318
|
-
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
src={resolvedUrl}
|
|
324
|
-
className="w-full h-auto max-h-[300px] object-contain bg-black"
|
|
325
|
-
controls
|
|
326
|
-
/>
|
|
327
|
-
)}
|
|
282
|
+
{/* aspect-video box: MuxPlayer fills its container (width/height 100%),
|
|
283
|
+
so the wrapper must reserve the height the intrinsic-sized native
|
|
284
|
+
<video> used to provide. */}
|
|
285
|
+
<div className="w-full aspect-video max-h-[300px]">
|
|
286
|
+
<Video kind="file" url={videoUrl} />
|
|
287
|
+
</div>
|
|
328
288
|
<button
|
|
329
289
|
type="button"
|
|
330
290
|
onClick={onDelete}
|