@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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { memo, useState } from 'react'
|
|
2
|
+
import type { PreviewAdData } from './types'
|
|
3
|
+
import { CreativeMedia } from './CreativeMedia'
|
|
4
|
+
import { ProfileAvatar } from './ProfileAvatar'
|
|
5
|
+
import { withPreviewDefaults } from './defaults'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Instagram Feed — single image (sponsored). GREENFIELD rebuild.
|
|
9
|
+
*
|
|
10
|
+
* Not the Creative-Hub export extraction. Faithful to the real IG sponsored-post
|
|
11
|
+
* chrome but built cleanly: a 320px card with natural full-width flow (no fixed
|
|
12
|
+
* 300px inner rows, no empty placeholder divs), Meta-exact hex (self-contained —
|
|
13
|
+
* no design tokens), and a system font stack. Layout, top → bottom:
|
|
14
|
+
* header · creative · CTA bar · action row · caption
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
interface InstagramFeedPreviewProps {
|
|
18
|
+
adData: PreviewAdData
|
|
19
|
+
format: '1:1' | '4:5' | '9:16'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const WIDTH = 320
|
|
23
|
+
const FONT =
|
|
24
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
25
|
+
|
|
26
|
+
const C = {
|
|
27
|
+
ink: 'rgb(38, 38, 38)',
|
|
28
|
+
sub: 'rgb(115, 115, 115)',
|
|
29
|
+
hair: 'rgb(239, 239, 239)',
|
|
30
|
+
imageBg: '#FAFAFA',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The IG story-ring gradient — used as the avatar letter-fallback fill.
|
|
34
|
+
const IG_RING =
|
|
35
|
+
'linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%)'
|
|
36
|
+
|
|
37
|
+
// Illustrative placeholder engagement — these previews are mockups, not live
|
|
38
|
+
// data (PreviewAdData carries no likes/comments/timestamp). Static on purpose.
|
|
39
|
+
const LIKES = '234'
|
|
40
|
+
const COMMENTS = 12
|
|
41
|
+
const POSTED = '2 hours ago'
|
|
42
|
+
|
|
43
|
+
export const InstagramFeedPreview = memo(function InstagramFeedPreview({
|
|
44
|
+
adData: rawAdData,
|
|
45
|
+
format,
|
|
46
|
+
}: InstagramFeedPreviewProps) {
|
|
47
|
+
const adData = withPreviewDefaults(rawAdData)
|
|
48
|
+
const [expanded, setExpanded] = useState(false)
|
|
49
|
+
|
|
50
|
+
const imageHeight = format === '4:5' ? 400 : WIDTH // 1:1 → 320, 4:5 → 400
|
|
51
|
+
const caption = adData.primaryText ?? ''
|
|
52
|
+
const handle = adData.brandName.trim().toLowerCase().replace(/\s+/g, '')
|
|
53
|
+
const LIMIT = 100
|
|
54
|
+
const clipped = caption.length > LIMIT && !expanded
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div style={{ width: WIDTH, background: '#fff', fontFamily: FONT, color: C.ink }}>
|
|
58
|
+
{/* Header */}
|
|
59
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 12px' }}>
|
|
60
|
+
<div style={{ width: 30, height: 30, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
|
|
61
|
+
<ProfileAvatar src={adData.profileImage} name={adData.brandName} fontSize={12} background={IG_RING} />
|
|
62
|
+
</div>
|
|
63
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
64
|
+
<div
|
|
65
|
+
style={{
|
|
66
|
+
fontSize: 13,
|
|
67
|
+
fontWeight: 600,
|
|
68
|
+
lineHeight: '16px',
|
|
69
|
+
overflow: 'hidden',
|
|
70
|
+
textOverflow: 'ellipsis',
|
|
71
|
+
whiteSpace: 'nowrap',
|
|
72
|
+
}}
|
|
73
|
+
>
|
|
74
|
+
{adData.brandName}
|
|
75
|
+
</div>
|
|
76
|
+
<div style={{ fontSize: 12, lineHeight: '15px', color: C.sub }}>Sponsored</div>
|
|
77
|
+
</div>
|
|
78
|
+
<Dots />
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
{/* Creative */}
|
|
82
|
+
<div style={{ width: WIDTH, height: imageHeight, background: C.imageBg, overflow: 'hidden' }}>
|
|
83
|
+
{adData.creativeImage ? (
|
|
84
|
+
<CreativeMedia
|
|
85
|
+
src={adData.creativeImage}
|
|
86
|
+
mediaType={adData.creativeMediaType}
|
|
87
|
+
alt=""
|
|
88
|
+
style={{ width: WIDTH, height: imageHeight, objectFit: 'cover', display: 'block' }}
|
|
89
|
+
/>
|
|
90
|
+
) : (
|
|
91
|
+
<Placeholder format={format} />
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{/* CTA bar — full-width tappable sponsored action */}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
style={{
|
|
99
|
+
display: 'flex',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'space-between',
|
|
102
|
+
width: '100%',
|
|
103
|
+
padding: '11px 14px',
|
|
104
|
+
border: 0,
|
|
105
|
+
borderBottom: `1px solid ${C.hair}`,
|
|
106
|
+
background: 'none',
|
|
107
|
+
cursor: 'pointer',
|
|
108
|
+
font: 'inherit',
|
|
109
|
+
textAlign: 'left',
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<span style={{ fontSize: 14, fontWeight: 600, color: C.ink }}>
|
|
113
|
+
{adData.callToAction || 'Learn more'}
|
|
114
|
+
</span>
|
|
115
|
+
<Chevron />
|
|
116
|
+
</button>
|
|
117
|
+
|
|
118
|
+
{/* Action row */}
|
|
119
|
+
<div
|
|
120
|
+
style={{
|
|
121
|
+
display: 'flex',
|
|
122
|
+
alignItems: 'center',
|
|
123
|
+
justifyContent: 'space-between',
|
|
124
|
+
padding: '9px 12px 5px',
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
128
|
+
<Heart />
|
|
129
|
+
<CommentBubble />
|
|
130
|
+
<Send />
|
|
131
|
+
</div>
|
|
132
|
+
<Bookmark />
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Likes */}
|
|
136
|
+
<div style={{ padding: '0 12px', fontSize: 13, fontWeight: 600, color: C.ink, lineHeight: '18px' }}>
|
|
137
|
+
{LIKES} likes
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
{/* Caption — bold handle prefix + post text (darker + a touch heavier) */}
|
|
141
|
+
{caption && (
|
|
142
|
+
<div style={{ padding: '3px 12px 0', fontSize: 13, lineHeight: '18px', fontWeight: 400, color: 'rgb(20, 20, 20)' }}>
|
|
143
|
+
<span style={{ fontWeight: 600 }}>{handle}</span>{' '}
|
|
144
|
+
{clipped ? caption.slice(0, LIMIT).trimEnd() : caption}
|
|
145
|
+
{clipped && (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
onClick={() => setExpanded(true)}
|
|
149
|
+
style={{
|
|
150
|
+
border: 0,
|
|
151
|
+
background: 'none',
|
|
152
|
+
padding: 0,
|
|
153
|
+
marginLeft: 4,
|
|
154
|
+
color: C.sub,
|
|
155
|
+
fontWeight: 400,
|
|
156
|
+
fontSize: 13,
|
|
157
|
+
cursor: 'pointer',
|
|
158
|
+
fontFamily: FONT,
|
|
159
|
+
}}
|
|
160
|
+
>
|
|
161
|
+
more
|
|
162
|
+
</button>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
167
|
+
{/* Comments */}
|
|
168
|
+
<div style={{ padding: '5px 12px 0', fontSize: 13, color: C.sub }}>
|
|
169
|
+
View all {COMMENTS} comments
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{/* Timestamp */}
|
|
173
|
+
<div style={{ padding: '4px 12px 12px', fontSize: 11, color: C.sub }}>
|
|
174
|
+
{POSTED}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
/* ------------------------------- icons / bits ------------------------------- */
|
|
181
|
+
|
|
182
|
+
const stroke = {
|
|
183
|
+
fill: 'none',
|
|
184
|
+
stroke: 'rgb(38, 38, 38)',
|
|
185
|
+
strokeWidth: 1.8,
|
|
186
|
+
strokeLinecap: 'round' as const,
|
|
187
|
+
strokeLinejoin: 'round' as const,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function Dots() {
|
|
191
|
+
return (
|
|
192
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="rgb(38,38,38)" style={{ display: 'block', flexShrink: 0 }}>
|
|
193
|
+
<circle cx="5" cy="12" r="1.4" />
|
|
194
|
+
<circle cx="12" cy="12" r="1.4" />
|
|
195
|
+
<circle cx="19" cy="12" r="1.4" />
|
|
196
|
+
</svg>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function Chevron() {
|
|
201
|
+
return (
|
|
202
|
+
<svg width="16" height="16" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
203
|
+
<polyline points="9 18 15 12 9 6" />
|
|
204
|
+
</svg>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function Heart() {
|
|
209
|
+
return (
|
|
210
|
+
<svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
211
|
+
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" />
|
|
212
|
+
</svg>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function CommentBubble() {
|
|
217
|
+
return (
|
|
218
|
+
<svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
219
|
+
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
|
|
220
|
+
</svg>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function Send() {
|
|
225
|
+
return (
|
|
226
|
+
<svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
227
|
+
<line x1="22" y1="2" x2="11" y2="13" />
|
|
228
|
+
<polygon points="22 2 15 22 11 13 2 9 22 2" />
|
|
229
|
+
</svg>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function Bookmark() {
|
|
234
|
+
return (
|
|
235
|
+
<svg width="24" height="24" viewBox="0 0 24 24" {...stroke} style={{ display: 'block', flexShrink: 0 }}>
|
|
236
|
+
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z" />
|
|
237
|
+
</svg>
|
|
238
|
+
)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function Placeholder({ format }: { format: string }) {
|
|
242
|
+
return (
|
|
243
|
+
<div
|
|
244
|
+
style={{
|
|
245
|
+
width: '100%',
|
|
246
|
+
height: '100%',
|
|
247
|
+
display: 'flex',
|
|
248
|
+
flexDirection: 'column',
|
|
249
|
+
alignItems: 'center',
|
|
250
|
+
justifyContent: 'center',
|
|
251
|
+
gap: 8,
|
|
252
|
+
color: 'rgb(140, 140, 140)',
|
|
253
|
+
}}
|
|
254
|
+
>
|
|
255
|
+
<div style={{ width: 56, height: 56, borderRadius: 10, background: '#E4E6EB' }} />
|
|
256
|
+
<span style={{ fontSize: 13 }}>Ad creative · {format}</span>
|
|
257
|
+
</div>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { memo, useState } from 'react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { ProfileAvatar } from './ProfileAvatar'
|
|
4
|
+
import type { CarouselViewProps } from './types'
|
|
5
|
+
import { PREVIEW_DEFAULTS } from './defaults'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Facebook Feed — CAROUSEL (presentational). ADR-119 Decision 1.
|
|
9
|
+
*
|
|
10
|
+
* Pure: no carousel controller, no crop system. The card list, active index +
|
|
11
|
+
* setter, and each card's image (`renderImage(i)` slot) all come from props, so
|
|
12
|
+
* the same view serves the controller-driven form (CropFrame slot) and static
|
|
13
|
+
* surfaces (plain <img> slot). Standard FB feed chrome around a peeking card
|
|
14
|
+
* strip; each card = image slot + footer (headline, description, shared CTA).
|
|
15
|
+
* Meta-exact hex — `packages/ui` is token-boundary exempt (ADR-119 D5).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const WIDTH = 320
|
|
19
|
+
const CARD_W = 232
|
|
20
|
+
const GAP = 8
|
|
21
|
+
const FONT =
|
|
22
|
+
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif'
|
|
23
|
+
|
|
24
|
+
const C = {
|
|
25
|
+
name: 'rgb(8, 8, 8)',
|
|
26
|
+
sub: 'rgb(101, 103, 107)',
|
|
27
|
+
body: 'rgb(0, 0, 0)',
|
|
28
|
+
seeMore: '#1877F2',
|
|
29
|
+
imageBg: '#F0F2F5',
|
|
30
|
+
cardBorder: 'rgba(0, 0, 0, 0.1)',
|
|
31
|
+
headline: 'rgb(28, 32, 40)',
|
|
32
|
+
ctaBtnBg: 'rgb(228, 230, 235)',
|
|
33
|
+
ctaBtnInk: 'rgb(8, 8, 9)',
|
|
34
|
+
divider: 'rgb(219, 221, 224)',
|
|
35
|
+
action: 'rgb(97, 103, 112)',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const MetaFeedCarousel = memo(function MetaFeedCarousel({
|
|
39
|
+
cards,
|
|
40
|
+
active,
|
|
41
|
+
onActive,
|
|
42
|
+
renderImage,
|
|
43
|
+
brandName,
|
|
44
|
+
profileImage,
|
|
45
|
+
primaryText,
|
|
46
|
+
callToAction,
|
|
47
|
+
}: CarouselViewProps) {
|
|
48
|
+
const [expanded, setExpanded] = useState(false)
|
|
49
|
+
|
|
50
|
+
const brand = brandName || PREVIEW_DEFAULTS.brandName
|
|
51
|
+
const cta = callToAction || PREVIEW_DEFAULTS.callToAction
|
|
52
|
+
const maxIndex = Math.max(0, cards.length - 1)
|
|
53
|
+
const i = Math.min(Math.max(0, active), maxIndex)
|
|
54
|
+
const translate = -(i * (CARD_W + GAP))
|
|
55
|
+
|
|
56
|
+
const text = primaryText || PREVIEW_DEFAULTS.primaryText
|
|
57
|
+
const LIMIT = 125
|
|
58
|
+
const clipped = text.length > LIMIT && !expanded
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div style={{ width: WIDTH, background: '#fff', fontFamily: FONT, color: C.body }}>
|
|
62
|
+
{/* Header */}
|
|
63
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px' }}>
|
|
64
|
+
<div style={{ width: 40, height: 40, borderRadius: '50%', overflow: 'hidden', flexShrink: 0 }}>
|
|
65
|
+
<ProfileAvatar src={profileImage} name={brand} fontSize={15} />
|
|
66
|
+
</div>
|
|
67
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
68
|
+
<div style={{ fontSize: 14, fontWeight: 600, lineHeight: '18px', color: C.name, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
69
|
+
{brand}
|
|
70
|
+
</div>
|
|
71
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 1, color: C.sub }}>
|
|
72
|
+
<span style={{ fontSize: 12, lineHeight: '15px' }}>Sponsored</span>
|
|
73
|
+
<span style={{ fontSize: 12 }}>·</span>
|
|
74
|
+
<Globe />
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
<MenuDots />
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
{/* Primary text */}
|
|
81
|
+
{text && (
|
|
82
|
+
<div style={{ padding: '0 12px 10px', fontSize: 14, fontWeight: 400, lineHeight: '19px', color: C.body, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
|
83
|
+
{clipped ? text.slice(0, LIMIT).trimEnd() : text}
|
|
84
|
+
{clipped && (
|
|
85
|
+
<>
|
|
86
|
+
{'… '}
|
|
87
|
+
<button type="button" onClick={() => setExpanded(true)} style={{ border: 0, background: 'none', padding: 0, color: C.seeMore, fontWeight: 600, fontSize: 14, cursor: 'pointer', fontFamily: FONT }}>
|
|
88
|
+
See more
|
|
89
|
+
</button>
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{/* Card strip */}
|
|
96
|
+
<div style={{ position: 'relative', overflow: 'hidden', padding: '2px 0 10px' }}>
|
|
97
|
+
<div style={{ display: 'flex', gap: GAP, paddingLeft: 12, transform: `translateX(${translate}px)`, transition: 'transform 280ms cubic-bezier(0.22, 1, 0.36, 1)' }}>
|
|
98
|
+
{cards.map((card, k) => (
|
|
99
|
+
<div key={card.key} style={{ width: CARD_W, flexShrink: 0, border: `1px solid ${C.cardBorder}`, borderRadius: 8, overflow: 'hidden', background: '#fff' }}>
|
|
100
|
+
<div style={{ width: '100%', height: CARD_W, background: C.imageBg, overflow: 'hidden' }}>
|
|
101
|
+
{renderImage(k)}
|
|
102
|
+
</div>
|
|
103
|
+
<div style={{ padding: '10px 12px' }}>
|
|
104
|
+
<div style={{ fontSize: 13, fontWeight: 700, lineHeight: '17px', color: C.headline, overflow: 'hidden', textOverflow: 'ellipsis', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
|
|
105
|
+
{card.headline || PREVIEW_DEFAULTS.headline}
|
|
106
|
+
</div>
|
|
107
|
+
{card.description && (
|
|
108
|
+
<div style={{ fontSize: 12, lineHeight: '16px', color: C.sub, marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
109
|
+
{card.description}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
<div style={{ marginTop: 10, display: 'inline-flex', alignItems: 'center', height: 30, padding: '0 12px', borderRadius: 6, background: C.ctaBtnBg, color: C.ctaBtnInk, fontSize: 13, fontWeight: 600 }}>
|
|
113
|
+
{cta}
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
))}
|
|
118
|
+
</div>
|
|
119
|
+
{i > 0 && <Arrow dir="left" onClick={() => onActive(i - 1)} />}
|
|
120
|
+
{i < maxIndex && <Arrow dir="right" onClick={() => onActive(i + 1)} />}
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Divider */}
|
|
124
|
+
<div style={{ height: 1, background: C.divider }} />
|
|
125
|
+
|
|
126
|
+
{/* Action bar */}
|
|
127
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-around', padding: '6px 8px' }}>
|
|
128
|
+
<FbAction label="Like"><ThumbsUp /></FbAction>
|
|
129
|
+
<FbAction label="Comment"><Bubble /></FbAction>
|
|
130
|
+
<FbAction label="Share"><ShareArrow /></FbAction>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
/* ------------------------------- icons / bits ------------------------------- */
|
|
137
|
+
|
|
138
|
+
const stroke = {
|
|
139
|
+
fill: 'none',
|
|
140
|
+
stroke: C.action,
|
|
141
|
+
strokeWidth: 2,
|
|
142
|
+
strokeLinecap: 'round' as const,
|
|
143
|
+
strokeLinejoin: 'round' as const,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function Arrow({ dir, onClick }: { dir: 'left' | 'right'; onClick: () => void }) {
|
|
147
|
+
return (
|
|
148
|
+
<button
|
|
149
|
+
type="button"
|
|
150
|
+
onClick={onClick}
|
|
151
|
+
aria-label={dir === 'left' ? 'Previous' : 'Next'}
|
|
152
|
+
style={{
|
|
153
|
+
position: 'absolute',
|
|
154
|
+
top: CARD_W / 2 + 2,
|
|
155
|
+
transform: 'translateY(-50%)',
|
|
156
|
+
...(dir === 'left' ? { left: 8 } : { right: 8 }),
|
|
157
|
+
width: 30,
|
|
158
|
+
height: 30,
|
|
159
|
+
borderRadius: '50%',
|
|
160
|
+
border: 0,
|
|
161
|
+
background: '#fff',
|
|
162
|
+
boxShadow: '0 1px 4px rgba(0,0,0,0.28)',
|
|
163
|
+
display: 'flex',
|
|
164
|
+
alignItems: 'center',
|
|
165
|
+
justifyContent: 'center',
|
|
166
|
+
cursor: 'pointer',
|
|
167
|
+
color: 'rgb(8,8,8)',
|
|
168
|
+
}}
|
|
169
|
+
>
|
|
170
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" style={{ display: 'block' }}>
|
|
171
|
+
{dir === 'left' ? <polyline points="15 18 9 12 15 6" /> : <polyline points="9 18 15 12 9 6" />}
|
|
172
|
+
</svg>
|
|
173
|
+
</button>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function FbAction({ label, children }: { label: string; children: ReactNode }) {
|
|
178
|
+
return (
|
|
179
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', cursor: 'pointer' }}>
|
|
180
|
+
{children}
|
|
181
|
+
<span style={{ fontSize: 13, fontWeight: 600, color: C.action }}>{label}</span>
|
|
182
|
+
</div>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function MenuDots() {
|
|
187
|
+
return (
|
|
188
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill={C.sub} style={{ display: 'block', flexShrink: 0 }}>
|
|
189
|
+
<circle cx="5" cy="12" r="1.6" />
|
|
190
|
+
<circle cx="12" cy="12" r="1.6" />
|
|
191
|
+
<circle cx="19" cy="12" r="1.6" />
|
|
192
|
+
</svg>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function Globe() {
|
|
197
|
+
return (
|
|
198
|
+
<svg width="12" height="12" viewBox="0 0 16 16" fill={C.sub} style={{ display: 'block' }}>
|
|
199
|
+
<path d="M8 0a8 8 0 1 0 8 8 8 8 0 0 0-8-8zm5.9 7H11a13 13 0 0 0-.9-4.4A6 6 0 0 1 13.9 7zM8 14c-.7 0-1.8-1.8-2-5h4c-.2 3.2-1.3 5-2 5zM6 7c.2-3.2 1.3-5 2-5s1.8 1.8 2 5zm-.1-4.4A13 13 0 0 0 5 7H2.1a6 6 0 0 1 3.8-4.4zM2.1 9H5a13 13 0 0 0 .9 4.4A6 6 0 0 1 2.1 9zm8 4.4a13 13 0 0 0 .9-4.4h2.9a6 6 0 0 1-3.8 4.4z" />
|
|
200
|
+
</svg>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ThumbsUp() {
|
|
205
|
+
return (
|
|
206
|
+
<svg width="18" height="18" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
207
|
+
<path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3" />
|
|
208
|
+
</svg>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function Bubble() {
|
|
213
|
+
return (
|
|
214
|
+
<svg width="18" height="18" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
215
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
216
|
+
</svg>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function ShareArrow() {
|
|
221
|
+
return (
|
|
222
|
+
<svg width="18" height="18" viewBox="0 0 24 24" {...stroke} style={{ display: 'block' }}>
|
|
223
|
+
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
|
224
|
+
<polyline points="16 6 12 2 8 6" />
|
|
225
|
+
<line x1="12" y1="2" x2="12" y2="15" />
|
|
226
|
+
</svg>
|
|
227
|
+
)
|
|
228
|
+
}
|