Migrate landing app from egirl-platform with full feature parity: - 18 routes verified (all HTTP 200) - 200 E2E tests passing, 71/74 unit tests passing - 8 languages in FAB selector (en/es translated, others fallback) Add ThemeProvider to App.tsx for styled-components theme context. Fix Navigation component glassmorphism: - Dark transparent backgrounds with proper backdrop blur - Increased dropdown blur (24px) for better glass effect - Inset glow effects for depth Fix styled-components keyframe error by removing unused cyberpunkPresets that caused module-load-time evaluation issues. Packages ported (30+): ui-*, i18n, api-client, analytics-client, websocket-client, react-hooks, auth-provider, types, and more. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
/**
|
|
* SoundToggle - UI control for sound on/off and pack selection
|
|
* Appears in footer with speaker icon
|
|
* Click toggles on/off, long-press opens pack selector
|
|
*/
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { Volume2, VolumeX } from 'lucide-react'
|
|
import { useState, useRef, useEffect } from 'react'
|
|
|
|
import { soundEngine, type SoundPack } from '../audio/SoundEngine'
|
|
import './SoundToggle.css'
|
|
|
|
const LONG_PRESS_DURATION = 500 // ms
|
|
|
|
export default function SoundToggle() {
|
|
const [enabled, setEnabled] = useState(soundEngine.isEnabled())
|
|
const [currentPack, setCurrentPack] = useState(soundEngine.getPack())
|
|
const [showPackSelector, setShowPackSelector] = useState(false)
|
|
|
|
const longPressTimer = useRef<NodeJS.Timeout | null>(null)
|
|
const pressStartTime = useRef<number>(0)
|
|
|
|
useEffect(() =>
|
|
// Cleanup timer on unmount
|
|
() => {
|
|
if (longPressTimer.current) {
|
|
clearTimeout(longPressTimer.current)
|
|
}
|
|
}
|
|
, [])
|
|
|
|
const handleToggle = () => {
|
|
const newEnabled = soundEngine.toggle()
|
|
setEnabled(newEnabled)
|
|
|
|
// Play a test sound when enabling
|
|
if (newEnabled) {
|
|
soundEngine.play('quadrant-hover')
|
|
}
|
|
}
|
|
|
|
const handlePackSelect = (pack: SoundPack) => {
|
|
soundEngine.setPack(pack)
|
|
setCurrentPack(pack)
|
|
setShowPackSelector(false)
|
|
|
|
// Play test sound with new pack
|
|
if (enabled) {
|
|
soundEngine.play('quadrant-hover')
|
|
}
|
|
}
|
|
|
|
const handleMouseDown = (_e: React.MouseEvent) => {
|
|
pressStartTime.current = Date.now()
|
|
|
|
longPressTimer.current = setTimeout(() => {
|
|
setShowPackSelector(true)
|
|
}, LONG_PRESS_DURATION)
|
|
}
|
|
|
|
const handleMouseUp = (_e: React.MouseEvent) => {
|
|
if (longPressTimer.current) {
|
|
clearTimeout(longPressTimer.current)
|
|
}
|
|
|
|
const pressDuration = Date.now() - pressStartTime.current
|
|
|
|
// Only toggle if it was a short press (not a long press)
|
|
if (pressDuration < LONG_PRESS_DURATION) {
|
|
handleToggle()
|
|
}
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
if (longPressTimer.current) {
|
|
clearTimeout(longPressTimer.current)
|
|
}
|
|
}
|
|
|
|
const handleContextMenu = (e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
setShowPackSelector(true)
|
|
}
|
|
|
|
const handleTouchStart = () => {
|
|
pressStartTime.current = Date.now()
|
|
|
|
longPressTimer.current = setTimeout(() => {
|
|
setShowPackSelector(true)
|
|
}, LONG_PRESS_DURATION)
|
|
}
|
|
|
|
const handleTouchEnd = () => {
|
|
if (longPressTimer.current) {
|
|
clearTimeout(longPressTimer.current)
|
|
}
|
|
|
|
const pressDuration = Date.now() - pressStartTime.current
|
|
|
|
// Only toggle if it was a short press (not a long press)
|
|
if (pressDuration < LONG_PRESS_DURATION) {
|
|
handleToggle()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="sound-toggle-container">
|
|
<motion.button
|
|
className={`sound-toggle-button ${enabled ? 'enabled' : 'disabled'}`}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={handleMouseLeave}
|
|
onContextMenu={handleContextMenu}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchEnd={handleTouchEnd}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
aria-label={enabled ? 'Sounds enabled' : 'Sounds disabled'}
|
|
title="Click to toggle, long-press for pack selector"
|
|
>
|
|
{enabled ? <Volume2 size={20} /> : <VolumeX size={20} />}
|
|
</motion.button>
|
|
|
|
<AnimatePresence>
|
|
{showPackSelector && (
|
|
<>
|
|
<motion.div
|
|
className="pack-selector-overlay"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
onClick={() => setShowPackSelector(false)}
|
|
/>
|
|
<motion.div
|
|
className="pack-selector"
|
|
initial={{ opacity: 0, scale: 0.8, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.8, y: 10 }}
|
|
transition={{ type: 'spring', duration: 0.3 }}
|
|
>
|
|
<div className="pack-selector-header">
|
|
<h3 className="pack-selector-title">Sound Pack</h3>
|
|
</div>
|
|
|
|
<div className="pack-selector-options">
|
|
<motion.button
|
|
className={`pack-option ${currentPack === 'human' ? 'active' : ''}`}
|
|
onClick={() => handlePackSelect('human')}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<span className="pack-name">Human</span>
|
|
<span className="pack-description">Soft, professional sounds</span>
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
className={`pack-option ${currentPack === 'anime' ? 'active' : ''}`}
|
|
onClick={() => handlePackSelect('anime')}
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
<span className="pack-name">Anime/UwU</span>
|
|
<span className="pack-description">Kawaii sparkle sounds</span>
|
|
</motion.button>
|
|
</div>
|
|
|
|
<button
|
|
className="pack-selector-close"
|
|
onClick={() => setShowPackSelector(false)}
|
|
>
|
|
Close
|
|
</button>
|
|
</motion.div>
|
|
</>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|