better errors and refactoring

This commit is contained in:
code002lover 2026-01-11 21:24:18 +01:00
parent e7fede576c
commit 2ee08dc7d8
7 changed files with 232 additions and 125 deletions

View File

@ -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<Props, State> {
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 (
<ErrorState
message={this.state.error?.message || "An unexpected error occurred"}
onRetry={this.handleReset}
/>
);
}
return this.props.children;
}
}

View File

@ -12,38 +12,32 @@ export function GameDetails({ onShowToast }: Props) {
const { title } = useParams<{ title: string }>(); const { title } = useParams<{ title: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [game, setGame] = useState<Game | null>(null); const [game, setGame] = useState<Game | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(!!title);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(title ? null : "Game title is missing");
const isAdmin = get_is_admin(); const isAdmin = get_is_admin();
useEffect(() => { useEffect(() => {
if (!title) { if (!title) return;
setError("Game title is missing");
setLoading(false);
return;
}
setLoading(true); (async () => {
setError(null); try {
apiFetch(`/api/game/${encodeURIComponent(title)}`) const res = await apiFetch(`/api/game/${encodeURIComponent(title)}`);
.then(async (res) => {
if (!res.ok) { if (!res.ok) {
if (res.status === 404) { if (res.status === 404) {
throw new Error("Game not found"); throw new Error("Game not found");
} }
throw new Error("Failed to load game"); throw new Error("Failed to load game");
} }
return Game.decode(new Uint8Array(await res.arrayBuffer())); const buffer = await res.arrayBuffer();
}) setGame(Game.decode(new Uint8Array(buffer)));
.then((data) => { } catch (err) {
setGame(data);
})
.catch((err) => {
console.error(err); console.error(err);
setError(err.message || "Failed to load game"); setError(err instanceof Error ? err.message : "Failed to load game");
}) } finally {
.finally(() => setLoading(false)); setLoading(false);
}
})();
}, [title]); }, [title]);
const handleDelete = async () => { const handleDelete = async () => {

View File

@ -18,15 +18,18 @@ export function GameFilter() {
); );
useEffect(() => { useEffect(() => {
setLoading(true); (async () => {
apiFetch("/api") try {
.then((res) => res.arrayBuffer()) const res = await apiFetch("/api");
.then((buffer) => { const buffer = await res.arrayBuffer();
const list = PersonListProto.decode(new Uint8Array(buffer)); const list = PersonListProto.decode(new Uint8Array(buffer));
setPeople(list.person); setPeople(list.person);
}) } catch (err) {
.catch((err) => console.error("Failed to fetch people:", err)) console.error("Failed to fetch people:", err);
.finally(() => setLoading(false)); } finally {
setLoading(false);
}
})();
}, []); }, []);
if (loading) return <LoadingState message="Loading people..." />; if (loading) return <LoadingState message="Loading people..." />;

View File

@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo, useCallback } from "react";
import { import {
Game, Game,
Source, Source,
@ -27,6 +27,8 @@ export function GameList({ onShowToast }: Props) {
const [price, setPrice] = useState(0); const [price, setPrice] = useState(0);
const [remoteId, setRemoteId] = useState(0); const [remoteId, setRemoteId] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [titleError, setTitleError] = useState("");
const [playersError, setPlayersError] = useState("");
const [remoteIdError, setRemoteIdError] = useState(""); const [remoteIdError, setRemoteIdError] = useState("");
const [opinions, setOpinions] = useState<Opinion[]>([]); const [opinions, setOpinions] = useState<Opinion[]>([]);
const [gamesLoading, setGamesLoading] = useState(true); const [gamesLoading, setGamesLoading] = useState(true);
@ -39,7 +41,7 @@ export function GameList({ onShowToast }: Props) {
); );
}, [games, searchQuery]); }, [games, searchQuery]);
const fetchGames = () => { const fetchGames = useCallback(() => {
setGamesLoading(true); setGamesLoading(true);
apiFetch("/api/games") apiFetch("/api/games")
.then((res) => res.arrayBuffer()) .then((res) => res.arrayBuffer())
@ -57,7 +59,7 @@ export function GameList({ onShowToast }: Props) {
onShowToast?.("Failed to fetch games", "error"); onShowToast?.("Failed to fetch games", "error");
}) })
.finally(() => setGamesLoading(false)); .finally(() => setGamesLoading(false));
}; }, [onShowToast]);
useEffect(() => { useEffect(() => {
get_auth_status().then((user) => { get_auth_status().then((user) => {
@ -92,20 +94,42 @@ export function GameList({ onShowToast }: Props) {
useEffect(() => { useEffect(() => {
fetchGames(); fetchGames();
}, []); }, [fetchGames]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (remoteId === 0) { setTitleError("");
setRemoteIdError("Remote ID must be greater than 0"); setPlayersError("");
return; 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); setIsSubmitting(true);
const game = { const game = {
title, title: title.trim(),
source, source,
minPlayers, minPlayers,
maxPlayers, maxPlayers,
@ -125,14 +149,11 @@ export function GameList({ onShowToast }: Props) {
if (res.ok) { if (res.ok) {
onShowToast?.("Game added successfully!", "success"); onShowToast?.("Game added successfully!", "success");
setTitle(""); clearForm();
setMinPlayers(1);
setMaxPlayers(1);
setPrice(0);
setRemoteId(0);
fetchGames(); fetchGames();
} else { } 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) { } catch (err) {
console.error(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 = { const formCardStyles: React.CSSProperties = {
background: background:
"linear-gradient(135deg, var(--secondary-bg) 0%, var(--secondary-alt-bg) 100%)", "linear-gradient(135deg, var(--secondary-bg) 0%, var(--secondary-alt-bg) 100%)",
@ -323,12 +355,23 @@ export function GameList({ onShowToast }: Props) {
<input <input
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => {
setTitle(e.target.value);
if (titleError) setTitleError("");
}}
required required
placeholder="Enter game title..." placeholder="Enter game title..."
style={inputStyles} style={{
...inputStyles,
borderColor: titleError ? "#f44336" : undefined
}}
className="add-game-input" className="add-game-input"
/> />
{titleError && (
<div style={{ color: "#f44336", fontSize: "0.75rem", marginTop: "0.25rem" }}>
{titleError}
</div>
)}
</div> </div>
<div style={inputGroupStyles}> <div style={inputGroupStyles}>
@ -368,9 +411,15 @@ export function GameList({ onShowToast }: Props) {
<input <input
type="number" type="number"
value={minPlayers} value={minPlayers}
onChange={(e) => setMinPlayers(Number(e.target.value))} onChange={(e) => {
setMinPlayers(Number(e.target.value));
if (playersError) setPlayersError("");
}}
min="1" min="1"
style={inputStyles} style={{
...inputStyles,
borderColor: playersError ? "#f44336" : undefined
}}
className="add-game-input" className="add-game-input"
/> />
</div> </div>
@ -379,13 +428,24 @@ export function GameList({ onShowToast }: Props) {
<input <input
type="number" type="number"
value={maxPlayers} value={maxPlayers}
onChange={(e) => setMaxPlayers(Number(e.target.value))} onChange={(e) => {
setMaxPlayers(Number(e.target.value));
if (playersError) setPlayersError("");
}}
min="1" min="1"
style={inputStyles} style={{
...inputStyles,
borderColor: playersError ? "#f44336" : undefined
}}
className="add-game-input" className="add-game-input"
/> />
</div> </div>
</div> </div>
{playersError && (
<div style={{ color: "#f44336", fontSize: "0.75rem", marginTop: "0.25rem" }}>
{playersError}
</div>
)}
</div> </div>
<div style={dividerStyles}></div> <div style={dividerStyles}></div>
@ -441,6 +501,19 @@ export function GameList({ onShowToast }: Props) {
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: "0.75rem", marginTop: "1rem" }}>
<button
type="button"
onClick={clearForm}
disabled={isSubmitting || (!title && minPlayers === 1 && maxPlayers === 1 && price === 0 && remoteId === 0)}
style={{
...submitButtonStyles,
background: "var(--tertiary-bg)",
flex: 1
}}
>
Clear
</button>
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
@ -468,6 +541,7 @@ export function GameList({ onShowToast }: Props) {
</> </>
)} )}
</button> </button>
</div>
</form> </form>
</div> </div>
</div> </div>

View File

@ -8,34 +8,27 @@ import { LoadingState, EmptyState } from "./components/EmptyState";
export const PersonDetails = () => { export const PersonDetails = () => {
const { name } = useParams<{ name: string }>(); const { name } = useParams<{ name: string }>();
const [person, setPerson] = useState<Person | null>(null); const [person, setPerson] = useState<Person | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(!!name);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(name ? null : "Person name is missing");
useEffect(() => { useEffect(() => {
if (name) { if (!name) return;
setLoading(true);
setError(null); (async () => {
apiFetch(`/api/${name}`) try {
.then((res) => { const res = await apiFetch(`/api/${name}`);
if (!res.ok) { if (!res.ok) {
throw new Error("Person not found"); throw new Error("Person not found");
} }
return res.arrayBuffer(); const buffer = await res.arrayBuffer();
})
.then((buffer) => {
try {
setPerson(Person.decode(new Uint8Array(buffer))); setPerson(Person.decode(new Uint8Array(buffer)));
} catch (e) { } catch (e) {
console.error("Failed to decode person:", e); console.error("Failed to decode person:", e);
throw new Error("Failed to load person data"); setError(e instanceof Error ? e.message : "Failed to load person data");
} } finally {
}) setLoading(false);
.catch((err) => {
console.error(err);
setError(err.message || "Failed to load person");
})
.finally(() => setLoading(false));
} }
})();
}, [name]); }, [name]);
if (loading) return <LoadingState message="Loading person details..." />; if (loading) return <LoadingState message="Loading person details..." />;

View File

@ -1,5 +1,6 @@
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { GameImage } from "../GameImage"; import { GameImage } from "../GameImage";
import { EmptyState } from "./EmptyState";
interface FilteredGamesListProps { interface FilteredGamesListProps {
filteredGames: string[]; filteredGames: string[];
@ -12,7 +13,15 @@ export function FilteredGamesList({
gameToPositive, gameToPositive,
selectedPeopleCount, selectedPeopleCount,
}: FilteredGamesListProps) { }: FilteredGamesListProps) {
if (selectedPeopleCount === 0) return null; if (selectedPeopleCount === 0) {
return (
<EmptyState
icon="👥"
title="Select people to find games"
description="Choose one or more people from the list above to see games they would play"
/>
);
}
return ( return (
<div> <div>
@ -66,29 +75,12 @@ export function FilteredGamesList({
})} })}
</ul> </ul>
) : ( ) : (
<EmptyState /> <EmptyState
icon="🔍"
title="No games found"
description="Try selecting fewer people or adding more opinions"
/>
)} )}
</div> </div>
); );
} }
function EmptyState() {
return (
<div
style={{
padding: "3rem",
textAlign: "center",
background: "var(--secondary-alt-bg)",
borderRadius: "16px",
border: "1px dashed var(--border-color)",
color: "var(--text-muted)",
}}
>
<div style={{ fontSize: "2rem", marginBottom: "1rem" }}>🔍</div>
<p>No games found where all selected people would play.</p>
<p style={{ fontSize: "0.9rem" }}>
Try selecting fewer people or adding more opinions!
</p>
</div>
);
}

View File

@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
import { ErrorBoundary } from './ErrorBoundary'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ErrorBoundary>
<App /> <App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
) )