From cd88fb544487ad40c7ca053188be06f0f11be60a Mon Sep 17 00:00:00 2001 From: Jose Luis Date: Sat, 21 Mar 2026 15:40:50 +0100 Subject: [PATCH] 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) --- src/App.jsx | 134 +++++++++++++----- src/components/BottomSheet.jsx | 49 +++++++ src/components/MobileTabBar.jsx | 16 +++ src/game/PuzzleView.jsx | 105 ++++++++++++-- src/game/WorldMap.jsx | 21 +++ src/hooks/useIsMobile.js | 14 ++ src/index.css | 233 ++++++++++++++++++++++++++++++++ 7 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 src/components/BottomSheet.jsx create mode 100644 src/components/MobileTabBar.jsx create mode 100644 src/hooks/useIsMobile.js diff --git a/src/App.jsx b/src/App.jsx index cbe4464..3e7ef56 100644 --- a/src/App.jsx +++ b/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 (
{/* Toolbar */}
- {onSwitchToGame && ( + {onSwitchToGame && !isMobile && ( )} Reaktor -
- -
-
- - - - - -
-
- - -
- + {!isMobile &&
} + {!isMobile && ( + + )} + {!isMobile &&
} + {!isMobile && ( +
+ + + + + +
+ )} + {!isMobile &&
} + {!isMobile && ( + <> + + +
+ + )} + {state.isRunning ? 'โ— LIVE' : 'โ—‹ OFF'} - - {state.modules.length} modules ยท {state.connections.length} wires - + {!isMobile && ( + + {state.modules.length} modules ยท {state.connections.length} wires + + )} + {isMobile && ( + + )}
+ {/* Mobile menu overlay */} + {isMobile && menuOpen && ( +
setMenuOpen(false)}> +
e.stopPropagation()}> + {onSwitchToGame && ( + + )} + + + + + + + +
+
+ )} + {/* Main canvas area */}
- {/* Wire layer (behind modules, uses getBoundingClientRect) */} + {/* Wire layer */} - {/* Modules container (offset by camera) */} + {/* Modules container */}
{state.modules.map(mod => (
- {/* Zoom controls โ€” top right of canvas */} + {/* Zoom controls */}
- {/* Module palette */} - + {/* Desktop palette */} + {!isMobile && }
- {/* Status bar */} + {/* Mobile action bar */} + {isMobile && ( +
+ + + + +
+ )} + + {/* Mobile bottom sheet with modules */} + {isMobile && ( + +
+ {allModuleDefs.map(def => ( +
handleAddModule(def.type)}> + {def.icon} + {def.name} +
+ ))} +
+
+ )} + + {/* Status bar (hidden on mobile via CSS) */}
Reaktor โ€” MontLab Modular Synth Zoom: {(state.zoom * 100).toFixed(0)}% diff --git a/src/components/BottomSheet.jsx b/src/components/BottomSheet.jsx new file mode 100644 index 0000000..62ad89c --- /dev/null +++ b/src/components/BottomSheet.jsx @@ -0,0 +1,49 @@ +import { useState, useRef, useCallback } from 'react'; + +export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) { + const [expanded, setExpanded] = useState(false); + const startY = useRef(0); + const sheetRef = useRef(null); + + const handleTouchStart = useCallback((e) => { + startY.current = e.touches[0].clientY; + }, []); + + const handleTouchEnd = useCallback((e) => { + const deltaY = e.changedTouches[0].clientY - startY.current; + if (deltaY < -40) setExpanded(true); + if (deltaY > 40) setExpanded(false); + }, []); + + return ( +
+
setExpanded(e => !e)}> +
+
+ + {tabs && tabs.length > 0 && ( +
+ {tabs.map(tab => ( + + ))} +
+ )} + +
+ {children} +
+
+ ); +} diff --git a/src/components/MobileTabBar.jsx b/src/components/MobileTabBar.jsx new file mode 100644 index 0000000..8c226dc --- /dev/null +++ b/src/components/MobileTabBar.jsx @@ -0,0 +1,16 @@ +export default function MobileTabBar({ tabs, activeTab, onTabChange }) { + return ( + + ); +} diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 32ce0d4..3d5b011 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -4,6 +4,8 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi import { getModuleDef } from '../engine/moduleRegistry.js'; import ModuleNode from '../components/ModuleNode.jsx'; import WireLayer from '../components/WireLayer.jsx'; +import BottomSheet from '../components/BottomSheet.jsx'; +import { useIsMobile } from '../hooks/useIsMobile.js'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import LevelComplete from './LevelComplete.jsx'; import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; @@ -20,6 +22,8 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN const [showHint, setShowHint] = useState(false); const [result, setResult] = useState(null); const [targetPlaying, setTargetPlaying] = useState(false); + const isMobile = useIsMobile(); + const [mobileTab, setMobileTab] = useState('mission'); useEffect(() => { const unsub = subscribe(() => { @@ -335,7 +339,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
{/* Top bar */}
- +
{levelIndex + 1}/{worldLevels.length} {level.title} @@ -345,19 +349,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN className={`gm-btn ${targetPlaying ? 'active' : 'target'}`} onClick={handlePlayTarget} > - {targetPlaying ? 'โน Parar' : '๐ŸŽฏ Objetivo'} + {targetPlaying ? 'โน' : '๐ŸŽฏ'}{!isMobile && {targetPlaying ? ' Parar' : ' Objetivo'}} - + {!isMobile && ( + + )} {adminMode && (
- {/* Left sidebar */} + {/* Left sidebar (desktop only โ€” hidden on mobile via CSS) */}
{/* Description โ€” always visible */}
@@ -502,6 +508,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
+ {/* Mobile bottom sheet with tabs (replaces sidebar) */} + {isMobile && ( + + {mobileTab === 'mission' && ( +
+

{level.description}

+ {!showHint ? ( + + ) : ( +
+
๐Ÿ’ก Pista max โ˜…โ˜…
+

{level.concept}

+
+ )} +
+ )} + + {mobileTab === 'objectives' && ( +
+ {level.checks.map((check, i) => { + const passed = result?.checks?.[i]?.passed; + const cappedByStar = hintUsed && check.star === 3; + return ( +
+ {'โ˜…'.repeat(check.star)} + + {check.desc} + {cappedByStar && ' ๐Ÿ”’'} + + {passed === true && !cappedByStar && โœ“} + {passed === false && โœ—} +
+ ); + })} + {hintUsed && ( +
+ Pista usada โ€” maximo 2 estrellas (permanente). +
+ )} +
+ )} + + {mobileTab === 'modules' && ( +
+ {level.availableModules.length > 0 ? ( +
+ {level.availableModules.map(type => { + const def = getModuleDef(type); + if (!def) return null; + return ( +
handleAddModule(type)}> + {def.icon} + {def.name} +
+ ); + })} +
+ ) : ( +

No hay modulos extra disponibles para este nivel.

+ )} + + +
+ )} +
+ )} + {/* Level complete overlay */} {result && result.stars >= 1 && ( = world.unlockStars; } +const MOBILE_TABS = [ + { id: 'game', label: 'JUEGO', icon: '๐ŸŽฎ' }, + { id: 'sandbox', label: 'SANDBOX', icon: '๐ŸŽ›' }, + { id: 'config', label: 'CONFIG', icon: 'โš™' }, +]; + export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { const totalStars = getTotalStars(); const maxStars = getMaxStars(); const [search, setSearch] = useState(''); const searchRef = useRef(null); + const isMobile = useIsMobile(); const query = search.trim().toLowerCase(); @@ -209,6 +218,18 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { ); }) )} + + {/* Mobile tab bar */} + {isMobile && ( + { + if (id === 'sandbox') onSandbox?.(); + if (id === 'config') onAdmin?.(); + }} + /> + )}
); } diff --git a/src/hooks/useIsMobile.js b/src/hooks/useIsMobile.js new file mode 100644 index 0000000..5f65d8a --- /dev/null +++ b/src/hooks/useIsMobile.js @@ -0,0 +1,14 @@ +import { useState, useEffect } from 'react'; + +export function useIsMobile(breakpoint = 768) { + const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint); + + useEffect(() => { + const mql = window.matchMedia(`(max-width: ${breakpoint}px)`); + const handler = (e) => setIsMobile(e.matches); + mql.addEventListener('change', handler); + return () => mql.removeEventListener('change', handler); + }, [breakpoint]); + + return isMobile; +} diff --git a/src/index.css b/src/index.css index bfce7b0..917e088 100644 --- a/src/index.css +++ b/src/index.css @@ -785,3 +785,236 @@ html, body, #root { .admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); } .admin-star-btn.zero { color: var(--red); } .admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); } + +/* ============================================ + MOBILE RESPONSIVE โ€” max-width: 768px + ============================================ */ + +/* --- Bottom Sheet --- */ +.bottom-sheet { + display: none; +} + +/* --- Mobile Tab Bar --- */ +.mobile-tab-bar { + display: none; +} + +@media (max-width: 768px) { + + /* --- Sandbox Toolbar --- */ + .toolbar { height: 44px; padding: 0 12px; gap: 6px; } + .toolbar-title { font-size: 13px; letter-spacing: 0.8px; } + .toolbar-sep, .toolbar .status-text, + .toolbar-btn.save-btn, .toolbar-btn.load-btn, + .toolbar-btn.export-btn, .toolbar-btn.import-btn, + .toolbar-btn.demo-btn, .toolbar-btn.clear-btn { display: none; } + .toolbar-btn.start-btn { padding: 4px 10px; font-size: 11px; } + + /* Hamburger menu button (added via JS) */ + .mobile-menu-btn { + padding: 6px 10px; background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 18px; + font-weight: 600; line-height: 1; + } + .mobile-menu-btn:hover { border-color: var(--accent); color: var(--text); } + + .mobile-menu-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.6); + z-index: 200; display: flex; justify-content: flex-end; + } + .mobile-menu-panel { + width: 260px; background: var(--panel); border-left: 1px solid var(--border); + padding: 16px; display: flex; flex-direction: column; gap: 6px; + animation: slideInRight 0.2s ease-out; + } + @keyframes slideInRight { + from { transform: translateX(100%); } + to { transform: translateX(0); } + } + .mobile-menu-panel .toolbar-btn { + display: flex; width: 100%; padding: 10px 14px; + font-size: 13px; text-align: left; + } + + /* --- Mobile Action Bar (Sandbox) --- */ + .mobile-action-bar { + display: flex; align-items: center; gap: 8px; + padding: 0 12px; height: 48px; background: var(--panel); + border-top: 1px solid var(--border); flex-shrink: 0; + } + .mobile-action-bar .start-btn-mobile { + flex: 1; padding: 8px 14px; background: var(--accent); color: #000; + border: none; border-radius: 6px; font-size: 12px; font-weight: 700; + font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; cursor: pointer; + text-transform: uppercase; + } + .mobile-action-bar .start-btn-mobile.active { + background: var(--red); + } + .mobile-action-bar .action-icon-btn { + padding: 8px 12px; background: var(--surface); border: 1px solid var(--border); + border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 14px; + } + .mobile-action-bar .action-icon-btn:hover { border-color: var(--accent); } + + /* --- Status Bar --- */ + .status-bar { display: none; } + + /* --- Module Palette --- */ + .palette { display: none; } + + /* --- Bottom Sheet (visible on mobile) --- */ + .bottom-sheet { + display: flex; flex-direction: column; + background: var(--panel); border-top: 1px solid var(--border); + border-radius: 16px 16px 0 0; flex-shrink: 0; + max-height: 40vh; transition: max-height 0.3s ease; + overflow: hidden; + } + .bottom-sheet.expanded { max-height: 60vh; } + + .bottom-sheet-handle { + display: flex; justify-content: center; padding: 10px 0 6px; + cursor: grab; + } + .bottom-sheet-handle-bar { + width: 40px; height: 4px; background: var(--border); + border-radius: 2px; + } + + .bottom-sheet-tabs { + display: flex; padding: 0 16px; gap: 0; + border-bottom: 1px solid var(--border); + } + .bottom-sheet-tab { + flex: 1; padding: 8px 0; background: none; border: none; + color: var(--text2); font-size: 10px; font-weight: 700; + font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; + cursor: pointer; text-align: center; position: relative; + text-transform: uppercase; + } + .bottom-sheet-tab.active { color: var(--accent); } + .bottom-sheet-tab-line { + position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); + width: 100%; height: 2px; background: var(--accent); border-radius: 1px; + } + + .bottom-sheet-content { + padding: 12px 16px; overflow-y: auto; flex: 1; + } + + /* Module grid tiles (mobile palette) */ + .mobile-module-grid { + display: grid; grid-template-columns: repeat(5, 1fr); + gap: 8px; + } + .mobile-module-tile { + display: flex; flex-direction: column; align-items: center; gap: 4px; + padding: 10px 4px; background: var(--surface); border: 1px solid var(--border); + border-radius: 8px; cursor: pointer; transition: all 0.15s; + } + .mobile-module-tile:hover, .mobile-module-tile:active { + border-color: var(--accent); background: var(--surface2); + } + .mobile-module-tile .tile-icon { font-size: 20px; } + .mobile-module-tile .tile-name { + font-size: 9px; font-weight: 600; color: var(--text2); + font-family: 'JetBrains Mono', monospace; text-transform: uppercase; + } + + /* --- Canvas adjustments --- */ + .node-canvas { cursor: default; } + .zoom-controls { right: 8px; top: 8px; } + .zoom-btn { width: 40px; height: 36px; min-height: 44px; } + .port-dot { width: 18px; height: 18px; } + + /* --- Mobile Tab Bar (visible on mobile) --- */ + .mobile-tab-bar { + display: flex; align-items: center; height: 56px; + background: var(--panel); border-top: 1px solid var(--border); + flex-shrink: 0; z-index: 10; + } + .mobile-tab { + flex: 1; display: flex; flex-direction: column; align-items: center; + gap: 3px; padding: 6px 0; background: none; border: none; + cursor: pointer; color: var(--text2); transition: color 0.15s; + } + .mobile-tab.active { color: var(--accent); } + .mobile-tab-icon { font-size: 18px; } + .mobile-tab-label { + font-size: 9px; font-weight: 600; letter-spacing: 1px; + font-family: 'JetBrains Mono', monospace; text-transform: uppercase; + } + + /* --- World Map Mobile --- */ + .gm-worldmap { padding: 0 12px 80px; } + .gm-header { padding: 12px 0; gap: 6px; } + .gm-header-top { gap: 8px; } + .gm-logo-icon { width: 32px; height: 32px; font-size: 16px; } + .gm-title { font-size: 18px; } + .gm-tagline { display: none; } + .gm-header-actions .gm-btn { display: none; } + .gm-search-bar { margin: 0 0 12px; } + .gm-level-grid { grid-template-columns: 1fr; } + .gm-level-card { padding: 10px 12px; } + .gm-world-section { margin-bottom: 16px; } + + /* --- Puzzle View Mobile --- */ + .gm-puzzle-bar { height: 44px; padding: 0 10px; gap: 6px; } + .gm-puzzle-bar .gm-btn { padding: 6px 10px; } + .gm-puzzle-bar .gm-btn .btn-label { display: none; } + .gm-puzzle-name { font-size: 13px; } + .gm-puzzle-num { font-size: 9px; padding: 2px 6px; } + + .gm-puzzle-sidebar { display: none; } + .gm-puzzle-canvas-wrap { width: 100%; } + + /* Puzzle bottom sheet specific */ + .puzzle-mission-text { + font-size: 12px; color: var(--text); line-height: 1.5; + } + .puzzle-hint-btn { + display: flex; align-items: center; gap: 8px; + padding: 10px 12px; border: 1px dashed var(--yellow); + border-radius: 8px; background: rgba(255,204,0,0.04); + cursor: pointer; width: 100%; margin-top: 8px; + } + .puzzle-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; } + .puzzle-hint-icon { font-size: 16px; } + .puzzle-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; } + .puzzle-hint-penalty { + font-size: 9px; font-weight: 700; color: var(--red); + background: rgba(255,68,102,0.15); padding: 2px 6px; border-radius: 3px; + } + + .puzzle-obj-item { + display: flex; align-items: center; gap: 10px; + padding: 8px 0; border-bottom: 1px solid var(--border); + font-size: 12px; + } + .puzzle-obj-item:last-child { border-bottom: none; } + .puzzle-obj-star { color: var(--yellow); width: 30px; flex-shrink: 0; } + .puzzle-obj-desc { color: var(--text2); flex: 1; } + + /* --- Level Complete Modal Mobile --- */ + .gm-complete-overlay { padding: 0 16px; } + .gm-complete-card { + min-width: unset; max-width: unset; width: 100%; + padding: 24px 20px; + } + .gm-complete-actions { flex-direction: column; width: 100%; } + .gm-complete-actions .gm-btn { width: 100%; justify-content: center; } + .gm-complete-actions .gm-btn.primary { + order: -1; padding: 14px; + font-size: 13px; font-weight: 700; + } + + /* --- Preset Modal Mobile --- */ + .modal { min-width: unset; max-width: unset; width: calc(100% - 32px); } + + /* --- General touch targets --- */ + .gm-btn { min-height: 44px; display: flex; align-items: center; } + .gm-palette-item { padding: 12px 10px; } + +}