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;
|
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)));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
45
frontend/pnpm-lock.yaml
generated
45
frontend/pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
</BrowserRouter>
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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