feat: Implement client-side routing for person list and details, including adding opinions.
This commit is contained in:
parent
f30af57934
commit
30950e6c83
@ -101,6 +101,11 @@ export interface GameRequest {
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface AddOpinionRequest {
|
||||
gameTitle: string;
|
||||
wouldPlay: boolean;
|
||||
}
|
||||
|
||||
function createBasePerson(): Person {
|
||||
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 */
|
||||
export interface AuthService {
|
||||
Login(request: LoginRequest): Promise<LoginResponse>;
|
||||
@ -1018,7 +1099,7 @@ export class AuthServiceClientImpl implements AuthService {
|
||||
|
||||
export interface MainService {
|
||||
GetGame(request: GameRequest): Promise<Game>;
|
||||
AddOpinion(request: GameRequest): Promise<Person>;
|
||||
AddOpinion(request: AddOpinionRequest): Promise<Person>;
|
||||
}
|
||||
|
||||
export const MainServiceServiceName = "items.MainService";
|
||||
@ -1037,8 +1118,8 @@ export class MainServiceClientImpl implements MainService {
|
||||
return promise.then((data) => Game.decode(new BinaryReader(data)));
|
||||
}
|
||||
|
||||
AddOpinion(request: GameRequest): Promise<Person> {
|
||||
const data = GameRequest.encode(request).finish();
|
||||
AddOpinion(request: AddOpinionRequest): Promise<Person> {
|
||||
const data = AddOpinionRequest.encode(request).finish();
|
||||
const promise = this.rpc.request(this.service, "AddOpinion", data);
|
||||
return promise.then((data) => Person.decode(new BinaryReader(data)));
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.10.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
||||
45
frontend/pnpm-lock.yaml
generated
45
frontend/pnpm-lock.yaml
generated
@ -20,6 +20,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^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:
|
||||
'@eslint/js':
|
||||
specifier: ^9.39.1
|
||||
@ -490,6 +493,10 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
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:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@ -865,6 +872,23 @@ packages:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
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:
|
||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@ -930,6 +954,9 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1500,6 +1527,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@ -1823,6 +1852,20 @@ snapshots:
|
||||
|
||||
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: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
@ -1866,6 +1909,8 @@ snapshots:
|
||||
|
||||
semver@7.7.3: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Person, PersonList } from "../items";
|
||||
import { Person, PersonList as PersonListProto } from "../items";
|
||||
import { Login } from "./Login";
|
||||
import { PersonList } from "./PersonList";
|
||||
import { PersonDetails } from "./PersonDetails";
|
||||
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
|
||||
import "./App.css";
|
||||
|
||||
function App() {
|
||||
@ -17,7 +20,7 @@ function App() {
|
||||
})
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buffer) => {
|
||||
const list = PersonList.decode(new Uint8Array(buffer));
|
||||
const list = PersonListProto.decode(new Uint8Array(buffer));
|
||||
setPeople(list.person);
|
||||
})
|
||||
.catch((err) => console.error("Failed to fetch people:", err));
|
||||
@ -33,7 +36,7 @@ function App() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BrowserRouter>
|
||||
<div className="card">
|
||||
<div
|
||||
style={{
|
||||
@ -43,31 +46,22 @@ function App() {
|
||||
marginBottom: "1rem",
|
||||
}}
|
||||
>
|
||||
<h2>People List</h2>
|
||||
<h2>
|
||||
<Link to="/" style={{ textDecoration: "none", color: "inherit" }}>
|
||||
People List
|
||||
</Link>
|
||||
</h2>
|
||||
<button onClick={handleLogout}>Logout</button>
|
||||
</div>
|
||||
{people.map((person, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
marginBottom: "1rem",
|
||||
padding: "1rem",
|
||||
border: "1px solid #ccc",
|
||||
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>
|
||||
))}
|
||||
<Routes>
|
||||
<Route path="/" element={<PersonList people={people} />} />
|
||||
<Route
|
||||
path="/person/:name"
|
||||
element={<PersonDetails token={token} />}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
</>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
104
frontend/src/PersonDetails.tsx
Normal file
104
frontend/src/PersonDetails.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
35
frontend/src/PersonList.tsx
Normal file
35
frontend/src/PersonList.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user