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:
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
@@ -42,6 +42,21 @@ function isWorldUnlocked(world) {
|
||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
const totalStars = getTotalStars();
|
||||
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 (
|
||||
<div className="gm-worldmap">
|
||||
@@ -69,8 +84,64 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All worlds */}
|
||||
{worlds.map((world, worldIdx) => {
|
||||
{/* Search bar */}
|
||||
<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 worldStars = world.levels.reduce((s, l) => {
|
||||
const p = getLevelProgress(l.id);
|
||||
@@ -136,7 +207,8 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -323,6 +323,28 @@ html, body, #root {
|
||||
}
|
||||
.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 */
|
||||
.gm-world-section { margin-bottom: 32px; }
|
||||
.gm-locked-world { opacity: 0.4; }
|
||||
|
||||
Reference in New Issue
Block a user