@lovett/ui 0.0.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/README.md +28 -0
- package/package.json +53 -0
- package/src/accordion.tsx +153 -0
- package/src/atoms.tsx +197 -0
- package/src/badge.tsx +91 -0
- package/src/brand-logo-tile.tsx +200 -0
- package/src/bulk-action-bar.tsx +104 -0
- package/src/button-group.tsx +46 -0
- package/src/button.tsx +93 -0
- package/src/calculator-shell-v2.tsx +380 -0
- package/src/calculator-shell.tsx +310 -0
- package/src/card.tsx +369 -0
- package/src/checkbox.tsx +114 -0
- package/src/chip-nav.tsx +347 -0
- package/src/choropleth.tsx +62 -0
- package/src/code-block.tsx +235 -0
- package/src/completion-ring.tsx +119 -0
- package/src/copy-field.tsx +169 -0
- package/src/data-grid/column-options-menu.tsx +127 -0
- package/src/data-grid/data-grid.tsx +391 -0
- package/src/data-grid/drawer-panel.tsx +146 -0
- package/src/data-grid/index.ts +29 -0
- package/src/data-grid/sortable-parts.tsx +215 -0
- package/src/data-grid/table-body.tsx +238 -0
- package/src/data-grid/table-elements.tsx +135 -0
- package/src/data-grid/table-header.tsx +95 -0
- package/src/data-grid/table-overlays.tsx +78 -0
- package/src/data-grid/table-parts.tsx +15 -0
- package/src/data-grid/table-summary-footer.tsx +101 -0
- package/src/data-grid/table-view.tsx +176 -0
- package/src/data-grid/types.ts +141 -0
- package/src/data-grid/use-grid-columns.ts +311 -0
- package/src/data-grid/use-grid-editing.ts +154 -0
- package/src/device-frame.tsx +274 -0
- package/src/dropdown-button.tsx +221 -0
- package/src/dropdown-menu.tsx +450 -0
- package/src/empty-state.tsx +85 -0
- package/src/file-upload/file-drop-zone.tsx +156 -0
- package/src/file-upload/file-preview-grid.tsx +39 -0
- package/src/file-upload/file-preview-item.tsx +175 -0
- package/src/file-upload/file-thumbnail.tsx +98 -0
- package/src/file-upload/file-upload-button.tsx +95 -0
- package/src/file-upload/index.ts +13 -0
- package/src/file-upload/use-file-upload.ts +371 -0
- package/src/filter-bar.tsx +125 -0
- package/src/filter-dropdown.tsx +289 -0
- package/src/floating-drawer.tsx +420 -0
- package/src/folder-card.tsx +188 -0
- package/src/folder-tree-picker.tsx +511 -0
- package/src/format.ts +117 -0
- package/src/frame-stack.tsx +73 -0
- package/src/hero-form-card.tsx +471 -0
- package/src/index.ts +372 -0
- package/src/input.tsx +130 -0
- package/src/kbd.tsx +41 -0
- package/src/lib/layout.ts +22 -0
- package/src/lib/utils.ts +6 -0
- package/src/markdown.tsx +214 -0
- package/src/menu-button.tsx +142 -0
- package/src/meta-previews/CreativeMedia.tsx +119 -0
- package/src/meta-previews/InstagramExplorePreview.tsx +340 -0
- package/src/meta-previews/InstagramFeedCarousel.tsx +245 -0
- package/src/meta-previews/InstagramFeedPreview.tsx +259 -0
- package/src/meta-previews/MetaFeedCarousel.tsx +228 -0
- package/src/meta-previews/MetaFeedPreview.tsx +294 -0
- package/src/meta-previews/MetaInstreamPreview.tsx +135 -0
- package/src/meta-previews/MetaMessengerPreview.tsx +795 -0
- package/src/meta-previews/MetaReelPreview.tsx +750 -0
- package/src/meta-previews/MetaRightColumnPreview.tsx +245 -0
- package/src/meta-previews/MetaSearchPreview.tsx +113 -0
- package/src/meta-previews/MetaStoryPreview.tsx +1132 -0
- package/src/meta-previews/ProfileAvatar.tsx +79 -0
- package/src/meta-previews/defaults.ts +27 -0
- package/src/meta-previews/index.ts +37 -0
- package/src/meta-previews/types.ts +219 -0
- package/src/metric-card.tsx +117 -0
- package/src/modal.tsx +131 -0
- package/src/option-tile.tsx +252 -0
- package/src/page-hero.tsx +78 -0
- package/src/page-shell.tsx +440 -0
- package/src/pagination.tsx +86 -0
- package/src/pill-button.tsx +103 -0
- package/src/profile-section.tsx +389 -0
- package/src/progress-bar.tsx +84 -0
- package/src/radio.tsx +172 -0
- package/src/range-slider.tsx +161 -0
- package/src/resizable.tsx +282 -0
- package/src/search-bar.tsx +81 -0
- package/src/segmented-pill.tsx +143 -0
- package/src/shell.tsx +464 -0
- package/src/slider.tsx +107 -0
- package/src/sortable-table.tsx +198 -0
- package/src/sortable.tsx +137 -0
- package/src/stat-row.tsx +132 -0
- package/src/stats-grid.tsx +38 -0
- package/src/step-loader.tsx +141 -0
- package/src/styles.css +562 -0
- package/src/tabs.tsx +143 -0
- package/src/tag-chip-input.tsx +394 -0
- package/src/textarea.tsx +39 -0
- package/src/toast.tsx +59 -0
- package/src/tokens.css +579 -0
package/src/markdown.tsx
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MarkdownRenderer — renders a markdown string using react-markdown + remark-gfm.
|
|
3
|
+
* Component overrides match the chat-message.tsx config from brand-ai-extension.
|
|
4
|
+
*
|
|
5
|
+
* Promoted from brand-ai-extension (ADR-036 D20).
|
|
6
|
+
* Extracted from components/chat/chat-message.tsx (lines 15-110 — the
|
|
7
|
+
* MARKDOWN_COMPONENTS and REMARK_PLUGINS constants + renderMarkdown helper).
|
|
8
|
+
*
|
|
9
|
+
* Token note: the source referenced --primary for blockquote border, link
|
|
10
|
+
* colour, and ordered-list marker accent. --primary is an internal token.
|
|
11
|
+
* Replaced with the public --accent token throughout.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
InputHTMLAttributes,
|
|
16
|
+
HTMLAttributes,
|
|
17
|
+
AnchorHTMLAttributes,
|
|
18
|
+
} from 'react'
|
|
19
|
+
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
20
|
+
import remarkGfm from 'remark-gfm'
|
|
21
|
+
import { MarkdownCode } from './code-block'
|
|
22
|
+
|
|
23
|
+
type NodeProps = HTMLAttributes<HTMLElement>
|
|
24
|
+
type AnchorProps = AnchorHTMLAttributes<HTMLAnchorElement>
|
|
25
|
+
|
|
26
|
+
const MARKDOWN_COMPONENTS: Components = {
|
|
27
|
+
h1: ({ children }: NodeProps) => (
|
|
28
|
+
<h1
|
|
29
|
+
className="mt-6 mb-3 text-xl font-bold first:mt-0"
|
|
30
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
31
|
+
>
|
|
32
|
+
{children}
|
|
33
|
+
</h1>
|
|
34
|
+
),
|
|
35
|
+
h2: ({ children }: NodeProps) => (
|
|
36
|
+
<h2
|
|
37
|
+
className="mt-5 mb-2 text-lg font-bold first:mt-0"
|
|
38
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</h2>
|
|
42
|
+
),
|
|
43
|
+
h3: ({ children }: NodeProps) => (
|
|
44
|
+
<h3
|
|
45
|
+
className="mt-4 mb-2 text-base font-semibold first:mt-0"
|
|
46
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
47
|
+
>
|
|
48
|
+
{children}
|
|
49
|
+
</h3>
|
|
50
|
+
),
|
|
51
|
+
p: ({ children }: NodeProps) => (
|
|
52
|
+
<p
|
|
53
|
+
className="mb-4 leading-relaxed last:mb-0"
|
|
54
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
55
|
+
>
|
|
56
|
+
{children}
|
|
57
|
+
</p>
|
|
58
|
+
),
|
|
59
|
+
// list-outside + left padding so wrapped lines indent under the text (not the
|
|
60
|
+
// marker). Bullets use text-tertiary; ordered markers use --accent + bold so
|
|
61
|
+
// the numbering reads as deliberate hierarchy rather than browser-default grey.
|
|
62
|
+
ul: ({ children }: NodeProps) => (
|
|
63
|
+
<ul
|
|
64
|
+
className="mb-4 list-disc list-outside space-y-2 pl-6"
|
|
65
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</ul>
|
|
69
|
+
),
|
|
70
|
+
ol: ({ children }: NodeProps) => (
|
|
71
|
+
<ol
|
|
72
|
+
className="mb-4 list-decimal list-outside space-y-2 pl-6 marker:font-semibold"
|
|
73
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
74
|
+
>
|
|
75
|
+
{children}
|
|
76
|
+
</ol>
|
|
77
|
+
),
|
|
78
|
+
li: ({ children }: NodeProps) => (
|
|
79
|
+
<li
|
|
80
|
+
className="pl-1 leading-relaxed"
|
|
81
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
82
|
+
>
|
|
83
|
+
{children}
|
|
84
|
+
</li>
|
|
85
|
+
),
|
|
86
|
+
strong: ({ children }: NodeProps) => (
|
|
87
|
+
<strong className="font-semibold" style={{ color: 'rgb(var(--foreground))' }}>
|
|
88
|
+
{children}
|
|
89
|
+
</strong>
|
|
90
|
+
),
|
|
91
|
+
em: ({ children }: NodeProps) => (
|
|
92
|
+
<em className="italic" style={{ color: 'rgb(var(--foreground))' }}>
|
|
93
|
+
{children}
|
|
94
|
+
</em>
|
|
95
|
+
),
|
|
96
|
+
// code: inline chip or fenced block (handled by CodeBlock via MarkdownCode).
|
|
97
|
+
// pre just passes through so the highlighter owns its own container.
|
|
98
|
+
code: MarkdownCode,
|
|
99
|
+
pre: ({ children }: NodeProps) => <>{children}</>,
|
|
100
|
+
blockquote: ({ children }: NodeProps) => (
|
|
101
|
+
<blockquote
|
|
102
|
+
className="my-4 border-l-4 py-1 pl-4 italic"
|
|
103
|
+
style={{
|
|
104
|
+
borderColor: 'rgb(var(--accent))',
|
|
105
|
+
color: 'rgb(var(--muted-foreground))',
|
|
106
|
+
}}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</blockquote>
|
|
110
|
+
),
|
|
111
|
+
hr: () => (
|
|
112
|
+
<hr className="my-6" style={{ borderColor: 'rgb(var(--border))' }} />
|
|
113
|
+
),
|
|
114
|
+
a: ({ href, children }: AnchorProps) => (
|
|
115
|
+
<a
|
|
116
|
+
href={href}
|
|
117
|
+
className="hover:underline"
|
|
118
|
+
style={{ color: 'rgb(var(--accent))' }}
|
|
119
|
+
target="_blank"
|
|
120
|
+
rel="noopener noreferrer"
|
|
121
|
+
>
|
|
122
|
+
{children}
|
|
123
|
+
</a>
|
|
124
|
+
),
|
|
125
|
+
// GFM additions — tables, task lists, strikethrough.
|
|
126
|
+
table: ({ children }: NodeProps) => (
|
|
127
|
+
<div
|
|
128
|
+
className="my-4 overflow-x-auto"
|
|
129
|
+
style={{
|
|
130
|
+
borderRadius: 'var(--radius-md)',
|
|
131
|
+
border: '1px solid rgb(var(--border))',
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<table className="w-full border-collapse text-sm">{children}</table>
|
|
135
|
+
</div>
|
|
136
|
+
),
|
|
137
|
+
thead: ({ children }: NodeProps) => (
|
|
138
|
+
<thead
|
|
139
|
+
style={{
|
|
140
|
+
background: 'rgb(var(--muted))',
|
|
141
|
+
color: 'rgb(var(--muted-foreground))',
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
{children}
|
|
145
|
+
</thead>
|
|
146
|
+
),
|
|
147
|
+
tbody: ({ children }: NodeProps) => <tbody>{children}</tbody>,
|
|
148
|
+
tr: ({ children }: NodeProps) => (
|
|
149
|
+
<tr
|
|
150
|
+
className="first:border-t-0"
|
|
151
|
+
style={{ borderTop: '1px solid rgb(var(--border))' }}
|
|
152
|
+
>
|
|
153
|
+
{children}
|
|
154
|
+
</tr>
|
|
155
|
+
),
|
|
156
|
+
th: ({ children }: NodeProps) => (
|
|
157
|
+
<th className="px-3 py-2 text-left font-medium">{children}</th>
|
|
158
|
+
),
|
|
159
|
+
td: ({ children }: NodeProps) => (
|
|
160
|
+
<td
|
|
161
|
+
className="px-3 py-2 align-top"
|
|
162
|
+
style={{ color: 'rgb(var(--foreground))' }}
|
|
163
|
+
>
|
|
164
|
+
{children}
|
|
165
|
+
</td>
|
|
166
|
+
),
|
|
167
|
+
del: ({ children }: NodeProps) => (
|
|
168
|
+
<del className="line-through" style={{ color: 'rgb(var(--muted-foreground))' }}>
|
|
169
|
+
{children}
|
|
170
|
+
</del>
|
|
171
|
+
),
|
|
172
|
+
// Task-list checkboxes rendered by remark-gfm for "- [ ] ..." / "- [x] ...".
|
|
173
|
+
input: (props: InputHTMLAttributes<HTMLInputElement>) =>
|
|
174
|
+
props.type === 'checkbox' ? (
|
|
175
|
+
<input
|
|
176
|
+
{...props}
|
|
177
|
+
disabled
|
|
178
|
+
className="mr-1.5 -translate-y-px"
|
|
179
|
+
style={{ accentColor: 'rgb(var(--accent))' }}
|
|
180
|
+
/>
|
|
181
|
+
) : (
|
|
182
|
+
<input {...props} />
|
|
183
|
+
),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const REMARK_PLUGINS = [remarkGfm]
|
|
187
|
+
|
|
188
|
+
export interface MarkdownRendererProps {
|
|
189
|
+
/** Raw markdown string to render. */
|
|
190
|
+
children: string
|
|
191
|
+
/** Optional className applied to the wrapping div. */
|
|
192
|
+
className?: string
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* MarkdownRenderer — drop-in markdown surface with GFM support.
|
|
197
|
+
* Renders headings, lists, code (via CodeBlock), tables, task lists,
|
|
198
|
+
* strikethrough, blockquotes, and links with workspace token styling.
|
|
199
|
+
*/
|
|
200
|
+
export function MarkdownRenderer({ children, className }: MarkdownRendererProps) {
|
|
201
|
+
return (
|
|
202
|
+
<div className={className}>
|
|
203
|
+
<ReactMarkdown
|
|
204
|
+
components={MARKDOWN_COMPONENTS}
|
|
205
|
+
remarkPlugins={REMARK_PLUGINS}
|
|
206
|
+
>
|
|
207
|
+
{children}
|
|
208
|
+
</ReactMarkdown>
|
|
209
|
+
</div>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Re-export for callers that just want the raw component map
|
|
214
|
+
export { MARKDOWN_COMPONENTS as markdownComponents }
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MenuButton — menu-bar dropdown for File / Edit-style affordances.
|
|
3
|
+
*
|
|
4
|
+
* The classic app menu-bar pattern: a bordered secondary button with a
|
|
5
|
+
* trailing chevron that opens a compact menu of actions.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from <DropdownButton>:
|
|
8
|
+
* • DropdownButton — a primary/secondary CTA whose items are
|
|
9
|
+
* icon-tile + title + description rows. For "primary action +
|
|
10
|
+
* secondary picker".
|
|
11
|
+
* • MenuButton — a compact secondary button whose items are
|
|
12
|
+
* `icon? + label + shortcut?` rows. For a menu bar.
|
|
13
|
+
*
|
|
14
|
+
* Trigger is a bordered `.btn .btn-secondary` (NOT a ghost button) —
|
|
15
|
+
* per CLAUDE.md §2, buttons carry a 1px border by default.
|
|
16
|
+
*
|
|
17
|
+
* Built on the <DropdownMenu> compound. Items accept a
|
|
18
|
+
* `{ type: 'separator' }` sentinel for dividers.
|
|
19
|
+
*
|
|
20
|
+
* Workspace ADR: ADR-031 Decision 1.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useId, type ReactNode } from 'react'
|
|
24
|
+
import { ChevronDown } from 'lucide-react'
|
|
25
|
+
import {
|
|
26
|
+
DropdownMenu,
|
|
27
|
+
DropdownMenuContent,
|
|
28
|
+
DropdownMenuItem,
|
|
29
|
+
DropdownMenuSeparator,
|
|
30
|
+
DropdownMenuTrigger,
|
|
31
|
+
type DropdownMenuContentProps,
|
|
32
|
+
} from './dropdown-menu'
|
|
33
|
+
import { cn } from './lib/utils'
|
|
34
|
+
|
|
35
|
+
export interface MenuButtonItem {
|
|
36
|
+
/** Stable id for keying. Falls back to the index. */
|
|
37
|
+
id?: string
|
|
38
|
+
/** Optional leading icon (Lucide or any ReactNode), sized ~14px. */
|
|
39
|
+
icon?: ReactNode
|
|
40
|
+
/** Primary label. */
|
|
41
|
+
label: string
|
|
42
|
+
/** Optional right-aligned shortcut hint, e.g. "⌘C". */
|
|
43
|
+
shortcut?: string
|
|
44
|
+
/** Visual variant. `destructive` tints the row red. */
|
|
45
|
+
variant?: 'default' | 'destructive'
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
onSelect: () => void
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A divider between menu items. */
|
|
51
|
+
export interface MenuButtonSeparator {
|
|
52
|
+
type: 'separator'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type MenuButtonEntry = MenuButtonItem | MenuButtonSeparator
|
|
56
|
+
|
|
57
|
+
export interface MenuButtonProps {
|
|
58
|
+
/** Trigger label — e.g. "File", "Edit". */
|
|
59
|
+
label: string
|
|
60
|
+
/** Menu entries, in order. */
|
|
61
|
+
items: MenuButtonEntry[]
|
|
62
|
+
/** Optional leading icon on the trigger. */
|
|
63
|
+
leadingIcon?: ReactNode
|
|
64
|
+
/** Disable the trigger entirely. */
|
|
65
|
+
disabled?: boolean
|
|
66
|
+
/** Popover alignment relative to the trigger. Default `start`. */
|
|
67
|
+
align?: DropdownMenuContentProps['align']
|
|
68
|
+
/** Popover width. Default `14rem`. */
|
|
69
|
+
menuWidth?: string
|
|
70
|
+
/** Forwarded to the trigger className for layout overrides. */
|
|
71
|
+
className?: string
|
|
72
|
+
/** aria-label for the trigger when the visible label is ambiguous. */
|
|
73
|
+
triggerAriaLabel?: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSeparator(entry: MenuButtonEntry): entry is MenuButtonSeparator {
|
|
77
|
+
return 'type' in entry && entry.type === 'separator'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function MenuButton({
|
|
81
|
+
label,
|
|
82
|
+
items,
|
|
83
|
+
leadingIcon,
|
|
84
|
+
disabled,
|
|
85
|
+
align = 'start',
|
|
86
|
+
menuWidth = '14rem',
|
|
87
|
+
className,
|
|
88
|
+
triggerAriaLabel,
|
|
89
|
+
}: MenuButtonProps) {
|
|
90
|
+
const reactId = useId()
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<DropdownMenu>
|
|
94
|
+
<DropdownMenuTrigger
|
|
95
|
+
unstyled
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
aria-label={triggerAriaLabel}
|
|
98
|
+
className={cn('btn btn-secondary size-sm', className)}
|
|
99
|
+
>
|
|
100
|
+
{leadingIcon}
|
|
101
|
+
<span>{label}</span>
|
|
102
|
+
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
|
103
|
+
</DropdownMenuTrigger>
|
|
104
|
+
<DropdownMenuContent
|
|
105
|
+
align={align}
|
|
106
|
+
sideOffset={6}
|
|
107
|
+
style={{ width: menuWidth, padding: 6 }}
|
|
108
|
+
>
|
|
109
|
+
{items.map((entry, index) => {
|
|
110
|
+
if (isSeparator(entry)) {
|
|
111
|
+
return <DropdownMenuSeparator key={`${reactId}-sep-${index}`} />
|
|
112
|
+
}
|
|
113
|
+
return (
|
|
114
|
+
<DropdownMenuItem
|
|
115
|
+
key={entry.id ?? `${reactId}-${index}`}
|
|
116
|
+
{...(entry.variant ? { variant: entry.variant } : {})}
|
|
117
|
+
{...(entry.disabled ? { disabled: entry.disabled } : {})}
|
|
118
|
+
onClick={entry.onSelect}
|
|
119
|
+
className="!py-1.5 !px-2 !gap-2 text-[13px]"
|
|
120
|
+
>
|
|
121
|
+
<span
|
|
122
|
+
className="inline-flex h-4 w-4 shrink-0 items-center justify-center"
|
|
123
|
+
aria-hidden="true"
|
|
124
|
+
>
|
|
125
|
+
{entry.icon}
|
|
126
|
+
</span>
|
|
127
|
+
<span className="flex-1 truncate">{entry.label}</span>
|
|
128
|
+
{entry.shortcut && (
|
|
129
|
+
<span
|
|
130
|
+
className="shrink-0 text-[11px] tabular-nums"
|
|
131
|
+
style={{ color: 'rgb(var(--text-tertiary))' }}
|
|
132
|
+
>
|
|
133
|
+
{entry.shortcut}
|
|
134
|
+
</span>
|
|
135
|
+
)}
|
|
136
|
+
</DropdownMenuItem>
|
|
137
|
+
)
|
|
138
|
+
})}
|
|
139
|
+
</DropdownMenuContent>
|
|
140
|
+
</DropdownMenu>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback } from 'react';
|
|
2
|
+
import { Play, Volume2, VolumeX } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
// `| undefined` on every optional so the workspace's
|
|
5
|
+
// exactOptionalPropertyTypes consumers may pass `undefined` literals.
|
|
6
|
+
interface CreativeMediaProps {
|
|
7
|
+
src: string;
|
|
8
|
+
alt?: string | undefined;
|
|
9
|
+
mediaType?: 'image' | 'video' | undefined;
|
|
10
|
+
className?: string | undefined;
|
|
11
|
+
style?: React.CSSProperties | undefined;
|
|
12
|
+
autoPlay?: boolean | undefined;
|
|
13
|
+
loop?: boolean | undefined;
|
|
14
|
+
showPlayOverlay?: boolean | undefined;
|
|
15
|
+
thumbnailSrc?: string | undefined;
|
|
16
|
+
onError?: ((e: React.SyntheticEvent) => void) | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const CreativeMedia: React.FC<CreativeMediaProps> = ({
|
|
20
|
+
src,
|
|
21
|
+
alt = 'Ad creative',
|
|
22
|
+
mediaType,
|
|
23
|
+
className,
|
|
24
|
+
style,
|
|
25
|
+
autoPlay = false,
|
|
26
|
+
loop = true,
|
|
27
|
+
showPlayOverlay = true,
|
|
28
|
+
thumbnailSrc,
|
|
29
|
+
onError,
|
|
30
|
+
}) => {
|
|
31
|
+
const isVideo = mediaType === 'video' || /\.(mp4|mov|webm|m4v)$/i.test(src);
|
|
32
|
+
const videoRef = useRef<HTMLVideoElement>(null);
|
|
33
|
+
const [isPlaying, setIsPlaying] = useState(autoPlay ?? false);
|
|
34
|
+
const [isMuted, setIsMuted] = useState(true);
|
|
35
|
+
|
|
36
|
+
// Hooks must run on every render — keep them above the early return.
|
|
37
|
+
const togglePlay = useCallback(() => {
|
|
38
|
+
const v = videoRef.current;
|
|
39
|
+
if (!v) return;
|
|
40
|
+
if (v.paused) {
|
|
41
|
+
void v.play();
|
|
42
|
+
setIsPlaying(true);
|
|
43
|
+
} else {
|
|
44
|
+
v.pause();
|
|
45
|
+
setIsPlaying(false);
|
|
46
|
+
}
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const toggleMute = useCallback((e: React.MouseEvent) => {
|
|
50
|
+
e.stopPropagation();
|
|
51
|
+
const v = videoRef.current;
|
|
52
|
+
if (!v) return;
|
|
53
|
+
v.muted = !v.muted;
|
|
54
|
+
setIsMuted(v.muted);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
if (!isVideo) {
|
|
58
|
+
return (
|
|
59
|
+
<img
|
|
60
|
+
src={src}
|
|
61
|
+
alt={alt}
|
|
62
|
+
className={className}
|
|
63
|
+
style={style}
|
|
64
|
+
loading="lazy"
|
|
65
|
+
onError={onError || ((e) => { (e.currentTarget as HTMLElement).style.display = 'none'; })}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const { objectFit, ...wrapperStyle } =
|
|
71
|
+
(style ?? {}) as React.CSSProperties & { objectFit?: React.CSSProperties['objectFit'] };
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={`relative ${className || ''}`}
|
|
76
|
+
style={wrapperStyle}
|
|
77
|
+
onClick={togglePlay}
|
|
78
|
+
>
|
|
79
|
+
<video
|
|
80
|
+
ref={videoRef}
|
|
81
|
+
src={src}
|
|
82
|
+
poster={thumbnailSrc}
|
|
83
|
+
style={{ width: '100%', height: '100%', objectFit: objectFit || 'cover' }}
|
|
84
|
+
muted={isMuted}
|
|
85
|
+
loop={loop}
|
|
86
|
+
playsInline
|
|
87
|
+
autoPlay={autoPlay}
|
|
88
|
+
preload="metadata"
|
|
89
|
+
onPlay={() => setIsPlaying(true)}
|
|
90
|
+
onPause={() => setIsPlaying(false)}
|
|
91
|
+
onError={onError || ((e) => { (e.currentTarget as HTMLElement).style.display = 'none'; })}
|
|
92
|
+
/>
|
|
93
|
+
|
|
94
|
+
{/* Play overlay — shown when paused */}
|
|
95
|
+
{showPlayOverlay && !isPlaying && (
|
|
96
|
+
<div className="absolute inset-0 flex items-center justify-center bg-black/20 cursor-pointer">
|
|
97
|
+
<div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center shadow-lg">
|
|
98
|
+
<Play className="w-5 h-5 text-[#1c1e21] ml-0.5" fill="currentColor" />
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Sound toggle — shown when playing */}
|
|
104
|
+
{isPlaying && (
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={toggleMute}
|
|
108
|
+
className="absolute bottom-2 right-2 w-7 h-7 rounded-full bg-black/60 flex items-center justify-center hover:bg-black/80 transition-colors"
|
|
109
|
+
>
|
|
110
|
+
{isMuted ? (
|
|
111
|
+
<VolumeX className="w-3.5 h-3.5 text-white" />
|
|
112
|
+
) : (
|
|
113
|
+
<Volume2 className="w-3.5 h-3.5 text-white" />
|
|
114
|
+
)}
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
};
|