610 lines
18 KiB
TypeScript
610 lines
18 KiB
TypeScript
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";
|
||
import type { ToastType } from "./Toast";
|
||
|
||
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 [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) {
|
||
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))}
|
||
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);
|
||
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>
|
||
);
|
||
}
|