better errors and refactoring
This commit is contained in:
parent
e7fede576c
commit
2ee08dc7d8
48
frontend/src/ErrorBoundary.tsx
Normal file
48
frontend/src/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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 () => {
|
||||
|
||||
@ -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..." />;
|
||||
|
||||
@ -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,6 +501,19 @@ export function GameList({ onShowToast }: Props) {
|
||||
</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
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
@ -468,6 +541,7 @@ export function GameList({ onShowToast }: Props) {
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 (!name) return;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch(`/api/${name}`);
|
||||
if (!res.ok) {
|
||||
throw new Error("Person not found");
|
||||
}
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buffer) => {
|
||||
try {
|
||||
const buffer = await res.arrayBuffer();
|
||||
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));
|
||||
setError(e instanceof Error ? e.message : "Failed to load person data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [name]);
|
||||
|
||||
if (loading) return <LoadingState message="Loading person details..." />;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user