diff --git a/frontend/src/ErrorBoundary.tsx b/frontend/src/ErrorBoundary.tsx new file mode 100644 index 0000000..a65f02c --- /dev/null +++ b/frontend/src/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { ErrorState } from "./components/EmptyState"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + error: null + }; + + public static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ErrorBoundary caught an error:", error, errorInfo); + } + + public handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + public render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( + + ); + } + + return this.props.children; + } +} diff --git a/frontend/src/GameDetails.tsx b/frontend/src/GameDetails.tsx index a3359a0..c84e237 100644 --- a/frontend/src/GameDetails.tsx +++ b/frontend/src/GameDetails.tsx @@ -12,38 +12,32 @@ export function GameDetails({ onShowToast }: Props) { const { title } = useParams<{ title: string }>(); const navigate = useNavigate(); const [game, setGame] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loading, setLoading] = useState(!!title); + const [error, setError] = useState(title ? null : "Game title is missing"); const isAdmin = get_is_admin(); useEffect(() => { - if (!title) { - setError("Game title is missing"); - setLoading(false); - return; - } + if (!title) return; - setLoading(true); - setError(null); - apiFetch(`/api/game/${encodeURIComponent(title)}`) - .then(async (res) => { + (async () => { + try { + const res = await apiFetch(`/api/game/${encodeURIComponent(title)}`); if (!res.ok) { if (res.status === 404) { throw new Error("Game not found"); } throw new Error("Failed to load game"); } - return Game.decode(new Uint8Array(await res.arrayBuffer())); - }) - .then((data) => { - setGame(data); - }) - .catch((err) => { + const buffer = await res.arrayBuffer(); + setGame(Game.decode(new Uint8Array(buffer))); + } catch (err) { console.error(err); - setError(err.message || "Failed to load game"); - }) - .finally(() => setLoading(false)); + setError(err instanceof Error ? err.message : "Failed to load game"); + } finally { + setLoading(false); + } + })(); }, [title]); const handleDelete = async () => { diff --git a/frontend/src/GameFilter.tsx b/frontend/src/GameFilter.tsx index 8220f47..06fe709 100644 --- a/frontend/src/GameFilter.tsx +++ b/frontend/src/GameFilter.tsx @@ -18,15 +18,18 @@ export function GameFilter() { ); useEffect(() => { - setLoading(true); - apiFetch("/api") - .then((res) => res.arrayBuffer()) - .then((buffer) => { + (async () => { + try { + const res = await apiFetch("/api"); + const buffer = await res.arrayBuffer(); const list = PersonListProto.decode(new Uint8Array(buffer)); setPeople(list.person); - }) - .catch((err) => console.error("Failed to fetch people:", err)) - .finally(() => setLoading(false)); + } catch (err) { + console.error("Failed to fetch people:", err); + } finally { + setLoading(false); + } + })(); }, []); if (loading) return ; diff --git a/frontend/src/GameList.tsx b/frontend/src/GameList.tsx index 7794366..98d1aea 100644 --- a/frontend/src/GameList.tsx +++ b/frontend/src/GameList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { Game, Source, @@ -27,6 +27,8 @@ export function GameList({ onShowToast }: Props) { const [price, setPrice] = useState(0); const [remoteId, setRemoteId] = useState(0); const [isSubmitting, setIsSubmitting] = useState(false); + const [titleError, setTitleError] = useState(""); + const [playersError, setPlayersError] = useState(""); const [remoteIdError, setRemoteIdError] = useState(""); const [opinions, setOpinions] = useState([]); const [gamesLoading, setGamesLoading] = useState(true); @@ -39,7 +41,7 @@ export function GameList({ onShowToast }: Props) { ); }, [games, searchQuery]); - const fetchGames = () => { + const fetchGames = useCallback(() => { setGamesLoading(true); apiFetch("/api/games") .then((res) => res.arrayBuffer()) @@ -57,7 +59,7 @@ export function GameList({ onShowToast }: Props) { onShowToast?.("Failed to fetch games", "error"); }) .finally(() => setGamesLoading(false)); - }; + }, [onShowToast]); useEffect(() => { get_auth_status().then((user) => { @@ -92,20 +94,42 @@ export function GameList({ onShowToast }: Props) { useEffect(() => { fetchGames(); - }, []); + }, [fetchGames]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (remoteId === 0) { - setRemoteIdError("Remote ID must be greater than 0"); - return; + setTitleError(""); + setPlayersError(""); + setRemoteIdError(""); + + let hasErrors = false; + + if (!title.trim()) { + setTitleError("Game title is required"); + hasErrors = true; } - setRemoteIdError(""); + if (minPlayers < 1) { + setPlayersError("Minimum players must be at least 1"); + hasErrors = true; + } + + if (maxPlayers < minPlayers) { + setPlayersError("Maximum players cannot be less than minimum players"); + hasErrors = true; + } + + if (remoteId === 0) { + setRemoteIdError("Remote ID must be greater than 0"); + hasErrors = true; + } + + if (hasErrors) return; + setIsSubmitting(true); const game = { - title, + title: title.trim(), source, minPlayers, maxPlayers, @@ -125,14 +149,11 @@ export function GameList({ onShowToast }: Props) { if (res.ok) { onShowToast?.("Game added successfully!", "success"); - setTitle(""); - setMinPlayers(1); - setMaxPlayers(1); - setPrice(0); - setRemoteId(0); + clearForm(); fetchGames(); } else { - onShowToast?.("Failed to add game. Please try again.", "error"); + const errorText = await res.text(); + onShowToast?.(errorText || "Failed to add game. Please try again.", "error"); } } catch (err) { console.error(err); @@ -142,6 +163,17 @@ export function GameList({ onShowToast }: Props) { } }; + const clearForm = () => { + setTitle(""); + setMinPlayers(1); + setMaxPlayers(1); + setPrice(0); + setRemoteId(0); + setTitleError(""); + setPlayersError(""); + setRemoteIdError(""); + }; + const formCardStyles: React.CSSProperties = { background: "linear-gradient(135deg, var(--secondary-bg) 0%, var(--secondary-alt-bg) 100%)", @@ -323,12 +355,23 @@ export function GameList({ onShowToast }: Props) { setTitle(e.target.value)} + onChange={(e) => { + setTitle(e.target.value); + if (titleError) setTitleError(""); + }} required placeholder="Enter game title..." - style={inputStyles} + style={{ + ...inputStyles, + borderColor: titleError ? "#f44336" : undefined + }} className="add-game-input" /> + {titleError && ( +
+ {titleError} +
+ )}
@@ -368,9 +411,15 @@ export function GameList({ onShowToast }: Props) { setMinPlayers(Number(e.target.value))} + onChange={(e) => { + setMinPlayers(Number(e.target.value)); + if (playersError) setPlayersError(""); + }} min="1" - style={inputStyles} + style={{ + ...inputStyles, + borderColor: playersError ? "#f44336" : undefined + }} className="add-game-input" />
@@ -379,13 +428,24 @@ export function GameList({ onShowToast }: Props) { setMaxPlayers(Number(e.target.value))} + onChange={(e) => { + setMaxPlayers(Number(e.target.value)); + if (playersError) setPlayersError(""); + }} min="1" - style={inputStyles} + style={{ + ...inputStyles, + borderColor: playersError ? "#f44336" : undefined + }} className="add-game-input" /> + {playersError && ( +
+ {playersError} +
+ )}
@@ -441,33 +501,47 @@ export function GameList({ onShowToast }: Props) { - +
+ + +
diff --git a/frontend/src/PersonDetails.tsx b/frontend/src/PersonDetails.tsx index 8a768d3..f84beb3 100644 --- a/frontend/src/PersonDetails.tsx +++ b/frontend/src/PersonDetails.tsx @@ -8,34 +8,27 @@ import { LoadingState, EmptyState } from "./components/EmptyState"; export const PersonDetails = () => { const { name } = useParams<{ name: string }>(); const [person, setPerson] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const [loading, setLoading] = useState(!!name); + const [error, setError] = useState(name ? null : "Person name is missing"); useEffect(() => { - if (name) { - setLoading(true); - setError(null); - apiFetch(`/api/${name}`) - .then((res) => { - if (!res.ok) { - throw new Error("Person not found"); - } - return res.arrayBuffer(); - }) - .then((buffer) => { - try { - setPerson(Person.decode(new Uint8Array(buffer))); - } catch (e) { - console.error("Failed to decode person:", e); - throw new Error("Failed to load person data"); - } - }) - .catch((err) => { - console.error(err); - setError(err.message || "Failed to load person"); - }) - .finally(() => setLoading(false)); - } + if (!name) return; + + (async () => { + try { + const res = await apiFetch(`/api/${name}`); + if (!res.ok) { + throw new Error("Person not found"); + } + const buffer = await res.arrayBuffer(); + setPerson(Person.decode(new Uint8Array(buffer))); + } catch (e) { + console.error("Failed to decode person:", e); + setError(e instanceof Error ? e.message : "Failed to load person data"); + } finally { + setLoading(false); + } + })(); }, [name]); if (loading) return ; diff --git a/frontend/src/components/FilteredGamesList.tsx b/frontend/src/components/FilteredGamesList.tsx index f6845d2..f8079db 100644 --- a/frontend/src/components/FilteredGamesList.tsx +++ b/frontend/src/components/FilteredGamesList.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router-dom"; import { GameImage } from "../GameImage"; +import { EmptyState } from "./EmptyState"; interface FilteredGamesListProps { filteredGames: string[]; @@ -12,7 +13,15 @@ export function FilteredGamesList({ gameToPositive, selectedPeopleCount, }: FilteredGamesListProps) { - if (selectedPeopleCount === 0) return null; + if (selectedPeopleCount === 0) { + return ( + + ); + } return (
@@ -66,29 +75,12 @@ export function FilteredGamesList({ })} ) : ( - + )}
); } - -function EmptyState() { - return ( -
-
🔍
-

No games found where all selected people would play.

-

- Try selecting fewer people or adding more opinions! -

-
- ); -} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index bef5202..7db3c5e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,9 +2,12 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { ErrorBoundary } from './ErrorBoundary' createRoot(document.getElementById('root')!).render( - + + + , )