import { useState, useEffect, useMemo, useCallback } from "react"; import { Game, Source, GameList as GameListProto, Person as PersonProto, Opinion, AddOpinionRequest, RemoveOpinionRequest, } from "../items"; 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; } export function GameList({ onShowToast }: Props) { const [games, setGames] = useState([]); const [title, setTitle] = useState(""); const [source, setSource] = useState(Source.STEAM); const [minPlayers, setMinPlayers] = useState(1); const [maxPlayers, setMaxPlayers] = useState(1); 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([]); 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 = useCallback(() => { setGamesLoading(true); apiFetch("/api/games") .then((res) => res.arrayBuffer()) .then((buffer) => { try { const list = GameListProto.decode(new Uint8Array(buffer)); setGames(list.games); } catch (e) { console.error("Failed to decode games:", e); onShowToast?.("Failed to load games", "error"); } }) .catch((err) => { console.error(err); onShowToast?.("Failed to fetch games", "error"); }) .finally(() => setGamesLoading(false)); }, [onShowToast]); useEffect(() => { get_auth_status().then((user) => { apiFetch(`/api/${user?.username}`) .then((res) => res.arrayBuffer()) .then((buffer) => { try { const person = PersonProto.decode(new Uint8Array(buffer)); const opinions = person.opinion; setOpinions(opinions); } catch (e) { console.error("Failed to decode games:", e); } }); }); }, []); // Scroll to Existing Games section if hash is present const location = useLocation(); useEffect(() => { if (location.hash === "#existing-games") { const el = document.getElementById("existing-games"); if (el) { setTimeout(() => { el.scrollIntoView({ behavior: "smooth", block: "start" }); }, 100); } else { console.error("Element not found"); } } }, [location]); useEffect(() => { fetchGames(); }, [fetchGames]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setTitleError(""); setPlayersError(""); setRemoteIdError(""); let hasErrors = false; if (!title.trim()) { setTitleError("Game title is required"); hasErrors = true; } 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.trim(), source, minPlayers, maxPlayers, price, remoteId, }; try { const encoded = Game.encode(game).finish(); const res = await apiFetch("/api/game", { method: "POST", headers: { "Content-Type": "application/octet-stream", }, body: encoded, }); if (res.ok) { onShowToast?.("Game added successfully!", "success"); clearForm(); fetchGames(); } else { const errorText = await res.text(); onShowToast?.(errorText || "Failed to add game. Please try again.", "error"); } } catch (err) { console.error(err); onShowToast?.("An error occurred while adding the game.", "error"); } finally { setIsSubmitting(false); } }; 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%)", borderRadius: "20px", padding: "0", maxWidth: "520px", boxShadow: "0 20px 40px rgba(0, 0, 0, 0.3), 0 0 0 1px var(--border-color)", overflow: "hidden", }; const formHeaderStyles: React.CSSProperties = { background: "linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)", padding: "1.5rem 2rem", display: "flex", alignItems: "center", gap: "1rem", }; const formBodyStyles: React.CSSProperties = { padding: "2rem", }; const sectionStyles: React.CSSProperties = { marginBottom: "1.5rem", }; const sectionTitleStyles: React.CSSProperties = { fontSize: "0.75rem", textTransform: "uppercase", letterSpacing: "0.1em", color: "var(--text-muted)", marginBottom: "1rem", display: "flex", alignItems: "center", gap: "0.5rem", }; const inputGroupStyles: React.CSSProperties = { position: "relative", marginBottom: "1rem", }; const labelStyles: React.CSSProperties = { fontSize: "0.85rem", color: "var(--text-muted)", marginBottom: "0.5rem", display: "block", fontWeight: 500, }; const inputStyles: React.CSSProperties = { width: "100%", padding: "0.875rem 1rem", backgroundColor: "var(--tertiary-bg)", border: "2px solid var(--border-color)", borderRadius: "12px", color: "var(--text-color)", fontSize: "1rem", transition: "all 0.2s ease", boxSizing: "border-box", }; const gridStyles: React.CSSProperties = { display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1rem", }; const dividerStyles: React.CSSProperties = { height: "1px", background: "linear-gradient(90deg, transparent, var(--border-color), transparent)", margin: "1.5rem 0", }; const submitButtonStyles: React.CSSProperties = { width: "100%", padding: "1rem", background: "linear-gradient(135deg, var(--accent-color) 0%, var(--secondary-accent) 100%)", border: "none", borderRadius: "12px", color: "white", fontSize: "1rem", fontWeight: 600, cursor: isSubmitting ? "not-allowed" : "pointer", transition: "all 0.3s ease", display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", opacity: isSubmitting ? 0.7 : 1, transform: isSubmitting ? "none" : undefined, }; return (
🎮

Add New Game

Add a game to your collection

{/* Basic Info Section */}
📝 Basic Information
{ setTitle(e.target.value); if (titleError) setTitleError(""); }} required placeholder="Enter game title..." style={{ ...inputStyles, borderColor: titleError ? "#f44336" : undefined }} className="add-game-input" /> {titleError && (
{titleError}
)}
{/* Player Count Section */}
👥 Player Count
{ setMinPlayers(Number(e.target.value)); if (playersError) setPlayersError(""); }} min="1" style={{ ...inputStyles, borderColor: playersError ? "#f44336" : undefined }} className="add-game-input" />
{ setMaxPlayers(Number(e.target.value)); if (playersError) setPlayersError(""); }} min="1" style={{ ...inputStyles, borderColor: playersError ? "#f44336" : undefined }} className="add-game-input" />
{playersError && (
{playersError}
)}
{/* Additional Info Section */}
💰 Additional Details
setPrice(Math.ceil(Number(e.target.value.replace(',', '.'))))} min="0" step="1" style={inputStyles} className="add-game-input" />
{ setRemoteId(Number(e.target.value)); setRemoteIdError(""); }} min="0" style={{ ...inputStyles, borderColor: remoteIdError ? "#f44336" : undefined, }} className="add-game-input" /> {remoteIdError && (
{remoteIdError}
)}

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) { apiFetch("/api/opinion", { method: "PATCH", headers: { "Content-Type": "application/octet-stream", }, body: RemoveOpinionRequest.encode( RemoveOpinionRequest.create({ gameTitle: title, }) ).finish(), }) .then((res) => res.arrayBuffer()) .then((resBuffer) => { const response = PersonProto.decode( new Uint8Array(resBuffer) ); setOpinions(response.opinion); onShowToast?.(`Updated opinion for ${title}`, "info"); }) .catch((err) => { console.error(err); onShowToast?.("Failed to update opinion", "error"); }); return; } const wouldPlay = number == 1; apiFetch(`/api/opinion`, { method: "POST", headers: { "Content-Type": "application/octet-stream", }, body: AddOpinionRequest.encode( AddOpinionRequest.create({ gameTitle: title, wouldPlay, }) ).finish(), }) .then((res) => res.arrayBuffer()) .then((resBuffer) => { const response = PersonProto.decode( new Uint8Array(resBuffer) ); setOpinions(response.opinion); onShowToast?.(`Updated opinion for ${title}`, "info"); }) .catch((err) => { console.error(err); onShowToast?.("Failed to update opinion", "error"); }); } return (
    {game.title}
    ); })}
)}
); }