mirror of
https://github.com/JorySeverijnse/ui-fixer-supreme.git
synced 2026-01-29 18:08:38 +00:00
Implemented Oscilloscope correctly now
Have tested it with a 5 minute long audio file Bigger files and different formats still needs testing
This commit is contained in:
parent
ad6587978a
commit
1495a9a5c5
175
src/components/Oscilloscope.tsx
Normal file
175
src/components/Oscilloscope.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { AudioUploader } from './AudioUploader';
|
||||||
|
import { ControlPanel } from './ControlPanel';
|
||||||
|
import { OscilloscopeDisplay } from './OscilloscopeDisplay';
|
||||||
|
import { useAudioAnalyzer } from '@/hooks/useAudioAnalyzer';
|
||||||
|
import { useOscilloscopeRenderer } from '@/hooks/useOscilloscopeRenderer';
|
||||||
|
import { useVideoExporter } from '@/hooks/useVideoExporter';
|
||||||
|
import { useSettings } from '@/contexts/SettingsContext';
|
||||||
|
import { Mic, MicOff } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
export function Oscilloscope() {
|
||||||
|
const [mode, setMode] = useState<'combined' | 'separate' | 'all'>('combined');
|
||||||
|
const [isMicActive, setIsMicActive] = useState(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentSample, setCurrentSample] = useState(0);
|
||||||
|
|
||||||
|
const { audioData, isLoading, fileName, originalFile, loadAudioFile, reset: resetAudio } = useAudioAnalyzer();
|
||||||
|
const { isExporting, progress, exportedUrl, exportVideo, reset: resetExport } = useVideoExporter();
|
||||||
|
const { playSound } = useSettings();
|
||||||
|
|
||||||
|
// Real-time microphone input
|
||||||
|
const [micStream, setMicStream] = useState<MediaStream | null>(null);
|
||||||
|
const [micAnalyzer, setMicAnalyzer] = useState<AnalyserNode | null>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = useCallback((file: File) => {
|
||||||
|
loadAudioFile(file);
|
||||||
|
if (isMicActive) {
|
||||||
|
setIsMicActive(false);
|
||||||
|
}
|
||||||
|
}, [loadAudioFile, isMicActive]);
|
||||||
|
|
||||||
|
const toggleMic = useCallback(async () => {
|
||||||
|
playSound('click');
|
||||||
|
if (isMicActive) {
|
||||||
|
// Stop microphone
|
||||||
|
if (micStream) {
|
||||||
|
micStream.getTracks().forEach(track => track.stop());
|
||||||
|
setMicStream(null);
|
||||||
|
}
|
||||||
|
setMicAnalyzer(null);
|
||||||
|
setIsMicActive(false);
|
||||||
|
resetAudio();
|
||||||
|
} else {
|
||||||
|
// Start microphone
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
setMicStream(stream);
|
||||||
|
|
||||||
|
const audioContext = new AudioContext();
|
||||||
|
const analyser = audioContext.createAnalyser();
|
||||||
|
analyser.fftSize = 2048;
|
||||||
|
|
||||||
|
const source = audioContext.createMediaStreamSource(stream);
|
||||||
|
source.connect(analyser);
|
||||||
|
|
||||||
|
setMicAnalyzer(analyser);
|
||||||
|
setIsMicActive(true);
|
||||||
|
resetAudio(); // Clear any loaded file
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing microphone:', error);
|
||||||
|
alert('Could not access microphone. Please check permissions.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isMicActive, micStream, playSound, resetAudio]);
|
||||||
|
|
||||||
|
const handlePreview = useCallback(() => {
|
||||||
|
playSound('click');
|
||||||
|
setIsPlaying(!isPlaying);
|
||||||
|
}, [isPlaying, playSound]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!audioData || !originalFile) return;
|
||||||
|
|
||||||
|
await exportVideo(audioData, originalFile, {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
fps: 60,
|
||||||
|
mode,
|
||||||
|
audioFile: originalFile,
|
||||||
|
});
|
||||||
|
}, [audioData, originalFile, exportVideo, mode]);
|
||||||
|
|
||||||
|
const handleReset = useCallback(() => {
|
||||||
|
playSound('click');
|
||||||
|
setIsPlaying(false);
|
||||||
|
resetAudio();
|
||||||
|
resetExport();
|
||||||
|
}, [playSound, resetAudio, resetExport]);
|
||||||
|
|
||||||
|
const canPreview = (audioData !== null || isMicActive) && !isExporting;
|
||||||
|
const canGenerate = audioData !== null && originalFile !== null && !isExporting && !isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h2 className="font-minecraft text-2xl md:text-3xl text-primary text-glow-strong">
|
||||||
|
Audio Oscilloscope
|
||||||
|
</h2>
|
||||||
|
<p className="font-pixel text-foreground/80">
|
||||||
|
Visualize audio waveforms in real-time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls Row */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Left Column: Audio Input */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<AudioUploader
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
isLoading={isLoading}
|
||||||
|
fileName={fileName}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Microphone Toggle */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={toggleMic}
|
||||||
|
variant={isMicActive ? "default" : "outline"}
|
||||||
|
className={`flex items-center gap-2 font-crt ${
|
||||||
|
isMicActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'border-primary/50 hover:bg-primary/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isMicActive ? <MicOff size={16} /> : <Mic size={16} />}
|
||||||
|
{isMicActive ? 'STOP MIC' : 'USE MICROPHONE'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isMicActive && (
|
||||||
|
<div className="text-sm text-muted-foreground font-mono-crt">
|
||||||
|
Real-time input active
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Control Panel */}
|
||||||
|
<ControlPanel
|
||||||
|
mode={mode}
|
||||||
|
onModeChange={setMode}
|
||||||
|
canGenerate={canGenerate}
|
||||||
|
isGenerating={isExporting}
|
||||||
|
progress={progress}
|
||||||
|
exportedUrl={exportedUrl}
|
||||||
|
onGenerate={handleGenerate}
|
||||||
|
onReset={handleReset}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onPreview={handlePreview}
|
||||||
|
canPreview={canPreview}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Oscilloscope Display */}
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<OscilloscopeDisplay
|
||||||
|
audioData={audioData}
|
||||||
|
micAnalyzer={micAnalyzer}
|
||||||
|
mode={mode}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onPlaybackEnd={() => setIsPlaying(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Info */}
|
||||||
|
{(isLoading || isExporting) && (
|
||||||
|
<div className="text-center text-muted-foreground font-mono-crt text-sm">
|
||||||
|
{isLoading && "Loading audio..."}
|
||||||
|
{isExporting && `Exporting video... ${progress}%`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import type { OscilloscopeMode } from '@/hooks/useOscilloscopeRenderer';
|
|||||||
|
|
||||||
interface OscilloscopeDisplayProps {
|
interface OscilloscopeDisplayProps {
|
||||||
audioData: AudioData | null;
|
audioData: AudioData | null;
|
||||||
|
micAnalyzer: AnalyserNode | null;
|
||||||
mode: OscilloscopeMode;
|
mode: OscilloscopeMode;
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
onPlaybackEnd?: () => void;
|
onPlaybackEnd?: () => void;
|
||||||
@ -13,11 +14,12 @@ const WIDTH = 800;
|
|||||||
const HEIGHT = 600;
|
const HEIGHT = 600;
|
||||||
const FPS = 60;
|
const FPS = 60;
|
||||||
|
|
||||||
export function OscilloscopeDisplay({
|
export function OscilloscopeDisplay({
|
||||||
audioData,
|
audioData,
|
||||||
mode,
|
micAnalyzer,
|
||||||
|
mode,
|
||||||
isPlaying,
|
isPlaying,
|
||||||
onPlaybackEnd
|
onPlaybackEnd
|
||||||
}: OscilloscopeDisplayProps) {
|
}: OscilloscopeDisplayProps) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const animationRef = useRef<number | null>(null);
|
const animationRef = useRef<number | null>(null);
|
||||||
@ -41,15 +43,69 @@ export function OscilloscopeDisplay({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const drawFrame = useCallback(() => {
|
const drawFrame = useCallback(() => {
|
||||||
if (!audioData || !canvasRef.current) return;
|
if ((!audioData && !micAnalyzer) || !canvasRef.current) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
const samplesPerFrame = Math.floor(audioData.sampleRate / FPS);
|
let samplesPerFrame: number;
|
||||||
const startSample = currentSampleRef.current;
|
let startSample: number;
|
||||||
const endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length);
|
let endSample: number;
|
||||||
|
|
||||||
|
if (micAnalyzer) {
|
||||||
|
// Real-time microphone data
|
||||||
|
const bufferLength = micAnalyzer.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
micAnalyzer.getByteTimeDomainData(dataArray);
|
||||||
|
|
||||||
|
// Convert to Float32Array-like for consistency
|
||||||
|
const micData = new Float32Array(dataArray.length);
|
||||||
|
for (let i = 0; i < dataArray.length; i++) {
|
||||||
|
micData[i] = (dataArray[i] - 128) / 128; // Normalize to -1 to 1
|
||||||
|
}
|
||||||
|
|
||||||
|
samplesPerFrame = micData.length;
|
||||||
|
startSample = 0;
|
||||||
|
endSample = micData.length;
|
||||||
|
|
||||||
|
// Draw mic data directly
|
||||||
|
ctx.strokeStyle = '#00ff00';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
const sliceWidth = WIDTH / samplesPerFrame;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < samplesPerFrame; i++) {
|
||||||
|
const v = micData[i];
|
||||||
|
const y = (v * HEIGHT) / 2 + HEIGHT / 2;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
x += sliceWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Draw graticule
|
||||||
|
drawGraticule(ctx);
|
||||||
|
|
||||||
|
// Request next frame for real-time
|
||||||
|
if (isPlaying) {
|
||||||
|
animationRef.current = requestAnimationFrame(drawFrame);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File playback mode
|
||||||
|
samplesPerFrame = Math.floor(audioData.sampleRate / FPS);
|
||||||
|
startSample = currentSampleRef.current;
|
||||||
|
endSample = Math.min(startSample + samplesPerFrame, audioData.leftChannel.length);
|
||||||
|
|
||||||
// Clear to pure black
|
// Clear to pure black
|
||||||
ctx.fillStyle = '#000000';
|
ctx.fillStyle = '#000000';
|
||||||
@ -189,7 +245,7 @@ export function OscilloscopeDisplay({
|
|||||||
}
|
}
|
||||||
|
|
||||||
animationRef.current = requestAnimationFrame(drawFrame);
|
animationRef.current = requestAnimationFrame(drawFrame);
|
||||||
}, [audioData, mode, drawGraticule, onPlaybackEnd]);
|
}, [audioData, micAnalyzer, mode, drawGraticule, onPlaybackEnd, isPlaying]);
|
||||||
|
|
||||||
// Initialize canvas
|
// Initialize canvas
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
15
src/components/OscilloscopeScreen.tsx
Normal file
15
src/components/OscilloscopeScreen.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface OscilloscopeScreenProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OscilloscopeScreen({ children }: OscilloscopeScreenProps) {
|
||||||
|
return (
|
||||||
|
<div className="crt-bezel">
|
||||||
|
<div className="screen-curve relative bg-black border border-primary/20">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -28,9 +28,12 @@ const commands: Record<string, string> = {
|
|||||||
'/achievements': '/achievements',
|
'/achievements': '/achievements',
|
||||||
'/ach': '/achievements',
|
'/ach': '/achievements',
|
||||||
'/a': '/achievements',
|
'/a': '/achievements',
|
||||||
'/credits': '/credits',
|
'/credits': '/credits',
|
||||||
'/cred': '/credits',
|
'/cred': '/credits',
|
||||||
};
|
'/oscilloscope': '/oscilloscope',
|
||||||
|
'/oscope': '/oscilloscope',
|
||||||
|
'/o': '/oscilloscope',
|
||||||
|
};
|
||||||
|
|
||||||
const helpText = `Available commands:
|
const helpText = `Available commands:
|
||||||
/home - Navigate to Home
|
/home - Navigate to Home
|
||||||
@ -46,9 +49,10 @@ const helpText = `Available commands:
|
|||||||
/breakout - Play Breakout
|
/breakout - Play Breakout
|
||||||
/music, /m - Navigate to Music Player
|
/music, /m - Navigate to Music Player
|
||||||
/ai, /chat - Navigate to AI Chat
|
/ai, /chat - Navigate to AI Chat
|
||||||
/achievements /a - View achievements
|
/achievements /a - View achievements
|
||||||
/credits /cred - View credits
|
/oscilloscope /o - Audio oscilloscope
|
||||||
/help, /h - Show this help message
|
/credits /cred - View credits
|
||||||
|
/help, /h - Show this help message
|
||||||
/clear, /c - Clear terminal output`;
|
/clear, /c - Clear terminal output`;
|
||||||
|
|
||||||
const helpHint = `
|
const helpHint = `
|
||||||
|
|||||||
@ -12,12 +12,14 @@ export function useAudioAnalyzer() {
|
|||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [fileName, setFileName] = useState<string | null>(null);
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
const [originalFile, setOriginalFile] = useState<File | null>(null);
|
||||||
const audioContextRef = useRef<AudioContext | null>(null);
|
const audioContextRef = useRef<AudioContext | null>(null);
|
||||||
|
|
||||||
const loadAudioFile = useCallback(async (file: File) => {
|
const loadAudioFile = useCallback(async (file: File) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
setFileName(file.name);
|
setFileName(file.name);
|
||||||
|
setOriginalFile(file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create or reuse AudioContext
|
// Create or reuse AudioContext
|
||||||
@ -55,6 +57,7 @@ export function useAudioAnalyzer() {
|
|||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setAudioData(null);
|
setAudioData(null);
|
||||||
setFileName(null);
|
setFileName(null);
|
||||||
|
setOriginalFile(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -63,6 +66,7 @@ export function useAudioAnalyzer() {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
fileName,
|
fileName,
|
||||||
|
originalFile,
|
||||||
loadAudioFile,
|
loadAudioFile,
|
||||||
reset,
|
reset,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,80 +1,7 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Oscilloscope } from '@/components/Oscilloscope';
|
import { Oscilloscope } from '@/components/Oscilloscope';
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
const OscilloscopePage = () => {
|
const OscilloscopePage = () => {
|
||||||
// Auto-test export functionality on page load
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('🔬 AUTO-TESTING OSCILLOSCOPE VIDEO EXPORT...');
|
|
||||||
|
|
||||||
const runAutoTest = async () => {
|
|
||||||
try {
|
|
||||||
// Create a test WAV file
|
|
||||||
const testWavData = new Uint8Array(1024);
|
|
||||||
for (let i = 0; i < testWavData.length; i++) {
|
|
||||||
testWavData[i] = Math.sin(i * 0.1) * 64 + 128; // Simple sine wave
|
|
||||||
}
|
|
||||||
const testFile = new File([testWavData], 'auto-test.wav', { type: 'audio/wav' });
|
|
||||||
|
|
||||||
console.log('📁 Created test audio file:', testFile.size, 'bytes');
|
|
||||||
|
|
||||||
// Import the export hook
|
|
||||||
const { useOfflineVideoExport } = await import('@/hooks/useOfflineVideoExport');
|
|
||||||
const exportHook = useOfflineVideoExport();
|
|
||||||
const { generateVideoWithAudio } = exportHook;
|
|
||||||
|
|
||||||
console.log('⚙️ Starting video export...');
|
|
||||||
|
|
||||||
// Mock drawFrame function
|
|
||||||
const mockDrawFrame = (ctx: CanvasRenderingContext2D, width: number, height: number) => {
|
|
||||||
ctx.fillStyle = '#0a0f0a';
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
ctx.fillStyle = '#00ff00';
|
|
||||||
ctx.font = '20px monospace';
|
|
||||||
ctx.fillText('AUTO-TEST VIDEO', 20, height/2);
|
|
||||||
ctx.fillText('Oscilloscope Export Test', 20, height/2 + 30);
|
|
||||||
ctx.fillText(`File: ${testFile.name}`, 20, height/2 + 60);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run export
|
|
||||||
const result = await generateVideoWithAudio(testFile, mockDrawFrame, {
|
|
||||||
fps: 30,
|
|
||||||
format: 'webm',
|
|
||||||
width: 640,
|
|
||||||
height: 480,
|
|
||||||
quality: 'medium'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
console.log('✅ AUTO-TEST SUCCESS!');
|
|
||||||
console.log(`📁 Generated video: ${result.size} bytes`);
|
|
||||||
console.log('🎬 Type:', result.type);
|
|
||||||
|
|
||||||
// Auto-download for testing
|
|
||||||
const url = URL.createObjectURL(result);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'oscilloscope-auto-test.webm';
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
|
|
||||||
console.log('⬇️ Auto-downloaded test video file');
|
|
||||||
} else {
|
|
||||||
console.error('❌ AUTO-TEST FAILED: No video generated');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ AUTO-TEST ERROR:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Run test after 3 seconds
|
|
||||||
const timer = setTimeout(runAutoTest, 3000);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@ -82,18 +9,6 @@ const OscilloscopePage = () => {
|
|||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
className="space-y-6"
|
className="space-y-6"
|
||||||
>
|
>
|
||||||
<div className="text-center space-y-2">
|
|
||||||
<h1 className="font-minecraft text-3xl md:text-4xl text-primary text-glow-strong">
|
|
||||||
Audio Oscilloscope
|
|
||||||
</h1>
|
|
||||||
<p className="font-pixel text-foreground/80">
|
|
||||||
Visualize audio waveforms in real-time with microphone input or audio files.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
🔬 Auto-testing video export in 3 seconds...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Oscilloscope />
|
<Oscilloscope />
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user