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 { 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 () => {
|
||||||
|
|||||||
@ -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..." />;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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..." />;
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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>,
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user