game_list/frontend/src/GameList.tsx
2026-01-11 20:32:34 +01:00

725 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useMemo } 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<Game[]>([]);
const [title, setTitle] = useState("");
const [source, setSource] = useState<Source>(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 [remoteIdError, setRemoteIdError] = useState("");
const [opinions, setOpinions] = useState<Opinion[]>([]);
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) => {
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));
};
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();
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (remoteId === 0) {
setRemoteIdError("Remote ID must be greater than 0");
return;
}
setRemoteIdError("");
setIsSubmitting(true);
const game = {
title,
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");
setTitle("");
setMinPlayers(1);
setMaxPlayers(1);
setPrice(0);
setRemoteId(0);
fetchGames();
} else {
onShowToast?.("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 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 (
<div>
<style>
{`
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.add-game-input:focus {
border-color: var(--accent-color) !important;
box-shadow: 0 0 0 4px rgba(9, 109, 192, 0.15) !important;
outline: none;
}
.add-game-input:hover:not(:focus) {
border-color: var(--text-muted);
}
.submit-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(9, 109, 192, 0.4);
}
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
`}
</style>
<div style={formCardStyles}>
<div style={formHeaderStyles}>
<div
style={{
width: "48px",
height: "48px",
borderRadius: "14px",
backgroundColor: "rgba(255, 255, 255, 0.2)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "1.5rem",
}}
>
🎮
</div>
<div>
<h2
style={{
margin: 0,
fontSize: "1.5rem",
fontWeight: 700,
color: "white",
}}
>
Add New Game
</h2>
<p
style={{
margin: 0,
fontSize: "0.9rem",
color: "rgba(255, 255, 255, 0.8)",
}}
>
Add a game to your collection
</p>
</div>
</div>
<div style={formBodyStyles}>
<form onSubmit={handleSubmit}>
{/* Basic Info Section */}
<div style={sectionStyles}>
<div style={sectionTitleStyles}>
<span>📝</span>
Basic Information
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Game Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
placeholder="Enter game title..."
style={inputStyles}
className="add-game-input"
/>
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Platform Source</label>
<select
value={source}
onChange={(e) => setSource(Number(e.target.value))}
style={{
...inputStyles,
cursor: "pointer",
appearance: "none",
backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23a0a0a0' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10l-5 5z'/%3E%3C/svg%3E")`,
backgroundRepeat: "no-repeat",
backgroundPosition: "right 1rem center",
paddingRight: "2.5rem",
}}
className="add-game-input"
>
<option value={Source.STEAM}>🎮 Steam</option>
<option value={Source.ROBLOX}>🟢 Roblox</option>
</select>
</div>
</div>
<div style={dividerStyles}></div>
{/* Player Count Section */}
<div style={sectionStyles}>
<div style={sectionTitleStyles}>
<span>👥</span>
Player Count
</div>
<div style={gridStyles}>
<div style={inputGroupStyles}>
<label style={labelStyles}>Minimum Players</label>
<input
type="number"
value={minPlayers}
onChange={(e) => setMinPlayers(Number(e.target.value))}
min="1"
style={inputStyles}
className="add-game-input"
/>
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Maximum Players</label>
<input
type="number"
value={maxPlayers}
onChange={(e) => setMaxPlayers(Number(e.target.value))}
min="1"
style={inputStyles}
className="add-game-input"
/>
</div>
</div>
</div>
<div style={dividerStyles}></div>
{/* Additional Info Section */}
<div style={sectionStyles}>
<div style={sectionTitleStyles}>
<span>💰</span>
Additional Details
</div>
<div style={gridStyles}>
<div style={inputGroupStyles}>
<label style={labelStyles}>Price ()</label>
<input
type="number"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
min="0"
step="0.01"
style={inputStyles}
className="add-game-input"
/>
</div>
<div style={inputGroupStyles}>
<label style={labelStyles}>Remote ID</label>
<input
type="number"
value={remoteId}
onChange={(e) => {
setRemoteId(Number(e.target.value));
setRemoteIdError("");
}}
min="0"
style={{
...inputStyles,
borderColor: remoteIdError ? "#f44336" : undefined,
}}
className="add-game-input"
/>
{remoteIdError && (
<div
style={{
color: "#f44336",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{remoteIdError}
</div>
)}
</div>
</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>
</form>
</div>
</div>
<style>
{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}
</style>
<div style={{ marginTop: "3rem" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
flexWrap: "wrap",
gap: "1rem"
}}
>
<h3
id="existing-games"
style={{
scrollMarginBottom: "0",
margin: 0
}}
>
Existing Games {filteredGames.length > 0 && <span style={{ fontSize: "0.7em", color: "var(--text-muted)" }}>({filteredGames.length})</span>}
</h3>
<input
type="text"
placeholder="🔍 Search games..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{
padding: "0.5rem 1rem",
fontSize: "0.9rem",
minWidth: "200px"
}}
/>
</div>
{gamesLoading ? (
<div className="grid-container">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
style={{
backgroundColor: "var(--secondary-alt-bg)",
border: "1px solid var(--border-color)",
borderRadius: "5px",
padding: "1rem",
minHeight: "100px",
animation: "shimmer 1.5s infinite"
}}
>
<div
style={{
width: "70%",
height: "20px",
backgroundColor: "var(--tertiary-bg)",
borderRadius: "4px",
marginBottom: "0.75rem",
animation: "shimmer 1.5s infinite"
}}
></div>
<div
style={{
width: "40%",
height: "40px",
backgroundColor: "var(--tertiary-bg)",
borderRadius: "8px",
marginTop: "0.5rem",
animation: "shimmer 1.5s infinite 0.2s"
}}
></div>
</div>
))}
<style>{`
@keyframes shimmer {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
`}</style>
</div>
) : filteredGames.length === 0 ? (
<EmptyState
icon="🎮"
title={searchQuery ? "No games found" : "No games yet"}
description={searchQuery ? "Try a different search term" : "Add your first game to get started"}
/>
) : (
<ul className="grid-container">
{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 (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
}}
>
<Link
to={`/game/${encodeURIComponent(game.title)}`}
key={game.title}
className="list-item"
style={{
textDecoration: "none",
color: "inherit",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
flex: "1",
borderColor: opinion
? opinion.wouldPlay
? "#4caf50" // would play (green)
: "#f44336" // would not play (red)
: "#ffff00", // no opinion (yellow)
}}
>
<strong
style={{
textShadow: opinion
? opinion.wouldPlay
? "0 0 10px #4caf50" // would play (green)
: "0 0 10px #f44336" // would not play (red)
: "0 0 10px #ffff00", // no opinion (yellow)
}}
>
{game.title}
</strong>
<GameImage game={game.title} />
</Link>
<div
style={{
display: "flex",
gap: "1rem",
marginTop: "1rem",
width: "100%",
}}
>
<button
onClick={() => handleOpinion(game.title, 1)}
className="theme-btn game-btn"
style={{
width: "33%",
borderColor: opinion?.wouldPlay
? "#4caf50"
: "transparent",
background: "rgba(76, 175, 80, 0.1)",
fontSize: "1.2rem",
}}
title="Would Play"
>
👍
</button>
<button
onClick={() => handleOpinion(game.title, 2)}
className="theme-btn game-btn"
style={{
width: "33%",
borderColor: !opinion ? "#ffff00" : "transparent",
background: "rgba(255, 255, 0, 0.1)",
fontSize: "1.2rem",
}}
title="Neutral"
>
😐
</button>
<button
onClick={() => handleOpinion(game.title, 0)}
className="theme-btn game-btn"
style={{
width: "33%",
borderColor:
opinion && !opinion.wouldPlay
? "#f44336"
: "transparent",
background: "rgba(244, 67, 54, 0.1)",
fontSize: "1.2rem",
}}
title="Would Not Play"
>
👎
</button>
</div>
</div>
);
})}
</ul>
)}
</div>
</div>
);
}