Compare commits
No commits in common. "453d7ca9518b1be9c7e4f0eb1e1f2046844869ad" and "4f30888c5c031a1012f0299ac1e024d7f03eeb9b" have entirely different histories.
453d7ca951
...
4f30888c5c
78
main.py
78
main.py
@ -1,11 +1,9 @@
|
|||||||
from typing import Annotated
|
from fastapi import APIRouter, Depends, FastAPI, Security
|
||||||
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
|
||||||
@ -20,9 +18,6 @@ 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")
|
||||||
@ -85,83 +80,20 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
wrong_user_id_exception = HTTPException(
|
@api_router.post("/mvps", dependencies=[Depends(get_current_active_user)])
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
def submit_mvps(mvps: MVPRanking):
|
||||||
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.get("/mvps")
|
@api_router.post("/chemistry", dependencies=[Depends(get_current_active_user)])
|
||||||
def get_mvps(
|
def submit_chemistry(chemistry: Chemistry):
|
||||||
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):
|
||||||
|
25
src/App.css
25
src/App.css
@ -235,17 +235,15 @@ 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 {
|
||||||
@ -323,20 +321,11 @@ button {
|
|||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-button {
|
.tablink {
|
||||||
flex: 1;
|
|
||||||
background-color: grey;
|
|
||||||
border: none;
|
|
||||||
margin: 4px auto;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab-button.active {
|
|
||||||
opacity: unset;
|
|
||||||
font-weight: bold;
|
|
||||||
background-color: black;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
margin: 4px auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
|
183
src/Rankings.tsx
183
src/Rankings.tsx
@ -1,8 +1,7 @@
|
|||||||
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
|
import { useEffect, 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;
|
||||||
@ -22,37 +21,15 @@ 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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChemistryDnD({ user, players }: PlayerInfoProps) {
|
export function Chemistry({ user, players }: PlayerInfoProps) {
|
||||||
var otherPlayers = players.filter((player) => player.id !== user.id);
|
const index = players.indexOf(user);
|
||||||
|
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[]>([]);
|
||||||
@ -62,36 +39,21 @@ function ChemistryDnD({ 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() {
|
||||||
if (dialogRef.current) dialogRef.current.showModal();
|
const dialog = document.querySelector("dialog[id='ChemistryDialog']");
|
||||||
|
(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, "PUT");
|
const response = await apiAuth("chemistry", data, "POST");
|
||||||
setDialog(response || "try sending again");
|
response ? setDialog(response) : setDialog("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>
|
||||||
@ -137,7 +99,6 @@ function ChemistryDnD({ 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();
|
||||||
@ -149,41 +110,28 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MVPDnD({ user, players }: PlayerInfoProps) {
|
export function MVP({ 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() {
|
||||||
if (dialogRef.current) dialogRef.current.showModal();
|
const dialog = document.querySelector("dialog[id='MVPDialog']");
|
||||||
|
(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, "PUT");
|
const response = await apiAuth("mvps", data, "POST");
|
||||||
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>
|
||||||
@ -229,7 +177,6 @@ function MVPDnD({ 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();
|
||||||
@ -241,30 +188,9 @@ function MVPDnD({ 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[] | null>(null);
|
const [players, setPlayers] = useState<User[]>([]);
|
||||||
const [openTab, setOpenTab] = useState("Chemistry");
|
const [openTab, setOpenTab] = useState("Chemistry");
|
||||||
|
|
||||||
async function loadPlayers() {
|
async function loadPlayers() {
|
||||||
@ -280,41 +206,66 @@ export default function Rankings() {
|
|||||||
loadPlayers();
|
loadPlayers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tabs = [
|
useEffect(() => {
|
||||||
{ id: "Chemistry", label: "🧪 Chemistry" },
|
openPage(openTab, "aliceblue");
|
||||||
{ id: "MVP", label: "🏆 MVP" },
|
}, [user]);
|
||||||
];
|
|
||||||
|
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
|
||||||
key={tab.id}
|
className="tablink"
|
||||||
className={
|
id="ChemistryButton"
|
||||||
openTab === tab.id ? "tab-button active" : "tab-button"
|
onClick={() => openPage("Chemistry", "aliceblue")}
|
||||||
}
|
|
||||||
onClick={() => setOpenTab(tab.id)}
|
|
||||||
>
|
>
|
||||||
{tab.label}
|
🧪 Chemistry
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="tablink"
|
||||||
|
id="MVPButton"
|
||||||
|
onClick={() => openPage("MVP", "aliceblue")}
|
||||||
|
>
|
||||||
|
🏆 MVP
|
||||||
</button>
|
</button>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
{tabs.map((tab) => {
|
|
||||||
if (openTab !== tab.id) return null;
|
<span className="grey">
|
||||||
switch (tab.id) {
|
assign as many or as few players as you want
|
||||||
case "Chemistry":
|
<br />
|
||||||
return <ChemistryDnD key={tab.id} {...{ user, players }} />;
|
and don't forget to <b>submit</b> (💾) when you're done :)
|
||||||
case "MVP":
|
</span>
|
||||||
return <MVPDnD key={tab.id} {...{ user, players }} />;
|
|
||||||
default:
|
<div id="Chemistry" className="tabcontent">
|
||||||
return null;
|
{user && <Chemistry {...{ user, players }} />}
|
||||||
}
|
</div>
|
||||||
})}
|
<div id="MVP" className="tabcontent">
|
||||||
|
{user && <MVP {...{ user, players }} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
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(
|
||||||
@ -24,8 +22,7 @@ export async function apiAuth(
|
|||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
const { onLogout } = useSession();
|
logout();
|
||||||
onLogout();
|
|
||||||
throw new Error("Unauthorized");
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
14
src/types.ts
14
src/types.ts
@ -18,17 +18,3 @@ 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