diff --git a/frontend/items.ts b/frontend/items.ts index 128ec85..3ab6bed 100644 --- a/frontend/items.ts +++ b/frontend/items.ts @@ -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 = { }, }; +function createBaseAddOpinionRequest(): AddOpinionRequest { + return { gameTitle: "", wouldPlay: false }; +} + +export const AddOpinionRequest: MessageFns = { + 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>>(base?: I): AddOpinionRequest { + return AddOpinionRequest.fromPartial(base ?? ({} as any)); + }, + fromPartial, 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; @@ -1018,7 +1099,7 @@ export class AuthServiceClientImpl implements AuthService { export interface MainService { GetGame(request: GameRequest): Promise; - AddOpinion(request: GameRequest): Promise; + AddOpinion(request: AddOpinionRequest): Promise; } 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 { - const data = GameRequest.encode(request).finish(); + AddOpinion(request: AddOpinionRequest): Promise { + const data = AddOpinionRequest.encode(request).finish(); const promise = this.rpc.request(this.service, "AddOpinion", data); return promise.then((data) => Person.decode(new BinaryReader(data))); } diff --git a/frontend/package.json b/frontend/package.json index 7ef2959..fa6d17d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fff5b9a..d8926de 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e9da0ab..23c7abc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 ( - <> +
-

People List

+

+ + People List + +

- {people.map((person, index) => ( -
-

{person.name}

-
    - {person.opinion.map((op, i) => ( -
  • - {op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"} -
  • - ))} -
-
- ))} + + } /> + } + /> +
- +
); } diff --git a/frontend/src/PersonDetails.tsx b/frontend/src/PersonDetails.tsx new file mode 100644 index 0000000..ca6021a --- /dev/null +++ b/frontend/src/PersonDetails.tsx @@ -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(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
Loading...
; + + return ( +
+

{person.name}

+
    + {person.opinion.map((op, i) => ( +
  • + {op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"} +
  • + ))} +
+ +
+

Add Opinion

+
+ setGameTitle(e.target.value)} + /> + + +
+
+
+ ); +}; diff --git a/frontend/src/PersonList.tsx b/frontend/src/PersonList.tsx new file mode 100644 index 0000000..7c7b3d3 --- /dev/null +++ b/frontend/src/PersonList.tsx @@ -0,0 +1,35 @@ +import { Person } from "../items"; +import { Link } from "react-router-dom"; + +interface Props { + people: Person[]; +} + +export const PersonList = ({ people }: Props) => { + return ( +
+ {people.map((person, index) => ( +
+

+ {person.name} +

+
    + {person.opinion.map((op, i) => ( +
  • + {op.title} - {op.wouldPlay ? "Would Play" : "Would Not Play"} +
  • + ))} +
+
+ ))} +
+ ); +};