Spaces:
Running
Running
| import { useState, useEffect, useRef, useCallback } from "react"; | |
| import { THEME, EXAMPLE_VIDEOS } from "../constants"; | |
| import type { VideoSourceType, VideoSource } from "../types"; | |
| type ExampleVideo = (typeof EXAMPLE_VIDEOS)[number]; | |
| interface SourceSelectorProps { | |
| onSourceSelected: (source: VideoSource) => void; | |
| onBack?: () => void; | |
| } | |
| interface SourceCardProps { | |
| icon: React.ReactNode; | |
| title: string; | |
| description: string; | |
| onClick: () => void; | |
| isActive?: boolean; | |
| isLoading?: boolean; | |
| badge?: string; | |
| } | |
| function SourceCard({ icon, title, description, onClick, isActive, isLoading, badge }: SourceCardProps) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| disabled={isLoading} | |
| className={` | |
| group relative w-full text-left p-6 border-2 transition-all duration-300 | |
| hover:border-[var(--mistral-orange)] hover:shadow-lg hover:-translate-y-1 | |
| ${isActive ? "border-[var(--mistral-orange)] bg-[var(--mistral-orange)]/5" : "border-[var(--beige-dark)]"} | |
| ${isLoading ? "opacity-50 cursor-wait" : "cursor-pointer"} | |
| `} | |
| style={{ | |
| "--mistral-orange": THEME.mistralOrange, | |
| "--beige-dark": THEME.beigeDark, | |
| } as React.CSSProperties} | |
| > | |
| {badge && ( | |
| <span | |
| className="absolute -top-2 -right-2 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-white" | |
| style={{ backgroundColor: THEME.mistralOrange }} | |
| > | |
| {badge} | |
| </span> | |
| )} | |
| <div className="flex items-start gap-4"> | |
| <div | |
| className={` | |
| w-14 h-14 flex items-center justify-center shrink-0 | |
| transition-all duration-300 group-hover:scale-110 | |
| ${isActive ? "text-white" : "text-[var(--mistral-orange)]"} | |
| `} | |
| style={{ | |
| backgroundColor: isActive ? THEME.mistralOrange : `${THEME.mistralOrange}15`, | |
| }} | |
| > | |
| {isLoading ? ( | |
| <svg className="w-6 h-6 animate-spin" viewBox="0 0 24 24" fill="none"> | |
| <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> | |
| <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> | |
| </svg> | |
| ) : ( | |
| icon | |
| )} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <h3 | |
| className="font-semibold text-lg mb-1 flex items-center gap-2" | |
| style={{ color: THEME.textBlack }} | |
| > | |
| {title} | |
| <svg | |
| className="w-4 h-4 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300" | |
| style={{ color: THEME.mistralOrange }} | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| stroke="currentColor" | |
| strokeWidth={2} | |
| > | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /> | |
| </svg> | |
| </h3> | |
| <p className="text-gray-500 text-sm leading-relaxed">{description}</p> | |
| </div> | |
| </div> | |
| </button> | |
| ); | |
| } | |
| function ExampleVideoCard({ video, onClick, isSelected }: { video: ExampleVideo; onClick: () => void; isSelected: boolean }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| className={` | |
| group relative overflow-hidden border-2 transition-all duration-300 | |
| hover:border-[var(--mistral-orange)] hover:shadow-lg | |
| ${isSelected ? "border-[var(--mistral-orange)] ring-2 ring-[var(--mistral-orange)]/20" : "border-[var(--beige-dark)]"} | |
| `} | |
| style={{ | |
| "--mistral-orange": THEME.mistralOrange, | |
| "--beige-dark": THEME.beigeDark, | |
| } as React.CSSProperties} | |
| > | |
| <div className="aspect-video relative overflow-hidden bg-gray-100"> | |
| <img | |
| src={video.thumbnail} | |
| alt={video.name} | |
| className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" | |
| /> | |
| <div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" /> | |
| {isSelected && ( | |
| <div | |
| className="absolute top-2 right-2 w-6 h-6 flex items-center justify-center text-white" | |
| style={{ backgroundColor: THEME.mistralOrange }} | |
| > | |
| <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> | |
| </svg> | |
| </div> | |
| )} | |
| <div className="absolute bottom-0 left-0 right-0 p-3"> | |
| <h4 className="font-semibold text-white text-sm">{video.name}</h4> | |
| <p className="text-white/70 text-xs">{video.description}</p> | |
| </div> | |
| </div> | |
| </button> | |
| ); | |
| } | |
| export default function SourceSelector({ onSourceSelected, onBack }: SourceSelectorProps) { | |
| const [mounted, setMounted] = useState(false); | |
| const [activeSource, setActiveSource] = useState<VideoSourceType | null>(null); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [selectedExample, setSelectedExample] = useState<string | null>(null); | |
| const [showExamples, setShowExamples] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| useEffect(() => { | |
| setMounted(true); | |
| }, []); | |
| const handleWebcam = useCallback(async () => { | |
| setActiveSource("webcam"); | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 1920, max: 1920 }, | |
| height: { ideal: 1080, max: 1080 }, | |
| facingMode: "user", | |
| }, | |
| }); | |
| onSourceSelected({ type: "webcam", stream }); | |
| } catch (err) { | |
| setError(err instanceof Error ? err.message : "Failed to access webcam"); | |
| setActiveSource(null); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }, [onSourceSelected]); | |
| const handleScreenShare = useCallback(async () => { | |
| setActiveSource("screen"); | |
| setIsLoading(true); | |
| setError(null); | |
| try { | |
| const stream = await navigator.mediaDevices.getDisplayMedia({ | |
| video: { | |
| width: { ideal: 1920 }, | |
| height: { ideal: 1080 }, | |
| }, | |
| }); | |
| // Handle stream end (user stops sharing) | |
| stream.getVideoTracks()[0].onended = () => { | |
| // This will be handled by the parent component | |
| }; | |
| onSourceSelected({ type: "screen", stream }); | |
| } catch (err) { | |
| if ((err as Error).name !== "AbortError") { | |
| setError(err instanceof Error ? err.message : "Failed to start screen sharing"); | |
| } | |
| setActiveSource(null); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }, [onSourceSelected]); | |
| const handleFileUpload = useCallback(() => { | |
| fileInputRef.current?.click(); | |
| }, []); | |
| const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (file) { | |
| setActiveSource("upload"); | |
| const url = URL.createObjectURL(file); | |
| onSourceSelected({ type: "upload", url, name: file.name }); | |
| } | |
| }, [onSourceSelected]); | |
| const handleExampleSelect = useCallback((video: ExampleVideo) => { | |
| setSelectedExample(video.id); | |
| setActiveSource("example"); | |
| onSourceSelected({ type: "example", url: video.url, name: video.name }); | |
| }, [onSourceSelected]); | |
| const toggleExamples = useCallback(() => { | |
| setShowExamples(prev => !prev); | |
| }, []); | |
| return ( | |
| <div | |
| className="absolute inset-0 flex items-center justify-center p-6 overflow-y-auto" | |
| style={{ | |
| backgroundColor: THEME.beigeLight, | |
| backgroundImage: ` | |
| linear-gradient(${THEME.beigeDark} 1px, transparent 1px), | |
| linear-gradient(90deg, ${THEME.beigeDark} 1px, transparent 1px) | |
| `, | |
| backgroundSize: "40px 40px", | |
| color: THEME.textBlack, | |
| }} | |
| > | |
| <input | |
| ref={fileInputRef} | |
| type="file" | |
| accept="video/*" | |
| onChange={handleFileChange} | |
| className="hidden" | |
| /> | |
| <div | |
| className={` | |
| relative max-w-3xl w-full backdrop-blur-sm p-10 border shadow-2xl | |
| transition-all duration-700 | |
| ${mounted ? "opacity-100 translate-y-0" : "opacity-0 translate-y-4"} | |
| `} | |
| style={{ | |
| backgroundColor: `${THEME.beigeLight}F2`, | |
| borderColor: THEME.beigeDark, | |
| }} | |
| > | |
| {/* Header Bar */} | |
| <div | |
| className="absolute top-0 left-0 right-0 h-1" | |
| style={{ backgroundColor: THEME.mistralOrange }} | |
| /> | |
| {/* Back Button */} | |
| {onBack && ( | |
| <button | |
| onClick={onBack} | |
| className="absolute top-6 left-6 p-2 text-gray-400 hover:text-gray-600 transition-colors" | |
| > | |
| <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" /> | |
| </svg> | |
| </button> | |
| )} | |
| {/* Header */} | |
| <div className="text-center mb-10"> | |
| <h2 | |
| className="text-3xl font-bold tracking-tight mb-3" | |
| style={{ color: THEME.textBlack }} | |
| > | |
| Choose Video Source | |
| </h2> | |
| <p className="text-gray-500 text-lg"> | |
| Select how you want to provide video for analysis | |
| </p> | |
| </div> | |
| {/* Error Message */} | |
| {error && ( | |
| <div | |
| className="mb-6 p-4 border-l-4 bg-red-50" | |
| style={{ borderColor: THEME.errorRed }} | |
| > | |
| <div className="flex items-center gap-2"> | |
| <svg | |
| className="w-5 h-5" | |
| style={{ color: THEME.errorRed }} | |
| fill="none" | |
| viewBox="0 0 24 24" | |
| stroke="currentColor" | |
| > | |
| <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> | |
| </svg> | |
| <span className="text-sm font-medium" style={{ color: THEME.errorRed }}> | |
| {error} | |
| </span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Source Options Grid */} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> | |
| <SourceCard | |
| icon={ | |
| <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" /> | |
| </svg> | |
| } | |
| title="Webcam" | |
| description="Use your camera for real-time video analysis" | |
| onClick={handleWebcam} | |
| isActive={activeSource === "webcam"} | |
| isLoading={isLoading && activeSource === "webcam"} | |
| badge="Live" | |
| /> | |
| <SourceCard | |
| icon={ | |
| <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 12V5.25" /> | |
| </svg> | |
| } | |
| title="Screen Share" | |
| description="Capture your screen, window, or browser tab" | |
| onClick={handleScreenShare} | |
| isActive={activeSource === "screen"} | |
| isLoading={isLoading && activeSource === "screen"} | |
| /> | |
| <SourceCard | |
| icon={ | |
| <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> | |
| </svg> | |
| } | |
| title="Upload Video" | |
| description="Select a video file from your device" | |
| onClick={handleFileUpload} | |
| isActive={activeSource === "upload"} | |
| /> | |
| <SourceCard | |
| icon={ | |
| <svg className="w-7 h-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> | |
| <path strokeLinecap="round" strokeLinejoin="round" d="M15.91 11.672a.375.375 0 010 .656l-5.603 3.113a.375.375 0 01-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112z" /> | |
| </svg> | |
| } | |
| title="Examples" | |
| description="Try with pre-loaded sample videos" | |
| onClick={toggleExamples} | |
| isActive={showExamples || activeSource === "example"} | |
| /> | |
| </div> | |
| {/* Example Videos Section */} | |
| {showExamples && ( | |
| <div | |
| className="border-t pt-6 animate-enter" | |
| style={{ borderColor: THEME.beigeDark }} | |
| > | |
| <h3 className="text-sm font-bold uppercase tracking-wider text-gray-500 mb-4 flex items-center gap-2"> | |
| <span | |
| className="w-2 h-2" | |
| style={{ backgroundColor: THEME.mistralOrange }} | |
| /> | |
| Sample Videos | |
| </h3> | |
| <div className="grid grid-cols-3 gap-3"> | |
| {EXAMPLE_VIDEOS.map((video) => ( | |
| <ExampleVideoCard | |
| key={video.id} | |
| video={video} | |
| onClick={() => handleExampleSelect(video)} | |
| isSelected={selectedExample === video.id} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Footer */} | |
| <div | |
| className="mt-8 pt-6 border-t text-center" | |
| style={{ borderColor: THEME.beigeDark }} | |
| > | |
| <p className="text-xs text-gray-400 font-mono uppercase tracking-wider"> | |
| All processing happens locally in your browser | |
| </p> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |