Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ Since the `1.0.0-beta` release, the version in `package.json` has stayed at `1.0

**Monthly batching (agents):** Use **one** `## [1.0.0-ci] - YYYY-MM-DD` header per calendar month. Before editing, search this file for `- YYYY-MM-` (today’s year-month); if a section exists, append bullets under `### Added` / `### Changed` / `### Fixed` there and set that header’s date to today — do **not** add a second `##` for the same month. A new ship day is not a new section.

## [1.0.0-ci] - 2026-11-06
## [1.0.0-ci] - 2026-11-07

### Added

- **Navbar game search:** a magnifying glass in the navbar finds games by title (or designer) from any page and opens the game page, with keyboard navigation.
- **Game wishlist:** anyone signed in can **add a missing cover image** on a wishlist entry’s detail page; **admins** can **replace** the single cover image. (Requires matching `feedback_update` rules on the API.)
- **Explore (all games):** quick **filter by tag** dropdown plus **Advanced filters** modal (combine goals, mechanics, board, and other tags with any/all/none; optional OR groups; global exclusions). Tag filters can be bookmarked via URL query parameters (`t`, `tf`, and related keys). Name, designer, star, and recommended filters are unchanged.
- **Site statistics (games tab):** game statistics and pie rule tables include a **Min. records** control to hide rows below a chosen record count (remembered per browser).
Expand Down
4 changes: 4 additions & 0 deletions docs/subsystems/explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ The explore page lists available games and meta-games.

Legacy bookmarks to `/games/:mode` where `:mode` is a catalog view slug redirect to `/explore/:mode`.

## Navbar game search

The magnifying glass in the navbar ([`GameSearch.js`](../src/components/GameSearch.js)) is the quick route to a game page. It filters the public catalog with `filterGameOptions` (the matching used by the game picker) and orders results with `rankGameSearchResults`, so title matches come first. Selecting a result navigates to `/games/:metaGame`.

## Key files

| File | Role |
Expand Down
4 changes: 4 additions & 0 deletions public/locales/en/apfront.json
Original file line number Diff line number Diff line change
Expand Up @@ -1245,6 +1245,7 @@
"toggleDarkMode": "Toggle dark mode",
"toggleLightMode": "Toggle light mode",
"notifications": "Notifications",
"searchGames": "Search games",
"moveUp": "move up",
"moveDown": "move down"
},
Expand Down Expand Up @@ -2392,6 +2393,9 @@
"recent": "Recent opponents"
}
},
"gameSearch": {
"placeholder": "Search games…"
},
"gamePicker": {
"allGames": "All games",
"choose": "Choose a game…",
Expand Down
160 changes: 160 additions & 0 deletions src/components/GameSearch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate } from "react-router-dom";
import NavDropdownPanel from "./NavDropdownPanel";
import {
buildGameBrowseEntries,
filterGameOptions,
rankGameSearchResults,
} from "../lib/gameOptions";

/** Navbar magnifying glass: find a game by title and jump to its page. */
function GameSearch({ closeBurger }) {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
// The navbar renders one instance for desktop and one for mobile.
const listId = useId();
const triggerRef = useRef(null);
const listRef = useRef(null);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [activeIndex, setActiveIndex] = useState(0);

const games = useMemo(
() => (open ? buildGameBrowseEntries({ locale: i18n.language }) : []),
[open, i18n.language]
);
const results = useMemo(
() => rankGameSearchResults(filterGameOptions(games, { query }), query),
[games, query]
);
const hasQuery = query.trim() !== "";

useEffect(() => {
listRef.current?.children[activeIndex]?.scrollIntoView?.({
block: "nearest",
});
}, [activeIndex]);

const close = useCallback(() => {
setOpen(false);
setQuery("");
setActiveIndex(0);
}, []);

const toggle = () => {
if (open) {
close();
} else {
closeBurger();
setOpen(true);
}
};

const handleKeyDown = (event) => {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (results.length > 0) {
const step = event.key === "ArrowDown" ? 1 : -1;
setActiveIndex(
(index) => (index + step + results.length) % results.length
);
}
} else if (event.key === "Enter" && results[activeIndex]) {
event.preventDefault();
navigate(`/games/${results[activeIndex].id}`);
close();
} else if (event.key === "Escape") {
close();
triggerRef.current?.focus();
}
};

return (
<NavDropdownPanel open={open} onClose={close}>
<button
ref={triggerRef}
type="button"
className="nav-icon-btn"
aria-label={t("a11y.searchGames")}
aria-expanded={open}
title={t("a11y.searchGames")}
onClick={toggle}
>
<span className="icon">
<i className="fa fa-search" aria-hidden="true"></i>
</span>
</button>
{open ? (
<div className="nav-dropdown-panel game-search-panel">
<input
className="input"
type="search"
role="combobox"
aria-label={t("a11y.searchGames")}
aria-autocomplete="list"
aria-expanded={hasQuery && results.length > 0}
aria-controls={listId}
aria-activedescendant={
results[activeIndex] ? `${listId}-${activeIndex}` : undefined
}
autoComplete="off"
autoFocus
placeholder={t("gameSearch.placeholder")}
value={query}
onChange={(event) => {
setQuery(event.target.value);
setActiveIndex(0);
}}
onKeyDown={handleKeyDown}
/>
<ul
ref={listRef}
id={listId}
className="game-search-results"
role="listbox"
aria-label={t("a11y.searchGames")}
>
{results.map((game, index) => (
<li key={game.id} role="presentation">
<Link
id={`${listId}-${index}`}
to={`/games/${game.id}`}
className={
index === activeIndex
? "game-search-result is-active"
: "game-search-result"
}
role="option"
aria-selected={index === activeIndex}
tabIndex={-1}
onClick={close}
onMouseMove={() => setActiveIndex(index)}
>
<span className="game-search-result-name">{game.name}</span>
{game.designers ? (
<span className="game-search-result-designers">
{game.designers}
</span>
) : null}
</Link>
</li>
))}
</ul>
{hasQuery && results.length === 0 ? (
<p className="game-search-empty">{t("gamePicker.noResults")}</p>
) : null}
</div>
) : null}
</NavDropdownPanel>
);
}

export default GameSearch;
125 changes: 125 additions & 0 deletions src/components/GameSearch.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import GameSearch from "./GameSearch";

vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal()),
useTranslation: () => ({
t: (key) => key,
i18n: { language: "en" },
}),
}));

vi.mock("../lib/gameOptions", async (importOriginal) => ({
...(await importOriginal()),
buildGameBrowseEntries: () => [
{ id: "cargo", name: "Cargo", designers: "Ann" },
{ id: "go", name: "Go", designers: "" },
{ id: "gorgon", name: "Gorgon", designers: "Bea" },
],
}));

function CurrentPath() {
return <span data-testid="path">{useLocation().pathname}</span>;
}

function setup(closeBurger = vi.fn()) {
render(
<MemoryRouter initialEntries={["/"]}>
<GameSearch closeBurger={closeBurger} />
<CurrentPath />
</MemoryRouter>
);
fireEvent.click(screen.getByRole("button", { name: "a11y.searchGames" }));
return { input: screen.getByRole("combobox"), closeBurger };
}

const path = () => screen.getByTestId("path").textContent;
const type = (input, value) => fireEvent.change(input, { target: { value } });

describe("GameSearch", () => {
it("opens a focused search box and closes the burger menu", () => {
const { input, closeBurger } = setup();

expect(input).toHaveFocus();
expect(closeBurger).toHaveBeenCalled();
expect(screen.queryAllByRole("option")).toHaveLength(0);
});

it("lists matching games as links, best title match first", () => {
const { input } = setup();

type(input, "go");

const options = screen.getAllByRole("option");
expect(options.map((o) => o.getAttribute("href"))).toEqual([
"/games/go",
"/games/gorgon",
"/games/cargo",
]);
});

it("navigates to the top result on Enter and closes", () => {
const { input } = setup();

type(input, "go");
fireEvent.keyDown(input, { key: "Enter" });

expect(path()).toBe("/games/go");
expect(screen.queryByRole("combobox")).toBeNull();
});

it("moves the selection with the arrow keys", () => {
const { input } = setup();

type(input, "go");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });

expect(path()).toBe("/games/gorgon");
});

it("wraps from the first result to the last", () => {
const { input } = setup();

type(input, "go");
fireEvent.keyDown(input, { key: "ArrowUp" });
fireEvent.keyDown(input, { key: "Enter" });

expect(path()).toBe("/games/cargo");
});

it("navigates when a result is clicked", () => {
const { input } = setup();

type(input, "gorgon");
fireEvent.click(screen.getByRole("option", { name: /Gorgon/ }));

expect(path()).toBe("/games/gorgon");
expect(screen.queryByRole("combobox")).toBeNull();
});

it("shows an empty state and ignores Enter when nothing matches", () => {
const { input } = setup();

type(input, "zzz");
fireEvent.keyDown(input, { key: "Enter" });

expect(screen.getByText("gamePicker.noResults")).toBeInTheDocument();
expect(path()).toBe("/");
});

it("closes on Escape, clears the query and refocuses the button", () => {
const { input } = setup();
const button = screen.getByRole("button", { name: "a11y.searchGames" });

type(input, "go");
fireEvent.keyDown(input, { key: "Escape" });

expect(screen.queryByRole("combobox")).toBeNull();
expect(button).toHaveFocus();
fireEvent.click(button);
expect(screen.getByRole("combobox")).toHaveValue("");
});
});
5 changes: 5 additions & 0 deletions src/components/Navbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import logoLight from "../assets/AbstractPlayLogo-light.svg";
import logoDark from "../assets/AbstractPlayLogo-dark.svg";
import GameSearch from "./GameSearch";
import ProfileMenu from "./ProfileMenu";
import NotificationBell from "./NotificationBell";
import { useStorageState } from "react-use-storage-state";
Expand Down Expand Up @@ -82,6 +83,7 @@ function Navbar() {
</Link>
</div>
<div className="navbar-brand-actions navbar-icon-cluster-mobile">
<GameSearch closeBurger={closeBurger} />
{loggedin && globalMe !== null ? (
<NotificationBell closeBurger={closeBurger} />
) : null}
Expand Down Expand Up @@ -246,6 +248,9 @@ function Navbar() {
</NavbarHoverDropdown>
</div>
<div className="navbar-end">
<div className="navbar-item navbar-search-desktop">
<GameSearch closeBurger={closeBurger} />
</div>
{loggedin ? (
<>
<div className="navbar-item navbar-dark-mode-desktop">
Expand Down
Loading