From 176fb3cbc765aa4abe486427fa7d562ecdfefa23 Mon Sep 17 00:00:00 2001 From: Calvin <8109978+Calvinjwala@users.noreply.github.com> Date: Mon, 23 Feb 2026 15:00:18 -0800 Subject: [PATCH] Component Library Refactor & New Components (#3510) * adding showcase page * adding CardStatsList * clean up, tighter code * code review and code clean up * update import, clean up env for error message * tweak some css code * less css, rebuilt * re-adding bem, modifier for bds variants --- @l10n/es-ES/translations.yaml | 1 + @l10n/ja/translations.yaml | 1 + .../components/Navbar/constants/navigation.ts | 1 + postcss.config.cjs | 12 +- shared/components/CardStat/CardStat.scss | 3 +- shared/components/CardStat/CardStat.tsx | 70 ++- shared/components/CardTextIcon/README.md | 6 +- shared/components/Video/Video.scss | 188 ++++++ shared/components/Video/Video.tsx | 278 +++++++++ shared/components/Video/index.ts | 1 + shared/patterns/CardStats/CardStats.scss | 104 ---- shared/patterns/CardStats/index.ts | 3 - .../patterns/LinkTextCard/LinkTextCard.scss | 40 +- .../patterns/SectionHeader/SectionHeader.scss | 50 ++ .../patterns/SectionHeader/SectionHeader.tsx | 111 ++++ shared/patterns/SectionHeader/index.ts | 1 + .../CalloutMediaBanner.scss | 0 .../CalloutMediaBanner/CalloutMediaBanner.tsx | 27 +- .../CalloutMediaBanner/README.md | 4 +- .../CalloutMediaBanner/index.ts | 0 .../CardStatsList/CardStatsList.md} | 4 +- .../sections/CardStatsList/CardStatsList.scss | 48 ++ .../CardStatsList/CardStatsList.tsx} | 17 +- shared/sections/CardStatsList/index.ts | 2 + .../CardsFeatured/CardsFeatured.md | 2 +- .../CardsFeatured/CardsFeatured.scss | 74 +-- .../CardsFeatured/CardsFeatured.tsx | 17 +- .../CardsFeatured/index.ts | 0 .../CardsIconGrid/CardsIconGrid.scss | 34 -- .../CardsIconGrid/CardsIconGrid.tsx | 10 +- .../CardsIconGrid/README.md | 2 +- .../CardsIconGrid/index.ts | 0 .../CardsTextGrid/CardsTextGrid.scss | 34 -- .../CardsTextGrid/CardsTextGrid.tsx | 10 +- .../CardsTextGrid/README.md | 2 +- .../CardsTextGrid/index.ts | 0 .../CardsTwoColumn/CardsTwoColumn.scss | 0 .../CardsTwoColumn/CardsTwoColumn.tsx | 0 .../CardsTwoColumn/README.md | 4 +- .../CardsTwoColumn/index.ts | 0 .../CarouselCardList/CarouselCardList.md | 2 +- .../CarouselCardList/CarouselCardList.scss | 0 .../CarouselCardList/CarouselCardList.tsx | 0 .../CarouselCardList/index.ts | 0 .../FeatureSingleTopic.scss | 0 .../FeatureSingleTopic/FeatureSingleTopic.tsx | 2 +- .../FeatureSingleTopic/README.md | 4 +- .../FeatureSingleTopic/index.ts | 0 .../FeatureTwoColumn/FeatureTwoColumn.md | 2 +- .../FeatureTwoColumn/FeatureTwoColumn.scss | 0 .../FeatureTwoColumn/FeatureTwoColumn.tsx | 2 +- .../FeatureTwoColumn/index.ts | 0 .../FeaturedVideoHero/FeaturedVideoHero.tsx | 0 .../FeaturedVideoHero/README.md | 29 +- .../_featured-video-hero.scss | 0 .../HeaderHeroPrimaryMedia.tsx | 52 +- .../HeaderHeroPrimaryMedia/README.md | 29 +- .../_header-hero-primary-media.scss | 0 .../HeaderHeroSplitMedia.md | 0 .../HeaderHeroSplitMedia.scss | 0 .../HeaderHeroSplitMedia.tsx | 0 .../HeaderHeroSplitMedia/index.ts | 0 .../LinkSmallGrid/LinkSmallGrid.scss | 20 +- .../LinkSmallGrid/LinkSmallGrid.tsx | 15 +- .../LinkSmallGrid/README.md | 0 .../LinkSmallGrid/index.ts | 0 .../LinkTextDirectory/LinkTextDirectory.scss | 35 +- .../LinkTextDirectory/LinkTextDirectory.tsx | 13 +- .../LinkTextDirectory/README.md | 4 +- .../LinkTextDirectory/index.ts | 0 .../LogoRectangleGrid/LogoRectangleGrid.scss} | 75 +-- .../LogoRectangleGrid/LogoRectangleGrid.tsx | 119 ++++ shared/sections/LogoRectangleGrid/README.md | 424 ++++++++++++++ shared/sections/LogoRectangleGrid/index.ts | 2 + .../LogoSquareGrid/LogoSquareGrid.scss | 52 ++ .../LogoSquareGrid/LogoSquareGrid.tsx | 48 +- .../LogoSquareGrid/README.md | 4 +- .../LogoSquareGrid/index.ts | 0 .../SmallTilesSection/SmallTilesSection.md | 0 .../SmallTilesSection/SmallTilesSection.tsx | 0 .../_small-tiles-section.scss | 0 .../StandardCardGroupSection/README.md | 2 +- .../StandardCardGroupSection.tsx | 25 +- .../_standard-card-group-section.scss | 52 +- shared/utils/types.ts | 10 + .../button-primary.page.tsx | 0 .../button-secondary.page.tsx | 0 .../button-tertiary.page.tsx | 0 .../callout-media-banner.page.tsx | 16 +- .../card-icon.page.tsx | 0 .../card-image.page.tsx | 0 .../card-offgrid.page.tsx | 0 .../card-stats.page.tsx | 6 +- .../cards-featured.page.tsx | 2 +- .../cards-icon-grid.page.tsx | 2 +- .../cards-text-grid.page.tsx | 3 +- .../cards-two-column.page.tsx | 3 +- .../carousel-card-list.page.tsx | 8 +- .../carousel-featured.page.tsx | 0 .../divider.page.tsx | 0 .../feature-single-topic.page.tsx | 4 +- .../feature-two-column.page.tsx | 4 +- .../featured-video-hero.page.tsx | 140 +++-- .../grid.page.tsx | 0 .../header-hero-primary-media.page.tsx | 101 ++-- .../header-hero-split-media.page.tsx | 6 +- showcase/index.page.tsx | 115 ++++ .../link-small-grid.page.tsx | 2 +- .../link-text-directory.page.tsx | 2 +- .../link.page.tsx | 0 showcase/logo-rectangle-grid.page.tsx | 535 ++++++++++++++++++ .../logo-square-grid.page.tsx | 4 +- .../small-tiles-section.page.tsx | 2 +- .../standard-card-group-section.page.tsx | 4 +- .../tile-link.page.tsx | 0 .../tile-logo.page.tsx | 0 .../typography.page.tsx | 0 static/css/devportal2024-v1.css | 2 +- styles/xrpl.scss | 41 +- top-nav.yaml | 2 +- 120 files changed, 2394 insertions(+), 793 deletions(-) create mode 100644 shared/components/Video/Video.scss create mode 100644 shared/components/Video/Video.tsx create mode 100644 shared/components/Video/index.ts delete mode 100644 shared/patterns/CardStats/CardStats.scss delete mode 100644 shared/patterns/CardStats/index.ts create mode 100644 shared/patterns/SectionHeader/SectionHeader.scss create mode 100644 shared/patterns/SectionHeader/SectionHeader.tsx create mode 100644 shared/patterns/SectionHeader/index.ts rename shared/{patterns => sections}/CalloutMediaBanner/CalloutMediaBanner.scss (100%) rename shared/{patterns => sections}/CalloutMediaBanner/CalloutMediaBanner.tsx (85%) rename shared/{patterns => sections}/CalloutMediaBanner/README.md (98%) rename shared/{patterns => sections}/CalloutMediaBanner/index.ts (100%) rename shared/{patterns/CardStats/CardStats.md => sections/CardStatsList/CardStatsList.md} (97%) create mode 100644 shared/sections/CardStatsList/CardStatsList.scss rename shared/{patterns/CardStats/CardStats.tsx => sections/CardStatsList/CardStatsList.tsx} (84%) create mode 100644 shared/sections/CardStatsList/index.ts rename shared/{patterns => sections}/CardsFeatured/CardsFeatured.md (98%) rename shared/{patterns => sections}/CardsFeatured/CardsFeatured.scss (59%) rename shared/{patterns => sections}/CardsFeatured/CardsFeatured.tsx (85%) rename shared/{patterns => sections}/CardsFeatured/index.ts (100%) rename shared/{patterns => sections}/CardsIconGrid/CardsIconGrid.scss (59%) rename shared/{patterns => sections}/CardsIconGrid/CardsIconGrid.tsx (81%) rename shared/{patterns => sections}/CardsIconGrid/README.md (95%) rename shared/{patterns => sections}/CardsIconGrid/index.ts (100%) rename shared/{patterns => sections}/CardsTextGrid/CardsTextGrid.scss (59%) rename shared/{patterns => sections}/CardsTextGrid/CardsTextGrid.tsx (80%) rename shared/{patterns => sections}/CardsTextGrid/README.md (95%) rename shared/{patterns => sections}/CardsTextGrid/index.ts (100%) rename shared/{patterns => sections}/CardsTwoColumn/CardsTwoColumn.scss (100%) rename shared/{patterns => sections}/CardsTwoColumn/CardsTwoColumn.tsx (100%) rename shared/{patterns => sections}/CardsTwoColumn/README.md (98%) rename shared/{patterns => sections}/CardsTwoColumn/index.ts (100%) rename shared/{patterns => sections}/CarouselCardList/CarouselCardList.md (98%) rename shared/{patterns => sections}/CarouselCardList/CarouselCardList.scss (100%) rename shared/{patterns => sections}/CarouselCardList/CarouselCardList.tsx (100%) rename shared/{patterns => sections}/CarouselCardList/index.ts (100%) rename shared/{patterns => sections}/FeatureSingleTopic/FeatureSingleTopic.scss (100%) rename shared/{patterns => sections}/FeatureSingleTopic/FeatureSingleTopic.tsx (99%) rename shared/{patterns => sections}/FeatureSingleTopic/README.md (98%) rename shared/{patterns => sections}/FeatureSingleTopic/index.ts (100%) rename shared/{patterns => sections}/FeatureTwoColumn/FeatureTwoColumn.md (99%) rename shared/{patterns => sections}/FeatureTwoColumn/FeatureTwoColumn.scss (100%) rename shared/{patterns => sections}/FeatureTwoColumn/FeatureTwoColumn.tsx (99%) rename shared/{patterns => sections}/FeatureTwoColumn/index.ts (100%) rename shared/{patterns => sections}/FeaturedVideoHero/FeaturedVideoHero.tsx (100%) rename shared/{patterns => sections}/FeaturedVideoHero/README.md (81%) rename shared/{patterns => sections}/FeaturedVideoHero/_featured-video-hero.scss (100%) rename shared/{patterns => sections}/HeaderHeroPrimaryMedia/HeaderHeroPrimaryMedia.tsx (79%) rename shared/{patterns => sections}/HeaderHeroPrimaryMedia/README.md (84%) rename shared/{patterns => sections}/HeaderHeroPrimaryMedia/_header-hero-primary-media.scss (100%) rename shared/{patterns => sections}/HeaderHeroSplitMedia/HeaderHeroSplitMedia.md (100%) rename shared/{patterns => sections}/HeaderHeroSplitMedia/HeaderHeroSplitMedia.scss (100%) rename shared/{patterns => sections}/HeaderHeroSplitMedia/HeaderHeroSplitMedia.tsx (100%) rename shared/{patterns => sections}/HeaderHeroSplitMedia/index.ts (100%) rename shared/{patterns => sections}/LinkSmallGrid/LinkSmallGrid.scss (70%) rename shared/{patterns => sections}/LinkSmallGrid/LinkSmallGrid.tsx (88%) rename shared/{patterns => sections}/LinkSmallGrid/README.md (100%) rename shared/{patterns => sections}/LinkSmallGrid/index.ts (100%) rename shared/{patterns => sections}/LinkTextDirectory/LinkTextDirectory.scss (52%) rename shared/{patterns => sections}/LinkTextDirectory/LinkTextDirectory.tsx (87%) rename shared/{patterns => sections}/LinkTextDirectory/README.md (98%) rename shared/{patterns => sections}/LinkTextDirectory/index.ts (100%) rename shared/{patterns/LogoSquareGrid/LogoSquareGrid.scss => sections/LogoRectangleGrid/LogoRectangleGrid.scss} (54%) create mode 100644 shared/sections/LogoRectangleGrid/LogoRectangleGrid.tsx create mode 100644 shared/sections/LogoRectangleGrid/README.md create mode 100644 shared/sections/LogoRectangleGrid/index.ts create mode 100644 shared/sections/LogoSquareGrid/LogoSquareGrid.scss rename shared/{patterns => sections}/LogoSquareGrid/LogoSquareGrid.tsx (70%) rename shared/{patterns => sections}/LogoSquareGrid/README.md (99%) rename shared/{patterns => sections}/LogoSquareGrid/index.ts (100%) rename shared/{patterns => sections}/SmallTilesSection/SmallTilesSection.md (100%) rename shared/{patterns => sections}/SmallTilesSection/SmallTilesSection.tsx (100%) rename shared/{patterns => sections}/SmallTilesSection/_small-tiles-section.scss (100%) rename shared/{patterns => sections}/StandardCardGroupSection/README.md (98%) rename shared/{patterns => sections}/StandardCardGroupSection/StandardCardGroupSection.tsx (85%) rename shared/{patterns => sections}/StandardCardGroupSection/_standard-card-group-section.scss (52%) rename about/button-showcase-primary.page.tsx => showcase/button-primary.page.tsx (100%) rename about/button-showcase-secondary.page.tsx => showcase/button-secondary.page.tsx (100%) rename about/button-showcase-tertiary.page.tsx => showcase/button-tertiary.page.tsx (100%) rename about/callout-media-banner-showcase.page.tsx => showcase/callout-media-banner.page.tsx (97%) rename about/card-icon-showcase.page.tsx => showcase/card-icon.page.tsx (100%) rename about/card-image-showcase.page.tsx => showcase/card-image.page.tsx (100%) rename about/card-offgrid-showcase.page.tsx => showcase/card-offgrid.page.tsx (100%) rename about/card-stats-showcase.page.tsx => showcase/card-stats.page.tsx (97%) rename about/cards-featured-showcase.page.tsx => showcase/cards-featured.page.tsx (99%) rename about/cards-icon-grid-showcase.page.tsx => showcase/cards-icon-grid.page.tsx (98%) rename about/cards-text-grid-showcase.page.tsx => showcase/cards-text-grid.page.tsx (97%) rename about/cards-two-column-showcase.page.tsx => showcase/cards-two-column.page.tsx (99%) rename about/carousel-card-list-showcase.page.tsx => showcase/carousel-card-list.page.tsx (99%) rename about/carousel-featured-showcase.page.tsx => showcase/carousel-featured.page.tsx (100%) rename about/divider-showcase.page.tsx => showcase/divider.page.tsx (100%) rename about/feature-single-topic-showcase.page.tsx => showcase/feature-single-topic.page.tsx (99%) rename about/feature-two-column-showcase.page.tsx => showcase/feature-two-column.page.tsx (99%) rename about/featured-video-hero-showcase.page.tsx => showcase/featured-video-hero.page.tsx (76%) rename about/grid-showcase.page.tsx => showcase/grid.page.tsx (100%) rename about/header-hero-primary-media-showcase.page.tsx => showcase/header-hero-primary-media.page.tsx (90%) rename about/header-hero-split-media-showcase.page.tsx => showcase/header-hero-split-media.page.tsx (99%) create mode 100644 showcase/index.page.tsx rename about/link-small-grid-showcase.page.tsx => showcase/link-small-grid.page.tsx (98%) rename about/link-text-directory-showcase.page.tsx => showcase/link-text-directory.page.tsx (98%) rename about/link-showcase.page.tsx => showcase/link.page.tsx (100%) create mode 100644 showcase/logo-rectangle-grid.page.tsx rename about/logo-square-grid-showcase.page.tsx => showcase/logo-square-grid.page.tsx (99%) rename about/small-tiles-section-showcase.page.tsx => showcase/small-tiles-section.page.tsx (99%) rename about/standard-card-group-section-showcase.page.tsx => showcase/standard-card-group-section.page.tsx (98%) rename about/tile-link-showcase.page.tsx => showcase/tile-link.page.tsx (100%) rename about/tile-logo-showcase.page.tsx => showcase/tile-logo.page.tsx (100%) rename about/typography-showcase.page.tsx => showcase/typography.page.tsx (100%) diff --git a/@l10n/es-ES/translations.yaml b/@l10n/es-ES/translations.yaml index 211dbe89b0..f0b49ea228 100644 --- a/@l10n/es-ES/translations.yaml +++ b/@l10n/es-ES/translations.yaml @@ -2,6 +2,7 @@ navbar.about: Acerca de navbar.docs: Docs navbar.resources: Recursos navbar.community: Comunidad +navbar.showcase: Escaparate footer.about: Acerca de footer.docs: Docs footer.resources: Recursos diff --git a/@l10n/ja/translations.yaml b/@l10n/ja/translations.yaml index bf239e6bbd..c8ed224af2 100644 --- a/@l10n/ja/translations.yaml +++ b/@l10n/ja/translations.yaml @@ -16,6 +16,7 @@ navbar.about: 概要 navbar.docs: ドキュメント navbar.resources: リソース navbar.community: コミュニティ +navbar.showcase: ショーケース footer.about: 概要 footer.docs: ドキュメント footer.resources: リソース diff --git a/@theme/components/Navbar/constants/navigation.ts b/@theme/components/Navbar/constants/navigation.ts index d17ea89c10..87a1e5ced6 100644 --- a/@theme/components/Navbar/constants/navigation.ts +++ b/@theme/components/Navbar/constants/navigation.ts @@ -14,6 +14,7 @@ export const navItems: NavItem[] = [ { label: "Use Cases", labelTranslationKey: "navbar.usecases", href: "/use-cases", hasSubmenu: true }, { label: "Community", labelTranslationKey: "navbar.community", href: "/community", hasSubmenu: true }, { label: "Network", labelTranslationKey: "navbar.network", href: "/resources", hasSubmenu: true }, + { label: "Showcase (Temporary)", labelTranslationKey: "navbar.showcase", href: "/showcase", hasSubmenu: false }, ]; // Develop submenu data structure diff --git a/postcss.config.cjs b/postcss.config.cjs index b255d27355..dca0557dff 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -19,13 +19,14 @@ module.exports = { ...(isProduction || process.env.PURGECSS === 'true' ? [ purgecss({ - // Scan all content files for class names + // Scan all content files for class names (TSX/TS/HTML = usage; SCSS = BEM modifier definitions) content: [ './**/*.tsx', './**/*.ts', './**/*.md', './**/*.yaml', './**/*.html', + './**/*.scss', './static/js/**/*.js', './static/vendor/**/*.js', // Ignore node_modules except for specific libraries that inject classes @@ -42,7 +43,11 @@ module.exports = { const m = match.match(/["']([^"']*)["']/); return m ? m[1].split(/\s+/) : []; }); - return [...broadMatches, ...classes]; + // Extract BEM modifier classes from SCSS (e.g. .bds-callout-media-banner--green) + // These are dynamically applied in TSX via template literals so PurgeCSS won't find them as literals + const scssBemMatches = content.match(/\.(bds-[a-z0-9-]+--[a-z0-9-]+)/g) || []; + const bemModifiers = [...new Set(scssBemMatches.map(m => m.replace(/^\./, '')))]; + return [...new Set([...broadMatches, ...classes, ...bemModifiers])]; }, // Safelist - classes that should never be removed @@ -68,6 +73,9 @@ module.exports = { /^col-/, // Column classes /^bds-grid__col/, // PageGrid column classes (dynamic span values) /^bds-grid__offset/, // PageGrid offset classes + // BDS BEM modifier classes - applied dynamically via template literals (e.g. bds-callout-media-banner--green) + // Required: PurgeCSS cannot find these in TSX/SCSS due to interpolation + /^bds-[a-z0-9-]+--/, /^g-/, // Gap utilities /^p-/, // Padding utilities /^m-/, // Margin utilities diff --git a/shared/components/CardStat/CardStat.scss b/shared/components/CardStat/CardStat.scss index 35e07da9bd..bcb4f2ff05 100644 --- a/shared/components/CardStat/CardStat.scss +++ b/shared/components/CardStat/CardStat.scss @@ -44,7 +44,8 @@ $bds-card-stat-transition-timing: $bds-transition-timing; .bds-card-stat { // Layout - display: flex; + // !important needed to override PageGridCol's default display (e.g. grid/flex from parent) + display: flex !important; flex-direction: column; width: 100%; min-height: 200px; diff --git a/shared/components/CardStat/CardStat.tsx b/shared/components/CardStat/CardStat.tsx index 2fac23b049..6b7602b513 100644 --- a/shared/components/CardStat/CardStat.tsx +++ b/shared/components/CardStat/CardStat.tsx @@ -87,43 +87,41 @@ export const CardStat: React.FC = ({ const isNumericSuperscript = superscript && /^[0-9]+$/.test(superscript); return ( - -
- {/* Text section */} -
-
- {statistic}{superscript && {superscript}}
-
{label}
-
- - {/* Buttons section */} - {hasButtons && ( -
- {primaryButton && ( - - )} - {secondaryButton && ( - - )} -
- )} + + {/* Text section */} +
+
+ {statistic}{superscript && {superscript}}
+
{label}
+ + {/* Buttons section */} + {hasButtons && ( +
+ {primaryButton && ( + + )} + {secondaryButton && ( + + )} +
+ )}
); }; diff --git a/shared/components/CardTextIcon/README.md b/shared/components/CardTextIcon/README.md index be433d75b3..29bd83ea06 100644 --- a/shared/components/CardTextIcon/README.md +++ b/shared/components/CardTextIcon/README.md @@ -92,9 +92,9 @@ CardTextIconCard displays an icon at the top, followed by a heading and descript | Breakpoint | Icon Size | Padding | Gap | |------------|-----------|---------|-----| -| Base (< 576px) | 56px | 16px | 16px | -| MD (576px - 991px) | 60px | 20px | 20px | -| LG (≥ 992px) | 64px | 24px | 24px | +| Base (< 576px) | 32px | 16px | 16px | +| MD (576px - 991px) | 36px | 20px | 20px | +| LG (≥ 992px) | 40px | 32px | 24px | ## Files diff --git a/shared/components/Video/Video.scss b/shared/components/Video/Video.scss new file mode 100644 index 0000000000..f3b66aa9af --- /dev/null +++ b/shared/components/Video/Video.scss @@ -0,0 +1,188 @@ +// BDS Video Component Styles +// Brand Design System - Flexible video component supporting native video, +// YouTube/Vimeo/Wistia embeds, and cover image + modal playback. +// +// .bds-video - Container +// .bds-video__element - Native video element +// .bds-video__iframe - Embed iframe +// .bds-video__cover-button - Clickable cover (opens modal) +// .bds-video__cover-image - Cover image +// .bds-video__play-icon - Play overlay icon +// .bds-video-modal - Full-screen modal overlay +// .bds-video-modal__backdrop - Backdrop (click to close) +// .bds-video-modal__content - Modal content wrapper +// .bds-video-modal__close - Close button +// .bds-video-modal__video - Video/iframe container in modal + +.bds-video { + width: 100%; + height: auto; + overflow: hidden; + position: relative; + + &--aspect-16-9 { + aspect-ratio: 16 / 9; + } + + &--aspect-4-3 { + aspect-ratio: 4 / 3; + } + + &--aspect-1-1 { + aspect-ratio: 1 / 1; + } + + &__inner { + width: 100%; + height: 100%; + position: relative; + } + + &__element { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + } + + &__iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } + + &__cover-button { + position: relative; + display: block; + width: 100%; + height: 100%; + padding: 0; + margin: 0; + border: none; + background: none; + cursor: pointer; + overflow: hidden; + } + + &__cover-image { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center; + display: block; + transition: transform 0.3s ease; + } + + &__cover-button:hover &__cover-image { + transform: scale(1.02); + } + + &__play-icon { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + + svg { + width: 64px; + height: 64px; + transition: transform 0.2s ease; + } + } + + &__cover-button:hover &__play-icon svg { + transform: scale(1.1); + } +} + +// ============================================================================= +// Modal +// ============================================================================= + +.bds-video-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1200; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.bds-video-modal__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + cursor: pointer; +} + +.bds-video-modal__content { + position: relative; + top: 0; + left: 0; + width: 100%; + max-width: 90vw; + max-height: 90vh; + aspect-ratio: 16 / 9; + z-index: 2; +} + +.bds-video-modal__close { + position: absolute; + top: -2.5rem; + right: 0; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + background: transparent; + color: white; + font-size: 2rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } + + span { + line-height: 1; + } +} + +.bds-video-modal__video { + width: 100%; + height: 100%; + position: relative; + overflow: hidden; + border-radius: 4px; + + .bds-video__element, + .bds-video__iframe, + iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; + } +} diff --git a/shared/components/Video/Video.tsx b/shared/components/Video/Video.tsx new file mode 100644 index 0000000000..da8def3bbd --- /dev/null +++ b/shared/components/Video/Video.tsx @@ -0,0 +1,278 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import clsx from 'clsx'; +import type { DesignConstrainedVideoProps } from 'shared/utils/types'; + +/** Native HTML video source */ +export type VideoSourceNative = { + type: 'native'; + props: DesignConstrainedVideoProps; +}; + +/** + * Embed source - raw HTML iframe code from YouTube/Vimeo/Wistia + * + * ⚠️ **SECURITY WARNING**: embedCode uses regex-based parsing which can be bypassed + * with malformed HTML (e.g., newlines in attributes, missing quotes, unusual spacing). + * **Always prefer `embedUrl` over `embedCode` when possible.** + * + * Only use embedCode when: + * - The HTML comes from a trusted source you control + * - You cannot extract the URL beforehand + * + * The component validates against TRUSTED_EMBED_ORIGINS, but regex parsing is not + * foolproof against all HTML variations. + */ +export type VideoSourceEmbedCode = { + type: 'embed'; + embedCode: string; + embedUrl?: never; +}; + +/** + * Embed source - direct embed URL (safer, preferred) + * + * ✅ **RECOMMENDED**: This is the preferred and safer method for embedding videos. + * Directly validates the URL against TRUSTED_EMBED_ORIGINS without HTML parsing. + */ +export type VideoSourceEmbedUrl = { + type: 'embed'; + embedUrl: string; + embedCode?: never; +}; + +export type VideoSource = + | VideoSourceNative + | VideoSourceEmbedCode + | VideoSourceEmbedUrl; + +export interface VideoProps { + /** + * Video source: native HTML video or embed (YouTube/Vimeo/Wistia) + * + * **For embeds, prefer `embedUrl` over `embedCode`:** + * - ✅ `embedUrl`: Direct URL validation (safer, recommended) + * - ⚠️ `embedCode`: Regex-based HTML parsing (can be bypassed, use with caution) + */ + source: VideoSource; + /** Optional cover image - when provided, video shows in modal on click */ + coverImage?: { + src: string; + alt: string; + }; + /** Aspect ratio for container (default 16/9) */ + aspectRatio?: '16/9' | '4/3' | '1/1'; + /** Additional className for container */ + className?: string; +} + +/** Trusted embed origins for embedCode sanitization */ +const TRUSTED_EMBED_ORIGINS = [ + 'youtube.com', + 'www.youtube.com', + 'youtube-nocookie.com', + 'www.youtube-nocookie.com', + 'vimeo.com', + 'player.vimeo.com', + 'fast.wistia.net', + 'fast.wistia.com', +]; + +function isTrustedEmbedUrl(url: string): boolean { + try { + const parsed = new URL(url); + const host = parsed.hostname.toLowerCase(); + return TRUSTED_EMBED_ORIGINS.some( + (origin) => host === origin || host.endsWith('.' + origin) + ); + } catch { + return false; + } +} + +/** + * Extract iframe src from embed code if from trusted origin + * + * ⚠️ **SECURITY WARNING**: This regex-based parser is NOT foolproof and can be bypassed + * with malformed HTML. Known bypass vectors include: + * - Newlines or unusual whitespace in attributes + * - Missing or unusual quote characters + * - HTML comments or CDATA sections + * - Encoded characters or HTML entities + * + * This function should only be used as a fallback when embedUrl is not available. + * Always prefer using embedUrl directly when possible. + * + * @param embedCode - Raw HTML iframe embed code (from trusted sources only) + * @returns Extracted URL if valid and from trusted origin, null otherwise + */ +function extractEmbedUrlFromCode(embedCode: string): string | null { + const iframeMatch = embedCode.match(/]+src=["']([^"']+)["']/i); + if (!iframeMatch) return null; + const url = iframeMatch[1]; + return isTrustedEmbedUrl(url) ? url : null; +} + +export const Video = React.forwardRef( + (props, ref) => { + const { + source, + coverImage, + aspectRatio = '16/9', + className, + } = props; + + const [isModalOpen, setIsModalOpen] = React.useState(false); + const closeButtonRef = useRef(null); + const previousActiveElementRef = useRef(null); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + }, []); + + useEffect(() => { + if (!isModalOpen) return; + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isModalOpen, closeModal]); + + // Focus trap: capture previous focus, focus close button, restore on close + useEffect(() => { + if (isModalOpen) { + previousActiveElementRef.current = document.activeElement as HTMLElement | null; + closeButtonRef.current?.focus(); + } else { + previousActiveElementRef.current?.focus(); + } + }, [isModalOpen]); + + + + const renderVideoContent = () => { + if (source.type === 'native') { + return ( +