feat: add mobile-responsive UI for all views
- Add useIsMobile hook (768px breakpoint with matchMedia) - Add BottomSheet component (swipe up/down, optional tabs, handle bar) - Add MobileTabBar component (bottom nav with icons + labels) - Sandbox mobile: compact toolbar, hamburger menu, action bar with START button, bottom sheet with module grid tiles - World Map mobile: compact header, single-column level list, bottom tab bar (JUEGO/SANDBOX/CONFIG) - Puzzle View mobile: icon-only top bar buttons, sidebar replaced by bottom sheet with 3 tabs (MISION/OBJETIVOS/MODULOS) - ~200 lines of CSS media queries: touch targets 44px, port dots 18px, zoom controls larger, modals full-width, level complete full-width buttons Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
134
src/App.jsx
134
src/App.jsx
@@ -8,7 +8,10 @@ import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
import PresetModal from './components/PresetModal.jsx';
|
||||
import BottomSheet from './components/BottomSheet.jsx';
|
||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||
|
||||
export default function App({ onSwitchToGame }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
@@ -18,6 +21,8 @@ export default function App({ onSwitchToGame }) {
|
||||
const connectingRef = useRef(null);
|
||||
const [presetModal, setPresetModal] = useState(null);
|
||||
const importRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Subscribe to state changes
|
||||
useEffect(() => {
|
||||
@@ -249,44 +254,82 @@ export default function App({ onSwitchToGame }) {
|
||||
emit();
|
||||
};
|
||||
|
||||
// Flatten all modules for mobile grid
|
||||
const allModuleDefs = Object.values(getModulesByCategory()).flat();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
{onSwitchToGame && (
|
||||
{onSwitchToGame && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<span className="toolbar-title">Reaktor</span>
|
||||
<div className="toolbar-sep" />
|
||||
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
<div className="toolbar-sep" />
|
||||
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={handleClearCanvas}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||
</button>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
</>
|
||||
)}
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||
</span>
|
||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||
{state.modules.length} modules · {state.connections.length} wires
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||
{state.modules.length} modules · {state.connections.length} wires
|
||||
</span>
|
||||
)}
|
||||
{isMobile && (
|
||||
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isMobile && menuOpen && (
|
||||
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||||
{onSwitchToGame && (
|
||||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main canvas area */}
|
||||
<div className="main-area">
|
||||
<div
|
||||
@@ -310,10 +353,10 @@ export default function App({ onSwitchToGame }) {
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
||||
{/* Wire layer */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules container (offset by camera) */}
|
||||
{/* Modules container */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
@@ -327,7 +370,7 @@ export default function App({ onSwitchToGame }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — top right of canvas */}
|
||||
{/* Zoom controls */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||||
@@ -337,11 +380,40 @@ export default function App({ onSwitchToGame }) {
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{/* Module palette */}
|
||||
<ModulePalette onAddModule={handleAddModule} />
|
||||
{/* Desktop palette */}
|
||||
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{/* Mobile action bar */}
|
||||
{isMobile && (
|
||||
<div className="mobile-action-bar">
|
||||
<button
|
||||
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹ STOP' : '▶ START'}
|
||||
</button>
|
||||
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
|
||||
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
|
||||
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom sheet with modules */}
|
||||
{isMobile && (
|
||||
<BottomSheet>
|
||||
<div className="mobile-module-grid">
|
||||
{allModuleDefs.map(def => (
|
||||
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
|
||||
<span className="tile-icon">{def.icon}</span>
|
||||
<span className="tile-name">{def.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{/* Status bar (hidden on mobile via CSS) */}
|
||||
<div className="status-bar">
|
||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||
|
||||
Reference in New Issue
Block a user