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:
Jose Luis
2026-03-21 15:40:50 +01:00
parent 4517e49ea6
commit cd88fb5444
7 changed files with 533 additions and 39 deletions

View File

@@ -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>