diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2c168e2..f156284 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -172,7 +172,7 @@ function App() { - } /> + } /> } /> } /> } /> diff --git a/frontend/src/GameDetails.tsx b/frontend/src/GameDetails.tsx index db45b98..a3359a0 100644 --- a/frontend/src/GameDetails.tsx +++ b/frontend/src/GameDetails.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { Game, Source } from "../items"; import { apiFetch, get_is_admin } from "./api"; +import { LoadingState, EmptyState, ErrorState } from "./components/EmptyState"; interface Props { onShowToast?: (message: string, type?: "success" | "error" | "info") => void; @@ -12,25 +13,37 @@ export function GameDetails({ onShowToast }: Props) { const navigate = useNavigate(); const [game, setGame] = useState(null); const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); const isAdmin = get_is_admin(); useEffect(() => { - if (!title) return; + if (!title) { + setError("Game title is missing"); + setLoading(false); + return; + } + setLoading(true); + setError(null); apiFetch(`/api/game/${encodeURIComponent(title)}`) .then(async (res) => { - if (!res.ok) throw new Error("Game not found"); + 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); - setLoading(false); }) .catch((err) => { console.error(err); - setLoading(false); - }); + setError(err.message || "Failed to load game"); + }) + .finally(() => setLoading(false)); }, [title]); const handleDelete = async () => { @@ -59,8 +72,9 @@ export function GameDetails({ onShowToast }: Props) { } }; - if (loading) return
Loading...
; - if (!game) return
Game not found
; + if (loading) return ; + if (error) return window.location.reload()} />; + if (!game) return ; const getExternalLink = () => { if (game.source === Source.STEAM) { diff --git a/frontend/src/GameFilter.tsx b/frontend/src/GameFilter.tsx index d80fd6d..8220f47 100644 --- a/frontend/src/GameFilter.tsx +++ b/frontend/src/GameFilter.tsx @@ -5,9 +5,11 @@ import "./GameFilter.css"; import { useGameFilter } from "./hooks/useGameFilter"; import { PersonSelector } from "./components/PersonSelector"; import { FilteredGamesList } from "./components/FilteredGamesList"; +import { LoadingState } from "./components/EmptyState"; export function GameFilter() { const [people, setPeople] = useState([]); + const [loading, setLoading] = useState(true); const [selectedPeople, setSelectedPeople] = useState>(new Set()); const { filteredGames, gameToPositive } = useGameFilter( @@ -16,15 +18,19 @@ export function GameFilter() { ); useEffect(() => { + setLoading(true); apiFetch("/api") .then((res) => res.arrayBuffer()) .then((buffer) => { const list = PersonListProto.decode(new Uint8Array(buffer)); setPeople(list.person); }) - .catch((err) => console.error("Failed to fetch people:", err)); + .catch((err) => console.error("Failed to fetch people:", err)) + .finally(() => setLoading(false)); }, []); + if (loading) return ; + const togglePerson = (name: string) => { const newSelected = new Set(selectedPeople); if (newSelected.has(name)) { diff --git a/frontend/src/GameList.tsx b/frontend/src/GameList.tsx index 042295e..7794366 100644 --- a/frontend/src/GameList.tsx +++ b/frontend/src/GameList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { Game, Source, @@ -12,6 +12,7 @@ import { Link, useLocation } from "react-router-dom"; import { apiFetch, get_auth_status } from "./api"; import { GameImage } from "./GameImage"; import type { ToastType } from "./Toast"; +import { EmptyState } from "./components/EmptyState"; interface Props { onShowToast?: (message: string, type?: ToastType) => void; @@ -28,8 +29,18 @@ export function GameList({ onShowToast }: Props) { const [isSubmitting, setIsSubmitting] = useState(false); const [remoteIdError, setRemoteIdError] = useState(""); const [opinions, setOpinions] = useState([]); + const [gamesLoading, setGamesLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(""); + + const filteredGames = useMemo(() => { + if (!searchQuery) return games; + return games.filter(game => + game.title.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [games, searchQuery]); const fetchGames = () => { + setGamesLoading(true); apiFetch("/api/games") .then((res) => res.arrayBuffer()) .then((buffer) => { @@ -38,9 +49,14 @@ export function GameList({ onShowToast }: Props) { setGames(list.games); } catch (e) { console.error("Failed to decode games:", e); + onShowToast?.("Failed to load games", "error"); } }) - .catch(console.error); + .catch((err) => { + console.error(err); + onShowToast?.("Failed to fetch games", "error"); + }) + .finally(() => setGamesLoading(false)); }; useEffect(() => { @@ -465,16 +481,89 @@ export function GameList({ onShowToast }: Props) {
-

- Existing Games -

-
    - {games.map((game) => { +

    + Existing Games {filteredGames.length > 0 && ({filteredGames.length})} +

    + setSearchQuery(e.target.value)} + style={{ + padding: "0.5rem 1rem", + fontSize: "0.9rem", + minWidth: "200px" + }} + /> +
+ {gamesLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} + +
+ ) : filteredGames.length === 0 ? ( + + ) : ( +
    + {filteredGames.map((game) => { const opinion = opinions.find((op) => op.title === game.title); function handleOpinion(title: string, number: number): void { if (number == 2) { @@ -628,6 +717,7 @@ export function GameList({ onShowToast }: Props) { ); })}
+ )} ); diff --git a/frontend/src/Login.tsx b/frontend/src/Login.tsx index fc32288..f09499c 100644 --- a/frontend/src/Login.tsx +++ b/frontend/src/Login.tsx @@ -9,13 +9,27 @@ export function Login({ onLogin }: LoginProps) { const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState<{ username?: string, password?: string }>({}); + + const validateForm = () => { + const errors: { username?: string, password?: string } = {}; + if (!username.trim()) errors.username = "Username is required"; + if (!password) errors.password = "Password is required"; + setFieldErrors(errors); + return Object.keys(errors).length === 0; + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); + if (!validateForm()) return; + + setIsSubmitting(true); + try { - const req = LoginRequest.create({ username, password }); + const req = LoginRequest.create({ username: username.trim(), password }); const body = LoginRequest.encode(req).finish(); const res = await fetch("/auth/login", { @@ -32,45 +46,109 @@ export function Login({ onLogin }: LoginProps) { if (response.success) { onLogin(response.token); } else { - setError(response.message); + setError(response.message || "Login failed"); } } catch (err) { console.error("Login error:", err); - setError("Failed to login"); + setError("An unexpected error occurred. Please try again."); + } finally { + setIsSubmitting(false); } }; return (
-

Login

+

🎮 Login

setUsername(e.target.value)} + onChange={(e) => { + setUsername(e.target.value); + if (fieldErrors.username) setFieldErrors({ ...fieldErrors, username: undefined }); + }} placeholder="Enter your username" + style={{ + borderColor: fieldErrors.username ? "#f44336" : undefined, + borderWidth: fieldErrors.username ? "2px" : undefined + }} /> + {fieldErrors.username && ( + + {fieldErrors.username} + + )}
setPassword(e.target.value)} + onChange={(e) => { + setPassword(e.target.value); + if (fieldErrors.password) setFieldErrors({ ...fieldErrors, password: undefined }); + }} placeholder="Enter your password" + style={{ + borderColor: fieldErrors.password ? "#f44336" : undefined, + borderWidth: fieldErrors.password ? "2px" : undefined + }} /> + {fieldErrors.password && ( + + {fieldErrors.password} + + )}
-
{error && ( -

- {error} -

+
+ ⚠️ {error} +
)} +
); } diff --git a/frontend/src/PersonDetails.tsx b/frontend/src/PersonDetails.tsx index 240b3d5..8a768d3 100644 --- a/frontend/src/PersonDetails.tsx +++ b/frontend/src/PersonDetails.tsx @@ -3,53 +3,78 @@ import { Link, useParams } from "react-router-dom"; import { Person } from "../items"; import { apiFetch } from "./api"; import { GameImage } from "./GameImage"; +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); useEffect(() => { if (name) { + setLoading(true); + setError(null); apiFetch(`/api/${name}`) - .then((res) => res.arrayBuffer()) + .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(console.error); + .catch((err) => { + console.error(err); + setError(err.message || "Failed to load person"); + }) + .finally(() => setLoading(false)); } }, [name]); - if (!person) return
Loading...
; + if (loading) return ; + if (error) return ; + if (!person) return ; return (

{person.name}

-
    - {person.opinion.map((op, i) => ( - - {op.title} + {person.opinion.length === 0 ? ( + + ) : ( +
      + {person.opinion.map((op, i) => ( + + {op.title} - - - ))} -
    + + + ))} +
+ )}
); diff --git a/frontend/src/PersonList.tsx b/frontend/src/PersonList.tsx index 54d164a..4a2a236 100644 --- a/frontend/src/PersonList.tsx +++ b/frontend/src/PersonList.tsx @@ -1,24 +1,36 @@ import { Person } from "../items"; import { Link } from "react-router-dom"; -import { useState } from "react"; +import { useState, useEffect, useMemo } from "react"; import { get_auth_status, refresh_state, get_is_admin } from "./api"; import type { ToastType } from "./Toast"; +import { EmptyState } from "./components/EmptyState"; import "./PersonList.css" interface Props { people: Person[]; + loading?: boolean; onShowToast?: (message: string, type?: ToastType) => void; } -export const PersonList = ({ people, onShowToast }: Props) => { +export const PersonList = ({ people, loading = false, onShowToast }: Props) => { const [current_user, set_current_user] = useState(""); const [isRefreshing, setIsRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); - get_auth_status().then((res) => { - if (res) { - set_current_user(res.username); - } - }); + useEffect(() => { + get_auth_status().then((res) => { + if (res) { + set_current_user(res.username); + } + }); + }, []); + + const filteredPeople = useMemo(() => { + if (!searchQuery) return people; + return people.filter(person => + person.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [people, searchQuery]); const handleRefresh = async () => { setIsRefreshing(true); @@ -44,53 +56,136 @@ export const PersonList = ({ people, onShowToast }: Props) => { justifyContent: "space-between", alignItems: "center", marginBottom: "1rem", + flexWrap: "wrap", + gap: "1rem" }} > -

People List

- {isAdmin && ( - - )} + /> + {isAdmin && ( + + )} + -
- {people.map((person, index) => { - if (person.name.toLowerCase() === current_user.toLowerCase()) { + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+ ))} + +
+ ) : filteredPeople.length === 0 ? ( + + ) : ( +
+ {filteredPeople.map((person, index) => { + if (person.name.toLowerCase() === current_user.toLowerCase()) { + return ( + +

{person.name}

+
+ {person.opinion.length} opinion(s) +
+ + ); + } + return ( { }} >

{person.name}

+
+ {person.opinion.length} opinion(s) +
); - } - - return ( - -

{person.name}

- - ); - })} -
+ })} +
+ )} ); }; diff --git a/frontend/src/components/EmptyState.tsx b/frontend/src/components/EmptyState.tsx new file mode 100644 index 0000000..7b20e71 --- /dev/null +++ b/frontend/src/components/EmptyState.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from "react"; + +interface EmptyStateProps { + icon?: string; + title: string; + description?: string; + action?: ReactNode; +} + +export function EmptyState({ icon = "📭", title, description, action }: EmptyStateProps) { + return ( +
+
{icon}
+

{title}

+ {description &&

{description}

} + {action} +
+ ); +} + +export function LoadingState({ message = "Loading..." }: { message?: string }) { + return ( +
+
+

{message}

+ +
+ ); +} + +export function ErrorState({ message, onRetry }: { message: string, onRetry?: () => void }) { + return ( +
+
⚠️
+

Something went wrong

+

{message}

+ {onRetry && ( + + )} +
+ ); +} diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx new file mode 100644 index 0000000..abf2f28 --- /dev/null +++ b/frontend/src/components/LoadingSpinner.tsx @@ -0,0 +1,28 @@ +export function LoadingSpinner({ size = "medium", text }: { size?: "small" | "medium" | "large", text?: string }) { + const sizeMap = { + small: "16px", + medium: "24px", + large: "32px" + }; + + return ( +
+
+ {text && {text}} + +
+ ); +} diff --git a/frontend/src/components/SkeletonLoader.tsx b/frontend/src/components/SkeletonLoader.tsx new file mode 100644 index 0000000..04fd221 --- /dev/null +++ b/frontend/src/components/SkeletonLoader.tsx @@ -0,0 +1,65 @@ +export function SkeletonLoader({ width, height, count = 1 }: { width?: string | number, height?: string | number, count?: number }) { + return ( + <> + {Array.from({ length: count }).map((_, i) => ( +
1 ? "1rem" : undefined + }} + >
+ ))} + + + ); +} + +export function CardSkeleton() { + return ( +
+
+
+ +
+ ); +}