feat(web): add search to Rules page

Filter sections by typing in the search box — matches against both
section titles and content. Shows result count and a "no results"
message. Expand/Collapse All buttons respect the filtered set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bas van Rossem
2026-02-19 17:28:22 +01:00
parent 4de0b1cb2a
commit 69e53ed695
2 changed files with 52 additions and 5 deletions

View File

@@ -542,12 +542,34 @@ button:disabled {
} }
/* Rules Page */ /* Rules Page */
.rules-search {
width: 100%;
padding: var(--spacing-sm) var(--spacing-md);
margin-bottom: var(--spacing-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text);
font-size: 1rem;
}
.rules-search::placeholder {
color: var(--color-text-muted);
}
.rules-actions { .rules-actions {
display: flex; display: flex;
align-items: center;
gap: var(--spacing-sm); gap: var(--spacing-sm);
margin-bottom: var(--spacing-md); margin-bottom: var(--spacing-md);
} }
.rules-count {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-left: auto;
}
.rules-sections { .rules-sections {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import Markdown from 'react-markdown'; import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import TopBar from '../components/layout/TopBar'; import TopBar from '../components/layout/TopBar';
@@ -7,6 +7,15 @@ import { battleReferenceSections } from '../rules/battle-reference';
export default function RulesPage() { export default function RulesPage() {
const [openSections, setOpenSections] = useState<Set<number>>(new Set()); const [openSections, setOpenSections] = useState<Set<number>>(new Set());
const [search, setSearch] = useState('');
const filtered = useMemo(() => {
if (!search.trim()) return battleReferenceSections.map((s, i) => ({ ...s, originalIndex: i }));
const q = search.toLowerCase();
return battleReferenceSections
.map((s, i) => ({ ...s, originalIndex: i }))
.filter((s) => s.title.toLowerCase().includes(q) || s.content.toLowerCase().includes(q));
}, [search]);
const toggle = (index: number) => { const toggle = (index: number) => {
setOpenSections((prev) => { setOpenSections((prev) => {
@@ -21,7 +30,7 @@ export default function RulesPage() {
}; };
const expandAll = () => { const expandAll = () => {
setOpenSections(new Set(battleReferenceSections.map((_, i) => i))); setOpenSections(new Set(filtered.map((s) => s.originalIndex)));
}; };
const collapseAll = () => { const collapseAll = () => {
@@ -32,6 +41,14 @@ export default function RulesPage() {
<> <>
<TopBar title="Ship Reference" /> <TopBar title="Ship Reference" />
<PageContainer> <PageContainer>
<input
type="search"
className="rules-search"
placeholder="Search rules..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="rules-actions"> <div className="rules-actions">
<button className="btn-secondary btn-sm" onClick={expandAll}> <button className="btn-secondary btn-sm" onClick={expandAll}>
Expand All Expand All
@@ -39,12 +56,15 @@ export default function RulesPage() {
<button className="btn-secondary btn-sm" onClick={collapseAll}> <button className="btn-secondary btn-sm" onClick={collapseAll}>
Collapse All Collapse All
</button> </button>
{search.trim() && (
<span className="rules-count">{filtered.length} result{filtered.length !== 1 ? 's' : ''}</span>
)}
</div> </div>
<div className="rules-sections"> <div className="rules-sections">
{battleReferenceSections.map((section, i) => ( {filtered.map((section) => (
<details key={i} className="rules-section" open={openSections.has(i)}> <details key={section.originalIndex} className="rules-section" open={openSections.has(section.originalIndex)}>
<summary className="rules-summary" onClick={(e) => { e.preventDefault(); toggle(i); }}> <summary className="rules-summary" onClick={(e) => { e.preventDefault(); toggle(section.originalIndex); }}>
{section.title} {section.title}
</summary> </summary>
<div className="rules-content"> <div className="rules-content">
@@ -59,6 +79,11 @@ export default function RulesPage() {
</div> </div>
</details> </details>
))} ))}
{filtered.length === 0 && (
<p className="empty-text" style={{ padding: 'var(--spacing-md) 0' }}>
No sections match "{search}".
</p>
)}
</div> </div>
</PageContainer> </PageContainer>
</> </>