@flamingo-stack/openframe-frontend-core 0.0.584 → 0.0.585-1985.5484.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/dist/{chunk-3ELW4VML.js → chunk-276BXVOB.js} +3 -3
- package/dist/{chunk-Q66AMADB.cjs → chunk-27DK5GCC.cjs} +6 -6
- package/dist/{chunk-Q66AMADB.cjs.map → chunk-27DK5GCC.cjs.map} +1 -1
- package/dist/{chunk-TOUD3AGK.js → chunk-36GXR7BR.js} +5 -5
- package/dist/{chunk-QJR32JM2.cjs → chunk-3F7V7GFU.cjs} +212 -194
- package/dist/chunk-3F7V7GFU.cjs.map +1 -0
- package/dist/{chunk-33DHEEMP.cjs → chunk-4TN6C7ZG.cjs} +33 -33
- package/dist/{chunk-33DHEEMP.cjs.map → chunk-4TN6C7ZG.cjs.map} +1 -1
- package/dist/{chunk-VEHZNQ77.cjs → chunk-5LUPIYPH.cjs} +30 -30
- package/dist/{chunk-VEHZNQ77.cjs.map → chunk-5LUPIYPH.cjs.map} +1 -1
- package/dist/{chunk-JAJQ5P2Q.cjs → chunk-5YVCIBUH.cjs} +28 -28
- package/dist/{chunk-JAJQ5P2Q.cjs.map → chunk-5YVCIBUH.cjs.map} +1 -1
- package/dist/{chunk-ZHUBNWEX.cjs → chunk-6VFO4JUH.cjs} +6 -6
- package/dist/{chunk-ZHUBNWEX.cjs.map → chunk-6VFO4JUH.cjs.map} +1 -1
- package/dist/{chunk-5CJKZHWO.js → chunk-7554QJ6R.js} +5 -5
- package/dist/{chunk-EJKEDCTH.js → chunk-BNZFLIEG.js} +7 -7
- package/dist/{chunk-UCNEQKIT.cjs → chunk-BU2KLFRB.cjs} +5 -5
- package/dist/{chunk-UCNEQKIT.cjs.map → chunk-BU2KLFRB.cjs.map} +1 -1
- package/dist/{chunk-YFZDIEVC.js → chunk-CTZP2NJQ.js} +6 -6
- package/dist/{chunk-XWBND4G5.js → chunk-D7YISHXC.js} +17 -2
- package/dist/{chunk-XWBND4G5.js.map → chunk-D7YISHXC.js.map} +1 -1
- package/dist/{chunk-JFKK3ZOA.cjs → chunk-DZM7KTHE.cjs} +57 -57
- package/dist/{chunk-JFKK3ZOA.cjs.map → chunk-DZM7KTHE.cjs.map} +1 -1
- package/dist/{chunk-NNFQLTJ3.cjs → chunk-EMF64FWA.cjs} +31 -5
- package/dist/chunk-EMF64FWA.cjs.map +1 -0
- package/dist/{chunk-7X5TCK4R.cjs → chunk-F5SV627B.cjs} +10 -10
- package/dist/{chunk-7X5TCK4R.cjs.map → chunk-F5SV627B.cjs.map} +1 -1
- package/dist/{chunk-KP6VJP36.js → chunk-FCKOWGTE.js} +4 -4
- package/dist/{chunk-IIT5HWLY.cjs → chunk-FQJBV5ON.cjs} +11 -11
- package/dist/{chunk-IIT5HWLY.cjs.map → chunk-FQJBV5ON.cjs.map} +1 -1
- package/dist/{chunk-IKUSKKFZ.cjs → chunk-G45RGWHF.cjs} +159 -144
- package/dist/chunk-G45RGWHF.cjs.map +1 -0
- package/dist/{chunk-LNKULR4V.js → chunk-GG76BPXJ.js} +2 -2
- package/dist/{chunk-3ZA2OYRE.cjs → chunk-HAIRDHWL.cjs} +3 -3
- package/dist/{chunk-3ZA2OYRE.cjs.map → chunk-HAIRDHWL.cjs.map} +1 -1
- package/dist/{chunk-JWKAJTNA.cjs → chunk-HBI7AWUE.cjs} +28 -28
- package/dist/{chunk-JWKAJTNA.cjs.map → chunk-HBI7AWUE.cjs.map} +1 -1
- package/dist/{chunk-RURZ6QLN.js → chunk-HQRKC6P7.js} +3 -3
- package/dist/{chunk-VZSTL5VG.js → chunk-J4MVZ53H.js} +3 -3
- package/dist/{chunk-MYEG5ZDT.js → chunk-JKWWACNI.js} +3 -3
- package/dist/{chunk-6BJYPG2V.js → chunk-LNKBNY3A.js} +4 -4
- package/dist/{chunk-MCWAZ5XI.js → chunk-NFWAL6UV.js} +2 -2
- package/dist/{chunk-P2VRLSAP.cjs → chunk-QIVZG4XK.cjs} +21 -21
- package/dist/{chunk-P2VRLSAP.cjs.map → chunk-QIVZG4XK.cjs.map} +1 -1
- package/dist/{chunk-4T5KOD3G.js → chunk-SCMESLXP.js} +6 -6
- package/dist/{chunk-XPJKQ63S.js → chunk-UG6SHUGE.js} +3 -3
- package/dist/{chunk-MEJFHD5N.cjs → chunk-ULGI56BW.cjs} +19 -19
- package/dist/{chunk-MEJFHD5N.cjs.map → chunk-ULGI56BW.cjs.map} +1 -1
- package/dist/{chunk-VOAZU46B.cjs → chunk-VQ5FMCZA.cjs} +80 -80
- package/dist/{chunk-VOAZU46B.cjs.map → chunk-VQ5FMCZA.cjs.map} +1 -1
- package/dist/{chunk-TLKHLGAB.js → chunk-VSXFBO4D.js} +3 -3
- package/dist/{chunk-JVWRMLQZ.js → chunk-WXL2AR5I.js} +1330 -1312
- package/dist/chunk-WXL2AR5I.js.map +1 -0
- package/dist/{chunk-3LMYQGAJ.js → chunk-X7YSF6A3.js} +28 -2
- package/dist/chunk-X7YSF6A3.js.map +1 -0
- package/dist/{chunk-HCKEDTKJ.cjs → chunk-XK4S47LG.cjs} +15 -15
- package/dist/{chunk-HCKEDTKJ.cjs.map → chunk-XK4S47LG.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +10 -10
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +4 -4
- package/dist/components/features/index.js +3 -3
- package/dist/components/features/media-gallery-manager.d.ts.map +1 -1
- package/dist/components/features/push-button-selector.d.ts +9 -7
- package/dist/components/features/push-button-selector.d.ts.map +1 -1
- package/dist/components/features/release-media-manager.d.ts.map +1 -1
- package/dist/components/features/video.d.ts +8 -0
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +53 -51
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -15
- package/dist/components/index.js.map +1 -1
- package/dist/components/loading/unified-skeleton.d.ts.map +1 -1
- package/dist/components/meeting-scheduler/index.cjs +31 -31
- package/dist/components/meeting-scheduler/index.js +3 -3
- package/dist/components/navigation/index.cjs +7 -7
- package/dist/components/navigation/index.js +6 -6
- 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 +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +8 -8
- package/dist/components/tickets/index.js +7 -7
- package/dist/components/ui/index.cjs +6 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +5 -3
- package/dist/components/ui/lazy-mount.d.ts +21 -0
- package/dist/components/ui/lazy-mount.d.ts.map +1 -0
- package/dist/index.cjs +20 -8
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +19 -7
- package/dist/index.js.map +1 -1
- package/dist/types/marketing.d.ts +1 -1
- package/dist/types/marketing.d.ts.map +1 -1
- package/dist/utils/index.cjs +26 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +22 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/media-type.d.ts +26 -0
- package/dist/utils/media-type.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/chat/entity-cards/program-card.tsx +2 -2
- package/src/components/features/media-gallery-manager.tsx +4 -3
- package/src/components/features/push-button-selector.tsx +15 -12
- package/src/components/features/release-media-manager.tsx +3 -2
- package/src/components/features/video.tsx +28 -0
- package/src/components/loading/__tests__/unified-skeleton.test.tsx +16 -0
- package/src/components/loading/unified-skeleton.tsx +5 -1
- package/src/components/ui/__tests__/lazy-mount.test.tsx +46 -0
- package/src/components/ui/index.ts +3 -0
- package/src/components/ui/lazy-mount.tsx +38 -0
- package/src/types/.marketing.md +0 -1
- package/src/types/marketing.ts +4 -2
- package/src/utils/__tests__/media-type.test.ts +30 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/media-type.ts +39 -0
- package/src/utils/vendor-media-stub.ts +7 -7
- package/dist/chunk-3LMYQGAJ.js.map +0 -1
- package/dist/chunk-IKUSKKFZ.cjs.map +0 -1
- package/dist/chunk-JVWRMLQZ.js.map +0 -1
- package/dist/chunk-NNFQLTJ3.cjs.map +0 -1
- package/dist/chunk-QJR32JM2.cjs.map +0 -1
- /package/dist/{chunk-3ELW4VML.js.map → chunk-276BXVOB.js.map} +0 -0
- /package/dist/{chunk-TOUD3AGK.js.map → chunk-36GXR7BR.js.map} +0 -0
- /package/dist/{chunk-5CJKZHWO.js.map → chunk-7554QJ6R.js.map} +0 -0
- /package/dist/{chunk-EJKEDCTH.js.map → chunk-BNZFLIEG.js.map} +0 -0
- /package/dist/{chunk-YFZDIEVC.js.map → chunk-CTZP2NJQ.js.map} +0 -0
- /package/dist/{chunk-KP6VJP36.js.map → chunk-FCKOWGTE.js.map} +0 -0
- /package/dist/{chunk-LNKULR4V.js.map → chunk-GG76BPXJ.js.map} +0 -0
- /package/dist/{chunk-RURZ6QLN.js.map → chunk-HQRKC6P7.js.map} +0 -0
- /package/dist/{chunk-VZSTL5VG.js.map → chunk-J4MVZ53H.js.map} +0 -0
- /package/dist/{chunk-MYEG5ZDT.js.map → chunk-JKWWACNI.js.map} +0 -0
- /package/dist/{chunk-6BJYPG2V.js.map → chunk-LNKBNY3A.js.map} +0 -0
- /package/dist/{chunk-MCWAZ5XI.js.map → chunk-NFWAL6UV.js.map} +0 -0
- /package/dist/{chunk-4T5KOD3G.js.map → chunk-SCMESLXP.js.map} +0 -0
- /package/dist/{chunk-XPJKQ63S.js.map → chunk-UG6SHUGE.js.map} +0 -0
- /package/dist/{chunk-TLKHLGAB.js.map → chunk-VSXFBO4D.js.map} +0 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media type — THE single source of truth for the `media_type` discriminator
|
|
3
|
+
* on media rows (ai_media, program media, vendor media). Zero imports (a leaf),
|
|
4
|
+
* so every consumer reads the same predicate instead of scattering
|
|
5
|
+
* `=== 'video'` literals. `MediaType` (types/marketing.ts) is the row union;
|
|
6
|
+
* these are the two values a post can publish.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MEDIA_TYPE: {
|
|
9
|
+
readonly IMAGE: "image";
|
|
10
|
+
readonly VIDEO: "video";
|
|
11
|
+
};
|
|
12
|
+
/** The two media types a post can publish. */
|
|
13
|
+
export type PublishableMediaType = (typeof MEDIA_TYPE)[keyof typeof MEDIA_TYPE];
|
|
14
|
+
interface MediaTyped {
|
|
15
|
+
media_type?: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function isVideoMedia(media: MediaTyped | null | undefined): boolean;
|
|
18
|
+
export declare function isImageMedia(media: MediaTyped | null | undefined): boolean;
|
|
19
|
+
export declare function isPublishableMediaType(mediaType: string | null | undefined): mediaType is PublishableMediaType;
|
|
20
|
+
/** Split a media list into its videos and images, preserving order. */
|
|
21
|
+
export declare function partitionMediaByType<T extends MediaTyped>(media: readonly T[]): {
|
|
22
|
+
videos: T[];
|
|
23
|
+
images: T[];
|
|
24
|
+
};
|
|
25
|
+
export {};
|
|
26
|
+
//# sourceMappingURL=media-type.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-type.d.ts","sourceRoot":"","sources":["../../src/utils/media-type.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,eAAO,MAAM,UAAU;;;CAGb,CAAC;AAEX,8CAA8C;AAC9C,MAAM,MAAM,oBAAoB,GAAG,CAAC,OAAO,UAAU,CAAC,CAAC,MAAM,OAAO,UAAU,CAAC,CAAC;AAEhF,UAAU,UAAU;IAClB,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE1E;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE1E;AAED,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,SAAS,IAAI,oBAAoB,CAE9G;AAED,uEAAuE;AACvE,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,KAAK,EAAE,SAAS,CAAC,EAAE,GAAG;IAAE,MAAM,EAAE,CAAC,EAAE,CAAC;IAAC,MAAM,EAAE,CAAC,EAAE,CAAA;CAAE,CAK5G"}
|
package/package.json
CHANGED
|
@@ -26,6 +26,7 @@ import { useState } from 'react';
|
|
|
26
26
|
import Image from '../../../embed-shims/next-image';
|
|
27
27
|
import { cn } from '../../../utils/cn';
|
|
28
28
|
import { formatDurationCompact, formatTimeWithTimezone, formatDurationFromRange } from '../../../utils/format';
|
|
29
|
+
import { isImageMedia } from '../../../utils/media-type';
|
|
29
30
|
import { Button } from '../../ui/button/button';
|
|
30
31
|
import { ImageGalleryModal } from '../../ui/image-gallery-modal';
|
|
31
32
|
import { SquareAvatar } from '../../ui/square-avatar';
|
|
@@ -51,7 +52,6 @@ import {
|
|
|
51
52
|
import { EntityPortraitCard } from './entity-portrait-card';
|
|
52
53
|
import { useEntityCardLink } from './use-entity-card-link';
|
|
53
54
|
import { useEntityCardPlaceholder } from './use-entity-card-placeholder';
|
|
54
|
-
|
|
55
55
|
type CardSize = 'default' | 'sm' | 'portrait';
|
|
56
56
|
|
|
57
57
|
/**
|
|
@@ -273,7 +273,7 @@ export function ProgramCard<T extends BaseProgramItem>({
|
|
|
273
273
|
aspect: size === 'sm' ? 'square' : 'wide',
|
|
274
274
|
});
|
|
275
275
|
const coverImage = item.cover_url;
|
|
276
|
-
const images = media.filter(m => m
|
|
276
|
+
const images = media.filter(m => isImageMedia(m));
|
|
277
277
|
const hosts = getHosts(item.hosts);
|
|
278
278
|
const accentColor = 'var(--color-accent-primary)';
|
|
279
279
|
// `status` / `duration_seconds` / `location_name` / `start_at` … live on the
|
|
@@ -4,9 +4,10 @@ import { Upload, Image as ImageIcon, Video as VideoIcon, Trash2, Loader2, GripVe
|
|
|
4
4
|
import type React from 'react';
|
|
5
5
|
import { useState, useRef, useCallback } from 'react';
|
|
6
6
|
import Image from '../../embed-shims/next-image';
|
|
7
|
+
import { isVideoMedia } from '../../utils/media-type';
|
|
7
8
|
import { Button, Card } from '../ui';
|
|
8
|
-
import { Video } from './video';
|
|
9
9
|
|
|
10
|
+
import { Video } from './video';
|
|
10
11
|
export interface MediaItem {
|
|
11
12
|
id?: string | number; // Optional for new items
|
|
12
13
|
media_type: 'image' | 'video' | 'screenshot' | 'demo';
|
|
@@ -157,7 +158,7 @@ export function MediaGalleryManager({
|
|
|
157
158
|
|
|
158
159
|
{/* Media Content */}
|
|
159
160
|
<div className="relative aspect-video overflow-hidden rounded-lg bg-ods-bg">
|
|
160
|
-
{mediaItem
|
|
161
|
+
{isVideoMedia(mediaItem) || mediaItem.media_type === 'demo' ? (
|
|
161
162
|
// <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
|
|
162
163
|
// fit="cover" crops to the aspect-video cell.
|
|
163
164
|
<Video kind="file" url={mediaItem.media_url} fit="cover" className="h-full w-full" />
|
|
@@ -175,7 +176,7 @@ export function MediaGalleryManager({
|
|
|
175
176
|
{/* Media Info */}
|
|
176
177
|
<div className="p-3">
|
|
177
178
|
<div className="mb-1 flex items-center gap-2">
|
|
178
|
-
{mediaItem
|
|
179
|
+
{isVideoMedia(mediaItem) || mediaItem.media_type === 'demo' ? (
|
|
179
180
|
<VideoIcon className="h-4 w-4 text-ods-text-secondary" />
|
|
180
181
|
) : (
|
|
181
182
|
<ImageIcon className="h-4 w-4 text-ods-text-secondary" />
|
|
@@ -7,8 +7,11 @@ import { UnifiedSkeleton } from '../loading';
|
|
|
7
7
|
import { Badge, Input } from '../ui';
|
|
8
8
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
/** Option ids are whatever the consumer keys its rows by — string (UUID / enum value) or numeric row id. */
|
|
11
|
+
export type SelectableOptionId = string | number;
|
|
12
|
+
|
|
13
|
+
export interface SelectableOption<Id extends SelectableOptionId = string> {
|
|
14
|
+
id: Id; // Selection ID (UUID / value for platforms, numeric row id for DB-keyed rows)
|
|
12
15
|
name: string; // Primary identifier (platform enum or item name)
|
|
13
16
|
displayName?: string; // Optional display name (for platforms)
|
|
14
17
|
description?: string;
|
|
@@ -51,10 +54,10 @@ export interface PushButtonSelectorLazyLoad {
|
|
|
51
54
|
maxHeight?: string;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
interface PushButtonSelectorProps {
|
|
55
|
-
options: SelectableOption[];
|
|
56
|
-
selectedIds:
|
|
57
|
-
onSelectionChange: (selectedIds:
|
|
57
|
+
interface PushButtonSelectorProps<Id extends SelectableOptionId = string> {
|
|
58
|
+
options: SelectableOption<Id>[];
|
|
59
|
+
selectedIds: Id[];
|
|
60
|
+
onSelectionChange: (selectedIds: Id[]) => void;
|
|
58
61
|
multiSelect?: boolean;
|
|
59
62
|
title?: string;
|
|
60
63
|
helpText?: string;
|
|
@@ -116,7 +119,7 @@ function PushButtonSelectorError({ message, title }: { message: string; title?:
|
|
|
116
119
|
);
|
|
117
120
|
}
|
|
118
121
|
|
|
119
|
-
export function PushButtonSelector({
|
|
122
|
+
export function PushButtonSelector<Id extends SelectableOptionId = string>({
|
|
120
123
|
options,
|
|
121
124
|
selectedIds,
|
|
122
125
|
onSelectionChange,
|
|
@@ -134,7 +137,7 @@ export function PushButtonSelector({
|
|
|
134
137
|
lazyLoad,
|
|
135
138
|
emptyMessage,
|
|
136
139
|
footer,
|
|
137
|
-
}: PushButtonSelectorProps) {
|
|
140
|
+
}: PushButtonSelectorProps<Id>) {
|
|
138
141
|
// Lazy-load sentinel: observe within the scroll viewport so onLoadMore
|
|
139
142
|
// fires as the user approaches the bottom. Hooks run unconditionally
|
|
140
143
|
// (before the early returns) per the rules of hooks. Pass a STABLE
|
|
@@ -209,7 +212,7 @@ export function PushButtonSelector({
|
|
|
209
212
|
console.warn('[PushButtonSelector] Invalid selected IDs filtered:', invalidIds);
|
|
210
213
|
}
|
|
211
214
|
|
|
212
|
-
const toggleSelection = (optionId:
|
|
215
|
+
const toggleSelection = (optionId: Id) => {
|
|
213
216
|
if (multiSelect) {
|
|
214
217
|
const isSelected = validSelectedIds.includes(optionId);
|
|
215
218
|
if (isSelected) {
|
|
@@ -226,7 +229,7 @@ export function PushButtonSelector({
|
|
|
226
229
|
const getSelectedOptions = () => options.filter(option => validSelectedIds.includes(option.id));
|
|
227
230
|
|
|
228
231
|
// Helper to render a single option
|
|
229
|
-
const renderOption = (option: SelectableOption) => {
|
|
232
|
+
const renderOption = (option: SelectableOption<Id>) => {
|
|
230
233
|
const isSelected = validSelectedIds.includes(option.id);
|
|
231
234
|
|
|
232
235
|
const optionEl = (
|
|
@@ -297,8 +300,8 @@ export function PushButtonSelector({
|
|
|
297
300
|
}
|
|
298
301
|
|
|
299
302
|
// Group options by section
|
|
300
|
-
const optionsBySection = new Map<string, SelectableOption[]>();
|
|
301
|
-
const ungroupedOptions: SelectableOption[] = [];
|
|
303
|
+
const optionsBySection = new Map<string, SelectableOption<Id>[]>();
|
|
304
|
+
const ungroupedOptions: SelectableOption<Id>[] = [];
|
|
302
305
|
|
|
303
306
|
options.forEach(option => {
|
|
304
307
|
if (option.section) {
|
|
@@ -4,9 +4,10 @@ import { Trash2, Plus, Image as ImageIcon, Video as VideoIcon, Upload, Loader2,
|
|
|
4
4
|
import { useState, useRef } from 'react';
|
|
5
5
|
import type { ChangeEvent, DragEvent } from 'react';
|
|
6
6
|
import Image from '../../embed-shims/next-image';
|
|
7
|
+
import { isVideoMedia } from '../../utils/media-type';
|
|
7
8
|
import { Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui';
|
|
8
|
-
import { Video } from './video';
|
|
9
9
|
|
|
10
|
+
import { Video } from './video';
|
|
10
11
|
export interface ReleaseMediaItem {
|
|
11
12
|
media_type: 'image' | 'video' | 'screenshot' | 'demo';
|
|
12
13
|
media_url: string;
|
|
@@ -199,7 +200,7 @@ export function ReleaseMediaManager({ media, onChange, onUpload, className = ''
|
|
|
199
200
|
{/* Media Preview */}
|
|
200
201
|
{item.media_url && (
|
|
201
202
|
<div className="relative aspect-video bg-ods-bg">
|
|
202
|
-
{item
|
|
203
|
+
{isVideoMedia(item) || item.media_type === 'demo' ? (
|
|
203
204
|
// <Video> SSOT (MuxPlayer) — plays Mux HLS + MP4 alike;
|
|
204
205
|
// fit="cover" crops to the aspect-video cell.
|
|
205
206
|
<Video kind="file" url={item.media_url} fit="cover" className="h-full w-full" />
|
|
@@ -34,6 +34,7 @@ import MuxPlayer from '@mux/mux-player-react';
|
|
|
34
34
|
import type React from 'react';
|
|
35
35
|
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
|
36
36
|
import { useAuthedAssetSrc } from '../../hooks/use-authed-asset-src';
|
|
37
|
+
import { useNearViewport } from '../../hooks/use-near-viewport';
|
|
37
38
|
import { fetchPriorityProp } from '../../utils/fetch-priority';
|
|
38
39
|
import { Button } from '../ui/button';
|
|
39
40
|
import { useIosNativeVideoFullscreen } from './use-ios-native-video-fullscreen';
|
|
@@ -396,6 +397,14 @@ interface VideoCommonProps {
|
|
|
396
397
|
className?: string;
|
|
397
398
|
/** Accessible label (used as YT facade title; ignored for file branch). */
|
|
398
399
|
title?: string;
|
|
400
|
+
/**
|
|
401
|
+
* Mount the player only once its box comes near the viewport (shared
|
|
402
|
+
* fire-once IntersectionObserver, `NEAR_VIEWPORT_ROOT_MARGIN` lookahead).
|
|
403
|
+
* Until then the box shows the poster (or nothing) — the caller's container
|
|
404
|
+
* still owns the size, so nothing shifts when the player mounts. For grids
|
|
405
|
+
* of players: a library of 20 clips costs 20 posters, not 20 players.
|
|
406
|
+
*/
|
|
407
|
+
lazy?: boolean;
|
|
399
408
|
/**
|
|
400
409
|
* YouTube-only: hide YT player chrome (controls, info, fullscreen, related
|
|
401
410
|
* videos, keyboard shortcuts). Used for marketing/landing-page embeds that
|
|
@@ -552,12 +561,31 @@ export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
|
|
|
552
561
|
// =============================================================================
|
|
553
562
|
|
|
554
563
|
export function Video(props: VideoProps): React.ReactElement | null {
|
|
564
|
+
// Hooks run unconditionally; the gate only applies when `lazy` is set.
|
|
565
|
+
const { ref: nearRef, isNear } = useNearViewport<HTMLDivElement>();
|
|
555
566
|
const url = props.url;
|
|
556
567
|
if (!url) return null;
|
|
557
568
|
|
|
558
569
|
const effectiveKind = resolveKind(props, url);
|
|
559
570
|
const layout = props.layout ?? 'native';
|
|
560
571
|
|
|
572
|
+
if (props.lazy && !isNear) {
|
|
573
|
+
// Same box, poster only: the observer target IS the reserved space.
|
|
574
|
+
return wrapWithLayout(
|
|
575
|
+
<div
|
|
576
|
+
ref={nearRef}
|
|
577
|
+
className={props.className}
|
|
578
|
+
style={
|
|
579
|
+
props.poster
|
|
580
|
+
? { backgroundImage: `url(${props.poster})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
|
581
|
+
: undefined
|
|
582
|
+
}
|
|
583
|
+
aria-hidden="true"
|
|
584
|
+
/>,
|
|
585
|
+
layout,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
561
589
|
const inner =
|
|
562
590
|
effectiveKind === 'youtube' ? (
|
|
563
591
|
<YouTubeFacade
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { render } from '@testing-library/react';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { UnifiedSkeleton } from '../unified-skeleton';
|
|
4
|
+
|
|
5
|
+
describe('UnifiedSkeleton sizing', () => {
|
|
6
|
+
it('applies the default height when nothing sizes the box', () => {
|
|
7
|
+
const { container } = render(<UnifiedSkeleton className="w-full" />);
|
|
8
|
+
expect(container.firstElementChild?.className).toMatch(/\bh-4\b/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('lets an aspect-* utility size the box instead of the default height', () => {
|
|
12
|
+
const { container } = render(<UnifiedSkeleton className="aspect-video w-full" />);
|
|
13
|
+
expect(container.firstElementChild?.className).not.toMatch(/\bh-4\b/);
|
|
14
|
+
expect(container.firstElementChild?.className).toMatch(/aspect-video/);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -60,6 +60,10 @@ export function UnifiedSkeleton({
|
|
|
60
60
|
|
|
61
61
|
const animationClasses = animate ? 'animate-pulse motion-reduce:animate-none' : '';
|
|
62
62
|
|
|
63
|
+
// An `aspect-*` utility sizes the box itself; a default height would win
|
|
64
|
+
// over it (explicit height beats aspect-ratio) and collapse the box to 16px.
|
|
65
|
+
const sizedByAspect = /(?:^|\s)aspect-/.test(className ?? '');
|
|
66
|
+
|
|
63
67
|
const style: React.CSSProperties = {
|
|
64
68
|
width: width,
|
|
65
69
|
height: height,
|
|
@@ -70,7 +74,7 @@ export function UnifiedSkeleton({
|
|
|
70
74
|
className={cn(
|
|
71
75
|
baseClasses,
|
|
72
76
|
variantClasses[variant],
|
|
73
|
-
!height && !style.height && defaultHeights[variant],
|
|
77
|
+
!height && !style.height && !sizedByAspect && defaultHeights[variant],
|
|
74
78
|
!width && !style.width && variant !== 'circular' && 'w-full',
|
|
75
79
|
animationClasses,
|
|
76
80
|
className,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { act, render, screen } from '@testing-library/react';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { LazyMount } from '../lazy-mount';
|
|
4
|
+
|
|
5
|
+
type IOCallback = (entries: Array<{ isIntersecting: boolean; intersectionRatio: number; target: Element }>) => void;
|
|
6
|
+
|
|
7
|
+
describe('LazyMount', () => {
|
|
8
|
+
let callbacks: IOCallback[];
|
|
9
|
+
let observed: Element[];
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
callbacks = [];
|
|
12
|
+
observed = [];
|
|
13
|
+
vi.stubGlobal(
|
|
14
|
+
'IntersectionObserver',
|
|
15
|
+
class {
|
|
16
|
+
constructor(cb: IOCallback) {
|
|
17
|
+
callbacks.push(cb);
|
|
18
|
+
}
|
|
19
|
+
observe(el: Element) {
|
|
20
|
+
observed.push(el);
|
|
21
|
+
}
|
|
22
|
+
unobserve() {}
|
|
23
|
+
disconnect() {}
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
28
|
+
|
|
29
|
+
it('renders the placeholder until the box intersects, then the content, and stays mounted', () => {
|
|
30
|
+
render(
|
|
31
|
+
<LazyMount rootMargin="1px" placeholder={<span>placeholder</span>}>
|
|
32
|
+
<span>player</span>
|
|
33
|
+
</LazyMount>,
|
|
34
|
+
);
|
|
35
|
+
expect(screen.getByText('placeholder')).toBeTruthy();
|
|
36
|
+
expect(screen.queryByText('player')).toBeNull();
|
|
37
|
+
// The observed element IS the wrapper the hook registered — no DOM walking.
|
|
38
|
+
const target = observed[observed.length - 1];
|
|
39
|
+
const fire = callbacks[callbacks.length - 1];
|
|
40
|
+
act(() => fire([{ isIntersecting: false, intersectionRatio: 0, target }]));
|
|
41
|
+
expect(screen.queryByText('player')).toBeNull();
|
|
42
|
+
act(() => fire([{ isIntersecting: true, intersectionRatio: 1, target }]));
|
|
43
|
+
expect(screen.getByText('player')).toBeTruthy();
|
|
44
|
+
expect(screen.queryByText('placeholder')).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -218,3 +218,6 @@ export * from './ticket-notes-section';
|
|
|
218
218
|
export * from './marquee-wall';
|
|
219
219
|
export * from './markdown';
|
|
220
220
|
export * from './filter-pill-row';
|
|
221
|
+
|
|
222
|
+
// Viewport-gated mounting for heavy previews (players, large images)
|
|
223
|
+
export { LazyMount, type LazyMountProps } from './lazy-mount';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
4
|
+
import { useNearViewport, NEAR_VIEWPORT_ROOT_MARGIN } from '../../hooks/use-near-viewport';
|
|
5
|
+
|
|
6
|
+
export interface LazyMountProps {
|
|
7
|
+
/** Mounted the first time the box comes within `rootMargin` of the viewport; never unmounted after. */
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
/** What fills the box until then. The box itself must already have its final size (aspect-ratio / min-height). */
|
|
10
|
+
placeholder?: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
style?: CSSProperties;
|
|
13
|
+
/** Lookahead — defaults to the shared `NEAR_VIEWPORT_ROOT_MARGIN`. */
|
|
14
|
+
rootMargin?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Sized box whose CONTENT mounts only near the viewport (shared fire-once
|
|
19
|
+
* IntersectionObserver via `useNearViewport`). The box owns the layout —
|
|
20
|
+
* pass the aspect-ratio / min-height on it — so lazy mounting never shifts
|
|
21
|
+
* anything: the content appears inside a space that was already reserved.
|
|
22
|
+
* Grids of players or large images render their chrome immediately and pay
|
|
23
|
+
* for each preview only when it is about to be seen.
|
|
24
|
+
*/
|
|
25
|
+
export function LazyMount({
|
|
26
|
+
children,
|
|
27
|
+
placeholder = null,
|
|
28
|
+
className,
|
|
29
|
+
style,
|
|
30
|
+
rootMargin = NEAR_VIEWPORT_ROOT_MARGIN,
|
|
31
|
+
}: LazyMountProps) {
|
|
32
|
+
const { ref, isNear } = useNearViewport<HTMLDivElement>(rootMargin);
|
|
33
|
+
return (
|
|
34
|
+
<div ref={ref} className={className} style={style}>
|
|
35
|
+
{isNear ? children : placeholder}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
package/src/types/.marketing.md
CHANGED
package/src/types/marketing.ts
CHANGED
|
@@ -109,6 +109,7 @@ export interface CreateAIContentInput {
|
|
|
109
109
|
// AI Media Types
|
|
110
110
|
// =============================================================================
|
|
111
111
|
|
|
112
|
+
/** Row union. Compare through `utils/media-type` (`isVideoMedia` / `isImageMedia` / `MEDIA_TYPE`), never a literal. */
|
|
112
113
|
export type MediaType = 'image' | 'video' | 'figma_export';
|
|
113
114
|
export type MediaProvider =
|
|
114
115
|
| 'dalle'
|
|
@@ -155,8 +156,9 @@ export interface AIMedia {
|
|
|
155
156
|
social_account_id: number | null; // FK to social_accounts table - primary source of truth
|
|
156
157
|
created_at: string;
|
|
157
158
|
updated_at: string;
|
|
158
|
-
//
|
|
159
|
-
|
|
159
|
+
// Override fields. NOTE: there is no `selected_for_publish` on media — a
|
|
160
|
+
// media item is "selected" by being ATTACHED to a post that is selected
|
|
161
|
+
// (`ai_content_media`); consumers derive the flag in their DAL.
|
|
160
162
|
override_url: string | null;
|
|
161
163
|
override_at: string | null;
|
|
162
164
|
override_by: string | null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { MEDIA_TYPE, isImageMedia, isPublishableMediaType, isVideoMedia, partitionMediaByType } from '../media-type';
|
|
3
|
+
|
|
4
|
+
describe('media-type', () => {
|
|
5
|
+
it('predicates read the discriminator and tolerate missing rows', () => {
|
|
6
|
+
expect(isVideoMedia({ media_type: MEDIA_TYPE.VIDEO })).toBe(true);
|
|
7
|
+
expect(isImageMedia({ media_type: MEDIA_TYPE.VIDEO })).toBe(false);
|
|
8
|
+
expect(isVideoMedia(null)).toBe(false);
|
|
9
|
+
expect(isImageMedia(undefined)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('only image and video are publishable', () => {
|
|
13
|
+
expect(isPublishableMediaType('image')).toBe(true);
|
|
14
|
+
expect(isPublishableMediaType('video')).toBe(true);
|
|
15
|
+
expect(isPublishableMediaType('figma_export')).toBe(false);
|
|
16
|
+
expect(isPublishableMediaType(null)).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('partitions in order', () => {
|
|
20
|
+
const rows = [
|
|
21
|
+
{ id: 1, media_type: 'video' },
|
|
22
|
+
{ id: 2, media_type: 'image' },
|
|
23
|
+
{ id: 3, media_type: 'video' },
|
|
24
|
+
{ id: 4, media_type: 'logo' },
|
|
25
|
+
];
|
|
26
|
+
const { videos, images } = partitionMediaByType(rows);
|
|
27
|
+
expect(videos.map(r => r.id)).toEqual([1, 3]);
|
|
28
|
+
expect(images.map(r => r.id)).toEqual([2]);
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/utils/index.ts
CHANGED
|
@@ -365,3 +365,6 @@ export * from './embed-url-converters';
|
|
|
365
365
|
export * from './hubspot-meetings-convention';
|
|
366
366
|
export * from './page-header-constants';
|
|
367
367
|
export * from './first-touch-attribution';
|
|
368
|
+
|
|
369
|
+
// Media type SSOT — `media_type` discriminator predicates (leaf, zero imports)
|
|
370
|
+
export * from './media-type';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media type — THE single source of truth for the `media_type` discriminator
|
|
3
|
+
* on media rows (ai_media, program media, vendor media). Zero imports (a leaf),
|
|
4
|
+
* so every consumer reads the same predicate instead of scattering
|
|
5
|
+
* `=== 'video'` literals. `MediaType` (types/marketing.ts) is the row union;
|
|
6
|
+
* these are the two values a post can publish.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MEDIA_TYPE = {
|
|
10
|
+
IMAGE: 'image',
|
|
11
|
+
VIDEO: 'video',
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/** The two media types a post can publish. */
|
|
15
|
+
export type PublishableMediaType = (typeof MEDIA_TYPE)[keyof typeof MEDIA_TYPE];
|
|
16
|
+
|
|
17
|
+
interface MediaTyped {
|
|
18
|
+
media_type?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isVideoMedia(media: MediaTyped | null | undefined): boolean {
|
|
22
|
+
return media?.media_type === MEDIA_TYPE.VIDEO;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isImageMedia(media: MediaTyped | null | undefined): boolean {
|
|
26
|
+
return media?.media_type === MEDIA_TYPE.IMAGE;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isPublishableMediaType(mediaType: string | null | undefined): mediaType is PublishableMediaType {
|
|
30
|
+
return mediaType === MEDIA_TYPE.VIDEO || mediaType === MEDIA_TYPE.IMAGE;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Split a media list into its videos and images, preserving order. */
|
|
34
|
+
export function partitionMediaByType<T extends MediaTyped>(media: readonly T[]): { videos: T[]; images: T[] } {
|
|
35
|
+
return {
|
|
36
|
+
videos: media.filter(m => isVideoMedia(m)),
|
|
37
|
+
images: media.filter(m => isImageMedia(m)),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Vendor media utilities for UI kit
|
|
2
2
|
// Real implementation copied from main project
|
|
3
3
|
|
|
4
|
+
import { isImageMedia, isVideoMedia } from './media-type';
|
|
4
5
|
import { fixSupabaseStorageUrl } from './url-fix';
|
|
5
|
-
|
|
6
6
|
export interface VendorMedia {
|
|
7
7
|
media_type: 'logo' | 'image' | 'video';
|
|
8
8
|
media_url: string;
|
|
@@ -44,7 +44,7 @@ export function getVendorLogo(vendor: VendorWithMedia): string | null {
|
|
|
44
44
|
* Get the main image URL from vendor_media array
|
|
45
45
|
*/
|
|
46
46
|
export function getVendorImage(vendor: VendorWithMedia): string | null {
|
|
47
|
-
const imageMedia = vendor.vendor_media?.find(m => m
|
|
47
|
+
const imageMedia = vendor.vendor_media?.find(m => isImageMedia(m));
|
|
48
48
|
return imageMedia?.media_url ? fixSupabaseStorageUrl(imageMedia.media_url) : null;
|
|
49
49
|
}
|
|
50
50
|
|
|
@@ -52,7 +52,7 @@ export function getVendorImage(vendor: VendorWithMedia): string | null {
|
|
|
52
52
|
* Get the video URL from vendor_media array
|
|
53
53
|
*/
|
|
54
54
|
export function getVendorVideo(vendor: VendorWithMedia): string | null {
|
|
55
|
-
const videoMedia = vendor.vendor_media?.find(m => m
|
|
55
|
+
const videoMedia = vendor.vendor_media?.find(m => isVideoMedia(m));
|
|
56
56
|
return videoMedia?.media_url ? fixSupabaseStorageUrl(videoMedia.media_url) : null;
|
|
57
57
|
}
|
|
58
58
|
|
|
@@ -75,8 +75,8 @@ export function getVendorMediaGrouped(vendor: VendorWithMedia): {
|
|
|
75
75
|
|
|
76
76
|
return {
|
|
77
77
|
logos: media.filter(m => m.media_type === 'logo').map(m => fixSupabaseStorageUrl(m.media_url)),
|
|
78
|
-
images: media.filter(m => m
|
|
79
|
-
videos: media.filter(m => m
|
|
78
|
+
images: media.filter(m => isImageMedia(m)).map(m => fixSupabaseStorageUrl(m.media_url)),
|
|
79
|
+
videos: media.filter(m => isVideoMedia(m)).map(m => fixSupabaseStorageUrl(m.media_url)),
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
@@ -115,8 +115,8 @@ export function getVendorMediaCount(vendor: VendorWithMedia): {
|
|
|
115
115
|
const media = vendor.vendor_media || [];
|
|
116
116
|
|
|
117
117
|
const logos = media.filter(m => m.media_type === 'logo').length;
|
|
118
|
-
const images = media.filter(m => m
|
|
119
|
-
const videos = media.filter(m => m
|
|
118
|
+
const images = media.filter(m => isImageMedia(m)).length;
|
|
119
|
+
const videos = media.filter(m => isVideoMedia(m)).length;
|
|
120
120
|
|
|
121
121
|
return {
|
|
122
122
|
logos,
|