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 {
|
game_list.push(Game {
|
||||||
title: "Naramo Nuclear Plant V2".to_string(),
|
title: "Naramo Nuclear Plant V2".to_string(),
|
||||||
source: items::Source::Roblox.into(),
|
source: items::Source::Roblox.into(),
|
||||||
multiplayer: true,
|
|
||||||
min_players: 1,
|
min_players: 1,
|
||||||
max_players: 90,
|
max_players: 90,
|
||||||
price: 0,
|
price: 0,
|
||||||
|
|||||||
@ -55,7 +55,6 @@ export interface Opinion {
|
|||||||
export interface Game {
|
export interface Game {
|
||||||
title: string;
|
title: string;
|
||||||
source: Source;
|
source: Source;
|
||||||
multiplayer: boolean;
|
|
||||||
minPlayers: number;
|
minPlayers: number;
|
||||||
maxPlayers: number;
|
maxPlayers: number;
|
||||||
price: number;
|
price: number;
|
||||||
@ -266,7 +265,7 @@ export const Opinion: MessageFns<Opinion> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function createBaseGame(): Game {
|
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> = {
|
export const Game: MessageFns<Game> = {
|
||||||
@ -277,9 +276,6 @@ export const Game: MessageFns<Game> = {
|
|||||||
if (message.source !== 0) {
|
if (message.source !== 0) {
|
||||||
writer.uint32(16).int32(message.source);
|
writer.uint32(16).int32(message.source);
|
||||||
}
|
}
|
||||||
if (message.multiplayer !== false) {
|
|
||||||
writer.uint32(24).bool(message.multiplayer);
|
|
||||||
}
|
|
||||||
if (message.minPlayers !== 0) {
|
if (message.minPlayers !== 0) {
|
||||||
writer.uint32(32).uint32(message.minPlayers);
|
writer.uint32(32).uint32(message.minPlayers);
|
||||||
}
|
}
|
||||||
@ -318,14 +314,6 @@ export const Game: MessageFns<Game> = {
|
|||||||
message.source = reader.int32() as any;
|
message.source = reader.int32() as any;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
case 3: {
|
|
||||||
if (tag !== 24) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
message.multiplayer = reader.bool();
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
case 4: {
|
case 4: {
|
||||||
if (tag !== 32) {
|
if (tag !== 32) {
|
||||||
break;
|
break;
|
||||||
@ -371,7 +359,6 @@ export const Game: MessageFns<Game> = {
|
|||||||
return {
|
return {
|
||||||
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
title: isSet(object.title) ? globalThis.String(object.title) : "",
|
||||||
source: isSet(object.source) ? sourceFromJSON(object.source) : 0,
|
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,
|
minPlayers: isSet(object.minPlayers) ? globalThis.Number(object.minPlayers) : 0,
|
||||||
maxPlayers: isSet(object.maxPlayers) ? globalThis.Number(object.maxPlayers) : 0,
|
maxPlayers: isSet(object.maxPlayers) ? globalThis.Number(object.maxPlayers) : 0,
|
||||||
price: isSet(object.price) ? globalThis.Number(object.price) : 0,
|
price: isSet(object.price) ? globalThis.Number(object.price) : 0,
|
||||||
@ -387,9 +374,6 @@ export const Game: MessageFns<Game> = {
|
|||||||
if (message.source !== 0) {
|
if (message.source !== 0) {
|
||||||
obj.source = sourceToJSON(message.source);
|
obj.source = sourceToJSON(message.source);
|
||||||
}
|
}
|
||||||
if (message.multiplayer !== false) {
|
|
||||||
obj.multiplayer = message.multiplayer;
|
|
||||||
}
|
|
||||||
if (message.minPlayers !== 0) {
|
if (message.minPlayers !== 0) {
|
||||||
obj.minPlayers = Math.round(message.minPlayers);
|
obj.minPlayers = Math.round(message.minPlayers);
|
||||||
}
|
}
|
||||||
@ -412,7 +396,6 @@ export const Game: MessageFns<Game> = {
|
|||||||
const message = createBaseGame();
|
const message = createBaseGame();
|
||||||
message.title = object.title ?? "";
|
message.title = object.title ?? "";
|
||||||
message.source = object.source ?? 0;
|
message.source = object.source ?? 0;
|
||||||
message.multiplayer = object.multiplayer ?? false;
|
|
||||||
message.minPlayers = object.minPlayers ?? 0;
|
message.minPlayers = object.minPlayers ?? 0;
|
||||||
message.maxPlayers = object.maxPlayers ?? 0;
|
message.maxPlayers = object.maxPlayers ?? 0;
|
||||||
message.price = object.price ?? 0;
|
message.price = object.price ?? 0;
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "pnpm run gen:proto && tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"gen:proto": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=. -I ../protobuf items.proto"
|
"gen:proto": "protoc --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=. -I ../protobuf items.proto"
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { PersonList } from "./PersonList";
|
|||||||
import { PersonDetails } from "./PersonDetails";
|
import { PersonDetails } from "./PersonDetails";
|
||||||
import { GameList } from "./GameList";
|
import { GameList } from "./GameList";
|
||||||
import { GameFilter } from "./GameFilter";
|
import { GameFilter } from "./GameFilter";
|
||||||
|
import { GameDetails } from "./GameDetails";
|
||||||
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
||||||
import "./App.css";
|
import "./App.css";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
@ -15,7 +16,7 @@ function App() {
|
|||||||
localStorage.getItem("token") || ""
|
localStorage.getItem("token") || ""
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchPeople = () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
apiFetch("/api")
|
apiFetch("/api")
|
||||||
@ -25,6 +26,11 @@ function App() {
|
|||||||
setPeople(list.person);
|
setPeople(list.person);
|
||||||
})
|
})
|
||||||
.catch((err) => console.error("Failed to fetch people:", err));
|
.catch((err) => console.error("Failed to fetch people:", err));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchPeople();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const handleLogin = (newToken: string) => {
|
const handleLogin = (newToken: string) => {
|
||||||
@ -62,10 +68,14 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<PersonList people={people} />} />
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<PersonList people={people} onRefresh={fetchPeople} />}
|
||||||
|
/>
|
||||||
<Route path="/games" element={<GameList />} />
|
<Route path="/games" element={<GameList />} />
|
||||||
<Route path="/filter" element={<GameFilter />} />
|
<Route path="/filter" element={<GameFilter />} />
|
||||||
<Route path="/person/:name" element={<PersonDetails />} />
|
<Route path="/person/:name" element={<PersonDetails />} />
|
||||||
|
<Route path="/game/:title" element={<GameDetails />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</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 { useState, useEffect } from "react";
|
||||||
import { Person, PersonList as PersonListProto } from "../items";
|
import { Person, PersonList as PersonListProto } from "../items";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
export function GameFilter() {
|
export function GameFilter() {
|
||||||
const [people, setPeople] = useState<Person[]>([]);
|
const [people, setPeople] = useState<Person[]>([]);
|
||||||
@ -116,7 +117,16 @@ export function GameFilter() {
|
|||||||
{filteredGames.length > 0 ? (
|
{filteredGames.length > 0 ? (
|
||||||
<ul className="grid-container">
|
<ul className="grid-container">
|
||||||
{filteredGames.map((game) => (
|
{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>
|
<strong>{game}</strong>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -127,7 +137,7 @@ export function GameFilter() {
|
|||||||
>
|
>
|
||||||
✓ All {selectedPeople.size} selected would play
|
✓ All {selectedPeople.size} selected would play
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Game, Source, GameList as GameListProto } from "../items";
|
import { Game, Source, GameList as GameListProto } from "../items";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { apiFetch } from "./api";
|
import { apiFetch } from "./api";
|
||||||
|
|
||||||
export function GameList() {
|
export function GameList() {
|
||||||
const [games, setGames] = useState<Game[]>([]);
|
const [games, setGames] = useState<Game[]>([]);
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [source, setSource] = useState<Source>(Source.STEAM);
|
const [source, setSource] = useState<Source>(Source.STEAM);
|
||||||
const [multiplayer, setMultiplayer] = useState(false);
|
|
||||||
const [minPlayers, setMinPlayers] = useState(1);
|
const [minPlayers, setMinPlayers] = useState(1);
|
||||||
const [maxPlayers, setMaxPlayers] = useState(1);
|
const [maxPlayers, setMaxPlayers] = useState(1);
|
||||||
const [price, setPrice] = useState(0);
|
const [price, setPrice] = useState(0);
|
||||||
@ -36,7 +36,6 @@ export function GameList() {
|
|||||||
const game = {
|
const game = {
|
||||||
title,
|
title,
|
||||||
source,
|
source,
|
||||||
multiplayer,
|
|
||||||
minPlayers,
|
minPlayers,
|
||||||
maxPlayers,
|
maxPlayers,
|
||||||
price,
|
price,
|
||||||
@ -102,23 +101,6 @@ export function GameList() {
|
|||||||
<option value={Source.ROBLOX}>Roblox</option>
|
<option value={Source.ROBLOX}>Roblox</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@ -176,7 +158,16 @@ export function GameList() {
|
|||||||
<h3>Existing Games</h3>
|
<h3>Existing Games</h3>
|
||||||
<ul className="grid-container">
|
<ul className="grid-container">
|
||||||
{games.map((game) => (
|
{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>
|
<strong>{game.title}</strong>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -187,7 +178,7 @@ export function GameList() {
|
|||||||
>
|
>
|
||||||
{game.source === Source.STEAM ? "Steam" : "Roblox"}
|
{game.source === Source.STEAM ? "Steam" : "Roblox"}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,16 +3,38 @@ import { Link } from "react-router-dom";
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
people: Person[];
|
people: Person[];
|
||||||
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PersonList = ({ people }: Props) => {
|
export const PersonList = ({ people, onRefresh }: Props) => {
|
||||||
return (
|
return (
|
||||||
|
<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">
|
<div className="grid-container">
|
||||||
{people.map((person, index) => (
|
{people.map((person, index) => (
|
||||||
<div key={index} className="list-item">
|
<Link
|
||||||
<h3>
|
to={`/person/${person.name}`}
|
||||||
<Link to={`/person/${person.name}`}>{person.name}</Link>
|
key={index}
|
||||||
</h3>
|
className="list-item"
|
||||||
|
style={{
|
||||||
|
textDecoration: "none",
|
||||||
|
color: "inherit",
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3>{person.name}</h3>
|
||||||
<ul>
|
<ul>
|
||||||
{person.opinion.map((op, i) => (
|
{person.opinion.map((op, i) => (
|
||||||
<li
|
<li
|
||||||
@ -23,8 +45,9 @@ export const PersonList = ({ people }: Props) => {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -13,9 +13,9 @@ message Opinion {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message Game {
|
message Game {
|
||||||
|
reserved 3;
|
||||||
string title = 1;
|
string title = 1;
|
||||||
Source source = 2;
|
Source source = 2;
|
||||||
bool multiplayer = 3;
|
|
||||||
uint32 min_players = 4;
|
uint32 min_players = 4;
|
||||||
uint32 max_players = 5;
|
uint32 max_players = 5;
|
||||||
uint32 price = 6;
|
uint32 price = 6;
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Naramo Nuclear Plant V2",
|
"title": "Naramo Nuclear Plant V2",
|
||||||
"source": 1,
|
"source": 1,
|
||||||
"multiplayer": true,
|
|
||||||
"min_players": 1,
|
"min_players": 1,
|
||||||
"max_players": 90,
|
"max_players": 90,
|
||||||
"price": 0,
|
"price": 0,
|
||||||
@ -12,7 +11,6 @@
|
|||||||
{
|
{
|
||||||
"title": "Test2",
|
"title": "Test2",
|
||||||
"source": 1,
|
"source": 1,
|
||||||
"multiplayer": true,
|
|
||||||
"min_players": 1,
|
"min_players": 1,
|
||||||
"max_players": 1,
|
"max_players": 1,
|
||||||
"price": 0,
|
"price": 0,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user