Ministral_3B_WebGPU / src /components /SourceSelector.tsx
Joffrey Thomas
Multiple sources
6709b20
Raw
History Blame Contribute Delete
14.7 kB
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>
);
}