feat: Introduce game detail view, remove multiplayer field, and enhance list navigation with refresh functionality.
This commit is contained in:
parent
af721e7716
commit
6bdfb49d59
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
69
frontend/src/GameDetails.tsx
Normal file
69
frontend/src/GameDetails.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user