Spaces:
Running
Running
| import React, { useState, useRef, useEffect, Suspense, useMemo, useCallback } from 'react'; | |
| import { Canvas, useFrame, useLoader } from '@react-three/fiber'; | |
| import { OrbitControls, Environment, useGLTF, Html, useAnimations, ContactShadows, PerspectiveCamera } from '@react-three/drei'; | |
| import * as THREE from 'three'; | |
| import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'; | |
| import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'; | |
| import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'; | |
| import { USDZLoader } from 'three/examples/jsm/loaders/USDZLoader'; | |
| import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'; | |
| import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'; | |
| import { | |
| Upload, RotateCw, Smartphone, Monitor, Square, Image as ImageIcon, | |
| Palette, X, Layers, ChevronDown, ChevronUp, Box, Grid, Play, Pause, | |
| Film, SkipForward, Video, StopCircle, RefreshCcw, Sun, Trash2, Globe | |
| } from 'lucide-react'; | |
| // --- Types --- | |
| type WireframeMode = 'none' | 'overlay' | 'pure' | 'points'; | |
| type ExportFormat = 'webm' | 'mp4' | 'mov'; | |
| type BgType = 'color' | 'hdri'; | |
| const DRACO_URL = 'https://www.gstatic.com/draco/versioned/decoders/1.5.5/'; | |
| const GUMROAD_URL = 'https://artel3d.gumroad.com/l/canva3Dpro'; | |
| // --- Loader Components --- | |
| const GLBModel = ({ url, onLoaded }: any) => { | |
| // Fix: Cast useGLTF result to any to avoid ambiguity between object and array return types | |
| const { scene, animations } = useGLTF(url, DRACO_URL) as any; | |
| useEffect(() => { if (scene) onLoaded(scene, animations); }, [scene, animations, onLoaded]); | |
| return <primitive object={scene} />; | |
| }; | |
| const FBXModel = ({ url, onLoaded }: any) => { | |
| // Fix: Cast useLoader result to any to safely access the animations property | |
| const fbx = useLoader(FBXLoader, url) as any; | |
| useEffect(() => { if (fbx) onLoaded(fbx, fbx.animations || []); }, [fbx, onLoaded]); | |
| return <primitive object={fbx} />; | |
| }; | |
| const OBJModel = ({ url, onLoaded }: any) => { | |
| const obj = useLoader(OBJLoader, url); | |
| useEffect(() => { if (obj) onLoaded(obj, []); }, [obj, onLoaded]); | |
| return <primitive object={obj} />; | |
| }; | |
| const STLModel = ({ url, onLoaded }: any) => { | |
| // Fix: Cast useLoader result to any because STLLoader returns a single BufferGeometry for one URL | |
| const geometry = useLoader(STLLoader, url) as any; | |
| const mesh = useMemo(() => new THREE.Mesh(geometry, new THREE.MeshStandardMaterial()), [geometry]); | |
| useEffect(() => { if (mesh) onLoaded(mesh, []); }, [mesh, onLoaded]); | |
| return <primitive object={mesh} />; | |
| }; | |
| const USDZModel = ({ url, onLoaded }: any) => { | |
| // Fix: Cast useLoader result to any to safely access the animations property | |
| const usdz = useLoader(USDZLoader, url) as any; | |
| useEffect(() => { if (usdz) onLoaded(usdz, usdz.animations || []); }, [usdz, onLoaded]); | |
| return <primitive object={usdz} />; | |
| }; | |
| // --- Custom Environment --- | |
| const CustomEnvironment = ({ url, type, background, intensity }: { url: string, type: string, background: boolean, intensity: number }) => { | |
| let loader: any = THREE.TextureLoader; | |
| if (type === 'hdr') loader = RGBELoader; | |
| if (type === 'exr') loader = EXRLoader; | |
| const texture = useLoader(loader, url, (loaderInstance: any) => { | |
| if (type === 'exr' || type === 'hdr') { | |
| loaderInstance.setDataType(THREE.HalfFloatType); | |
| } | |
| }) as THREE.Texture; | |
| // Configure synchronously during render so drei receives the correct mapping immediately. | |
| // useEffect is too late — drei processes the texture before the effect fires, causing black screen. | |
| texture.mapping = THREE.EquirectangularReflectionMapping; | |
| if (type === 'exr' || type === 'hdr') { | |
| texture.minFilter = THREE.LinearFilter; | |
| texture.magFilter = THREE.LinearFilter; | |
| texture.generateMipmaps = false; | |
| } else { | |
| texture.colorSpace = THREE.SRGBColorSpace; | |
| } | |
| // Use a large sphere for the background to avoid WebGL crashes with large EXR DataTextures on scene.background | |
| return ( | |
| <> | |
| <Environment map={texture} background={false} environmentIntensity={intensity} resolution={256} /> | |
| {background && ( | |
| <mesh scale={500}> | |
| <sphereGeometry args={[1, 64, 64]} /> | |
| <meshBasicMaterial map={texture} side={THREE.BackSide} depthWrite={false} /> | |
| </mesh> | |
| )} | |
| </> | |
| ); | |
| }; | |
| // --- Model Wrapper --- | |
| const ModelManager = ({ | |
| url, type, isAlbedo, isNeutral, wireframeMode, techColor, | |
| animationIndex, isPlaying, onAnimsLoaded, | |
| rotationSpeed, rotationDirection | |
| }: any) => { | |
| const groupRef = useRef<THREE.Group>(null); | |
| const [model, setModel] = useState<THREE.Object3D | null>(null); | |
| const [animations, setAnimations] = useState<THREE.AnimationClip[]>([]); | |
| const { actions, names } = useAnimations(animations, groupRef); | |
| const handleLoaded = useCallback((obj: THREE.Object3D, anims: THREE.AnimationClip[]) => { | |
| if (groupRef.current) groupRef.current.rotation.set(0, 0, 0); | |
| const box = new THREE.Box3().setFromObject(obj); | |
| const size = box.getSize(new THREE.Vector3()); | |
| const center = box.getCenter(new THREE.Vector3()); | |
| const maxDim = Math.max(size.x, size.y, size.z); | |
| const scale = 3.5 / (maxDim || 1); | |
| obj.scale.setScalar(scale); | |
| obj.position.set(-center.x * scale, -center.y * scale + (size.y * scale) / 2, -center.z * scale); | |
| setModel(obj); | |
| setAnimations(anims); | |
| }, []); | |
| useEffect(() => { if (names.length > 0) onAnimsLoaded(names); }, [names, onAnimsLoaded]); | |
| useEffect(() => { | |
| if (actions && names.length > 0) { | |
| const action = actions[names[animationIndex % names.length]]; | |
| if (action) { | |
| if (isPlaying) action.reset().fadeIn(0.2).play(); | |
| else action.fadeOut(0.2); | |
| } | |
| return () => { action?.fadeOut(0.2); }; | |
| } | |
| }, [animationIndex, isPlaying, actions, names]); | |
| useFrame((state, delta) => { | |
| if (groupRef.current && rotationSpeed > 0) { | |
| groupRef.current.rotation.y += delta * rotationSpeed * rotationDirection * 0.5; | |
| } | |
| }); | |
| useEffect(() => { | |
| if (!model) return; | |
| model.traverse((child: any) => { | |
| if (child.isMesh) { | |
| if (!child.userData.originalMat) child.userData.originalMat = child.material.clone(); | |
| const orig = child.userData.originalMat; | |
| if (wireframeMode === 'pure') { | |
| child.material = new THREE.MeshStandardMaterial({ color: techColor, wireframe: true, emissive: techColor, emissiveIntensity: 0.5 }); | |
| } else if (isNeutral) { | |
| child.material = new THREE.MeshStandardMaterial({ color: '#444', roughness: 0.8, metalness: 0.2 }); | |
| } else if (isAlbedo) { | |
| child.material = new THREE.MeshBasicMaterial({ map: orig.map, color: 'white' }); | |
| } else { | |
| child.material = orig; | |
| child.material.wireframe = (wireframeMode === 'overlay'); | |
| } | |
| } | |
| }); | |
| }, [model, wireframeMode, isNeutral, isAlbedo, techColor]); | |
| const ext = type.toLowerCase(); | |
| return ( | |
| <group ref={groupRef}> | |
| <Suspense fallback={null}> | |
| {(ext === 'glb' || ext === 'gltf') && <GLBModel url={url} onLoaded={handleLoaded} />} | |
| {ext === 'fbx' && <FBXModel url={url} onLoaded={handleLoaded} />} | |
| {ext === 'obj' && <OBJModel url={url} onLoaded={handleLoaded} />} | |
| {ext === 'stl' && <STLModel url={url} onLoaded={handleLoaded} />} | |
| {ext === 'usdz' && <USDZModel url={url} onLoaded={handleLoaded} />} | |
| </Suspense> | |
| </group> | |
| ); | |
| }; | |
| // --- Props Interface --- | |
| interface Viewer3DProps { | |
| modelUrl: string | null; | |
| onModelLoaded?: () => void; | |
| } | |
| // --- Main Component --- | |
| export default function Viewer3D({ modelUrl, onModelLoaded }: Viewer3DProps) { | |
| const [fileUrl, setFileUrl] = useState<string | null>(null); | |
| const [fileType, setFileType] = useState(''); | |
| const [anims, setAnims] = useState<string[]>([]); | |
| const [animIndex, setAnimIndex] = useState(0); | |
| const [isPlaying, setIsPlaying] = useState(true); | |
| const [wireframe, setWireframe] = useState<WireframeMode>('none'); | |
| const [isNeutral, setIsNeutral] = useState(false); | |
| const [isAlbedo, setIsAlbedo] = useState(false); | |
| const [bgColor, setBgColor] = useState('#050505'); | |
| const [rotationSpeed, setRotationSpeed] = useState(0.4); | |
| const [rotationDirection, setRotationDirection] = useState<1 | -1>(1); | |
| const [lightIntensity, setLightIntensity] = useState(1.5); | |
| const [aspect, setAspect] = useState<'9:16' | '1:1' | '16:9'>('1:1'); | |
| const [uiCollapsed, setUiCollapsed] = useState(false); | |
| // Background & HDRI | |
| const [bgType, setBgType] = useState<BgType>('color'); | |
| const [hdriFile, setHdriFile] = useState<{url: string, type: string} | null>(null); | |
| const [hdriPreset, setHdriPreset] = useState('city'); | |
| // Export | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [recordTime, setRecordTime] = useState(0); | |
| const [exportFormat, setExportFormat] = useState<ExportFormat>('mp4'); | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| const controlsRef = useRef<any>(null); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const hdriInputRef = useRef<HTMLInputElement>(null); | |
| const timerRef = useRef<number | null>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const chunksRef = useRef<Blob[]>([]); | |
| // Load model from external URL when prop changes | |
| useEffect(() => { | |
| if (modelUrl) { | |
| setFileUrl(modelUrl); | |
| setFileType('glb'); | |
| if (onModelLoaded) onModelLoaded(); | |
| } | |
| }, [modelUrl]); | |
| const handleUpload = (file: File) => { | |
| if (fileUrl) URL.revokeObjectURL(fileUrl); | |
| setAnims([]); | |
| setAnimIndex(0); | |
| const ext = file.name.split('.').pop()?.toLowerCase() || ''; | |
| setFileType(ext); | |
| setFileUrl(URL.createObjectURL(file)); | |
| }; | |
| const handleHdriUpload = (file: File) => { | |
| if (hdriFile) URL.revokeObjectURL(hdriFile.url); | |
| const ext = file.name.split('.').pop()?.toLowerCase() || 'hdr'; | |
| setHdriFile({ url: URL.createObjectURL(file), type: ext }); | |
| setBgType('hdri'); | |
| }; | |
| const startRecording = () => { | |
| if (!canvasRef.current) return; | |
| chunksRef.current = []; | |
| const stream = canvasRef.current.captureStream(60); | |
| let mimeType = 'video/webm;codecs=vp9'; | |
| if (exportFormat === 'mp4') { | |
| mimeType = MediaRecorder.isTypeSupported('video/mp4') ? 'video/mp4' : 'video/webm;codecs=h264'; | |
| } else if (exportFormat === 'mov') { | |
| mimeType = 'video/quicktime'; | |
| } | |
| try { | |
| const recorder = new MediaRecorder(stream, { | |
| mimeType: MediaRecorder.isTypeSupported(mimeType) ? mimeType : 'video/webm', | |
| videoBitsPerSecond: 12000000 | |
| }); | |
| recorder.ondataavailable = (e) => chunksRef.current.push(e.data); | |
| recorder.onstop = () => { | |
| const blob = new Blob(chunksRef.current, { type: mimeType }); | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = `3d-studio-render-${Date.now()}.${exportFormat}`; | |
| a.click(); | |
| }; | |
| recorder.start(); | |
| mediaRecorderRef.current = recorder; | |
| setIsRecording(true); | |
| setRecordTime(0); | |
| timerRef.current = window.setInterval(() => setRecordTime(t => t + 1), 1000); | |
| } catch (e) { alert("Recording error: Check browser compatibility for this format."); } | |
| }; | |
| const stopRecording = () => { | |
| mediaRecorderRef.current?.stop(); | |
| setIsRecording(false); | |
| if (timerRef.current) clearInterval(timerRef.current); | |
| }; | |
| const dim = useMemo(() => { | |
| const base = Math.min(window.innerWidth, window.innerHeight) * 0.75; | |
| if (aspect === '9:16') return { w: base * 0.56, h: base }; | |
| if (aspect === '16:9') return { w: base * 1.4, h: base * 0.8 }; | |
| return { w: base, h: base }; | |
| }, [aspect]); | |
| return ( | |
| <div style={{ width: '100vw', height: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#000', color: 'white', overflow: 'hidden', fontFamily: 'sans-serif' }}> | |
| {/* Bouton Get Pro */} | |
| <a | |
| href={GUMROAD_URL} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| style={{ | |
| position: 'absolute', | |
| top: 16, | |
| right: 16, | |
| zIndex: 200, | |
| background: '#f43f5e', | |
| color: 'white', | |
| padding: '6px 14px', | |
| borderRadius: '20px', | |
| textDecoration: 'none', | |
| fontSize: '0.8rem', | |
| fontWeight: 'bold', | |
| letterSpacing: '0.05em', | |
| }} | |
| > | |
| ⬆ Get Pro | |
| </a> | |
| {isRecording && ( | |
| <div style={{ position: 'absolute', top: 30, zIndex: 100, background: '#ef4444', padding: '12px 24px', borderRadius: '40px', fontWeight: 'bold', boxShadow: '0 0 30px rgba(239, 68, 68, 0.5)', display: 'flex', alignItems: 'center', gap: 10, animation: 'pulse 1s infinite' }}> | |
| <div style={{ width: 10, height: 10, borderRadius: '50%', background: 'white' }}></div> | |
| {Math.floor(recordTime/60)}:{(recordTime%60).toString().padStart(2, '0')} - RECORDING {exportFormat.toUpperCase()} | |
| </div> | |
| )} | |
| {!fileUrl ? ( | |
| <div style={{ width: '400px', height: '250px', border: '2px dashed #333', borderRadius: '40px', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: '#080808' }} onClick={() => fileInputRef.current?.click()}> | |
| <Upload size={48} color="#3b82f6" style={{ marginBottom: 15 }} /> | |
| <h2 style={{ margin: 0 }}>CANVA 3D</h2> | |
| <p style={{ opacity: 0.4, fontSize: '0.9rem' }}>GLB, FBX, OBJ, STL</p> | |
| </div> | |
| ) : ( | |
| <div style={{ width: dim.w, height: dim.h, borderRadius: '32px', background: bgColor, position: 'relative', border: '1px solid #1a1a1a', overflow: 'hidden' }}> | |
| <Canvas ref={canvasRef} gl={{ preserveDrawingBuffer: true, antialias: true }} shadows dpr={[1, 2]}> | |
| <PerspectiveCamera makeDefault position={[0, 0, 8]} fov={35} /> | |
| <Suspense fallback={<Html center><div className="loader"></div></Html>}> | |
| {bgType === 'color' ? <color attach="background" args={[bgColor]} /> : null} | |
| {hdriFile ? ( | |
| <CustomEnvironment | |
| url={hdriFile.url} | |
| type={hdriFile.type} | |
| background={bgType === 'hdri'} | |
| intensity={bgType === 'hdri' ? 1 : 0.5} | |
| /> | |
| ) : ( | |
| <Environment | |
| preset={hdriPreset as any} | |
| background={bgType === 'hdri'} | |
| environmentIntensity={bgType === 'hdri' ? 1 : 0.5} | |
| /> | |
| )} | |
| <ambientLight intensity={0.4} /> | |
| <hemisphereLight intensity={0.6} color="white" groundColor="#333" /> | |
| <directionalLight position={[10, 10, 5]} intensity={lightIntensity} castShadow /> | |
| <directionalLight position={[-10, 5, -5]} intensity={lightIntensity * 0.5} color="#3b82f6" /> | |
| <ModelManager | |
| url={fileUrl} type={fileType} | |
| isAlbedo={isAlbedo} isNeutral={isNeutral} | |
| wireframeMode={wireframe} techColor="#3b82f6" | |
| animationIndex={animIndex} isPlaying={isPlaying} | |
| onAnimsLoaded={setAnims} | |
| rotationSpeed={rotationSpeed} rotationDirection={rotationDirection} | |
| /> | |
| <ContactShadows opacity={0.4} scale={15} blur={2} far={4} /> | |
| <OrbitControls ref={controlsRef} makeDefault enableDamping dampingFactor={0.05} maxDistance={400} /> | |
| </Suspense> | |
| </Canvas> | |
| <button onClick={() => controlsRef.current?.reset()} style={{ position: 'absolute', bottom: 20, right: 20, background: 'rgba(0,0,0,0.5)', borderRadius: '50%', width: 40, height: 40, padding: 0 }}><RefreshCcw size={16} /></button> | |
| </div> | |
| )} | |
| {fileUrl && ( | |
| <div className="ui-panel" style={{ position: 'absolute', bottom: 30, width: '90%', maxWidth: '480px', background: 'rgba(15,15,15,0.95)', padding: '20px', borderRadius: '28px', border: '1px solid rgba(255,255,255,0.05)', backdropFilter: 'blur(20px)' }}> | |
| <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}> | |
| <div style={{ display: 'flex', gap: 6 }}> | |
| <button className={aspect === '9:16' ? 'active' : ''} onClick={() => setAspect('9:16')}><Smartphone size={14} /></button> | |
| <button className={aspect === '1:1' ? 'active' : ''} onClick={() => setAspect('1:1')}><Square size={14} /></button> | |
| <button className={aspect === '16:9' ? 'active' : ''} onClick={() => setAspect('16:9')}><Monitor size={14} /></button> | |
| </div> | |
| <button onClick={() => setUiCollapsed(!uiCollapsed)} style={{ background: 'none' }}>{uiCollapsed ? <ChevronUp size={20} /> : <ChevronDown size={20} />}</button> | |
| </div> | |
| {!uiCollapsed && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 15 }}> | |
| <div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}> | |
| <button className={wireframe !== 'none' ? 'active' : ''} onClick={() => setWireframe(w => w === 'none' ? 'overlay' : w === 'overlay' ? 'pure' : 'none')}><Grid size={16} /></button> | |
| <button className={isAlbedo ? 'active' : ''} onClick={() => { setIsAlbedo(!isAlbedo); setIsNeutral(false); }}><Layers size={16} /></button> | |
| <button className={isNeutral ? 'active' : ''} onClick={() => { setIsNeutral(!isNeutral); setIsAlbedo(false); }}><Box size={16} /></button> | |
| <div style={{ width: 1, height: 20, background: '#333', margin: '0 5px' }} /> | |
| <button className={bgType === 'color' ? 'active' : ''} onClick={() => setBgType('color')} title="Mode Couleur"><Palette size={16} /></button> | |
| <button className={bgType === 'hdri' ? 'active' : ''} onClick={() => setBgType('hdri')} title="Mode HDRI"><Globe size={16} /></button> | |
| </div> | |
| <div style={{ display: 'flex', gap: 10, background: 'rgba(255,255,255,0.03)', padding: 10, borderRadius: '15px', alignItems: 'center' }}> | |
| {bgType === 'color' ? ( | |
| <div style={{ flex: 1, fontSize: '0.7rem', opacity: 0.5, textAlign: 'center' }}>COULEUR UNIE</div> | |
| ) : ( | |
| <div style={{ display: 'flex', gap: 5, flex: 1 }}> | |
| <button onClick={() => setHdriPreset('city')} className={hdriPreset === 'city' ? 'active' : ''} style={{fontSize:'0.6rem'}}>CITY</button> | |
| <button onClick={() => setHdriPreset('studio')} className={hdriPreset === 'studio' ? 'active' : ''} style={{fontSize:'0.6rem'}}>STUDIO</button> | |
| <button onClick={() => hdriInputRef.current?.click()} style={{fontSize:'0.6rem', border: '1px solid #3b82f6'}}><ImageIcon size={12} /> HDR</button> | |
| </div> | |
| )} | |
| <div style={{ width: 1, height: 24, background: 'rgba(255,255,255,0.1)' }} /> | |
| <input type="color" value={bgColor} onChange={e => setBgColor(e.target.value)} title="Couleur de fond" style={{ width: 28, height: 28, background: 'none', border: 'none', cursor: 'pointer' }} /> | |
| <button onClick={() => setFileUrl(null)} style={{ color: '#ef4444', background: 'none' }}><Trash2 size={16} /></button> | |
| </div> | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}> | |
| <label style={{ fontSize: '0.6rem', opacity: 0.5 }}>FORMAT D'EXPORT</label> | |
| <div style={{ display: 'flex', gap: 5 }}> | |
| <button className={exportFormat === 'mp4' ? 'active' : ''} onClick={() => setExportFormat('mp4')} style={{flex: 1, fontSize: '0.7rem'}}>MP4</button> | |
| <button className={exportFormat === 'mov' ? 'active' : ''} onClick={() => setExportFormat('mov')} style={{flex: 1, fontSize: '0.7rem'}}>MOV</button> | |
| <button className={exportFormat === 'webm' ? 'active' : ''} onClick={() => setExportFormat('webm')} style={{flex: 1, fontSize: '0.7rem'}}>WEBM</button> | |
| </div> | |
| </div> | |
| <button className={isRecording ? 'recording' : ''} onClick={isRecording ? stopRecording : startRecording} style={{ background: isRecording ? '#ef4444' : '#3b82f6', borderRadius: '15px', padding: '12px', fontSize: '0.9rem', fontWeight: 900, width: '100%' }}> | |
| {isRecording ? <StopCircle size={20} /> : <Video size={20} />} | |
| {isRecording ? 'STOP' : `CAPTURER .${exportFormat.toUpperCase()}`} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <input type="file" ref={fileInputRef} style={{ display: 'none' }} accept=".glb,.gltf,.fbx,.obj,.stl" onChange={e => e.target.files && handleUpload(e.target.files[0])} /> | |
| <input type="file" ref={hdriInputRef} style={{ display: 'none' }} accept=".hdr,.exr,.jpg,.jpeg,.png" onChange={e => e.target.files && handleHdriUpload(e.target.files[0])} /> | |
| <style>{` | |
| @keyframes pulse { 0% { opacity: 1; } 50% { opacity: 0.7; } 100% { opacity: 1; } } | |
| .loader { border: 2px solid rgba(255,255,255,0.1); border-top: 2px solid #3b82f6; border-radius: 50%; width: 28px; height: 28px; animation: spin 0.8s linear infinite; } | |
| @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } | |
| button { border: none; background: rgba(255,255,255,0.05); color: white; padding: 8px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: all 0.2s; } | |
| button:hover { background: rgba(255,255,255,0.1); } | |
| button.active { background: #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4); } | |
| input[type="range"] { width: 100%; accent-color: #3b82f6; cursor: pointer; } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |