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 navigate = useNavigate();
const [game, setGame] = useState<Game | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(!!title);
const [error, setError] = useState<string | null>(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 () => {

View File

@ -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 <LoadingState message="Loading people..." />;

View File

@ -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<Opinion[]>([]);
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) {
<input
type="text"
value={title}
onChange={(e) => 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 && (
<div style={{ color: "#f44336", fontSize: "0.75rem", marginTop: "0.25rem" }}>
{titleError}
</div>
)}
</div>
<div style={inputGroupStyles}>
@ -368,9 +411,15 @@ export function GameList({ onShowToast }: Props) {
<input
type="number"
value={minPlayers}
onChange={(e) => 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"
/>
</div>
@ -379,13 +428,24 @@ export function GameList({ onShowToast }: Props) {
<input
type="number"
value={maxPlayers}
onChange={(e) => 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"
/>
</div>
</div>
{playersError && (
<div style={{ color: "#f44336", fontSize: "0.75rem", marginTop: "0.25rem" }}>
{playersError}
</div>
)}
</div>
<div style={dividerStyles}></div>
@ -441,33 +501,47 @@ export function GameList({ onShowToast }: Props) {
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
style={submitButtonStyles}
className="submit-btn"
>
{isSubmitting ? (
<>
<span
style={{
width: "18px",
height: "18px",
border: "2px solid rgba(255,255,255,0.3)",
borderTopColor: "white",
borderRadius: "50%",
animation: "spin 0.8s linear infinite",
}}
></span>
Adding Game...
</>
) : (
<>
<span style={{ fontSize: "1.1rem" }}></span>
Add Game to Collection
</>
)}
</button>
<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
type="submit"
disabled={isSubmitting}
style={submitButtonStyles}
className="submit-btn"
>
{isSubmitting ? (
<>
<span
style={{
width: "18px",
height: "18px",
border: "2px solid rgba(255,255,255,0.3)",
borderTopColor: "white",
borderRadius: "50%",
animation: "spin 0.8s linear infinite",
}}
></span>
Adding Game...
</>
) : (
<>
<span style={{ fontSize: "1.1rem" }}></span>
Add Game to Collection
</>
)}
</button>
</div>
</form>
</div>
</div>

View File

@ -8,34 +8,27 @@ import { LoadingState, EmptyState } from "./components/EmptyState";
export const PersonDetails = () => {
const { name } = useParams<{ name: string }>();
const [person, setPerson] = useState<Person | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(!!name);
const [error, setError] = useState<string | null>(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 <LoadingState message="Loading person details..." />;

View File

@ -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 (
<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 (
<div>
@ -66,29 +75,12 @@ export function FilteredGamesList({
})}
</ul>
) : (
<EmptyState />
<EmptyState
icon="🔍"
title="No games found"
description="Try selecting fewer people or adding more opinions"
/>
)}
</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 './index.css'
import App from './App.tsx'
import { ErrorBoundary } from './ErrorBoundary'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)