feat: Implement client-side routing for person list and details, including adding opinions.

This commit is contained in:
code002lover 2025-12-02 21:45:16 +01:00
parent f30af57934
commit 30950e6c83
6 changed files with 289 additions and 29 deletions

View File

@ -101,6 +101,11 @@ export interface GameRequest {
title: string; title: string;
} }
export interface AddOpinionRequest {
gameTitle: string;
wouldPlay: boolean;
}
function createBasePerson(): Person { function createBasePerson(): Person {
return { name: "", opinion: [] }; return { name: "", opinion: [] };
} }
@ -979,6 +984,82 @@ export const GameRequest: MessageFns<GameRequest> = {
}, },
}; };
function createBaseAddOpinionRequest(): AddOpinionRequest {
return { gameTitle: "", wouldPlay: false };
}
export const AddOpinionRequest: MessageFns<AddOpinionRequest> = {
encode(message: AddOpinionRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
if (message.gameTitle !== "") {
writer.uint32(10).string(message.gameTitle);
}
if (message.wouldPlay !== false) {
writer.uint32(16).bool(message.wouldPlay);
}
return writer;
},
decode(input: BinaryReader | Uint8Array, length?: number): AddOpinionRequest {
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
const end = length === undefined ? reader.len : reader.pos + length;
const message = createBaseAddOpinionRequest();
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1: {
if (tag !== 10) {
break;
}
message.gameTitle = reader.string();
continue;
}
case 2: {
if (tag !== 16) {
break;
}
message.wouldPlay = reader.bool();
continue;
}
}
if ((tag & 7) === 4 || tag === 0) {
break;
}
reader.skip(tag & 7);
}
return message;
},
fromJSON(object: any): AddOpinionRequest {
return {
gameTitle: isSet(object.gameTitle) ? globalThis.String(object.gameTitle) : "",
wouldPlay: isSet(object.wouldPlay) ? globalThis.Boolean(object.wouldPlay) : false,
};
},
toJSON(message: AddOpinionRequest): unknown {
const obj: any = {};
if (message.gameTitle !== "") {
obj.gameTitle = message.gameTitle;
}
if (message.wouldPlay !== false) {
obj.wouldPlay = message.wouldPlay;
}
return obj;
},
create<I extends Exact<DeepPartial<AddOpinionRequest>, I>>(base?: I): AddOpinionRequest {
return AddOpinionRequest.fromPartial(base ?? ({} as any));
},
fromPartial<I extends Exact<DeepPartial<AddOpinionRequest>, I>>(object: I): AddOpinionRequest {
const message = createBaseAddOpinionRequest();
message.gameTitle = object.gameTitle ?? "";
message.wouldPlay = object.wouldPlay ?? false;
return message;
},
};
/** Authentication service */ /** Authentication service */
export interface AuthService { export interface AuthService {
Login(request: LoginRequest): Promise<LoginResponse>; Login(request: LoginRequest): Promise<LoginResponse>;
@ -1018,7 +1099,7 @@ export class AuthServiceClientImpl implements AuthService {
export interface MainService { export interface MainService {
GetGame(request: GameRequest): Promise<Game>; GetGame(request: GameRequest): Promise<Game>;
AddOpinion(request: GameRequest): Promise<Person>; AddOpinion(request: AddOpinionRequest): Promise<Person>;
} }
export const MainServiceServiceName = "items.MainService"; export const MainServiceServiceName = "items.MainService";
@ -1037,8 +1118,8 @@ export class MainServiceClientImpl implements MainService {
return promise.then((data) => Game.decode(new BinaryReader(data))); return promise.then((data) => Game.decode(new BinaryReader(data)));
} }
AddOpinion(request: GameRequest): Promise<Person> { AddOpinion(request: AddOpinionRequest): Promise<Person> {
const data = GameRequest.encode(request).finish(); const data = AddOpinionRequest.encode(request).finish();
const promise = this.rpc.request(this.service, "AddOpinion", data); const promise = this.rpc.request(this.service, "AddOpinion", data);
return promise.then((data) => Person.decode(new BinaryReader(data))); return promise.then((data) => Person.decode(new BinaryReader(data)));
} }

View File

@ -13,7 +13,8 @@
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^2.10.1", "@bufbuild/protobuf": "^2.10.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.10.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

@ -20,6 +20,9 @@ importers:
react-dom: react-dom:
specifier: ^19.2.0 specifier: ^19.2.0
version: 19.2.0(react@19.2.0) version: 19.2.0(react@19.2.0)
react-router-dom:
specifier: ^7.10.0
version: 7.10.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
devDependencies: devDependencies:
'@eslint/js': '@eslint/js':
specifier: ^9.39.1 specifier: ^9.39.1
@ -490,6 +493,10 @@ packages:
convert-source-map@2.0.0: convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@ -865,6 +872,23 @@ packages:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
react-router-dom@7.10.0:
resolution: {integrity: sha512-Q4haR150pN/5N75O30iIsRJcr3ef7p7opFaKpcaREy0GQit6uCRu1NEiIFIwnHJQy0bsziRFBweR/5EkmHgVUQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
react-router@7.10.0:
resolution: {integrity: sha512-FVyCOH4IZ0eDDRycODfUqoN8ZSR2LbTvtx6RPsBgzvJ8xAXlMZNCrOFpu+jb8QbtZnpAd/cEki2pwE848pNGxw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: '>=18'
react-dom: '>=18'
peerDependenciesMeta:
react-dom:
optional: true
react@19.2.0: react@19.2.0:
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -930,6 +954,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
set-cookie-parser@2.7.2:
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
shebang-command@2.0.0: shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@ -1500,6 +1527,8 @@ snapshots:
convert-source-map@2.0.0: {} convert-source-map@2.0.0: {}
cookie@1.1.1: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@ -1823,6 +1852,20 @@ snapshots:
react-refresh@0.18.0: {} react-refresh@0.18.0: {}
react-router-dom@7.10.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-router: 7.10.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react-router@7.10.0(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
cookie: 1.1.1
react: 19.2.0
set-cookie-parser: 2.7.2
optionalDependencies:
react-dom: 19.2.0(react@19.2.0)
react@19.2.0: {} react@19.2.0: {}
resolve-from@4.0.0: {} resolve-from@4.0.0: {}
@ -1866,6 +1909,8 @@ snapshots:
semver@7.7.3: {} semver@7.7.3: {}
set-cookie-parser@2.7.2: {}
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies:
shebang-regex: 3.0.0 shebang-regex: 3.0.0

View File

@ -1,6 +1,9 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Person, PersonList } from "../items"; import { Person, PersonList as PersonListProto } from "../items";
import { Login } from "./Login"; import { Login } from "./Login";
import { PersonList } from "./PersonList";
import { PersonDetails } from "./PersonDetails";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import "./App.css"; import "./App.css";
function App() { function App() {
@ -17,7 +20,7 @@ function App() {
}) })
.then((res) => res.arrayBuffer()) .then((res) => res.arrayBuffer())
.then((buffer) => { .then((buffer) => {
const list = PersonList.decode(new Uint8Array(buffer)); const list = PersonListProto.decode(new Uint8Array(buffer));
setPeople(list.person); setPeople(list.person);
}) })
.catch((err) => console.error("Failed to fetch people:", err)); .catch((err) => console.error("Failed to fetch people:", err));
@ -33,7 +36,7 @@ function App() {
} }
return ( return (
<> <BrowserRouter>
<div className="card"> <div className="card">
<div <div
style={{ style={{
@ -43,31 +46,22 @@ function App() {
marginBottom: "1rem", marginBottom: "1rem",
}} }}
> >
<h2>People List</h2> <h2>
<Link to="/" style={{ textDecoration: "none", color: "inherit" }}>
People List
</Link>
</h2>
<button onClick={handleLogout}>Logout</button> <button onClick={handleLogout}>Logout</button>
</div> </div>
{people.map((person, index) => ( <Routes>
<div <Route path="/" element={<PersonList people={people} />} />
key={index} <Route
style={{ path="/person/:name"
marginBottom: "1rem", element={<PersonDetails token={token} />}
padding: "1rem", />
border: "1px solid #ccc", </Routes>
borderRadius: "8px",
}}
>
<h3>{person.name}</h3>
<ul>
{person.opinion.map((op, i) => (
<li key={i}>
{op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"}
</li>
))}
</ul>
</div>
))}
</div> </div>
</> </BrowserRouter>
); );
} }

View File

@ -0,0 +1,104 @@
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { Person, AddOpinionRequest } from "../items";
interface Props {
token: string;
}
export const PersonDetails = ({ token }: Props) => {
const { name } = useParams<{ name: string }>();
const [person, setPerson] = useState<Person | null>(null);
const [gameTitle, setGameTitle] = useState("");
const [wouldPlay, setWouldPlay] = useState(false);
useEffect(() => {
if (name) {
fetch(`/api/${name}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.arrayBuffer())
.then((buffer) => {
try {
setPerson(Person.decode(new Uint8Array(buffer)));
} catch (e) {
console.error("Failed to decode person:", e);
}
})
.catch(console.error);
}
}, [name, token]);
const handleAddOpinion = async () => {
if (!person) return;
const req = AddOpinionRequest.create({
gameTitle,
wouldPlay,
});
const buffer = AddOpinionRequest.encode(req).finish();
try {
const res = await fetch("/api/opinion", {
method: "POST",
headers: {
"Content-Type": "application/octet-stream",
Authorization: `Bearer ${token}`,
},
body: buffer,
});
if (res.ok) {
const resBuffer = await res.arrayBuffer();
setPerson(Person.decode(new Uint8Array(resBuffer)));
setGameTitle("");
setWouldPlay(false);
}
} catch (e) {
console.error(e);
}
};
if (!person) return <div>Loading...</div>;
return (
<div className="card">
<h2>{person.name}</h2>
<ul>
{person.opinion.map((op, i) => (
<li key={i}>
{op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"}
</li>
))}
</ul>
<div
style={{
marginTop: "2rem",
borderTop: "1px solid #ccc",
paddingTop: "1rem",
}}
>
<h3>Add Opinion</h3>
<div style={{ display: "flex", gap: "1rem", alignItems: "center" }}>
<input
type="text"
placeholder="Game Title"
value={gameTitle}
onChange={(e) => setGameTitle(e.target.value)}
/>
<label>
<input
type="checkbox"
checked={wouldPlay}
onChange={(e) => setWouldPlay(e.target.checked)}
/>
Would Play
</label>
<button onClick={handleAddOpinion}>Add</button>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,35 @@
import { Person } from "../items";
import { Link } from "react-router-dom";
interface Props {
people: Person[];
}
export const PersonList = ({ people }: Props) => {
return (
<div>
{people.map((person, index) => (
<div
key={index}
style={{
marginBottom: "1rem",
padding: "1rem",
border: "1px solid #ccc",
borderRadius: "8px",
}}
>
<h3>
<Link to={`/person/${person.name}`}>{person.name}</Link>
</h3>
<ul>
{person.opinion.map((op, i) => (
<li key={i}>
{op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"}
</li>
))}
</ul>
</div>
))}
</div>
);
};