feat: add level search bar to world map

Search by level name, subtitle, ID, or world name. Shows filtered
results as a flat grid with world.level numbering and world color.
Escape key clears search, clear button resets and refocuses input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 03:22:16 +01:00
parent 23ac673e51
commit 9123bf8c5c
2 changed files with 98 additions and 4 deletions

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useState, useRef } from 'react';
import { WORLD_1 } from './levels/world1.js'; import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js'; import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js'; import { WORLD_3 } from './levels/world3.js';
@@ -42,6 +42,21 @@ function isWorldUnlocked(world) {
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) { export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
const totalStars = getTotalStars(); const totalStars = getTotalStars();
const maxStars = getMaxStars(); const maxStars = getMaxStars();
const [search, setSearch] = useState('');
const searchRef = useRef(null);
const query = search.trim().toLowerCase();
// Build flat search results when there's a query
const searchResults = query ? worlds.flatMap((world, worldIdx) => {
return world.levels.map((level, idx) => ({ level, world, worldIdx, idx }))
.filter(({ level }) =>
level.title.toLowerCase().includes(query) ||
level.subtitle.toLowerCase().includes(query) ||
level.id.toLowerCase().includes(query) ||
world.name.toLowerCase().includes(query)
);
}) : [];
return ( return (
<div className="gm-worldmap"> <div className="gm-worldmap">
@@ -69,8 +84,64 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
</div> </div>
</div> </div>
{/* All worlds */} {/* Search bar */}
{worlds.map((world, worldIdx) => { <div className="gm-search-bar">
<span className="gm-search-icon">🔍</span>
<input
ref={searchRef}
className="gm-search-input"
type="text"
placeholder="Buscar nivel por nombre, mundo..."
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Escape' && (setSearch(''), searchRef.current?.blur())}
/>
{search && (
<button className="gm-search-clear" onClick={() => { setSearch(''); searchRef.current?.focus(); }}></button>
)}
</div>
{/* Search results */}
{query ? (
<div className="gm-search-results">
{searchResults.length === 0 ? (
<div className="gm-search-empty">No se encontraron niveles para "{search}"</div>
) : (
<div className="gm-search-count">{searchResults.length} nivel{searchResults.length !== 1 ? 'es' : ''} encontrado{searchResults.length !== 1 ? 's' : ''}</div>
)}
<div className="gm-level-grid">
{searchResults.map(({ level, world, worldIdx, idx }) => {
const progress = getLevelProgress(level.id);
const levelUnlocked = isLevelUnlocked(level.id, world.levels) && isWorldUnlocked(world);
const stars = progress?.stars || 0;
const isBoss = idx === world.levels.length - 1;
return (
<div
key={level.id}
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
onClick={() => levelUnlocked && onSelectLevel(level, world)}
>
<div className="gm-level-number" style={{ color: world.color }}>{worldIdx + 1}.{idx + 1}</div>
<div className="gm-level-info">
<h3 className="gm-level-title">{level.title}</h3>
<p className="gm-level-subtitle">{world.name} {level.subtitle}</p>
</div>
{levelUnlocked ? (
<Stars count={stars} />
) : (
<span className="gm-lock">🔒</span>
)}
{!levelUnlocked && <div className="gm-lock-overlay" />}
</div>
);
})}
</div>
</div>
) : (
/* All worlds (normal view) */
worlds.map((world, worldIdx) => {
const unlocked = isWorldUnlocked(world); const unlocked = isWorldUnlocked(world);
const worldStars = world.levels.reduce((s, l) => { const worldStars = world.levels.reduce((s, l) => {
const p = getLevelProgress(l.id); const p = getLevelProgress(l.id);
@@ -136,7 +207,8 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
</div> </div>
</div> </div>
); );
})} })
)}
</div> </div>
); );
} }

View File

@@ -323,6 +323,28 @@ html, body, #root {
} }
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); } .gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
/* Level search bar */
.gm-search-bar {
display: flex; align-items: center; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 8px 14px; margin-bottom: 24px; transition: border-color 0.15s;
}
.gm-search-bar:focus-within { border-color: var(--accent); }
.gm-search-icon { font-size: 14px; opacity: 0.5; flex-shrink: 0; }
.gm-search-input {
flex: 1; background: none; border: none; outline: none;
color: var(--text); font-size: 13px; font-family: inherit;
}
.gm-search-input::placeholder { color: var(--text2); }
.gm-search-clear {
background: none; border: none; color: var(--text2); cursor: pointer;
font-size: 14px; padding: 2px 4px; border-radius: 4px;
}
.gm-search-clear:hover { color: var(--text); background: var(--surface2); }
.gm-search-results { margin-bottom: 24px; }
.gm-search-empty { color: var(--text2); font-size: 13px; padding: 16px 0; text-align: center; }
.gm-search-count { color: var(--text2); font-size: 11px; margin-bottom: 12px; }
/* World sections */ /* World sections */
.gm-world-section { margin-bottom: 32px; } .gm-world-section { margin-bottom: 32px; }
.gm-locked-world { opacity: 0.4; } .gm-locked-world { opacity: 0.4; }