From 1135fa38260eb1026cc3bb6cfaca855910ecca00 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Mar 2026 12:48:05 +0000 Subject: [PATCH 1/5] refactor: comprehensively refactor media components - Remove SbMediaWrapper entirely; inline spacing/width logic directly into SbImage, SbVideo, and SbSlideshow using Box - Image: use AspectRatio component when aspectRatio prop is provided, remove wrapperStyle/wrapperClassName props, simplify copyright rendering - Video: use AspectRatio component, consolidate YouTube/Vimeo render paths into a single branch, remove wrapperStyle/wrapperClassName, simplify copyright rendering, strip AI-generated emoji JSDoc - CopyrightLabel: merge inline-white and inline-black into a single block differing only in color, reducing duplication - Tighten aspectRatio prop type on Image to match AspectRatio/Video https://claude.ai/code/session_01DLAMCPXYx7Z9kuqQ5RKLNM --- .../src/components/image/SbImage.tsx | 48 +-- .../media-wrapper/SbMediaWrapper.tsx | 44 --- .../src/components/media-wrapper/index.ts | 2 - .../src/components/slideshow/SbSlideshow.tsx | 24 +- .../src/components/video/SbVideo.tsx | 60 +-- packages/storyblok-ui/src/index.ts | 4 - .../copyright-label/copyright-label.tsx | 65 +--- packages/ui/src/components/image/image.tsx | 250 ++++--------- packages/ui/src/components/video/video.tsx | 352 +++--------------- 9 files changed, 172 insertions(+), 677 deletions(-) delete mode 100644 packages/storyblok-ui/src/components/media-wrapper/SbMediaWrapper.tsx delete mode 100644 packages/storyblok-ui/src/components/media-wrapper/index.ts diff --git a/packages/storyblok-ui/src/components/image/SbImage.tsx b/packages/storyblok-ui/src/components/image/SbImage.tsx index ae630ea4..2b1340b0 100644 --- a/packages/storyblok-ui/src/components/image/SbImage.tsx +++ b/packages/storyblok-ui/src/components/image/SbImage.tsx @@ -2,13 +2,14 @@ import type { StoryblokRichTextProps } from "@httpjpg/storyblok-richtext"; import { getProcessedImage } from "@httpjpg/storyblok-utils"; -import { Image } from "@httpjpg/ui"; +import { Box, Image } from "@httpjpg/ui"; import { memo } from "react"; import { css } from "styled-system/css"; import { useStoryblokEditable } from "../../lib/use-storyblok-editable"; +import { mapSpacingToToken } from "../../lib/spacing-utils"; +import { mapWidthToToken } from "../../lib/token-mapping"; import type { StoryblokImage } from "../../types"; import { SbCaption } from "../caption"; -import { SbMediaWrapper } from "../media-wrapper"; // Predefine all responsive width classes so Panda generates them at build time const RESPONSIVE_WIDTH_CLASSES = { @@ -48,13 +49,8 @@ export interface SbImageProps { }; } -// Default image width for Storyblok image service when aspect ratio is specified const DEFAULT_IMAGE_WIDTH = 1200; -/** - * Storyblok Image Component - * Optimized image with Storyblok image service - */ export const SbImage = memo(function SbImage({ blok }: SbImageProps) { const { image, @@ -78,8 +74,6 @@ export const SbImage = memo(function SbImage({ blok }: SbImageProps) { return null; } - // Process image with Storyblok image service (or return external URL as-is) - // Calculate dimensions based on aspect ratio let cropDimensions = ""; if (aspectRatio) { const parts = aspectRatio.split("/"); @@ -87,7 +81,6 @@ export const SbImage = memo(function SbImage({ blok }: SbImageProps) { const [widthRatio, heightRatio] = parts.map((part) => Number(part.trim()), ); - // Validate that both ratios are valid positive finite numbers if ( widthRatio > 0 && heightRatio > 0 && @@ -109,20 +102,12 @@ export const SbImage = memo(function SbImage({ blok }: SbImageProps) { "", ); - // Generate blur placeholder if blurOnLoad is enabled const blurDataURL = blurOnLoad - ? getProcessedImage( - image.filename, - "20x0", // Small width for blur placeholder - image.focus || "", - "", - ) + ? getProcessedImage(image.filename, "20x0", image.focus || "", "") : undefined; - // Use copyright from custom field first, fallback to image copyright const finalCopyright = copyright || image.copyright || ""; - // Calculate width based on imageWidth value const calculatedWidth = imageWidth === "original" || imageWidth === "auto" ? "auto" @@ -133,11 +118,14 @@ export const SbImage = memo(function SbImage({ blok }: SbImageProps) { : "auto"; return ( -
{caption && } -
+ ); }); diff --git a/packages/storyblok-ui/src/components/media-wrapper/SbMediaWrapper.tsx b/packages/storyblok-ui/src/components/media-wrapper/SbMediaWrapper.tsx deleted file mode 100644 index 0db72779..00000000 --- a/packages/storyblok-ui/src/components/media-wrapper/SbMediaWrapper.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * SbMediaWrapper component for Storyblok media components - * Provides consistent spacing and width constraints - */ - -import { Box } from "@httpjpg/ui"; -import type { ReactNode } from "react"; -import { memo } from "react"; -import { mapSpacingToToken } from "../../lib/spacing-utils"; -import { mapWidthToToken } from "../../lib/token-mapping"; - -export interface SbMediaWrapperProps { - children: ReactNode; - spacingTop?: string; - spacingBottom?: string; - width?: string; - editable?: Record; -} - -/** - * SbMediaWrapper component - * Wraps media components with consistent spacing and layout - */ -export const SbMediaWrapper = memo(function SbMediaWrapper({ - children, - spacingTop, - spacingBottom, - width, - editable, -}: SbMediaWrapperProps) { - return ( - - {children} - - ); -}); diff --git a/packages/storyblok-ui/src/components/media-wrapper/index.ts b/packages/storyblok-ui/src/components/media-wrapper/index.ts deleted file mode 100644 index e9881e3b..00000000 --- a/packages/storyblok-ui/src/components/media-wrapper/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { SbMediaWrapperProps } from "./SbMediaWrapper"; -export { SbMediaWrapper } from "./SbMediaWrapper"; diff --git a/packages/storyblok-ui/src/components/slideshow/SbSlideshow.tsx b/packages/storyblok-ui/src/components/slideshow/SbSlideshow.tsx index f2026ac3..1bb64940 100644 --- a/packages/storyblok-ui/src/components/slideshow/SbSlideshow.tsx +++ b/packages/storyblok-ui/src/components/slideshow/SbSlideshow.tsx @@ -1,10 +1,11 @@ "use client"; -import { Slideshow } from "@httpjpg/ui"; +import { Box, Slideshow } from "@httpjpg/ui"; import { memo } from "react"; import { useStoryblokEditable } from "../../lib/use-storyblok-editable"; +import { mapSpacingToToken } from "../../lib/spacing-utils"; +import { mapWidthToToken } from "../../lib/token-mapping"; import type { StoryblokImage } from "../../types"; -import { SbMediaWrapper } from "../media-wrapper"; export interface SbSlideshowProps { blok: { @@ -39,10 +40,6 @@ export interface SbSlideshowProps { }; } -/** - * Storyblok Slideshow Component - * Carousel/slideshow of images - */ export const SbSlideshow = memo(function SbSlideshow({ blok, }: SbSlideshowProps) { @@ -67,11 +64,14 @@ export const SbSlideshow = memo(function SbSlideshow({ } return ( - ({ @@ -88,6 +88,6 @@ export const SbSlideshow = memo(function SbSlideshow({ animation={animation} animationDelay={animationDelay} /> - + ); }); diff --git a/packages/storyblok-ui/src/components/video/SbVideo.tsx b/packages/storyblok-ui/src/components/video/SbVideo.tsx index 99e6e31a..2ad6f582 100644 --- a/packages/storyblok-ui/src/components/video/SbVideo.tsx +++ b/packages/storyblok-ui/src/components/video/SbVideo.tsx @@ -3,12 +3,13 @@ import { ConsentPlaceholder, hasVendorConsent } from "@httpjpg/consent"; import type { StoryblokRichTextProps } from "@httpjpg/storyblok-richtext"; import type { VideoSource } from "@httpjpg/ui"; -import { Video as VideoComponent } from "@httpjpg/ui"; +import { Box, Video as VideoComponent } from "@httpjpg/ui"; import { memo, useEffect, useState } from "react"; import { useStoryblokEditable } from "../../lib/use-storyblok-editable"; +import { mapSpacingToToken } from "../../lib/spacing-utils"; +import { mapWidthToToken } from "../../lib/token-mapping"; import type { StoryblokVideoAsset } from "../../types"; import { SbCaption } from "../caption"; -import { SbMediaWrapper } from "../media-wrapper"; export interface SbVideoProps { blok: { @@ -33,10 +34,6 @@ export interface SbVideoProps { }; } -/** - * Storyblok Video Component - * Supports native videos, YouTube and Vimeo embeds - */ export const SbVideo = memo(function SbVideo({ blok }: SbVideoProps) { const { source = "native", @@ -58,7 +55,6 @@ export const SbVideo = memo(function SbVideo({ blok }: SbVideoProps) { spacingBottom, } = blok; - // Determine video source let videoSrc = ""; if (source === "native") { videoSrc = video?.filename || videoUrl || ""; @@ -70,11 +66,8 @@ export const SbVideo = memo(function SbVideo({ blok }: SbVideoProps) { const editableProps = useStoryblokEditable(blok); - // Track consent state and re-render on changes - // Start with false to avoid hydration mismatch (server has no cookies) const [hasConsent, setHasConsent] = useState(false); - // Check consent client-side only to avoid SSR/client mismatch useEffect(() => { if (source === "youtube") { setHasConsent(hasVendorConsent("youtube")); @@ -103,40 +96,26 @@ export const SbVideo = memo(function SbVideo({ blok }: SbVideoProps) { return null; } - // Check consent for external video providers - if (source === "youtube" && !hasConsent) { - return ( - - - - ); - } + const wrapperCss = { + mt: mapSpacingToToken(spacingTop), + mb: mapSpacingToToken(spacingBottom), + width: mapWidthToToken(width), + mx: width === "container" || width === "narrow" ? "auto" : undefined, + }; - if (source === "vimeo" && !hasConsent) { + if ((source === "youtube" || source === "vimeo") && !hasConsent) { return ( - - - + + + ); } return ( - + {caption?.content && caption.content.length > 0 && ( )} - + ); }); diff --git a/packages/storyblok-ui/src/index.ts b/packages/storyblok-ui/src/index.ts index 3210f9e0..b5440c87 100644 --- a/packages/storyblok-ui/src/index.ts +++ b/packages/storyblok-ui/src/index.ts @@ -40,10 +40,6 @@ export { SbMarquee, type SbMarqueeProps, } from "./components/marquee"; -export { - SbMediaWrapper, - type SbMediaWrapperProps, -} from "./components/media-wrapper"; export { SbMissing, type SbMissingProps, diff --git a/packages/ui/src/components/copyright-label/copyright-label.tsx b/packages/ui/src/components/copyright-label/copyright-label.tsx index 3e3480c6..1503e2f6 100644 --- a/packages/ui/src/components/copyright-label/copyright-label.tsx +++ b/packages/ui/src/components/copyright-label/copyright-label.tsx @@ -10,42 +10,11 @@ export type CopyrightPosition = | "inline-white"; export interface CopyrightLabelProps { - /** - * Copyright text (without Š symbol, will be added automatically) - */ text: string; - /** - * Position variant - * @default "below" - */ position?: CopyrightPosition; - /** - * Custom styles using Panda CSS SystemStyleObject - */ css?: SystemStyleObject; } -/** - * CopyrightLabel component - Displays copyright information - * - * A reusable component for displaying copyright notices in various positions. - * Automatically adds the Š symbol to the text. - * - * @example - * ```tsx - * // Below content - * - * - * // Overlay at bottom - * - * - * // Inline white (for dark images/slideshows) - * - * - * // Inline black (for bright images) - * - * ``` - */ export function CopyrightLabel({ text, position = "below", @@ -55,7 +24,6 @@ export function CopyrightLabel({ return null; } - // Overlay position (e.g., for videos) if (position === "overlay") { return ( - Š {text} - - ); - } - - // Inline white position (vertical on right side, for dark images) - if (position === "inline-white") { + if (position === "inline-white" || position === "inline-black") { return ( , "css"> { - /** - * Image source URL - */ src: string; - /** - * Alt text for accessibility - */ alt: string; - /** - * Copyright text - */ copyright?: string; - /** - * Copyright position - * @default "inline-white" - */ copyrightPosition?: CopyrightPosition; - /** - * Enable blur-up loading effect - * @default false - */ blurOnLoad?: boolean; - /** - * Low-quality image placeholder (LQIP) for blur effect - */ blurDataURL?: string; - /** - * Object fit - * @default "cover" - */ objectFit?: "cover" | "contain" | "fill" | "none" | "scale-down"; - /** - * Aspect ratio (e.g., "16/9", "1/1", "4/3") - */ - aspectRatio?: string; - /** - * Custom styles for the wrapper - */ - wrapperStyle?: React.CSSProperties; - /** - * Custom class name for the wrapper - */ - wrapperClassName?: string; - /** - * Custom styles using Panda CSS SystemStyleObject - */ + aspectRatio?: "1/1" | "4/3" | "3/4" | "16/9" | "9/16" | "21/9"; css?: SystemStyleObject; } -/** - * Image component with copyright and blur loading - * - * Supports: - * - Copyright text (inline, below, or overlay) - * - Blur-up loading effect for better UX - * - Object fit options - * - Fully styled with Panda CSS - * - * @example - * ```tsx - * Description - * ``` - */ export const Image = forwardRef( ( { @@ -93,8 +32,6 @@ export const Image = forwardRef( blurDataURL, objectFit = "cover", aspectRatio, - wrapperStyle, - wrapperClassName, className, style, css: cssProp, @@ -110,11 +47,14 @@ export const Image = forwardRef( const containerRef = useRef(null); const currentSrcRef = useRef(""); + const mergeRef = (node: HTMLDivElement | null) => { + if (typeof ref === "function") ref(node); + else if (ref) ref.current = node; + containerRef.current = node; + }; + const handleLoad = (e: React.SyntheticEvent) => { - // Only set loaded if we're loading the high-res image - if (blurOnLoad && currentSrcRef.current === src) { - setHighResLoaded(true); - } + if (blurOnLoad && currentSrcRef.current === src) setHighResLoaded(true); setIsLoaded(true); onLoad?.(e); }; @@ -124,7 +64,6 @@ export const Image = forwardRef( setHighResLoaded(true); }; - // Intersection Observer for lazy loading with blur effect useEffect(() => { if (!blurOnLoad) { setIsInView(true); @@ -143,20 +82,15 @@ export const Image = forwardRef( } }); }, - { - rootMargin: "50px", // Start loading slightly before image enters viewport - }, + { rootMargin: "50px" }, ); observer.observe(container); - return () => observer.disconnect(); }, [blurOnLoad]); - // Check if image is already loaded (e.g., from cache) useEffect(() => { if (!isInView) return; - const img = imgRef.current; if (img?.complete && img.naturalHeight !== 0 && img.src === src) { setIsLoaded(true); @@ -164,119 +98,85 @@ export const Image = forwardRef( } }, [isInView, src]); - // Calculate aspect ratio padding (must be inline - truly dynamic calculation) - const dynamicAspectRatio = aspectRatio - ? (() => { - const [width, height] = aspectRatio.split("/").map(Number); - return { aspectRatio: `${width} / ${height}`, width: "100%" }; - })() - : undefined; - const showBlur = blurOnLoad && !highResLoaded && blurDataURL; - const showCopyrightInside = - copyright && - (copyrightPosition === "inline-white" || - copyrightPosition === "inline-black" || - copyrightPosition === "overlay"); + const showCopyrightInside = copyright && copyrightPosition !== "below"; - // Don't render if no src provided - if (!src) { - return null; - } + if (!src) return null; - return ( + const mediaContent = ( <> - { - // Handle multiple refs - if (typeof ref === "function") { - ref(node); - } else if (ref) { - ref.current = node; + {showBlur && ( + + )} + { + if (node) { + imgRef.current = node; + currentSrcRef.current = isInView ? src : blurDataURL || src; } - containerRef.current = node; }} + src={isInView ? src : blurDataURL || src} + alt={alt} className={cx( css({ - position: "relative", + width: "100%", + height: "100%", display: "block", - overflow: "hidden", boxSizing: "border-box", - ...cssProp, + transition: "opacity 0.3s ease-in-out", }), - wrapperClassName, - )} - style={{ ...wrapperStyle, ...dynamicAspectRatio }} - > - {/* Blur placeholder */} - {showBlur && ( - + className, )} + style={{ + objectFit, + ...style, + opacity: blurOnLoad && !highResLoaded ? 0 : 1, + }} + onLoad={handleLoad} + onError={handleError} + {...props} + /> + {showCopyrightInside && ( + + )} + + ); - {/* Main image */} - { - if (node) { - imgRef.current = node; - // Track current src for load detection - const imgSrc = isInView ? src : blurDataURL || src; - currentSrcRef.current = imgSrc; - } - }} - src={isInView ? src : blurDataURL || src} - alt={alt} - className={cx( - css({ - width: "100%", - height: "100%", - display: "block", - boxSizing: "border-box", - transition: "opacity 0.3s ease-in-out", - }), - className, - )} - style={{ - objectFit, - ...style, - opacity: blurOnLoad && !highResLoaded ? 0 : 1, + return ( + <> + {aspectRatio ? ( + + {mediaContent} + + ) : ( + - - {/* Copyright inline white (vertical on right side) */} - {showCopyrightInside && copyrightPosition === "inline-white" && ( - - )} - - {/* Copyright overlay (bottom gradient) */} - {showCopyrightInside && copyrightPosition === "overlay" && ( - - )} - - {/* Copyright inline black */} - {showCopyrightInside && copyrightPosition === "inline-black" && ( - - )} - - - {/* Copyright below image */} + > + {mediaContent} + + )} {copyright && copyrightPosition === "below" && ( )} diff --git a/packages/ui/src/components/video/video.tsx b/packages/ui/src/components/video/video.tsx index 1fa9a044..e2ca2c1a 100644 --- a/packages/ui/src/components/video/video.tsx +++ b/packages/ui/src/components/video/video.tsx @@ -4,87 +4,27 @@ import type { VideoHTMLAttributes } from "react"; import { forwardRef, useEffect, useRef, useState } from "react"; import { css, cx } from "styled-system/css"; import type { SystemStyleObject } from "styled-system/types"; -import { Box } from "../box/box"; +import { AspectRatio } from "../aspect-ratio"; import { CopyrightLabel, type CopyrightPosition } from "../copyright-label"; import { VideoControls } from "./video-controls"; -/** - * Video source types - */ export type VideoSource = "native" | "youtube" | "vimeo"; -/** - * Video component props - */ export interface VideoProps extends Omit, "css" | "src"> { - /** - * Video source URL or ID - * - For native: direct video URL - * - For YouTube: video ID or full URL - * - For Vimeo: video ID or full URL - */ src: string; - /** - * Video source type - * @default "native" - */ source?: VideoSource; - /** - * Poster image URL (only for native videos) - */ poster?: string; - /** - * Show custom controls - * @default true - */ controls?: boolean; - /** - * Autoplay video - * @default false - */ autoPlay?: boolean; - /** - * Loop video - * @default false - */ loop?: boolean; - /** - * Mute video - * @default false - */ muted?: boolean; - /** - * Aspect ratio preset or custom value - * @default "16/9" - */ aspectRatio?: "1/1" | "4/3" | "16/9" | "21/9" | "9/16" | number; - /** - * Copyright text - */ copyright?: string; - /** - * Copyright position - * @default "inline" - */ copyrightPosition?: CopyrightPosition; - /** - * Custom styles for the wrapper - */ - wrapperStyle?: React.CSSProperties; - /** - * Custom class name for the wrapper - */ - wrapperClassName?: string; - /** - * Custom styles using Panda CSS SystemStyleObject - */ css?: SystemStyleObject; } -/** - * Extract YouTube video ID from URL - */ function getYouTubeId(url: string): string { if (url.length === 11 && !url.includes("/") && !url.includes("?")) { return url; @@ -95,71 +35,12 @@ function getYouTubeId(url: string): string { return match && match[7].length === 11 ? match[7] : url; } -/** - * Extract Vimeo video ID from URL - */ function getVimeoId(url: string): string { - if (/^\d+$/.test(url)) { - return url; - } - const regExp = /vimeo\.com\/(?:video\/)?(\d+)/; - const match = url.match(regExp); + if (/^\d+$/.test(url)) return url; + const match = url.match(/vimeo\.com\/(?:video\/)?(\d+)/); return match ? match[1] : url; } -/** - * Video component - Multi-source video player with custom controls - * - * A versatile video component that supports native HTML5 videos, YouTube embeds, - * and Vimeo embeds. Features custom controls, copyright display, and responsive - * aspect ratios using Panda CSS. - * - * **Supported Sources:** - * - Native HTML5 videos with custom controls (play/pause, seek, volume) - * - YouTube embeds with configurable player options - * - Vimeo embeds with configurable player options - * - * **Features:** - * - đŸŽŦ Custom video controls with progress bar and volume - * - 📐 Responsive aspect ratios (16/9, 4/3, 1/1, 21/9, 9/16, custom) - * - ÂŠī¸ Copyright text support (below or overlay) - * - 🎨 Fully styled with Panda CSS - * - â™ŋī¸ Accessible controls with ARIA labels - * - * @example - * ```tsx - * // Native HTML5 video with custom controls - *