Compare commits

...

3 Commits

Author SHA1 Message Date
Renovate Bot
3dd07cb6a6 fix(deps): update dependency react-router-dom to v7.12.0 2026-01-12 08:05:05 +00:00
d560b6db6f add extra filters 2026-01-12 08:57:42 +01:00
500be84f39 fix price error 2026-01-11 22:28:53 +01:00
7 changed files with 224 additions and 19 deletions

View File

@ -22,7 +22,7 @@ importers:
version: 19.2.1(react@19.2.1)
react-router-dom:
specifier: ^7.10.1
version: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
version: 7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
devDependencies:
'@eslint/js':
specifier: ^9.39.1
@ -425,7 +425,7 @@ packages:
resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
vite: npm:rolldown-vite@7.2.5
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
@ -872,15 +872,15 @@ packages:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
react-router-dom@7.10.1:
resolution: {integrity: sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==}
react-router-dom@7.12.0:
resolution: {integrity: sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.10.1:
resolution: {integrity: sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==}
react-router@7.12.0:
resolution: {integrity: sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
@ -1852,13 +1852,13 @@ snapshots:
react-refresh@0.18.0: {}
react-router-dom@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
react-router-dom@7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
react-router: 7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
react-router: 7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
react-router@7.10.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
react-router@7.12.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1):
dependencies:
cookie: 1.1.1
react: 19.2.1

View File

@ -333,7 +333,7 @@ export function EditGame({ onShowToast }: Props) {
<input
type="number"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
onChange={(e) => setPrice(Number(e.target.value.replace(',', '.')))}
min="0"
step="0.01"
style={inputStyles}

View File

@ -11,4 +11,82 @@
.gamefilter-entry:hover {
background-color: var(--primary-bg);
}
.filter-controls {
background-color: var(--secondary-alt-bg);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
border: 1px solid var(--border-color);
}
.filter-controls h3 {
margin: 0 0 1rem 0;
font-size: 1.1rem;
color: var(--text-color);
}
.filter-groups {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.filter-group label {
font-size: 0.9rem;
color: var(--text-color);
font-weight: 500;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-wrapper input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--accent-color);
}
.checkbox-wrapper span {
font-size: 0.9rem;
color: var(--text-color);
}
.price-input {
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--primary-bg);
color: var(--text-color);
width: 120px;
font-size: 0.9rem;
}
.price-input:focus {
outline: none;
border-color: var(--accent-color);
}
.tooltip-icon {
display: inline-block;
margin-left: 0.3rem;
cursor: help;
font-size: 0.9rem;
opacity: 0.6;
}
.tooltip-icon:hover {
opacity: 1;
}

View File

@ -11,10 +11,16 @@ export function GameFilter() {
const [people, setPeople] = useState<Person[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPeople, setSelectedPeople] = useState<Set<string>>(new Set());
const [freeGamesOnly, setFreeGamesOnly] = useState(false);
const [maxPrice, setMaxPrice] = useState<number | null>(null);
const [ownershipMode, setOwnershipMode] = useState(false);
const { filteredGames, gameToPositive } = useGameFilter(
const { filteredGames, gameToPositive, games } = useGameFilter(
people,
selectedPeople
selectedPeople,
freeGamesOnly,
maxPrice,
ownershipMode
);
useEffect(() => {
@ -57,10 +63,62 @@ export function GameFilter() {
onTogglePerson={togglePerson}
/>
<div className="filter-controls">
<h3>Additional Filters</h3>
<div className="filter-groups">
<div className="filter-group">
<label className="checkbox-wrapper">
<input
type="checkbox"
checked={freeGamesOnly}
onChange={(e) => setFreeGamesOnly(e.target.checked)}
/>
<span>Free games only</span>
</label>
</div>
<div className="filter-group">
<label htmlFor="max-price">Maximum price ($)</label>
<input
id="max-price"
type="number"
className="price-input"
min="0"
placeholder="No limit"
value={maxPrice ?? ""}
onChange={(e) => {
const value = e.target.value;
setMaxPrice(value === "" ? null : parseInt(value, 10));
}}
/>
</div>
<div className="filter-group">
<label className="checkbox-wrapper">
<input
type="checkbox"
checked={ownershipMode}
onChange={(e) => setOwnershipMode(e.target.checked)}
/>
<span>
Ownership mode
<span
className="tooltip-icon"
title="For paid games, only show if ALL selected people have marked it as wouldPlay"
>
?
</span>
</span>
</label>
</div>
</div>
</div>
<FilteredGamesList
filteredGames={filteredGames}
gameToPositive={gameToPositive}
selectedPeopleCount={selectedPeople.size}
games={games}
/>
</div>
);

View File

@ -463,7 +463,7 @@ export function GameList({ onShowToast }: Props) {
<input
type="number"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
onChange={(e) => setPrice(Number(e.target.value.replace(',', '.')))}
min="0"
step="0.01"
style={inputStyles}

View File

@ -1,17 +1,20 @@
import { Link } from "react-router-dom";
import { GameImage } from "../GameImage";
import { EmptyState } from "./EmptyState";
import { Game as GameProto } from "../../items";
interface FilteredGamesListProps {
filteredGames: string[];
gameToPositive: Map<string, Set<string>>;
selectedPeopleCount: number;
games: Map<string, GameProto>;
}
export function FilteredGamesList({
filteredGames,
gameToPositive,
selectedPeopleCount,
games,
}: FilteredGamesListProps) {
if (selectedPeopleCount === 0) {
return (
@ -31,6 +34,8 @@ export function FilteredGamesList({
{filteredGames.map((game) => {
const positiveCount = gameToPositive.get(game)?.size || 0;
const neutralCount = selectedPeopleCount - positiveCount;
const gameData = games.get(game);
const price = gameData?.price ?? 0;
return (
<Link
@ -68,6 +73,21 @@ export function FilteredGamesList({
{neutralCount > 1 ? "are" : "is"} neutral
</div>
)}
<div
className="price-badge"
style={{
fontSize: "0.85em",
marginTop: "0.5rem",
padding: "0.2rem 0.6rem",
borderRadius: "4px",
display: "inline-block",
backgroundColor: price === 0 ? "rgba(76, 175, 80, 0.2)" : "rgba(255, 152, 0, 0.2)",
color: price === 0 ? "#4caf50" : "#ff9800",
fontWeight: 600,
}}
>
${price === 0 ? "0 (Free)" : price}
</div>
</div>
<GameImage game={game} />
</Link>

View File

@ -7,7 +7,13 @@ import {
} from "../../items";
import { apiFetch } from "../api";
export function useGameFilter(people: Person[], selectedPeople: Set<string>) {
export function useGameFilter(
people: Person[],
selectedPeople: Set<string>,
freeGamesOnly: boolean,
maxPrice: number | null,
ownershipMode: boolean
) {
const [fetchedTitles, setFetchedTitles] = useState<string[]>([]);
const metaDataRef = useRef<{ [key: string]: GameProto }>({});
@ -79,20 +85,63 @@ export function useGameFilter(people: Person[], selectedPeople: Set<string>) {
.filter((title) => metaDataRef.current[title])
.map((title) => metaDataRef.current[title]);
return filterByPlayerCount(games, selectedPeople.size);
return filterGames(
games,
selectedPeople.size,
freeGamesOnly,
maxPrice,
ownershipMode,
selectedPeople,
people
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [titlesEveryoneWouldPlay, selectedPeople.size, fetchedTitles]);
}, [
titlesEveryoneWouldPlay,
selectedPeople.size,
fetchedTitles,
freeGamesOnly,
maxPrice,
ownershipMode,
people,
selectedPeople,
]);
return { filteredGames, gameToPositive: gameToPositiveOpinion };
const gamesMap = useMemo(() => {
return new Map(Object.entries(metaDataRef.current));
}, [fetchedTitles]);
return { filteredGames, gameToPositive: gameToPositiveOpinion, games: gamesMap };
}
function filterByPlayerCount(
function filterGames(
games: GameProto[],
playerCount: number
playerCount: number,
freeGamesOnly: boolean,
maxPrice: number | null,
ownershipMode: boolean,
selectedPeople: Set<string>,
people: Person[]
): string[] {
const selectedPersons = people.filter((p) => selectedPeople.has(p.name));
return games
.filter(
(game) => game.maxPlayers >= playerCount && game.minPlayers <= playerCount
)
.filter((game) => {
if (freeGamesOnly) return game.price === 0;
if (maxPrice !== null) return game.price <= maxPrice;
return true;
})
.filter((game) => {
if (!ownershipMode) return true;
if (game.price === 0) return true;
return selectedPersons.every((person) =>
person.opinion.some(
(op) => op.title === game.title && op.wouldPlay
)
);
})
.map((game) => game.title);
}