@flamingo-stack/openframe-frontend-core 0.0.178 → 0.0.179

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.
Files changed (53) hide show
  1. package/dist/{chunk-L4T24AN4.cjs → chunk-4TM2SBMX.cjs} +855 -1325
  2. package/dist/chunk-4TM2SBMX.cjs.map +1 -0
  3. package/dist/{chunk-AAX27BCR.js → chunk-ZMQP3UZJ.js} +3690 -4160
  4. package/dist/chunk-ZMQP3UZJ.js.map +1 -0
  5. package/dist/components/features/entity-video-section.d.ts +54 -0
  6. package/dist/components/features/entity-video-section.d.ts.map +1 -0
  7. package/dist/components/features/index.cjs +18 -2
  8. package/dist/components/features/index.cjs.map +1 -1
  9. package/dist/components/features/index.d.ts +4 -2
  10. package/dist/components/features/index.d.ts.map +1 -1
  11. package/dist/components/features/index.js +21 -5
  12. package/dist/components/features/video-bites-display.d.ts +38 -0
  13. package/dist/components/features/video-bites-display.d.ts.map +1 -0
  14. package/dist/components/features/video-ratio-tabs.d.ts +62 -0
  15. package/dist/components/features/video-ratio-tabs.d.ts.map +1 -0
  16. package/dist/components/features/video.d.ts +94 -0
  17. package/dist/components/features/video.d.ts.map +1 -0
  18. package/dist/components/index.cjs +18 -2
  19. package/dist/components/index.cjs.map +1 -1
  20. package/dist/components/index.js +21 -5
  21. package/dist/components/media-carousel.d.ts.map +1 -1
  22. package/dist/components/navigation/index.cjs +2 -2
  23. package/dist/components/navigation/index.js +1 -1
  24. package/dist/components/shared/product-release/release-detail-page.d.ts.map +1 -1
  25. package/dist/components/ui/index.cjs +2 -2
  26. package/dist/components/ui/index.js +1 -1
  27. package/dist/index.cjs +18 -2
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.js +21 -5
  30. package/package.json +2 -2
  31. package/src/components/features/entity-video-section.tsx +175 -0
  32. package/src/components/features/index.ts +9 -2
  33. package/src/components/features/video-bites-display.tsx +216 -0
  34. package/src/components/features/video-ratio-tabs.tsx +174 -0
  35. package/src/components/features/video.tsx +474 -0
  36. package/src/components/media-carousel.tsx +43 -236
  37. package/src/components/shared/product-release/release-detail-page.tsx +26 -19
  38. package/dist/chunk-AAX27BCR.js.map +0 -1
  39. package/dist/chunk-L4T24AN4.cjs.map +0 -1
  40. package/dist/components/features/video-player.d.ts +0 -44
  41. package/dist/components/features/video-player.d.ts.map +0 -1
  42. package/dist/components/features/youtube-embed.d.ts +0 -31
  43. package/dist/components/features/youtube-embed.d.ts.map +0 -1
  44. package/dist/utils/lite-youtube-embed-stub.d.ts +0 -8
  45. package/dist/utils/lite-youtube-embed-stub.d.ts.map +0 -1
  46. package/dist/utils/lite-youtube-embed.d.ts +0 -9
  47. package/dist/utils/lite-youtube-embed.d.ts.map +0 -1
  48. package/src/components/features/.video-player.md +0 -44
  49. package/src/components/features/.youtube-embed.md +0 -40
  50. package/src/components/features/video-player.tsx +0 -893
  51. package/src/components/features/youtube-embed.tsx +0 -158
  52. package/src/utils/lite-youtube-embed-stub.tsx +0 -21
  53. package/src/utils/lite-youtube-embed.tsx +0 -46
@@ -0,0 +1,474 @@
1
+ "use client";
2
+
3
+ /**
4
+ * <Video> — single source of truth for every public video surface
5
+ * across every Flamingo platform consumer of this lib.
6
+ *
7
+ * One component, three sources, three layouts. Replaces and deletes
8
+ * the previous lib primitives:
9
+ *
10
+ * - `<VideoPlayer>` (react-player wrapper, ~900 LOC custom controls)
11
+ * - `<YouTubeEmbed>` (separate lite-youtube facade)
12
+ *
13
+ * Routing (`kind` discriminant, default `'auto'`):
14
+ *
15
+ * kind="youtube" → inline lite-youtube facade (poster + click→iframe)
16
+ * kind="file" → <MuxPlayer> (HLS + MP4 + Mux Data + CMCD all in one)
17
+ * kind="auto" → strict URL parse:
18
+ * bare 11-char id → youtube
19
+ * YouTube hostname → youtube
20
+ * anything else → file
21
+ *
22
+ * `<MuxPlayer>` handles both `.m3u8` (HLS via hls.js, native on Safari)
23
+ * AND plain `.mp4` (uses the underlying `<video>` element). One component,
24
+ * both paths — no internal "HLS vs MP4" branch needed. Captions are
25
+ * rendered as native `<track>` children when `captionsUrl` is passed.
26
+ *
27
+ * Layouts:
28
+ * layout="centered" → max-w-3xl centered wrapper. Detail-page surface.
29
+ * layout="fill" → absolute inset-0 w-full h-full. Carousel slides.
30
+ * layout="native" → intrinsic aspect ratio. Bites grid, blog cards.
31
+ */
32
+
33
+ import React, { useEffect, useRef, useState } from 'react';
34
+ import MuxPlayer from '@mux/mux-player-react';
35
+
36
+ // =============================================================================
37
+ // URL classifiers (private — `<Video>` is the only consumer)
38
+ // =============================================================================
39
+
40
+ const YT_HOSTS = new Set([
41
+ 'youtube.com', 'www.youtube.com', 'm.youtube.com',
42
+ 'youtu.be',
43
+ 'youtube-nocookie.com', 'www.youtube-nocookie.com',
44
+ ]);
45
+
46
+ /** Strict YouTube URL detection — parses the URL and checks the hostname. */
47
+ function isYouTubeUrl(url: string): boolean {
48
+ try {
49
+ return YT_HOSTS.has(new URL(url, 'http://placeholder.local').hostname.toLowerCase());
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ // `youtube.com/(embed|v|shorts)/<id>` — anchored, no `.*`, ReDoS-safe.
56
+ const YT_PATH_RE = /^\/(?:embed|v|shorts)\/([^/]+)\/?$/;
57
+
58
+ // Bare 11-char YouTube id — base62 alphabet `[A-Za-z0-9_-]`.
59
+ // Anchored, no `.*`, ReDoS-safe; rejects anything that contains `/` or `:`.
60
+ const BARE_YT_ID_RE = /^[A-Za-z0-9_-]{11}$/;
61
+
62
+ /**
63
+ * Extract the YouTube video id from any common URL shape OR from a
64
+ * bare 11-char id (carousels and some admin shapes pass that form
65
+ * directly).
66
+ *
67
+ * Uses strict `URL` parsing + an anchored pathname regex — NOT the
68
+ * legacy `.*v=` pattern that CodeQL flagged for polynomial-time
69
+ * backtracking on adversarial input like `youtube.com/watch?`
70
+ * repeated N times. Bare-id fallback uses an anchored character-class
71
+ * regex so it can never ReDoS either.
72
+ *
73
+ * Exported so admin tooling and carousel thumbnail logic can validate
74
+ * a URL without rendering the full `<Video>` component.
75
+ */
76
+ export function extractYouTubeId(url: string): string | null {
77
+ if (!url) return null;
78
+ // Bare-id form first — `new URL('dQw4w9WgXcQ')` throws, so this MUST
79
+ // run before the URL parse. The 11-char anchored regex rejects URLs
80
+ // (which always contain `:` or `/`).
81
+ if (BARE_YT_ID_RE.test(url)) return url;
82
+ let u: URL;
83
+ // Match `isYouTubeUrl`'s relative-safe parsing — a base URL with a
84
+ // placeholder origin lets us handle protocol-relative inputs (`//youtube.com/...`)
85
+ // and protocol-less inputs (`youtube.com/...`) without throwing.
86
+ try { u = new URL(url, 'http://placeholder.local'); } catch { return null; }
87
+ if (!YT_HOSTS.has(u.hostname.toLowerCase())) return null;
88
+ // `youtu.be/<id>` — id is the first non-empty path segment.
89
+ if (u.hostname.toLowerCase().endsWith('youtu.be')) {
90
+ return u.pathname.split('/').filter(Boolean)[0] ?? null;
91
+ }
92
+ // `youtube.com/watch?v=<id>` — query parameter.
93
+ const v = u.searchParams.get('v');
94
+ if (v) return v;
95
+ // `youtube.com/(embed|v|shorts)/<id>` — anchored pathname match.
96
+ const m = u.pathname.match(YT_PATH_RE);
97
+ return m ? m[1] : null;
98
+ }
99
+
100
+ // =============================================================================
101
+ // Props
102
+ // =============================================================================
103
+
104
+ export type VideoLayout = 'centered' | 'fill' | 'native';
105
+
106
+ interface VideoCommonProps {
107
+ /** Layout wrapper. Detail pages pass `"centered"`. Default `"native"`. */
108
+ layout?: VideoLayout;
109
+ /** Poster / thumbnail. */
110
+ poster?: string | null;
111
+ /** Mute by default — for autoplay carousels. */
112
+ muted?: boolean;
113
+ /** LCP hint — YouTube facade poster gets `fetchpriority="high"`. */
114
+ priority?: boolean;
115
+ /** Tailwind classes applied to the underlying player root. */
116
+ className?: string;
117
+ /** Accessible label (used as YT facade title; ignored for file branch). */
118
+ title?: string;
119
+ /**
120
+ * YouTube-only: hide YT player chrome (controls, info, fullscreen, related
121
+ * videos, keyboard shortcuts). Used for marketing/landing-page embeds that
122
+ * want a minimal look. No-op for file (MP4/HLS) branches.
123
+ */
124
+ minimalControls?: boolean;
125
+ }
126
+
127
+ interface VideoFileProps extends VideoCommonProps {
128
+ kind: 'file';
129
+ url: string;
130
+ /**
131
+ * SRT raw content. Deprecated: pass `captionsUrl` (VTT) instead.
132
+ * Native `<track>` requires a URL; raw SRT can't be rendered without
133
+ * a custom overlay. Setting this without `captionsUrl` is a no-op
134
+ * with a dev warning.
135
+ */
136
+ srtContent?: string | null;
137
+ /** HTTPS URL to a VTT captions file. Rendered as a native `<track>`. */
138
+ captionsUrl?: string | null;
139
+ }
140
+
141
+ interface VideoYouTubeProps extends VideoCommonProps {
142
+ kind: 'youtube';
143
+ /** Either a full YT URL or just the video id. */
144
+ url: string;
145
+ }
146
+
147
+ interface VideoAutoProps extends VideoCommonProps {
148
+ kind?: 'auto';
149
+ url: string;
150
+ srtContent?: string | null;
151
+ captionsUrl?: string | null;
152
+ }
153
+
154
+ export type VideoProps = VideoFileProps | VideoYouTubeProps | VideoAutoProps;
155
+
156
+ // =============================================================================
157
+ // Component
158
+ // =============================================================================
159
+
160
+ export function Video(props: VideoProps): React.ReactElement | null {
161
+ const url = props.url;
162
+ if (!url) return null;
163
+
164
+ const effectiveKind = resolveKind(props, url);
165
+ const layout = props.layout ?? 'native';
166
+
167
+ const inner =
168
+ effectiveKind === 'youtube' ? (
169
+ <YouTubeFacade
170
+ url={url}
171
+ title={props.title}
172
+ priority={props.priority}
173
+ className={props.className}
174
+ minimalControls={props.minimalControls}
175
+ />
176
+ ) : (
177
+ <FilePlayer
178
+ url={url}
179
+ poster={props.poster}
180
+ muted={props.muted}
181
+ srtContent={'srtContent' in props ? props.srtContent : null}
182
+ captionsUrl={'captionsUrl' in props ? props.captionsUrl : null}
183
+ className={props.className}
184
+ />
185
+ );
186
+
187
+ return wrapWithLayout(inner, layout);
188
+ }
189
+
190
+ // =============================================================================
191
+ // Internals — never imported by call sites; `<Video>` is the only entry.
192
+ // =============================================================================
193
+
194
+ /**
195
+ * Resolve the rendering branch. `'auto'` (or no `kind`) inspects the URL:
196
+ * YouTube host (or a bare 11-char video id) → 'youtube', anything else →
197
+ * 'file' (HLS / MP4 both handled by MuxPlayer). Type-safe — no `as` casts.
198
+ */
199
+ function resolveKind(props: VideoProps, url: string): 'youtube' | 'file' {
200
+ if ('kind' in props) {
201
+ if (props.kind === 'youtube') return 'youtube';
202
+ if (props.kind === 'file') return 'file';
203
+ // kind === 'auto' falls through to URL-based detection
204
+ }
205
+ // Bare 11-char YouTube id — use the same anchored regex as
206
+ // `extractYouTubeId` so both code paths agree on which strings are
207
+ // bare ids vs. URLs (avoids the regression where `videos/clip`
208
+ // length-11 strings were mis-routed to YouTube).
209
+ if (BARE_YT_ID_RE.test(url)) return 'youtube';
210
+ return isYouTubeUrl(url) ? 'youtube' : 'file';
211
+ }
212
+
213
+ function wrapWithLayout(
214
+ inner: React.ReactElement,
215
+ layout: VideoLayout,
216
+ ): React.ReactElement {
217
+ switch (layout) {
218
+ case 'centered':
219
+ // `aspect-video` (16:9) reserves the box from first paint so MuxPlayer
220
+ // doesn't flicker tiny→full while video metadata loads. Both branches
221
+ // are sized to fill 100% of this container (MuxPlayer via `style`,
222
+ // YouTube facade via internal `paddingBottom: 56.25%` which compounds
223
+ // harmlessly inside an already-16:9 box).
224
+ return (
225
+ <div className="flex justify-center w-full">
226
+ <div className="w-full max-w-3xl aspect-video">{inner}</div>
227
+ </div>
228
+ );
229
+ case 'fill':
230
+ return <div className="absolute inset-0 w-full h-full">{inner}</div>;
231
+ case 'native':
232
+ default:
233
+ // `native` callers (LazyBite in `<VideoBitesDisplay>`, blog cards) are
234
+ // expected to provide their own aspect-ratio container so the layout
235
+ // primitive doesn't override portrait/square/landscape bites with 16:9.
236
+ return inner;
237
+ }
238
+ }
239
+
240
+ // -----------------------------------------------------------------------------
241
+ // File branch — MuxPlayer (handles both .m3u8 HLS and plain .mp4)
242
+ // -----------------------------------------------------------------------------
243
+
244
+ interface FilePlayerProps {
245
+ url: string;
246
+ poster?: string | null;
247
+ muted?: boolean;
248
+ srtContent?: string | null;
249
+ captionsUrl?: string | null;
250
+ className?: string;
251
+ }
252
+
253
+ function FilePlayer({
254
+ url,
255
+ poster,
256
+ muted,
257
+ srtContent,
258
+ captionsUrl,
259
+ className,
260
+ }: FilePlayerProps): React.ReactElement {
261
+ // Raw SRT text is unusable without a custom overlay — and we just deleted
262
+ // the 900-LOC custom-controls layer that owned that overlay. Consumers
263
+ // pass `captionsUrl` (the API-side VTT conversion) alongside `srtContent`
264
+ // anyway. Warn in dev if the deprecated prop is the only one supplied
265
+ // so a single-prop call site doesn't silently lose captions.
266
+ if (process.env.NODE_ENV !== 'production' && srtContent && !captionsUrl) {
267
+ // eslint-disable-next-line no-console
268
+ console.warn(
269
+ '[Video] srtContent supplied without captionsUrl — captions will not render. ' +
270
+ 'Pass captionsUrl (the VTT URL) instead; raw SRT text overlays are no longer supported.',
271
+ );
272
+ }
273
+
274
+ return (
275
+ <MuxPlayer
276
+ src={url}
277
+ poster={poster || undefined}
278
+ streamType="on-demand"
279
+ playsInline
280
+ muted={muted}
281
+ preferCmcd="header"
282
+ accentColor="var(--ods-accent)"
283
+ className={className}
284
+ // Fill the wrapping aspect-ratio container instead of MuxPlayer's
285
+ // intrinsic size. Without this, MuxPlayer renders at its default
286
+ // dimensions before video metadata loads, then grows to its
287
+ // metadata-derived size — that's the "starts super small and
288
+ // flickers and grows" CLS we're killing. With `aspect-video` on
289
+ // the centered wrapper and `width/height: 100%` here, the box is
290
+ // 16:9 from first paint and stays put.
291
+ style={{ width: '100%', height: '100%' }}
292
+ >
293
+ {captionsUrl ? (
294
+ <track
295
+ kind="captions"
296
+ src={captionsUrl}
297
+ srcLang="en"
298
+ label="English"
299
+ default
300
+ />
301
+ ) : null}
302
+ </MuxPlayer>
303
+ );
304
+ }
305
+
306
+ // -----------------------------------------------------------------------------
307
+ // YouTube facade — inlined lite-youtube-embed pattern
308
+ // -----------------------------------------------------------------------------
309
+
310
+ interface YouTubeFacadeProps {
311
+ url: string;
312
+ title?: string;
313
+ priority?: boolean;
314
+ className?: string;
315
+ minimalControls?: boolean;
316
+ }
317
+
318
+ function YouTubeFacade({
319
+ url,
320
+ title = 'YouTube Video',
321
+ priority,
322
+ className,
323
+ minimalControls,
324
+ }: YouTubeFacadeProps): React.ReactElement | null {
325
+ // `extractYouTubeId` handles both bare 11-char ids AND full URLs in a
326
+ // single call site, so the resolution logic lives in exactly one place.
327
+ const videoId = extractYouTubeId(url);
328
+ if (!videoId) return null;
329
+
330
+ return <YouTubeFacadeInner videoId={videoId} title={title} priority={priority} className={className} minimalControls={minimalControls} />;
331
+ }
332
+
333
+ interface YouTubeFacadeInnerProps {
334
+ videoId: string;
335
+ title: string;
336
+ priority?: boolean;
337
+ className?: string;
338
+ minimalControls?: boolean;
339
+ }
340
+
341
+ function YouTubeFacadeInner({
342
+ videoId,
343
+ title,
344
+ priority,
345
+ className,
346
+ minimalControls,
347
+ }: YouTubeFacadeInnerProps): React.ReactElement {
348
+ const [activated, setActivated] = useState(false);
349
+ // Wrapper ref used by the outside-click dismissal — clicks inside this
350
+ // box keep the iframe mounted; clicks anywhere else tear the iframe
351
+ // down so YouTube's persistent native controls go with it.
352
+ const wrapperRef = useRef<HTMLDivElement | null>(null);
353
+
354
+ const embedParams = new URLSearchParams({
355
+ autoplay: '1',
356
+ rel: '0',
357
+ modestbranding: '1',
358
+ playsinline: '1',
359
+ });
360
+ if (minimalControls) {
361
+ embedParams.set('controls', '0');
362
+ embedParams.set('showinfo', '0');
363
+ embedParams.set('fs', '0');
364
+ embedParams.set('iv_load_policy', '3');
365
+ embedParams.set('cc_load_policy', '0');
366
+ embedParams.set('disablekb', '1');
367
+ }
368
+ const embedUrl = `https://www.youtube-nocookie.com/embed/${videoId}?${embedParams.toString()}`;
369
+ const posterJpg = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
370
+ const posterWebp = `https://i.ytimg.com/vi_webp/${videoId}/mqdefault.webp`;
371
+
372
+ // Outside-click dismissal — when the iframe is mounted, listen for any
373
+ // pointerdown on the document. Clicks INSIDE the wrapper bubble through
374
+ // the iframe DOM element but never reach our handler when they occur on
375
+ // YouTube's own UI (the iframe is a separate browsing context, so
376
+ // pointer events fired inside it don't propagate to our document).
377
+ // Clicks OUTSIDE the wrapper fire here normally — we tear down the
378
+ // iframe by flipping `activated` to false. The iframe unmounts, taking
379
+ // YouTube's persistent native controls with it. A second click on the
380
+ // play poster re-mounts the iframe with `autoplay=1` (a user gesture,
381
+ // so iOS Safari + Chrome autoplay restrictions are satisfied).
382
+ //
383
+ // We hook `pointerdown` (not `click`) so the dismissal feels instant —
384
+ // by the time `click` fires the user has already seen the controls
385
+ // overlay another beat.
386
+ useEffect(() => {
387
+ if (!activated) return;
388
+
389
+ function handleOutsideClick(event: PointerEvent) {
390
+ const target = event.target as Node | null;
391
+ if (!target) return;
392
+ if (wrapperRef.current?.contains(target)) return;
393
+ setActivated(false);
394
+ }
395
+
396
+ document.addEventListener('pointerdown', handleOutsideClick);
397
+ return () => document.removeEventListener('pointerdown', handleOutsideClick);
398
+ }, [activated]);
399
+
400
+ // Escape-key dismissal — keyboard users should have parity with the
401
+ // pointer outside-click. Same tear-down semantics.
402
+ useEffect(() => {
403
+ if (!activated) return;
404
+
405
+ function handleEscape(event: KeyboardEvent) {
406
+ if (event.key === 'Escape') setActivated(false);
407
+ }
408
+
409
+ document.addEventListener('keydown', handleEscape);
410
+ return () => document.removeEventListener('keydown', handleEscape);
411
+ }, [activated]);
412
+
413
+ // Early-return rendering. The previous imperative implementation
414
+ // (`document.createElement('iframe')` + state flip) had a subtle bug
415
+ // where the play-button overlay could linger past activation because
416
+ // React's commit phase and the imperative DOM mutation raced. Two
417
+ // mutually-exclusive return paths eliminate that race entirely — when
418
+ // `activated` flips, React unmounts the button branch and mounts the
419
+ // iframe branch in a single commit.
420
+ //
421
+ // Autoplay on iOS Safari: the user gesture (the button's onClick) and
422
+ // the iframe mount happen in the SAME React commit, which flushes
423
+ // synchronously inside event handlers. iOS treats the iframe insertion
424
+ // as still being inside the user-activation tick, so `autoplay=1` plays.
425
+ // (Verified empirically; lite-youtube-embed uses imperative DOM for
426
+ // legacy-React compatibility — modern React's sync-commit-on-event
427
+ // makes the JSX path equivalent.)
428
+ const wrapperClass = `relative w-full ${className ?? ''}`;
429
+ const wrapperStyle = { paddingBottom: '56.25%' as const };
430
+
431
+ if (activated) {
432
+ return (
433
+ <div ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
434
+ <iframe
435
+ src={embedUrl}
436
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
437
+ allowFullScreen
438
+ title={title}
439
+ className="absolute inset-0 w-full h-full border-0 rounded-lg"
440
+ />
441
+ </div>
442
+ );
443
+ }
444
+
445
+ return (
446
+ <div ref={wrapperRef} className={wrapperClass} style={wrapperStyle}>
447
+ <button
448
+ type="button"
449
+ aria-label={`Play: ${title}`}
450
+ onClick={() => setActivated(true)}
451
+ className="group absolute inset-0 p-0 m-0 border border-ods-border rounded-lg overflow-hidden bg-ods-card cursor-pointer"
452
+ >
453
+ <picture>
454
+ <source type="image/webp" srcSet={posterWebp} />
455
+ <img
456
+ src={posterJpg}
457
+ alt={title}
458
+ loading="lazy"
459
+ fetchPriority={priority ? 'high' : 'low'}
460
+ decoding={priority ? 'sync' : 'async'}
461
+ className="absolute inset-0 w-full h-full object-cover"
462
+ />
463
+ </picture>
464
+ <div className="absolute inset-0 flex items-center justify-center bg-ods-bg-inverse bg-opacity-20 transition-opacity duration-200 group-hover:bg-opacity-30">
465
+ <span className="flex items-center justify-center w-16 h-16 rounded-full bg-ods-accent text-ods-text-on-accent shadow-lg transition-transform duration-200 group-hover:scale-110">
466
+ <svg width={24} height={24} fill="currentColor" viewBox="0 0 24 24" className="ml-1">
467
+ <polygon points="5,3 19,12 5,21" />
468
+ </svg>
469
+ </span>
470
+ </div>
471
+ </button>
472
+ </div>
473
+ );
474
+ }