feat: Introduce game detail view, remove multiplayer field, and enhance list navigation with refresh functionality.

This commit is contained in:
code002lover 2025-12-03 23:58:47 +01:00
parent af721e7716
commit 6bdfb49d59
10 changed files with 152 additions and 69 deletions

View File

@ -185,7 +185,6 @@ async fn main() -> Result<(), std::io::Error> {
game_list.push(Game {
title: "Naramo Nuclear Plant V2".to_string(),
source: items::Source::Roblox.into(),
multiplayer: true,
min_players: 1,
max_players: 90,
price: 0,

View File

@ -55,7 +55,6 @@ export interface Opinion {
export interface Game {
title: string;
source: Source;
multiplayer: boolean;
minPlayers: number;
maxPlayers: number;
price: number;
@ -266,7 +265,7 @@ export const Opinion: MessageFns<Opinion> = {
};
function createBaseGame(): Game {
return { title: "", source: 0, multiplayer: false, minPlayers: 0, maxPlayers: 0, price: 0, remoteId: 0 };
return { title: "", source: 0, minPlayers: 0, maxPlayers: 0, price: 0, remoteId: 0 };
}
export const Game: MessageFns<Game> = {
@ -277,9 +276,6 @@ export const Game: MessageFns<Game> = {
if (message.source !== 0) {
writer.uint32(16).int32(message.source);
}
if (message.multiplayer !== false) {
writer.uint32(24).bool(message.multiplayer);
}
if (message.minPlayers !== 0) {
writer.uint32(32).uint32(message.minPlayers);
}
@ -318,14 +314,6 @@ export const Game: MessageFns<Game> = {
message.source = reader.int32() as any;
continue;
}
case 3: {
if (tag !== 24) {
break;
}
message.multiplayer = reader.bool();
continue;
}
case 4: {
if (tag !== 32) {
break;
@ -371,7 +359,6 @@ export const Game: MessageFns<Game> = {
return {
title: isSet(object.title) ? globalThis.String(object.title) : "",
source: isSet(object.source) ? sourceFromJSON(object.source) : 0,
multiplayer: isSet(object.multiplayer) ? globalThis.Boolean(object.multiplayer) : false,
minPlayers: isSet(object.minPlayers) ? globalThis.Number(object.minPlayers) : 0,
maxPlayers: isSet(object.maxPlayers) ? globalThis.Number(object.maxPlayers) : 0,
price: isSet(object.price) ? globalThis.Number(object.price) : 0,
@ -387,9 +374,6 @@ export const Game: MessageFns<Game> = {
if (message.source !== 0) {
obj.source = sourceToJSON(message.source);
}
if (message.multiplayer !== false) {
obj.multiplayer = message.multiplayer;
}
if (message.minPlayers !== 0) {
obj.minPlayers = Math.round(message.minPlayers);
}
@ -412,7 +396,6 @@ export const Game: MessageFns<Game> = {
const message = createBaseGame();
message.title = object.title ?? "";
message.source = object.source ?? 0;
message.multiplayer = object.multiplayer ?? false;
message.minPlayers = object.minPlayers ?? 0;
message.maxPlayers = object.maxPlayers ?? 0;
message.price = object.price ?? 0;

View File

@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"build": "pnpm run gen:proto && tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"gen:proto": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=. -I ../protobuf items.proto"
@ -36,4 +36,4 @@
"vite": "npm:rolldown-vite@7.2.5"
}
}
}
}

View File

@ -5,6 +5,7 @@ import { PersonList } from "./PersonList";
import { PersonDetails } from "./PersonDetails";
import { GameList } from "./GameList";
import { GameFilter } from "./GameFilter";
import { GameDetails } from "./GameDetails";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import "./App.css";
import { apiFetch } from "./api";
@ -15,7 +16,7 @@ function App() {
localStorage.getItem("token") || ""
);
useEffect(() => {
const fetchPeople = () => {
if (!token) return;
apiFetch("/api")
@ -25,6 +26,11 @@ function App() {
setPeople(list.person);
})
.catch((err) => console.error("Failed to fetch people:", err));
};
useEffect(() => {
fetchPeople();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const handleLogin = (newToken: string) => {
@ -62,10 +68,14 @@ function App() {
</button>
</div>
<Routes>
<Route path="/" element={<PersonList people={people} />} />
<Route
path="/"
element={<PersonList people={people} onRefresh={fetchPeople} />}
/>
<Route path="/games" element={<GameList />} />
<Route path="/filter" element={<GameFilter />} />
<Route path="/person/:name" element={<PersonDetails />} />
<Route path="/game/:title" element={<GameDetails />} />
</Routes>
</div>
</BrowserRouter>

View File

@ -0,0 +1,69 @@
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { Game, Source } from "../items";
import { apiFetch } from "./api";
export function GameDetails() {
const { title } = useParams<{ title: string }>();
const [game, setGame] = useState<Game | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!title) return;
apiFetch(`/api/game/${encodeURIComponent(title)}`)
.then(async (res) => {
if (!res.ok) throw new Error("Game not found");
return Game.decode(new Uint8Array(await res.arrayBuffer()));
})
.then((data) => {
setGame(data);
setLoading(false);
})
.catch((err) => {
console.error(err);
setLoading(false);
});
}, [title]);
if (loading) return <div>Loading...</div>;
if (!game) return <div>Game not found</div>;
const getExternalLink = () => {
if (game.source === Source.STEAM) {
return `https://store.steampowered.com/app/${game.remoteId}`;
} else if (game.source === Source.ROBLOX) {
return `https://www.roblox.com/games/${game.remoteId}`;
}
return "#";
};
return (
<div className="card" style={{ maxWidth: "600px", margin: "0 auto" }}>
<h2>{game.title}</h2>
<div style={{ display: "grid", gap: "1rem", marginTop: "1rem" }}>
<div>
<strong>Source:</strong>{" "}
{game.source === Source.STEAM ? "Steam" : "Roblox"}
</div>
<div>
<strong>Players:</strong> {game.minPlayers} - {game.maxPlayers}
</div>
<div>
<strong>Price:</strong> ${game.price}
</div>
</div>
<div style={{ marginTop: "2rem" }}>
<a
href={getExternalLink()}
target="_blank"
rel="noopener noreferrer"
className="btn-primary"
style={{ textDecoration: "none", display: "inline-block" }}
>
View on {game.source === Source.STEAM ? "Steam" : "Roblox"}
</a>
</div>
</div>
);
}

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { Person, PersonList as PersonListProto } from "../items";
import { apiFetch } from "./api";
import { Link } from "react-router-dom";
export function GameFilter() {
const [people, setPeople] = useState<Person[]>([]);
@ -116,7 +117,16 @@ export function GameFilter() {
{filteredGames.length > 0 ? (
<ul className="grid-container">
{filteredGames.map((game) => (
<li key={game} className="list-item">
<Link
to={`/game/${encodeURIComponent(game)}`}
key={game}
className="list-item"
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<strong>{game}</strong>
<div
style={{
@ -127,7 +137,7 @@ export function GameFilter() {
>
All {selectedPeople.size} selected would play
</div>
</li>
</Link>
))}
</ul>
) : (

View File

@ -1,12 +1,12 @@
import { useState, useEffect } from "react";
import { Game, Source, GameList as GameListProto } from "../items";
import { Link } from "react-router-dom";
import { apiFetch } from "./api";
export function GameList() {
const [games, setGames] = useState<Game[]>([]);
const [title, setTitle] = useState("");
const [source, setSource] = useState<Source>(Source.STEAM);
const [multiplayer, setMultiplayer] = useState(false);
const [minPlayers, setMinPlayers] = useState(1);
const [maxPlayers, setMaxPlayers] = useState(1);
const [price, setPrice] = useState(0);
@ -36,7 +36,6 @@ export function GameList() {
const game = {
title,
source,
multiplayer,
minPlayers,
maxPlayers,
price,
@ -102,23 +101,6 @@ export function GameList() {
<option value={Source.ROBLOX}>Roblox</option>
</select>
</div>
<div
className="form-group"
style={{ flexDirection: "row", alignItems: "center" }}
>
<input
type="checkbox"
checked={multiplayer}
onChange={(e) => setMultiplayer(e.target.checked)}
id="multiplayer"
/>
<label
htmlFor="multiplayer"
style={{ marginBottom: 0, cursor: "pointer" }}
>
Multiplayer
</label>
</div>
<div
style={{
display: "grid",
@ -176,7 +158,16 @@ export function GameList() {
<h3>Existing Games</h3>
<ul className="grid-container">
{games.map((game) => (
<li key={game.title} className="list-item">
<Link
to={`/game/${encodeURIComponent(game.title)}`}
key={game.title}
className="list-item"
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<strong>{game.title}</strong>
<div
style={{
@ -187,7 +178,7 @@ export function GameList() {
>
{game.source === Source.STEAM ? "Steam" : "Roblox"}
</div>
</li>
</Link>
))}
</ul>
</div>

View File

@ -3,28 +3,51 @@ import { Link } from "react-router-dom";
interface Props {
people: Person[];
onRefresh: () => void;
}
export const PersonList = ({ people }: Props) => {
export const PersonList = ({ people, onRefresh }: Props) => {
return (
<div className="grid-container">
{people.map((person, index) => (
<div key={index} className="list-item">
<h3>
<Link to={`/person/${person.name}`}>{person.name}</Link>
</h3>
<ul>
{person.opinion.map((op, i) => (
<li
key={i}
style={{ color: op.wouldPlay ? "#4caf50" : "#f44336" }}
>
{op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"}
</li>
))}
</ul>
</div>
))}
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>People List</h2>
<button onClick={onRefresh} className="btn-secondary">
Refresh List
</button>
</div>
<div className="grid-container">
{people.map((person, index) => (
<Link
to={`/person/${person.name}`}
key={index}
className="list-item"
style={{
textDecoration: "none",
color: "inherit",
display: "block",
}}
>
<h3>{person.name}</h3>
<ul>
{person.opinion.map((op, i) => (
<li
key={i}
style={{ color: op.wouldPlay ? "#4caf50" : "#f44336" }}
>
{op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"}
</li>
))}
</ul>
</Link>
))}
</div>
</div>
);
};

View File

@ -13,9 +13,9 @@ message Opinion {
}
message Game {
reserved 3;
string title = 1;
Source source = 2;
bool multiplayer = 3;
uint32 min_players = 4;
uint32 max_players = 5;
uint32 price = 6;

View File

@ -3,7 +3,6 @@
{
"title": "Naramo Nuclear Plant V2",
"source": 1,
"multiplayer": true,
"min_players": 1,
"max_players": 90,
"price": 0,
@ -12,7 +11,6 @@
{
"title": "Test2",
"source": 1,
"multiplayer": true,
"min_players": 1,
"max_players": 1,
"price": 0,
@ -69,4 +67,4 @@
"password_hash": "$2b$12$DRvTP/ibTWULkuJJr285bumRd7SG3n5bYkDpb09Qpklqf6FeTiHkC"
}
]
}
}