@flamingo-stack/openframe-frontend-core 0.0.287 → 0.0.288

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.287",
3
+ "version": "0.0.288",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -9,3 +9,10 @@ export type { GoogleSheetsViewerProps } from './google-sheets-viewer'
9
9
 
10
10
  export { FigmaEmbed } from './figma-embed'
11
11
  export type { FigmaEmbedProps } from './figma-embed'
12
+
13
+ export { OGLinkPreview, OGLinkErrorBoundary } from './og-link-preview'
14
+ export type {
15
+ OGLinkPreviewProps,
16
+ OGData,
17
+ BuildPlaceholderUrl,
18
+ } from './og-link-preview'
@@ -0,0 +1,446 @@
1
+ "use client"
2
+
3
+ import React, { useState, useEffect, Component, ReactNode } from 'react'
4
+ import Image from '../../embed-shims/next-image'
5
+ import { useImageEdgeColor } from '../../hooks'
6
+
7
+ /**
8
+ * Open-Graph metadata returned by the consumer's scrape endpoint.
9
+ *
10
+ * The shape MUST match the JSON the OG endpoint serves at `ogEndpointPath`.
11
+ * The hub's `/api/blog/og-scraper` returns exactly these fields — embedders
12
+ * with a different endpoint must return the same shape (or adapt at the
13
+ * route boundary). Keeps the consumer surface trivial: one URL → one card.
14
+ */
15
+ export interface OGData {
16
+ title: string
17
+ description: string
18
+ image: string
19
+ originalImage?: string
20
+ url: string
21
+ siteName: string
22
+ type: string
23
+ favicon: string
24
+ }
25
+
26
+ interface ErrorBoundaryProps {
27
+ children: ReactNode
28
+ fallback: ReactNode
29
+ }
30
+
31
+ interface ErrorBoundaryState {
32
+ hasError: boolean
33
+ }
34
+
35
+ /**
36
+ * Tiny error boundary tailored for OG link previews — caught errors quietly
37
+ * fall back to the `fallback` prop (typically a plain hyperlink) so a single
38
+ * broken third-party preview can't crash a whole article view.
39
+ *
40
+ * Named `OGLinkErrorBoundary` (not the generic `ErrorBoundary`) because the
41
+ * lib already exports a separate `ErrorBoundary` from
42
+ * `components/features/error-boundary.tsx`. The top-level `components/index.ts`
43
+ * barrel re-exports both `./embeds` and `./features` via `export *`, so a
44
+ * second `ErrorBoundary` here collides as TS2308.
45
+ */
46
+ export class OGLinkErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
47
+ constructor(props: ErrorBoundaryProps) {
48
+ super(props)
49
+ this.state = { hasError: false }
50
+ }
51
+
52
+ static getDerivedStateFromError(): ErrorBoundaryState {
53
+ return { hasError: true }
54
+ }
55
+
56
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
57
+ console.warn('Link preview error caught by boundary:', error, errorInfo)
58
+ }
59
+
60
+ render() {
61
+ if (this.state.hasError) return this.props.fallback
62
+ return this.props.children
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Builds a placeholder image URL when the scrape returns no image. Hub passes
68
+ * its own `buildOgPlaceholderUrl` (which resolves CSS-var ODS colors against
69
+ * the platform's brand palette + hits `/api/og-placeholder`); other embedders
70
+ * can omit the prop to disable the placeholder entirely.
71
+ *
72
+ * Receives the post-scrape `title` and `siteName` so the placeholder can echo
73
+ * the actual card content, not a generic graphic.
74
+ */
75
+ export type BuildPlaceholderUrl = (
76
+ title: string,
77
+ siteName: string,
78
+ ) => string | null
79
+
80
+ export interface OGLinkPreviewProps {
81
+ /** The external URL to preview. */
82
+ url: string
83
+ /** Origin / base URL the OG endpoint is served from. Empty / undefined
84
+ * means same-origin (hub-direct use). Embed contexts pass the hub's
85
+ * origin here (e.g. `'https://hub.example.com'`) so the fetch hits
86
+ * the hub instead of the embedder origin.
87
+ *
88
+ * Pattern matches lib's `useNatsDialogSubscription({apiBaseUrl})` +
89
+ * `buildSuggestionUrl({apiBaseUrl})` so all embed-ready surfaces share
90
+ * one configuration knob. */
91
+ apiBaseUrl?: string
92
+ /** Path of the OG endpoint on the configured base. Default
93
+ * `'/api/blog/og-scraper'` matches the hub's route. Override if the
94
+ * embedder serves the same `OGData` shape from a different path. */
95
+ ogEndpointPath?: string
96
+ /** Optional placeholder-builder. Omit to disable the placeholder image
97
+ * (the card then degrades to a favicon+title chip when no scraped image
98
+ * is available). The hub injects its `buildOgPlaceholderUrl` here. */
99
+ buildPlaceholderUrl?: BuildPlaceholderUrl
100
+ /** Override the scraped title (used by publication cards that already know
101
+ * the title locally — e.g. a CMS-managed press link). */
102
+ fallbackTitle?: string
103
+ /** Override the scraped description. */
104
+ fallbackDescription?: string
105
+ /** Override the scraped image — useful when the scrape returns no image but
106
+ * the embedder has a CMS-stored hero image to fall back to. */
107
+ fallbackImage?: string
108
+ /** Publication / source name shown alongside the favicon (e.g. "TechCrunch"). */
109
+ publicationName?: string
110
+ /** Publication logo URL shown alongside the title (defaults to favicon). */
111
+ publicationLogo?: string
112
+ /** Card variant. `compact` = horizontal layout (~120px tall) suited for
113
+ * in-doc placements; `default` = larger vertical layout for press / hero
114
+ * positions. */
115
+ variant?: 'default' | 'compact'
116
+ /** Disable the synthesized placeholder image even when `buildPlaceholderUrl`
117
+ * is provided — used by the markdown renderer to keep doc cards lighter. */
118
+ enablePlaceholder?: boolean
119
+ }
120
+
121
+ function getDomain(urlStr: string): string {
122
+ try { return new URL(urlStr).hostname.replace('www.', '') }
123
+ catch { return 'External Link' }
124
+ }
125
+
126
+ function domainToTitle(domain: string): string {
127
+ return domain.split('.')[0].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
128
+ }
129
+
130
+ const ExternalLinkIcon = ({ size = 16 }: { size?: number }) => (
131
+ <svg width={size} height={size} fill="none" stroke="currentColor" viewBox="0 0 24 24" className="text-ods-text-secondary group-hover:text-ods-accent transition-colors flex-shrink-0">
132
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
133
+ </svg>
134
+ )
135
+
136
+ const Favicon = ({ src, size = 'w-6 h-6' }: { src: string; size?: string }) => (
137
+ <img src={src} alt="" className={size} onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
138
+ )
139
+
140
+ /**
141
+ * Rich Open-Graph link preview card with skeleton, fallback, and image-edge
142
+ * background detection.
143
+ *
144
+ * Flow:
145
+ * 1. Validate URL early (no network for malformed input, localhost, or
146
+ * RFC1918 ranges — those render as plain `<a>` tags).
147
+ * 2. `GET ogEndpointPath?url=<encoded>` — embedder serves the shape declared
148
+ * in `OGData`.
149
+ * 3. Resolve image: scraped og:image → `originalImage` fallback → `fallbackImage`
150
+ * prop → `buildPlaceholderUrl(title, siteName)`. Each step has its own
151
+ * error toggle so a 404 / CORS-tainted image gracefully degrades.
152
+ * 4. Extract a letterbox background color from the resolved image via
153
+ * `useImageEdgeColor`. Same-origin proxy is REQUIRED for cross-origin
154
+ * images so the `<canvas>` extraction doesn't taint.
155
+ * 5. Render compact (h-[120px] horizontal) or default (vertical w/ aspect-video
156
+ * hero) variant, with image-less degraded variants for each.
157
+ */
158
+ export const OGLinkPreview: React.FC<OGLinkPreviewProps> = ({
159
+ url,
160
+ apiBaseUrl,
161
+ ogEndpointPath = '/api/blog/og-scraper',
162
+ buildPlaceholderUrl,
163
+ fallbackTitle,
164
+ fallbackDescription,
165
+ fallbackImage,
166
+ publicationName,
167
+ publicationLogo,
168
+ variant = 'default',
169
+ enablePlaceholder = true,
170
+ }) => {
171
+ const [ogData, setOgData] = useState<OGData | null>(null)
172
+ const [loading, setLoading] = useState(true)
173
+ const [error, setError] = useState(false)
174
+ const [imageError, setImageError] = useState(false)
175
+ const [originalImageError, setOriginalImageError] = useState(false)
176
+ const [fallbackImageError, setFallbackImageError] = useState(false)
177
+
178
+ let isValidUrl = true
179
+ let isLocalhost = false
180
+ try {
181
+ if (url && typeof url === 'string') {
182
+ const urlObj = new URL(url)
183
+ if (['localhost', '127.0.0.1', '0.0.0.0'].includes(urlObj.hostname) ||
184
+ urlObj.hostname.startsWith('192.168.') || urlObj.hostname.startsWith('10.') || urlObj.hostname.startsWith('172.')) {
185
+ isLocalhost = true
186
+ }
187
+ } else {
188
+ isValidUrl = false
189
+ }
190
+ } catch {
191
+ isValidUrl = false
192
+ }
193
+
194
+ useEffect(() => {
195
+ if (!isValidUrl || isLocalhost) return
196
+
197
+ const fetchOGData = async () => {
198
+ try {
199
+ new URL(url)
200
+ setLoading(true)
201
+ // Compose `${base}${path}?url=…`. Empty base → relative path
202
+ // (same-origin); absolute base → cross-origin embed against the hub.
203
+ // Plain string concat is safer than `new URL(path, base)` because
204
+ // the latter resolves `path` against the BASE's pathname when
205
+ // `path` is relative, producing surprising URLs when the embedder
206
+ // serves the lib from a subpath.
207
+ const endpoint = `${apiBaseUrl ?? ''}${ogEndpointPath}?url=${encodeURIComponent(url)}`
208
+ const response = await fetch(endpoint)
209
+ if (response.ok) {
210
+ const data = await response.json()
211
+ if (data?.title && data.title !== 'Link Preview Unavailable') {
212
+ setOgData(data)
213
+ } else {
214
+ setError(true)
215
+ }
216
+ } else {
217
+ setError(true)
218
+ }
219
+ } catch {
220
+ setError(true)
221
+ } finally {
222
+ setLoading(false)
223
+ }
224
+ }
225
+
226
+ fetchOGData()
227
+ }, [url, isValidUrl, isLocalhost, apiBaseUrl, ogEndpointPath])
228
+
229
+ const isCompact = variant === 'compact'
230
+ const domain = getDomain(url)
231
+
232
+ const effectiveData: OGData | null = ogData ?? (error ? {
233
+ title: fallbackTitle || domainToTitle(domain),
234
+ description: fallbackDescription || domain,
235
+ image: '',
236
+ url,
237
+ siteName: publicationName || domain,
238
+ type: 'website',
239
+ favicon: `https://www.google.com/s2/favicons?domain=${domain}&sz=32`,
240
+ } : null)
241
+
242
+ // Hub-injected placeholder builder — fires only when the post-scrape image
243
+ // chain is empty AND `enablePlaceholder` is true. `null` when unprovided.
244
+ const placeholderImageUrl =
245
+ enablePlaceholder && buildPlaceholderUrl && effectiveData?.title
246
+ ? buildPlaceholderUrl(effectiveData.title, effectiveData.siteName || domain)
247
+ : null
248
+
249
+ const resolvedImageUrl = (effectiveData?.image && !imageError)
250
+ ? effectiveData.image
251
+ : (effectiveData?.originalImage && !originalImageError)
252
+ ? effectiveData.originalImage
253
+ : (fallbackImage && !fallbackImageError)
254
+ ? fallbackImage
255
+ : placeholderImageUrl
256
+
257
+ const hasImage = !!resolvedImageUrl
258
+ const isFallbackImage = resolvedImageUrl === fallbackImage
259
+ const isPlaceholder = resolvedImageUrl === placeholderImageUrl && !isFallbackImage
260
+ const bgColor = useImageEdgeColor(resolvedImageUrl ?? null, 'var(--ods-bg-secondary)')
261
+
262
+ const renderSkeleton = () => isCompact ? (
263
+ <div className="my-4">
264
+ <div className="flex flex-row border border-ods-border rounded-lg overflow-hidden bg-ods-card h-[120px]">
265
+ <div className="w-[200px] h-full flex-shrink-0 bg-ods-skeleton animate-pulse" />
266
+ <div className="flex-1 p-3 flex flex-col justify-center">
267
+ <div className="bg-ods-skeleton rounded animate-pulse h-4 w-3/4 mb-2" />
268
+ <div className="bg-ods-skeleton rounded animate-pulse h-3 w-full mb-1" />
269
+ <div className="bg-ods-skeleton rounded animate-pulse h-3 w-2/3 mb-2" />
270
+ <div className="bg-ods-skeleton rounded animate-pulse h-3 w-1/3" />
271
+ </div>
272
+ </div>
273
+ </div>
274
+ ) : (
275
+ <div className="my-6">
276
+ <div className="block border border-ods-border rounded-lg overflow-hidden bg-ods-card">
277
+ <div className="aspect-video w-full bg-ods-skeleton overflow-hidden relative animate-pulse" />
278
+ <div className="p-4">
279
+ <div className="flex items-start gap-3">
280
+ <div className="w-6 h-6 bg-ods-skeleton rounded flex-shrink-0 mt-0.5 animate-pulse" />
281
+ <div className="flex-1 min-w-0">
282
+ <div className="h-[2.5rem] leading-[1.25rem] mb-2 overflow-hidden">
283
+ <div className="bg-ods-skeleton rounded animate-pulse" style={{ height: '1.25rem', marginBottom: '0.25rem' }} />
284
+ <div className="bg-ods-skeleton rounded animate-pulse w-3/4" style={{ height: '1.25rem' }} />
285
+ </div>
286
+ <div className="h-[2.5rem] leading-[1.25rem] mb-2 overflow-hidden">
287
+ <div className="bg-ods-skeleton rounded animate-pulse" style={{ height: '1.25rem', marginBottom: '0.25rem' }} />
288
+ <div className="bg-ods-skeleton rounded animate-pulse w-5/6" style={{ height: '1.25rem' }} />
289
+ </div>
290
+ <div className="flex items-center gap-2">
291
+ <div className="bg-ods-skeleton rounded animate-pulse" style={{ height: '0.75rem', width: '6rem' }} />
292
+ <div className="bg-ods-skeleton rounded animate-pulse" style={{ height: '0.75rem', width: '5rem' }} />
293
+ </div>
294
+ </div>
295
+ </div>
296
+ </div>
297
+ </div>
298
+ </div>
299
+ )
300
+
301
+ if (!url || typeof url !== 'string' || !isValidUrl) return renderSkeleton()
302
+
303
+ if (isLocalhost) {
304
+ return (
305
+ <div className="my-6">
306
+ <a href={url} target="_blank" rel="noopener noreferrer"
307
+ className="inline-flex items-center gap-2 text-ods-accent hover:text-ods-accent-hover transition-colors">
308
+ <span className="underline">{url}</span>
309
+ <ExternalLinkIcon size={14} />
310
+ </a>
311
+ </div>
312
+ )
313
+ }
314
+
315
+ if (loading) return renderSkeleton()
316
+ if (!effectiveData) return renderSkeleton()
317
+
318
+ const title = fallbackTitle || effectiveData.title
319
+ // Empty string when the scrape returned nothing — descriptions render
320
+ // conditionally below. Avoids the legacy `'No description available'` filler
321
+ // that signaled "broken card" to users.
322
+ const description = fallbackDescription || effectiveData.description || ''
323
+ const ogDomain = getDomain(effectiveData.url)
324
+ const faviconSrc = effectiveData.favicon || `https://www.google.com/s2/favicons?domain=${ogDomain}&sz=32`
325
+ const logoSrc = publicationLogo || faviconSrc
326
+
327
+ const handleImageError = () => {
328
+ if (effectiveData.image && !imageError) setImageError(true)
329
+ else if (effectiveData.originalImage && !originalImageError) setOriginalImageError(true)
330
+ else setFallbackImageError(true)
331
+ }
332
+
333
+ const renderImage = () => {
334
+ if (!resolvedImageUrl) return null
335
+ if (isPlaceholder) {
336
+ return (
337
+ <img src={resolvedImageUrl} alt={title}
338
+ className="absolute inset-0 w-full h-full object-cover rounded-md" />
339
+ )
340
+ }
341
+ if (isFallbackImage) {
342
+ return (
343
+ <Image src={resolvedImageUrl} alt={title} fill
344
+ className="object-contain rounded-md group-hover:scale-105 transition-transform duration-300"
345
+ onError={handleImageError}
346
+ unoptimized={resolvedImageUrl.includes('/render/image/')} />
347
+ )
348
+ }
349
+ return (
350
+ <img src={resolvedImageUrl} alt={title}
351
+ className="absolute inset-0 w-full h-full object-contain rounded-md group-hover:scale-105 transition-transform duration-300"
352
+ onError={handleImageError} />
353
+ )
354
+ }
355
+
356
+ if (isCompact) {
357
+ if (!hasImage) {
358
+ return (
359
+ <div className="my-4">
360
+ <a href={effectiveData.url} target="_blank" rel="noopener noreferrer"
361
+ className="flex flex-row items-center gap-3 border border-ods-border rounded-lg overflow-hidden bg-ods-card hover:border-ods-accent transition-all duration-200 group px-4 py-3">
362
+ <div className="w-8 h-8 bg-ods-bg-secondary rounded-lg flex items-center justify-center flex-shrink-0">
363
+ <Favicon src={faviconSrc} size="w-5 h-5" />
364
+ </div>
365
+ <div className="flex-1 min-w-0">
366
+ <h3 className="font-sans text-sm font-semibold text-ods-text-primary group-hover:text-ods-accent transition-colors truncate">{title}</h3>
367
+ {description && (
368
+ <p className="font-sans text-xs text-ods-text-secondary truncate">{description}</p>
369
+ )}
370
+ </div>
371
+ <ExternalLinkIcon size={14} />
372
+ </a>
373
+ </div>
374
+ )
375
+ }
376
+ return (
377
+ <div className="my-4">
378
+ <a href={effectiveData.url} target="_blank" rel="noopener noreferrer"
379
+ className="flex flex-row border border-ods-border rounded-lg overflow-hidden bg-ods-card hover:border-ods-accent transition-colors group h-[120px]">
380
+ <div className="w-[200px] h-full flex-shrink-0 overflow-hidden relative flex items-center justify-center rounded-lg transition-colors duration-300" style={{ backgroundColor: bgColor }}>
381
+ {renderImage()}
382
+ </div>
383
+ <div className="flex-1 p-3 flex flex-col justify-center min-w-0">
384
+ <h3 className="font-sans text-sm font-semibold text-ods-text-primary overflow-hidden group-hover:text-ods-accent transition-colors"
385
+ style={{ display: '-webkit-box', WebkitLineClamp: 1, WebkitBoxOrient: 'vertical' }}>{title}</h3>
386
+ {description && (
387
+ <p className="font-sans text-xs text-ods-text-secondary overflow-hidden mt-1"
388
+ style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>{description}</p>
389
+ )}
390
+ <div className="text-xs text-ods-text-secondary mt-1 truncate">{effectiveData.siteName || ogDomain}</div>
391
+ </div>
392
+ </a>
393
+ </div>
394
+ )
395
+ }
396
+
397
+ if (!hasImage) {
398
+ return (
399
+ <div className="my-6">
400
+ <a href={effectiveData.url} target="_blank" rel="noopener noreferrer"
401
+ className="flex items-center gap-3 border border-ods-border rounded-lg overflow-hidden bg-ods-card hover:border-ods-accent transition-all duration-200 group px-4 py-3">
402
+ <div className="w-10 h-10 bg-ods-bg-secondary rounded-lg flex items-center justify-center flex-shrink-0">
403
+ <Favicon src={faviconSrc} />
404
+ </div>
405
+ <div className="flex-1 min-w-0">
406
+ <h3 className="font-sans font-semibold text-ods-text-primary text-base group-hover:text-ods-accent transition-colors truncate">{title}</h3>
407
+ {description && (
408
+ <p className="font-sans text-sm text-ods-text-secondary truncate">{description}</p>
409
+ )}
410
+ </div>
411
+ <ExternalLinkIcon />
412
+ </a>
413
+ </div>
414
+ )
415
+ }
416
+
417
+ return (
418
+ <div className="my-6">
419
+ <a href={effectiveData.url} target="_blank" rel="noopener noreferrer"
420
+ className="block border border-ods-border rounded-lg overflow-hidden bg-ods-card hover:border-ods-accent transition-colors group">
421
+ <div className="aspect-video w-full overflow-hidden relative flex items-center justify-center rounded-lg transition-colors duration-300" style={{ backgroundColor: bgColor }}>
422
+ {renderImage()}
423
+ </div>
424
+ <div className="p-4">
425
+ <div className="flex items-start gap-3">
426
+ <img src={logoSrc} alt={publicationName || ''} className="w-6 h-6 rounded object-contain flex-shrink-0 mt-0.5"
427
+ onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }} />
428
+ <div className="flex-1 min-w-0">
429
+ <h3 className="font-sans font-semibold text-ods-text-primary text-base overflow-hidden group-hover:text-ods-accent transition-colors h-[2.5rem] leading-[1.25rem] mb-2"
430
+ style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>{title}</h3>
431
+ {description && (
432
+ <p className="font-sans text-sm text-ods-text-secondary overflow-hidden h-[2.5rem] leading-[1.25rem] mb-2"
433
+ style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>{description}</p>
434
+ )}
435
+ <div className="flex items-center gap-2 text-xs text-ods-text-secondary">
436
+ <span className="font-medium">{effectiveData.siteName}</span>
437
+ <span>•</span>
438
+ <span className="truncate">{ogDomain}</span>
439
+ </div>
440
+ </div>
441
+ </div>
442
+ </div>
443
+ </a>
444
+ </div>
445
+ )
446
+ }