diff --git a/frontend/src/components/showcase/FeatureShowcaseModal.tsx b/frontend/src/components/showcase/FeatureShowcaseModal.tsx new file mode 100644 index 00000000..9d227203 --- /dev/null +++ b/frontend/src/components/showcase/FeatureShowcaseModal.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { ChevronRight, ChevronLeft } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useHotkeysContext } from 'react-hotkeys-hook'; +import { useKeyExit, Scope } from '@/keyboard'; +import { ShowcaseStageMedia } from './ShowcaseStageMedia'; +import type { ShowcaseConfig } from '@/types/showcase'; + +interface FeatureShowcaseModalProps { + isOpen: boolean; + onClose: () => void; + config: ShowcaseConfig; +} + +/** + * FeatureShowcaseModal - Generic multi-stage modal for showcasing features with media + * + * Displays a bottom-aligned modal with stages containing videos or images, title, description, + * and navigation controls. Properly manages keyboard shortcuts (ESC captured but disabled) + * and scopes to prevent closing underlying features. + * + * Features: + * - Multi-stage or single-stage support (hides navigation if 1 stage) + * - Video support with loading states and progress bars + * - Image support with loading skeleton + * - Responsive design (full-width on mobile, 2/3 width on desktop) + * - i18n support via translation keys + * - Smooth transitions between stages + * + * @param isOpen - Controls modal visibility + * @param onClose - Called when user finishes the showcase (via Finish button on last stage) + * @param config - ShowcaseConfig object defining stages, media, and translation keys + */ +export function FeatureShowcaseModal({ + isOpen, + onClose, + config, +}: FeatureShowcaseModalProps) { + const [currentStage, setCurrentStage] = useState(0); + const { t } = useTranslation('tasks'); + const { enableScope, disableScope, activeScopes } = useHotkeysContext(); + const previousScopesRef = useRef([]); + + const stage = config.stages[currentStage]; + const totalStages = config.stages.length; + + /** + * Scope management for keyboard shortcuts: + * When showcase opens, we capture all currently active scopes, disable them, + * and enable only DIALOG scope. This ensures ESC key presses are captured by + * our showcase handler (which does nothing) instead of triggering underlying + * close handlers. When closing, we restore the original scopes. + */ + useEffect(() => { + if (isOpen) { + previousScopesRef.current = activeScopes; + activeScopes.forEach((scope) => disableScope(scope)); + enableScope(Scope.DIALOG); + } else { + disableScope(Scope.DIALOG); + previousScopesRef.current.forEach((scope) => enableScope(scope)); + } + + return () => { + disableScope(Scope.DIALOG); + previousScopesRef.current.forEach((scope) => enableScope(scope)); + }; + // activeScopes intentionally omitted - we only capture on open, not on every scope change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, enableScope, disableScope]); + + useKeyExit( + (e) => { + e?.preventDefault(); + }, + { scope: Scope.DIALOG, enabled: isOpen } + ); + + const handleNext = () => { + if (currentStage < totalStages - 1) { + setCurrentStage((prev) => prev + 1); + } else { + onClose(); + } + }; + + const handlePrevious = () => { + if (currentStage > 0) { + setCurrentStage((prev) => prev - 1); + } + }; + + return ( + + {isOpen && ( + <> + + + + + + +
+
+
+

+ {t(stage.titleKey)} +

+
+
+ {currentStage + 1} / {totalStages} +
+
+ +

+ {t(stage.descriptionKey)} +

+ +
+ {Array.from({ length: totalStages }).map((_, index) => ( +
+ ))} +
+ + {totalStages > 1 && ( +
+ {currentStage > 0 && ( + + )} + +
+ )} +
+ + + + + )} + + ); +} diff --git a/frontend/src/components/showcase/ShowcaseStageMedia.tsx b/frontend/src/components/showcase/ShowcaseStageMedia.tsx new file mode 100644 index 00000000..9c9b6f11 --- /dev/null +++ b/frontend/src/components/showcase/ShowcaseStageMedia.tsx @@ -0,0 +1,98 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Loader } from '@/components/ui/loader'; +import { useVideoProgress } from '@/hooks/useVideoProgress'; +import type { ShowcaseMedia } from '@/types/showcase'; +import { RefreshCw } from 'lucide-react'; + +interface ShowcaseStageMediaProps { + media: ShowcaseMedia; +} + +/** + * ShowcaseStageMedia - Renders media (images or videos) for showcase stages + * + * Handles different media types with appropriate loading states: + * - Videos: Shows loading spinner, autoplay once, and thin progress bar + * displaying both buffered (light) and played (primary) progress + * - Images: Shows loading skeleton until image loads + * + * Uses fixed aspect ratio (16:10) to prevent layout shift during loading. + * + * @param media - ShowcaseMedia object with type ('image' or 'video') and src URL + */ +export function ShowcaseStageMedia({ media }: ShowcaseStageMediaProps) { + const { t } = useTranslation('common'); + const videoRef = useRef(null); + const { isLoading, playedPercent, bufferedPercent } = + useVideoProgress(videoRef); + const [imageLoaded, setImageLoaded] = useState(false); + const [videoEnded, setVideoEnded] = useState(false); + + if (media.type === 'video') { + const handleReplay = () => { + if (videoRef.current) { + videoRef.current.currentTime = 0; + videoRef.current.play(); + setVideoEnded(false); + } + }; + + return ( +
+ {isLoading && ( +
+ +
+ )} +