Compare commits
3 Commits
4f30888c5c
...
453d7ca951
Author | SHA1 | Date | |
---|---|---|---|
453d7ca951 | |||
9afa4a88a8 | |||
630986d49c |
78
main.py
78
main.py
@ -1,9 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends, FastAPI, Security
|
from typing import Annotated
|
||||||
|
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from db import Player, Team, Chemistry, MVPRanking, engine
|
from db import Player, Team, Chemistry, MVPRanking, engine
|
||||||
from sqlmodel import (
|
from sqlmodel import (
|
||||||
Session,
|
Session,
|
||||||
|
func,
|
||||||
select,
|
select,
|
||||||
)
|
)
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
@ -18,6 +20,9 @@ from security import (
|
|||||||
set_first_password,
|
set_first_password,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
C = Chemistry
|
||||||
|
R = MVPRanking
|
||||||
|
P = Player
|
||||||
|
|
||||||
app = FastAPI(title="cutt")
|
app = FastAPI(title="cutt")
|
||||||
api_router = APIRouter(prefix="/api")
|
api_router = APIRouter(prefix="/api")
|
||||||
@ -80,20 +85,83 @@ team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
|
|||||||
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
|
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/mvps", dependencies=[Depends(get_current_active_user)])
|
wrong_user_id_exception = HTTPException(
|
||||||
def submit_mvps(mvps: MVPRanking):
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="you're not who you think you are...",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.put("/mvps")
|
||||||
|
def submit_mvps(
|
||||||
|
mvps: MVPRanking,
|
||||||
|
user: Annotated[Player, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
if user.id == mvps.user:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
session.add(mvps)
|
session.add(mvps)
|
||||||
session.commit()
|
session.commit()
|
||||||
return JSONResponse("success!")
|
return JSONResponse("success!")
|
||||||
|
else:
|
||||||
|
raise wrong_user_id_exception
|
||||||
|
|
||||||
|
|
||||||
@api_router.post("/chemistry", dependencies=[Depends(get_current_active_user)])
|
@api_router.get("/mvps")
|
||||||
def submit_chemistry(chemistry: Chemistry):
|
def get_mvps(
|
||||||
|
user: Annotated[Player, Depends(get_current_active_user)],
|
||||||
|
):
|
||||||
|
with Session(engine) as session:
|
||||||
|
subquery = (
|
||||||
|
select(R.user, func.max(R.time).label("latest"))
|
||||||
|
.where(R.user == user.id)
|
||||||
|
.group_by(R.user)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
statement2 = select(R).join(
|
||||||
|
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
|
||||||
|
)
|
||||||
|
mvps = session.exec(statement2).one_or_none()
|
||||||
|
if mvps:
|
||||||
|
return mvps
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="no previous state was found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.put("/chemistry")
|
||||||
|
def submit_chemistry(
|
||||||
|
chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)]
|
||||||
|
):
|
||||||
|
if user.id == chemistry.user:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
session.add(chemistry)
|
session.add(chemistry)
|
||||||
session.commit()
|
session.commit()
|
||||||
return JSONResponse("success!")
|
return JSONResponse("success!")
|
||||||
|
else:
|
||||||
|
raise wrong_user_id_exception
|
||||||
|
|
||||||
|
|
||||||
|
@api_router.get("/chemistry")
|
||||||
|
def get_chemistry(user: Annotated[Player, Depends(get_current_active_user)]):
|
||||||
|
with Session(engine) as session:
|
||||||
|
subquery = (
|
||||||
|
select(C.user, func.max(C.time).label("latest"))
|
||||||
|
.where(C.user == user.id)
|
||||||
|
.group_by(C.user)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
statement2 = select(C).join(
|
||||||
|
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
|
||||||
|
)
|
||||||
|
chemistry = session.exec(statement2).one_or_none()
|
||||||
|
if chemistry:
|
||||||
|
return chemistry
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="no previous state was found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SPAStaticFiles(StaticFiles):
|
class SPAStaticFiles(StaticFiles):
|
||||||
|
23
src/App.css
23
src/App.css
@ -235,15 +235,17 @@ h3 {
|
|||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
button {
|
||||||
.button {
|
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: large;
|
|
||||||
color: aliceblue;
|
color: aliceblue;
|
||||||
background-color: black;
|
background-color: black;
|
||||||
border-radius: 1.2em;
|
border-radius: 1.2em;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 75%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#control-panel {
|
#control-panel {
|
||||||
@ -321,11 +323,20 @@ button,
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tablink {
|
.tab-button {
|
||||||
color: white;
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
background-color: grey;
|
||||||
|
border: none;
|
||||||
margin: 4px auto;
|
margin: 4px auto;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button.active {
|
||||||
|
opacity: unset;
|
||||||
|
font-weight: bold;
|
||||||
|
background-color: black;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
|
183
src/Rankings.tsx
183
src/Rankings.tsx
@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
|
||||||
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
|
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
|
||||||
import { apiAuth, User } from "./api";
|
import { apiAuth, User } from "./api";
|
||||||
import { useSession } from "./Session";
|
import { useSession } from "./Session";
|
||||||
|
import { Chemistry, MVPRanking } from "./types";
|
||||||
|
|
||||||
type PlayerListProps = Partial<ReactSortableProps<any>> & {
|
type PlayerListProps = Partial<ReactSortableProps<any>> & {
|
||||||
orderedList?: boolean;
|
orderedList?: boolean;
|
||||||
@ -21,15 +22,37 @@ function PlayerList(props: PlayerListProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||||
|
return (
|
||||||
|
<button {...props} style={{ padding: "4px 16px" }}>
|
||||||
|
🗃️ restore previous
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||||
|
return (
|
||||||
|
<button {...props} style={{ padding: "4px 16px" }}>
|
||||||
|
🗑️ start over
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterSort(list: User[], ids: number[]): User[] {
|
||||||
|
const objectMap = new Map(list.map((obj) => [obj.id, obj]));
|
||||||
|
const filteredAndSortedObjects = ids
|
||||||
|
.map((id) => objectMap.get(id))
|
||||||
|
.filter((obj) => obj !== undefined);
|
||||||
|
return filteredAndSortedObjects;
|
||||||
|
}
|
||||||
|
|
||||||
interface PlayerInfoProps {
|
interface PlayerInfoProps {
|
||||||
user: User;
|
user: User;
|
||||||
players: User[];
|
players: User[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Chemistry({ user, players }: PlayerInfoProps) {
|
function ChemistryDnD({ user, players }: PlayerInfoProps) {
|
||||||
const index = players.indexOf(user);
|
var otherPlayers = players.filter((player) => player.id !== user.id);
|
||||||
var otherPlayers = players.slice();
|
|
||||||
otherPlayers.splice(index, 1);
|
|
||||||
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
|
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
|
||||||
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
|
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
|
||||||
const [playersRight, setPlayersRight] = useState<User[]>([]);
|
const [playersRight, setPlayersRight] = useState<User[]>([]);
|
||||||
@ -39,21 +62,36 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
|
|||||||
}, [players]);
|
}, [players]);
|
||||||
|
|
||||||
const [dialog, setDialog] = useState("dialog");
|
const [dialog, setDialog] = useState("dialog");
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const dialog = document.querySelector("dialog[id='ChemistryDialog']");
|
if (dialogRef.current) dialogRef.current.showModal();
|
||||||
(dialog as HTMLDialogElement).showModal();
|
|
||||||
setDialog("sending...");
|
setDialog("sending...");
|
||||||
let left = playersLeft.map(({ id }) => id);
|
let left = playersLeft.map(({ id }) => id);
|
||||||
let middle = playersMiddle.map(({ id }) => id);
|
let middle = playersMiddle.map(({ id }) => id);
|
||||||
let right = playersRight.map(({ id }) => id);
|
let right = playersRight.map(({ id }) => id);
|
||||||
const data = { user: user.id, hate: left, undecided: middle, love: right };
|
const data = { user: user.id, hate: left, undecided: middle, love: right };
|
||||||
const response = await apiAuth("chemistry", data, "POST");
|
const response = await apiAuth("chemistry", data, "PUT");
|
||||||
response ? setDialog(response) : setDialog("try sending again");
|
setDialog(response || "try sending again");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGet() {
|
||||||
|
const chemistry = (await apiAuth("chemistry", null, "GET")) as Chemistry;
|
||||||
|
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
|
||||||
|
setPlayersMiddle(filterSort(otherPlayers, chemistry.undecided));
|
||||||
|
setPlayersRight(filterSort(otherPlayers, chemistry.love));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<HeaderControl
|
||||||
|
onLoad={handleGet}
|
||||||
|
onClear={() => {
|
||||||
|
setPlayersRight([]);
|
||||||
|
setPlayersMiddle(otherPlayers);
|
||||||
|
setPlayersLeft([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="box three">
|
<div className="box three">
|
||||||
<h2>😬</h2>
|
<h2>😬</h2>
|
||||||
@ -99,6 +137,7 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
|
|||||||
💾 <span className="submit_text">submit</span>
|
💾 <span className="submit_text">submit</span>
|
||||||
</button>
|
</button>
|
||||||
<dialog
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
id="ChemistryDialog"
|
id="ChemistryDialog"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.currentTarget.close();
|
event.currentTarget.close();
|
||||||
@ -110,28 +149,41 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MVP({ user, players }: PlayerInfoProps) {
|
function MVPDnD({ user, players }: PlayerInfoProps) {
|
||||||
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
|
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
|
||||||
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
|
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
|
||||||
|
|
||||||
const [dialog, setDialog] = useState("dialog");
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAvailablePlayers(players);
|
setAvailablePlayers(players);
|
||||||
}, [players]);
|
}, [players]);
|
||||||
|
|
||||||
|
const [dialog, setDialog] = useState("dialog");
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
const dialog = document.querySelector("dialog[id='MVPDialog']");
|
if (dialogRef.current) dialogRef.current.showModal();
|
||||||
(dialog as HTMLDialogElement).showModal();
|
|
||||||
setDialog("sending...");
|
setDialog("sending...");
|
||||||
let mvps = rankedPlayers.map(({ id }) => id);
|
let mvps = rankedPlayers.map(({ id }) => id);
|
||||||
const data = { user: user.id, mvps: mvps };
|
const data = { user: user.id, mvps: mvps };
|
||||||
const response = await apiAuth("mvps", data, "POST");
|
const response = await apiAuth("mvps", data, "PUT");
|
||||||
response ? setDialog(response) : setDialog("try sending again");
|
response ? setDialog(response) : setDialog("try sending again");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGet() {
|
||||||
|
const mvps = (await apiAuth("mvps", null, "GET")) as MVPRanking;
|
||||||
|
setRankedPlayers(filterSort(players, mvps.mvps));
|
||||||
|
setAvailablePlayers(players.filter((user) => !mvps.mvps.includes(user.id)));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<HeaderControl
|
||||||
|
onLoad={handleGet}
|
||||||
|
onClear={() => {
|
||||||
|
setAvailablePlayers(players);
|
||||||
|
setRankedPlayers([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="box two">
|
<div className="box two">
|
||||||
<h2>🥏🏃</h2>
|
<h2>🥏🏃</h2>
|
||||||
@ -177,6 +229,7 @@ export function MVP({ user, players }: PlayerInfoProps) {
|
|||||||
💾 <span className="submit_text">submit</span>
|
💾 <span className="submit_text">submit</span>
|
||||||
</button>
|
</button>
|
||||||
<dialog
|
<dialog
|
||||||
|
ref={dialogRef}
|
||||||
id="MVPDialog"
|
id="MVPDialog"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.currentTarget.close();
|
event.currentTarget.close();
|
||||||
@ -188,9 +241,30 @@ export function MVP({ user, players }: PlayerInfoProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HeaderControlProps {
|
||||||
|
onLoad: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}
|
||||||
|
function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<LoadButton onClick={onLoad} />
|
||||||
|
<ClearButton onClick={onClear} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="grey">
|
||||||
|
assign as many or as few players as you want and don't forget to{" "}
|
||||||
|
<b>submit</b> 💾 when you're done :)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Rankings() {
|
export default function Rankings() {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const [players, setPlayers] = useState<User[]>([]);
|
const [players, setPlayers] = useState<User[] | null>(null);
|
||||||
const [openTab, setOpenTab] = useState("Chemistry");
|
const [openTab, setOpenTab] = useState("Chemistry");
|
||||||
|
|
||||||
async function loadPlayers() {
|
async function loadPlayers() {
|
||||||
@ -206,66 +280,41 @@ export default function Rankings() {
|
|||||||
loadPlayers();
|
loadPlayers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const tabs = [
|
||||||
openPage(openTab, "aliceblue");
|
{ id: "Chemistry", label: "🧪 Chemistry" },
|
||||||
}, [user]);
|
{ id: "MVP", label: "🏆 MVP" },
|
||||||
|
];
|
||||||
function openPage(pageName: string, color: string) {
|
|
||||||
// Hide all elements with class="tabcontent" by default */
|
|
||||||
var i, tabcontent, tablinks;
|
|
||||||
tabcontent = document.getElementsByClassName("tabcontent");
|
|
||||||
for (i = 0; i < tabcontent.length; i++) {
|
|
||||||
(tabcontent[i] as HTMLElement).style.display = "none";
|
|
||||||
}
|
|
||||||
// Remove the background color of all tablinks/buttons
|
|
||||||
tablinks = document.getElementsByClassName("tablink");
|
|
||||||
for (i = 0; i < tablinks.length; i++) {
|
|
||||||
let button = tablinks[i] as HTMLElement;
|
|
||||||
button.style.opacity = "50%";
|
|
||||||
}
|
|
||||||
// Show the specific tab content
|
|
||||||
(document.getElementById(pageName) as HTMLElement).style.display = "block";
|
|
||||||
// Add the specific color to the button used to open the tab content
|
|
||||||
let activeButton = document.getElementById(
|
|
||||||
pageName + "Button"
|
|
||||||
) as HTMLElement;
|
|
||||||
activeButton.style.fontWeight = "bold";
|
|
||||||
activeButton.style.opacity = "100%";
|
|
||||||
document.body.style.backgroundColor = color;
|
|
||||||
setOpenTab(pageName);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{user && players && (
|
||||||
|
<div>
|
||||||
<div className="container navbar">
|
<div className="container navbar">
|
||||||
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
className="tablink"
|
key={tab.id}
|
||||||
id="ChemistryButton"
|
className={
|
||||||
onClick={() => openPage("Chemistry", "aliceblue")}
|
openTab === tab.id ? "tab-button active" : "tab-button"
|
||||||
|
}
|
||||||
|
onClick={() => setOpenTab(tab.id)}
|
||||||
>
|
>
|
||||||
🧪 Chemistry
|
{tab.label}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="tablink"
|
|
||||||
id="MVPButton"
|
|
||||||
onClick={() => openPage("MVP", "aliceblue")}
|
|
||||||
>
|
|
||||||
🏆 MVP
|
|
||||||
</button>
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{tabs.map((tab) => {
|
||||||
<span className="grey">
|
if (openTab !== tab.id) return null;
|
||||||
assign as many or as few players as you want
|
switch (tab.id) {
|
||||||
<br />
|
case "Chemistry":
|
||||||
and don't forget to <b>submit</b> (💾) when you're done :)
|
return <ChemistryDnD key={tab.id} {...{ user, players }} />;
|
||||||
</span>
|
case "MVP":
|
||||||
|
return <MVPDnD key={tab.id} {...{ user, players }} />;
|
||||||
<div id="Chemistry" className="tabcontent">
|
default:
|
||||||
{user && <Chemistry {...{ user, players }} />}
|
return null;
|
||||||
</div>
|
}
|
||||||
<div id="MVP" className="tabcontent">
|
})}
|
||||||
{user && <MVP {...{ user, players }} />}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { useSession } from "./Session";
|
||||||
|
|
||||||
export const baseUrl = import.meta.env.VITE_BASE_URL as string;
|
export const baseUrl = import.meta.env.VITE_BASE_URL as string;
|
||||||
|
|
||||||
export async function apiAuth(
|
export async function apiAuth(
|
||||||
@ -22,7 +24,8 @@ export async function apiAuth(
|
|||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
logout();
|
const { onLogout } = useSession();
|
||||||
|
onLogout();
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
src/types.ts
14
src/types.ts
@ -18,3 +18,17 @@ export interface PlayerRanking {
|
|||||||
std: number;
|
std: number;
|
||||||
n: number;
|
n: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Chemistry {
|
||||||
|
id: number;
|
||||||
|
user: number;
|
||||||
|
hate: number[];
|
||||||
|
undecided: number[];
|
||||||
|
love: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MVPRanking {
|
||||||
|
id: number;
|
||||||
|
user: number;
|
||||||
|
mvps: number[];
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user