/ App.tsx
App.tsx
1 2 import React, { useState, useEffect, useRef, useCallback } from 'react'; 3 import { Upload, Save, RefreshCw, Eraser, Info, Shield, CheckCircle, Globe, Play, Pause, Video, MonitorPlay, FileVideo, FileImage, VolumeX, ShieldPlus } from 'lucide-react'; 4 import { SUPPORTED_LANGUAGES, TRANSLATIONS } from './constants'; 5 import { LanguageCode, MediaState, FilterState, BlurRegion } from './types'; 6 import { renderToCanvas } from './utils/imageProcessing'; 7 import { Button } from './components/ui/Button'; 8 import { Slider } from './components/ui/Slider'; 9 10 function App() { 11 // --- State --- 12 const [lang, setLang] = useState<LanguageCode>('en'); 13 const t = TRANSLATIONS[lang]; 14 const isRTL = lang === 'ar' || lang === 'fa'; 15 16 const [mediaState, setMediaState] = useState<MediaState>({ 17 type: 'image', 18 file: null, 19 source: null, 20 url: null, 21 width: 0, 22 height: 0, 23 duration: 0, 24 loaded: false, 25 isPlaying: false, 26 isRecording: false 27 }); 28 29 const [filters, setFilters] = useState<FilterState>({ 30 grayscale: 0, 31 noise: 0, 32 scanlines: 0, 33 rgbShift: 0, 34 glitchIntensity: 0, 35 blurRegions: [], 36 }); 37 38 const [blurMode, setBlurMode] = useState(false); 39 const [audioEnabled, setAudioEnabled] = useState(false); // Audio OFF by default (secure) 40 const [isDragging, setIsDragging] = useState(false); 41 const [dragStart, setDragStart] = useState<{ x: number, y: number } | null>(null); 42 const [dragCurrent, setDragCurrent] = useState<{ x: number, y: number } | null>(null); 43 const [showHelp, setShowHelp] = useState(false); 44 const [isLangMenuOpen, setIsLangMenuOpen] = useState(false); 45 const [deferredPrompt, setDeferredPrompt] = useState<any>(null); 46 const [showInstallButton, setShowInstallButton] = useState(false); 47 48 // --- Refs --- 49 const canvasRef = useRef<HTMLCanvasElement>(null); 50 const containerRef = useRef<HTMLDivElement>(null); 51 const fileInputRef = useRef<HTMLInputElement>(null); 52 const videoRef = useRef<HTMLVideoElement | null>(null); 53 const requestRef = useRef<number | null>(null); 54 const mediaRecorderRef = useRef<MediaRecorder | null>(null); 55 const recordedChunksRef = useRef<Blob[]>([]); 56 57 // --- Animation Loop for Video --- 58 useEffect(() => { 59 const animateFrame = () => { 60 if (mediaState.type === 'video' && mediaState.source && canvasRef.current) { 61 // Render if playing, recording, OR if we just want to update the filter preview while paused 62 if (mediaState.isPlaying || mediaState.isRecording) { 63 renderToCanvas(canvasRef.current, mediaState.source as HTMLVideoElement, filters); 64 requestRef.current = requestAnimationFrame(animateFrame); 65 } else { 66 // Even if paused, render one frame to show filter updates 67 // But we don't loop. 68 renderToCanvas(canvasRef.current, mediaState.source as HTMLVideoElement, filters); 69 } 70 } 71 }; 72 73 if (mediaState.isPlaying || mediaState.isRecording) { 74 requestRef.current = requestAnimationFrame(animateFrame); 75 } else { 76 // Trigger single render when filters change while paused 77 animateFrame(); 78 } 79 80 return () => { 81 if (requestRef.current) cancelAnimationFrame(requestRef.current); 82 }; 83 }, [mediaState.type, mediaState.source, mediaState.isPlaying, mediaState.isRecording, filters]); 84 85 // --- Render Image to Canvas --- 86 useEffect(() => { 87 if (mediaState.type === 'image' && mediaState.source && mediaState.loaded && canvasRef.current) { 88 renderToCanvas(canvasRef.current, mediaState.source as ImageBitmap, filters); 89 } 90 }, [mediaState.type, mediaState.source, mediaState.loaded, filters]); 91 92 // --- PWA Install Prompt --- 93 useEffect(() => { 94 // Check if already running as PWA 95 const isStandalone = window.matchMedia('(display-mode: standalone)').matches || 96 (window.navigator as any).standalone || 97 document.referrer.includes('android-app://'); 98 99 if (isStandalone) { 100 setShowInstallButton(false); 101 return; 102 } 103 104 const handleBeforeInstallPrompt = (e: Event) => { 105 e.preventDefault(); 106 setDeferredPrompt(e); 107 setShowInstallButton(true); 108 }; 109 110 window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 111 112 return () => { 113 window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 114 }; 115 }, []); 116 117 const handleInstallClick = async () => { 118 if (!deferredPrompt) return; 119 120 deferredPrompt.prompt(); 121 const { outcome } = await deferredPrompt.userChoice; 122 123 if (outcome === 'accepted') { 124 setShowInstallButton(false); 125 } 126 127 setDeferredPrompt(null); 128 }; 129 130 // --- Handlers --- 131 132 const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { 133 if (e.target.files && e.target.files[0]) { 134 const file = e.target.files[0]; 135 const isVideo = file.type.startsWith('video/'); 136 137 // Clean up old URL 138 if (mediaState.url) URL.revokeObjectURL(mediaState.url); 139 140 if (isVideo) { 141 const url = URL.createObjectURL(file); 142 // Create hidden video element 143 const vid = document.createElement('video'); 144 vid.src = url; 145 vid.muted = true; // Mute for processing 146 vid.loop = true; 147 vid.playsInline = true; 148 vid.crossOrigin = "anonymous"; 149 150 // Wait for metadata to load 151 vid.onloadedmetadata = () => { 152 setMediaState({ 153 type: 'video', 154 file, 155 source: vid, 156 url, 157 width: vid.videoWidth, 158 height: vid.videoHeight, 159 duration: vid.duration, 160 loaded: true, 161 isPlaying: true, // Auto-play on load so user sees it works 162 isRecording: false 163 }); 164 // Reset filters for new file 165 setFilters({ 166 grayscale: 0, 167 noise: 0, 168 scanlines: 0, 169 rgbShift: 0, 170 glitchIntensity: 0, 171 blurRegions: [], 172 }); 173 setBlurMode(false); 174 videoRef.current = vid; 175 vid.play(); 176 }; 177 } else { 178 // Image 179 try { 180 const bitmap = await createImageBitmap(file); 181 setMediaState({ 182 type: 'image', 183 file, 184 source: bitmap, 185 url: null, 186 width: bitmap.width, 187 height: bitmap.height, 188 duration: 0, 189 loaded: true, 190 isPlaying: false, 191 isRecording: false 192 }); 193 // Reset filters for new file 194 setFilters({ 195 grayscale: 0, 196 noise: 0, 197 scanlines: 0, 198 rgbShift: 0, 199 glitchIntensity: 0, 200 blurRegions: [], 201 }); 202 setBlurMode(false); 203 videoRef.current = null; 204 } catch (err) { 205 console.error("Failed to load image", err); 206 alert("Error loading image"); 207 } 208 } 209 } 210 }; 211 212 const generateRandomFilename = (ext: string) => { 213 const nameLength = Math.floor(Math.random() * 9) + 8; 214 const randomName = Array.from({ length: nameLength }, () => 215 'abcdefghijklmnopqrstuvwxyz0123456789'[Math.floor(Math.random() * 36)] 216 ).join(''); 217 // Purely random filename for privacy, no project prefix 218 return `${randomName}.${ext}`; 219 }; 220 221 const handleSave = async () => { 222 if (mediaState.type === 'image') { 223 // Save Image 224 const canvas = canvasRef.current; 225 if (!canvas) return; 226 227 canvas.toBlob((blob) => { 228 if (blob) { 229 const url = URL.createObjectURL(blob); 230 const a = document.createElement('a'); 231 a.href = url; 232 a.download = generateRandomFilename('jpg'); 233 document.body.appendChild(a); 234 a.click(); 235 document.body.removeChild(a); 236 URL.revokeObjectURL(url); 237 } 238 }, 'image/jpeg', 0.9); 239 240 } else if (mediaState.type === 'video') { 241 // If already recording, this button acts as stop? 242 // The UI logic below separates "Record" from "Save" usually, 243 // but let's make the main action button handle the recording flow. 244 245 if (mediaState.isRecording) { 246 // Stop recording 247 const vid = mediaState.source as HTMLVideoElement; 248 if (vid && mediaRecorderRef.current) { 249 mediaRecorderRef.current.stop(); 250 } 251 return; 252 } 253 254 // Start Recording 255 const canvas = canvasRef.current; 256 const vid = mediaState.source as HTMLVideoElement; 257 if (!canvas || !vid) return; 258 259 // 1. Reset Video 260 vid.pause(); 261 vid.currentTime = 0; 262 263 // 2. Start Playback & Recording 264 const canvasStream = canvas.captureStream(30); // 30 FPS recording 265 266 // 3. Conditionally add audio 267 let recordingStream: MediaStream; 268 if (audioEnabled) { 269 // Capture audio from video element 270 try { 271 const videoStream = (vid as any).captureStream(); 272 const audioTracks = videoStream.getAudioTracks(); 273 274 if (audioTracks.length > 0) { 275 // Combine video from canvas with audio from video 276 recordingStream = new MediaStream([ 277 ...canvasStream.getVideoTracks(), 278 ...audioTracks 279 ]); 280 } else { 281 // No audio track in video, use canvas only 282 recordingStream = canvasStream; 283 } 284 } catch (e) { 285 // captureStream not supported, fallback to no audio 286 console.warn('Audio capture not supported', e); 287 recordingStream = canvasStream; 288 } 289 } else { 290 // Audio disabled (secure mode) 291 recordingStream = canvasStream; 292 } 293 294 const mediaRecorder = new MediaRecorder(recordingStream, { 295 mimeType: audioEnabled ? 'video/webm;codecs=vp9,opus' : 'video/webm;codecs=vp9' 296 }); 297 298 mediaRecorderRef.current = mediaRecorder; 299 recordedChunksRef.current = []; 300 301 mediaRecorder.ondataavailable = (e) => { 302 if (e.data.size > 0) recordedChunksRef.current.push(e.data); 303 }; 304 305 mediaRecorder.onstop = () => { 306 const blob = new Blob(recordedChunksRef.current, { type: 'video/webm' }); 307 const url = URL.createObjectURL(blob); 308 const a = document.createElement('a'); 309 a.href = url; 310 a.download = generateRandomFilename('webm'); 311 document.body.appendChild(a); 312 a.click(); 313 document.body.removeChild(a); 314 URL.revokeObjectURL(url); 315 316 // Restore state 317 setMediaState(prev => ({ ...prev, isRecording: false, isPlaying: false })); 318 vid.pause(); 319 vid.currentTime = 0; 320 vid.loop = true; 321 }; 322 323 // Start 324 setMediaState(prev => ({ ...prev, isRecording: true, isPlaying: true })); 325 mediaRecorder.start(); 326 vid.play(); 327 vid.loop = false; // Play once through for recording 328 vid.onended = () => { 329 if (mediaRecorder.state === "recording") { 330 mediaRecorder.stop(); 331 } 332 }; 333 } 334 }; 335 336 const handleVideoToggle = () => { 337 const vid = mediaState.source as HTMLVideoElement; 338 if (!vid) return; 339 340 if (mediaState.isPlaying) { 341 vid.pause(); 342 setMediaState(prev => ({ ...prev, isPlaying: false })); 343 } else { 344 vid.play(); 345 setMediaState(prev => ({ ...prev, isPlaying: true })); 346 } 347 }; 348 349 const handleReset = () => { 350 setFilters({ 351 grayscale: 0, 352 noise: 0, 353 scanlines: 0, 354 rgbShift: 0, 355 glitchIntensity: 0, 356 blurRegions: [] 357 }); 358 }; 359 360 // --- Canvas Interaction --- 361 362 const getCanvasCoords = (e: React.MouseEvent | React.TouchEvent) => { 363 const canvas = canvasRef.current; 364 if (!canvas) return { x: 0, y: 0 }; 365 const rect = canvas.getBoundingClientRect(); 366 const clientX = 'touches' in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; 367 const clientY = 'touches' in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; 368 return { x: clientX - rect.left, y: clientY - rect.top }; 369 }; 370 371 const startDrag = (e: React.MouseEvent | React.TouchEvent) => { 372 if (!blurMode || !mediaState.loaded) return; 373 // Prevent scrolling on touch 374 // e.preventDefault(); // Done in CSS 'touch-none', but here we manage React event 375 376 setIsDragging(true); 377 const coords = getCanvasCoords(e); 378 setDragStart(coords); 379 setDragCurrent(coords); 380 }; 381 382 const moveDrag = (e: React.MouseEvent | React.TouchEvent) => { 383 if (!isDragging || !dragStart) return; 384 setDragCurrent(getCanvasCoords(e)); 385 }; 386 387 const endDrag = () => { 388 if (!isDragging || !dragStart || !dragCurrent) return; 389 const canvas = canvasRef.current; 390 if (!canvas) return; 391 392 const rect = canvas.getBoundingClientRect(); 393 const scaleX = canvas.width / rect.width; 394 const scaleY = canvas.height / rect.height; 395 396 const cssX = Math.min(dragStart.x, dragCurrent.x); 397 const cssY = Math.min(dragStart.y, dragCurrent.y); 398 const cssW = Math.abs(dragCurrent.x - dragStart.x); 399 const cssH = Math.abs(dragCurrent.y - dragStart.y); 400 401 if (cssW > 5 && cssH > 5) { 402 const newRegion: BlurRegion = { 403 x: cssX * scaleX, 404 y: cssY * scaleY, 405 w: cssW * scaleX, 406 h: cssH * scaleY 407 }; 408 setFilters(prev => ({ ...prev, blurRegions: [...prev.blurRegions, newRegion] })); 409 } 410 411 setIsDragging(false); 412 setDragStart(null); 413 setDragCurrent(null); 414 }; 415 416 return ( 417 <div 418 className="min-h-screen flex flex-col font-sans antialiased selection:bg-cyan-500/30" 419 dir={isRTL ? 'rtl' : 'ltr'} 420 > 421 {/* --- Header --- */} 422 <header className="bg-slate-900/80 backdrop-blur-md border-b border-slate-800 sticky top-0 z-50"> 423 <div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between"> 424 <div className="flex items-center gap-3"> 425 <ShieldPlus size={32} className="text-white" /> 426 <div> 427 <h1 className="font-bold text-slate-100 leading-none text-lg tracking-tight">{t.title}</h1> 428 <p className="text-[10px] text-slate-400 font-mono mt-1 uppercase tracking-wider hidden sm:block">{t.privacyNote}</p> 429 </div> 430 </div> 431 432 <div className="flex items-center gap-2 sm:gap-4"> 433 {showInstallButton && ( 434 <Button 435 variant="primary" 436 onClick={handleInstallClick} 437 className="gap-2 text-xs px-3 py-1.5" 438 > 439 <Upload size={14} /> 440 <span className="hidden sm:inline">Install</span> 441 </Button> 442 )} 443 444 <Button variant="ghost" onClick={() => setShowHelp(!showHelp)} className="p-2 rounded-full"> 445 <Info size={20} /> 446 </Button> 447 448 <div className="relative"> 449 <button 450 onClick={() => setIsLangMenuOpen(!isLangMenuOpen)} 451 className="flex items-center gap-1 bg-slate-800 hover:bg-slate-700 text-xs py-1.5 px-3 rounded-full border border-slate-700 transition-colors text-slate-300" 452 > 453 <Globe size={12} /> 454 <span className="uppercase">{lang}</span> 455 </button> 456 457 {isLangMenuOpen && ( 458 <> 459 <div className="fixed inset-0 z-10" onClick={() => setIsLangMenuOpen(false)} /> 460 <div className="absolute right-0 top-full mt-2 w-32 bg-slate-800 border border-slate-700 rounded-lg shadow-xl overflow-hidden z-20"> 461 {SUPPORTED_LANGUAGES.map(l => ( 462 <button 463 key={l.code} 464 onClick={() => { 465 setLang(l.code); 466 setIsLangMenuOpen(false); 467 }} 468 className="w-full text-left px-4 py-2 text-xs hover:bg-slate-700 text-slate-300" 469 > 470 {l.label} 471 </button> 472 ))} 473 </div> 474 </> 475 )} 476 </div> 477 </div> 478 </div> 479 </header> 480 481 {/* --- Help Modal --- */} 482 {showHelp && ( 483 <div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm" onClick={() => setShowHelp(false)}> 484 <div className="bg-slate-900 border border-slate-700 rounded-2xl max-w-md w-full p-6 shadow-2xl" onClick={e => e.stopPropagation()}> 485 <div className="flex items-center gap-3 mb-4 text-cyan-400"> 486 <Shield size={24} /> 487 <h2 className="text-xl font-bold text-white">{t.helpTitle}</h2> 488 </div> 489 <p className="text-slate-300 text-sm leading-relaxed mb-6"> 490 {t.helpContent} 491 </p> 492 <ul className="space-y-2 text-xs text-slate-400 mb-4 font-mono"> 493 <li className="flex items-center gap-2"><CheckCircle size={12} className="text-green-500"/> {t.noUploads}</li> 494 <li className="flex items-center gap-2"><CheckCircle size={12} className="text-green-500"/> {t.exifStripped}</li> 495 <li className="flex items-center gap-2"><CheckCircle size={12} className="text-green-500"/> {t.runsOffline}</li> 496 </ul> 497 <p className="text-xs text-slate-400 mb-6"> 498 {t.radicleLink}{' '} 499 <a 500 href="https://app.radicle.xyz/nodes/rosa.radicle.xyz/rad%3Az25ksth8ffPkY4Tg5s2zeiE6xhyYC" 501 target="_blank" 502 rel="noopener noreferrer" 503 className="text-cyan-400 hover:text-cyan-300 underline" 504 > 505 rad:z25ksth8ffPkY4Tg5s2zeiE6xhyYC 506 </a> 507 </p> 508 <Button onClick={() => setShowHelp(false)} className="w-full">Got it</Button> 509 </div> 510 </div> 511 )} 512 513 {/* --- Main Content --- */} 514 <main className="flex-1 flex flex-col lg:flex-row overflow-hidden h-[calc(100vh-64px)]"> 515 516 {/* Canvas Area */} 517 <div 518 ref={containerRef} 519 className="flex-1 bg-slate-950 relative flex flex-col items-center justify-center p-4 lg:p-8 overflow-auto" 520 onDragOver={(e) => e.preventDefault()} 521 onDrop={(e) => { 522 e.preventDefault(); 523 if (e.dataTransfer.files && e.dataTransfer.files[0]) { 524 const dt = new DataTransfer(); 525 dt.items.add(e.dataTransfer.files[0]); 526 if (fileInputRef.current) { 527 fileInputRef.current.files = dt.files; 528 const event = new Event('change', { bubbles: true }); 529 fileInputRef.current.dispatchEvent(event); 530 } 531 } 532 }} 533 > 534 {!mediaState.loaded ? ( 535 <div 536 onClick={() => fileInputRef.current?.click()} 537 className="border-2 border-dashed border-slate-700 rounded-3xl p-12 text-center max-w-md w-full hover:border-cyan-500/50 hover:bg-slate-900/50 transition-all cursor-pointer group" 538 > 539 <div className="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform"> 540 <Upload className="text-slate-400 group-hover:text-cyan-400" size={24} /> 541 </div> 542 <h3 className="text-lg font-medium text-slate-200 mb-2">{t.openButton}</h3> 543 <p className="text-sm text-slate-500">{t.placeholder}</p> 544 </div> 545 ) : ( 546 <div className="flex flex-col gap-4 w-full max-w-4xl items-center"> 547 <div className="relative shadow-2xl shadow-black/50 rounded-sm overflow-hidden inline-flex flex-col bg-black"> 548 {/* Canvas */} 549 <canvas 550 ref={canvasRef} 551 className={`max-w-full max-h-[50vh] lg:max-h-[70vh] object-contain block touch-none ${blurMode ? 'cursor-crosshair' : 'cursor-default'}`} 552 onMouseDown={startDrag} 553 onMouseMove={moveDrag} 554 onMouseUp={endDrag} 555 onMouseLeave={endDrag} 556 onTouchStart={startDrag} 557 onTouchMove={moveDrag} 558 onTouchEnd={endDrag} 559 /> 560 561 {/* Recording Indicator */} 562 {mediaState.isRecording && ( 563 <div className="absolute top-4 right-4 flex items-center gap-2 bg-red-500/90 text-white text-xs font-bold px-3 py-1.5 rounded-full animate-pulse shadow-lg z-20"> 564 <div className="w-2 h-2 bg-white rounded-full"></div> 565 REC 566 </div> 567 )} 568 569 {/* Drag Box */} 570 {isDragging && dragStart && dragCurrent && ( 571 <div 572 className="absolute border-2 border-yellow-400 bg-yellow-400/20 pointer-events-none z-10 shadow-[0_0_10px_rgba(250,204,21,0.3)]" 573 style={{ 574 left: Math.min(dragStart.x, dragCurrent.x), 575 top: Math.min(dragStart.y, dragCurrent.y), 576 width: Math.abs(dragCurrent.x - dragStart.x), 577 height: Math.abs(dragCurrent.y - dragStart.y), 578 }} 579 /> 580 )} 581 </div> 582 583 {/* Persistent Video Controls */} 584 {mediaState.type === 'video' && ( 585 <div className="flex items-center gap-4 bg-slate-900 border border-slate-800 rounded-2xl p-2 shadow-xl w-full max-w-md"> 586 <Button 587 variant="ghost" 588 onClick={handleVideoToggle} 589 className="rounded-xl w-12 h-12 flex-shrink-0 hover:bg-slate-800" 590 title={t.playPause} 591 > 592 {mediaState.isPlaying ? <Pause size={24} className="fill-current" /> : <Play size={24} className="fill-current" />} 593 </Button> 594 595 <div className="flex-1 h-1 bg-slate-800 rounded-full overflow-hidden relative"> 596 {/* Simple static progress bar visual for now, since we are looping/processing */} 597 <div className={`absolute top-0 bottom-0 left-0 bg-cyan-500 ${mediaState.isPlaying ? 'w-full animate-[progress_2s_linear_infinite]' : 'w-1/2'}`}></div> 598 </div> 599 600 <Button 601 variant="ghost" 602 onClick={() => setAudioEnabled(!audioEnabled)} 603 className={`rounded-xl w-12 h-12 flex-shrink-0 ${audioEnabled ? 'text-yellow-500 hover:bg-yellow-500/10' : 'text-red-500 hover:bg-red-500/10'}`} 604 title={audioEnabled ? "Audio ON" : "Audio OFF"} 605 disabled={mediaState.isRecording} 606 > 607 <VolumeX size={24} /> 608 </Button> 609 610 <Button 611 variant={mediaState.isRecording ? "danger" : "primary"} 612 onClick={handleSave} 613 className="rounded-xl px-6 py-2 text-xs font-bold uppercase tracking-wider min-w-[120px]" 614 > 615 {mediaState.isRecording ? t.stopRec : t.record} 616 </Button> 617 </div> 618 )} 619 </div> 620 )} 621 622 <input 623 type="file" 624 ref={fileInputRef} 625 onChange={handleFileSelect} 626 accept="image/*,video/*" 627 className="hidden" 628 /> 629 </div> 630 631 {/* Controls Sidebar */} 632 <div className="w-full lg:w-80 bg-slate-900 border-t lg:border-t-0 lg:border-l border-slate-800 p-6 flex flex-col gap-6 shadow-2xl z-40 overflow-y-auto h-1/2 lg:h-auto shrink-0"> 633 634 {/* Status */} 635 <div className="flex items-center justify-between bg-slate-950/50 p-3 rounded-xl border border-slate-800 shrink-0"> 636 <span className="text-xs font-mono text-slate-400 uppercase tracking-wider">{t.statusLabel}</span> 637 <div className="flex items-center gap-2"> 638 <span className={`w-2 h-2 rounded-full ${mediaState.isRecording ? 'bg-red-500 animate-pulse' : mediaState.loaded ? 'bg-green-500' : 'bg-slate-600'}`}></span> 639 <span className="text-xs font-medium text-slate-200"> 640 {mediaState.isRecording ? t.statusRecording : mediaState.loaded ? t.statusReady : t.statusNoImage} 641 </span> 642 </div> 643 </div> 644 645 {/* Privacy Guarantee Message */} 646 {!mediaState.loaded && ( 647 <div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-slate-800/50 to-slate-900/50 rounded-2xl border border-slate-700/50"> 648 <div className="flex items-center gap-2"> 649 <Shield size={18} className="text-cyan-500" /> 650 <h3 className="text-sm font-semibold text-slate-200">{t.privacyTitle}</h3> 651 </div> 652 <p className="text-xs leading-relaxed text-slate-400"> 653 {t.privacyMessage} 654 </p> 655 </div> 656 )} 657 658 {/* Conditional Content based on Load State */} 659 {mediaState.loaded && ( 660 <div className="space-y-6 overflow-y-auto pr-2 custom-scrollbar flex-1"> 661 662 {/* Anonymize Tools (Priority) */} 663 <div> 664 <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-2"> 665 {t.anonymizeSection} 666 </h3> 667 <Button 668 variant={blurMode ? "primary" : "secondary"} 669 className="w-full justify-start gap-3" 670 onClick={() => setBlurMode(!blurMode)} 671 disabled={mediaState.isRecording} 672 > 673 <div className={`w-2 h-2 rounded-full ${blurMode ? 'bg-white animate-pulse' : 'bg-transparent'}`} /> 674 {t.blurTool} 675 </Button> 676 677 {filters.blurRegions.length > 0 && ( 678 <div className="text-xs text-slate-500 text-center mt-2"> 679 {filters.blurRegions.length} {t.areasBlurred} 680 </div> 681 )} 682 </div> 683 684 {/* Global Filters */} 685 <div> 686 <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-2"> 687 {t.globalFiltersSection} 688 </h3> 689 <div className="space-y-5 p-4 bg-slate-800/30 rounded-2xl border border-slate-800/50"> 690 <Slider 691 label={t.grayLabel} 692 value={filters.grayscale} 693 onChange={(v) => setFilters(prev => ({ ...prev, grayscale: v }))} 694 disabled={mediaState.isRecording} 695 /> 696 <Slider 697 label={t.noiseLabel} 698 value={filters.noise} 699 onChange={(v) => setFilters(prev => ({ ...prev, noise: v }))} 700 disabled={mediaState.isRecording} 701 /> 702 </div> 703 </div> 704 705 {/* Glitch / VFX */} 706 <div> 707 <h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-3 flex items-center gap-2"> 708 {t.glitchSection} 709 </h3> 710 <div className="space-y-5 p-4 bg-slate-800/30 rounded-2xl border border-slate-800/50"> 711 <Slider 712 label={t.scanlineLabel} 713 value={filters.scanlines} 714 onChange={(v) => setFilters(prev => ({ ...prev, scanlines: v }))} 715 disabled={mediaState.isRecording} 716 /> 717 <Slider 718 label={t.rgbShiftLabel} 719 value={filters.rgbShift} 720 onChange={(v) => setFilters(prev => ({ ...prev, rgbShift: v }))} 721 disabled={mediaState.isRecording} 722 /> 723 <Slider 724 label={t.glitchIntensityLabel} 725 value={filters.glitchIntensity} 726 onChange={(v) => setFilters(prev => ({ ...prev, glitchIntensity: v }))} 727 disabled={mediaState.isRecording} 728 /> 729 </div> 730 </div> 731 </div> 732 )} 733 734 {/* Actions Footer */} 735 <div className="mt-auto space-y-3 pt-4 border-t border-slate-800 shrink-0"> 736 {/* Main Save Button - Logic varies by Type */} 737 {mediaState.type === 'image' ? ( 738 <Button 739 variant="primary" 740 className="w-full gap-2 py-4 text-base" 741 onClick={handleSave} 742 disabled={!mediaState.loaded} 743 > 744 <Save size={18} /> 745 {t.save} 746 </Button> 747 ) : ( 748 <Button 749 variant="secondary" 750 className="w-full gap-2 py-4 text-base opacity-50 cursor-not-allowed" 751 disabled={true} 752 > 753 <Info size={14} /> 754 {t.useControlsAbove} 755 </Button> 756 )} 757 758 <div className="grid grid-cols-2 gap-3"> 759 <Button 760 variant="danger" 761 className="w-full gap-2" 762 onClick={handleReset} 763 disabled={!mediaState.loaded || mediaState.isRecording} 764 > 765 <RefreshCw size={14} /> 766 {t.reset} 767 </Button> 768 <Button 769 variant="secondary" 770 className="w-full gap-2" 771 onClick={() => fileInputRef.current?.click()} 772 disabled={mediaState.isRecording} 773 > 774 <Upload size={14} /> 775 {t.openButton} 776 </Button> 777 </div> 778 </div> 779 780 </div> 781 </main> 782 </div> 783 ); 784 } 785 786 export default App;