game_list/frontend/src/GameList.tsx

619 lines
18 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 } 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";
export function GameList() {
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 [message, setMessage] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [opinions, setOpinions] = useState<Opinion[]>([]);
const fetchGames = () => {
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);
}
})
.catch(console.error);
};
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();
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) {
setMessage("success");
setTitle("");
setMinPlayers(1);
setMaxPlayers(1);
setPrice(0);
setRemoteId(0);
fetchGames();
setTimeout(() => setMessage(""), 3000);
} else {
setMessage("error");
setTimeout(() => setMessage(""), 3000);
}
} catch (err) {
console.error(err);
setMessage("error");
setTimeout(() => setMessage(""), 3000);
} 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,
};
const messageStyles: React.CSSProperties = {
padding: "1rem",
borderRadius: "12px",
marginBottom: "1.5rem",
display: "flex",
alignItems: "center",
gap: "0.75rem",
animation: "slideIn 0.3s ease",
backgroundColor:
message === "success"
? "rgba(76, 175, 80, 0.15)"
: "rgba(244, 67, 54, 0.15)",
border: `1px solid ${
message === "success"
? "rgba(76, 175, 80, 0.3)"
: "rgba(244, 67, 54, 0.3)"
}`,
color: message === "success" ? "#4caf50" : "#f44336",
};
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}>
{message && (
<div style={messageStyles}>
<span style={{ fontSize: "1.2rem" }}>
{message === "success" ? "✓" : "✕"}
</span>
<span style={{ fontWeight: 500 }}>
{message === "success"
? "Game added successfully!"
: "Failed to add game. Please try again."}
</span>
</div>
)}
<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))}
min="0"
style={inputStyles}
className="add-game-input"
/>
</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" }}>
<h3
id="existing-games"
style={{
scrollMarginBottom: "0",
}}
>
Existing Games
</h3>
<ul className="grid-container">
{games.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(
AddOpinionRequest.create({
gameTitle: title,
})
).finish(),
})
.then((res) => res.arrayBuffer())
.then((resBuffer) => {
const response = PersonProto.decode(
new Uint8Array(resBuffer)
);
setOpinions(response.opinion);
})
.catch((err) => {
console.error(err);
});
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);
})
.catch((err) => {
console.error(err);
});
}
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)
: "#191f2e", // no opinion (bg-2)
}}
>
<strong
style={{
textShadow: opinion
? opinion.wouldPlay
? "0 0 10px #4caf50" // would play (green)
: "0 0 10px #f44336" // would not play (red)
: "none", // no opinion (bg-2)
}}
>
{game.title}
</strong>
<GameImage game={game.title} />
</Link>
<div
style={{
display: "flex",
gap: "1rem",
marginTop: "1rem",
width: "100%",
}}
>
<button
onClick={() => handleOpinion(game.title, 1)}
style={{
width: "50%",
borderColor: "#4caf50",
}}
>
Would Play
</button>
<button
onClick={() => handleOpinion(game.title, 2)}
style={{
width: "50%",
borderColor: "#ffff00",
}}
>
Neutral
</button>
<button
onClick={() => handleOpinion(game.title, 0)}
style={{
width: "50%",
borderColor: "#f44336",
}}
>
Would Not Play
</button>
</div>
</div>
);
})}
</ul>
</div>
</div>
);
}