Compare commits

...

4 Commits

Author SHA1 Message Date
d9ad903798
feat: add back files in new location 2025-03-21 14:48:55 +01:00
b28752830a
feat: towards multi-team support
also testing at different points whether team association is correct
2025-03-21 14:44:55 +01:00
7f4f6142c9
feat: don't rely on secure JWT when it comes to scopes 2025-03-20 17:04:20 +01:00
ded2b79db7
feat: begin to add support for multiple teams 2025-03-19 15:08:18 +01:00
14 changed files with 383 additions and 206 deletions

0
cutt/__init__.py Normal file
View File

View File

@ -1,15 +1,18 @@
import io
import base64
from fastapi import APIRouter
from typing import Annotated
from fastapi import APIRouter, HTTPException, Security, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from db import Chemistry, MVPRanking, Player, engine
from cutt.db import Chemistry, MVPRanking, Player, Team, engine
import networkx as nx
import numpy as np
import matplotlib
from cutt.security import TeamScopedRequest, verify_team_scope
matplotlib.use("agg")
import matplotlib.pyplot as plt
@ -51,24 +54,37 @@ def sociogram_json():
return JSONResponse({"nodes": nodes, "edges": edges})
def graph_json():
def graph_json(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
):
nodes = []
edges = []
players = {}
player_map = {}
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
players[p.id] = p.display_name
statement = select(Team).where(Team.id == request.team_id)
players = [t.players for t in session.exec(statement)][0]
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
for p in players:
player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name})
subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
select(C.user, func.max(C.time).label("latest"))
.where(C.team == request.team_id)
.group_by(C.user)
.subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
user = players[c.user]
user = player_map[c.user]
for i, p_id in enumerate(c.love):
p = players[p_id]
p = player_map[p_id]
edges.append(
{
"id": f"{user}->{p}",
@ -83,7 +99,7 @@ def graph_json():
}
)
for p_id in c.hate:
p = players[p_id]
p = player_map[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
@ -95,6 +111,10 @@ def graph_json():
}
)
if not edges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
G = nx.DiGraph()
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
in_degrees = G.in_degree(weight="weight")
@ -199,20 +219,36 @@ async def render_sociogram(params: Params):
return {"image": encoded_image}
def mvp():
def mvp(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
):
ranks = dict()
with Session(engine) as session:
players = {p.id: p.display_name for p in session.exec(select(P)).fetchall()}
statement = select(Team).where(Team.id == request.team_id)
players = [t.players for t in session.exec(statement)][0]
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players}
subquery = (
select(R.user, func.max(R.time).label("latest")).group_by(R.user).subquery()
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
.group_by(R.user)
.subquery()
)
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
p = players[p_id]
p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1]
if not ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [
{
"name": p,
@ -224,10 +260,12 @@ def mvp():
]
analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"])
# analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route(
"/graph_json/{team_id}", endpoint=graph_json, methods=["GET"]
)
analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
analysis_router.add_api_route("/mvp", endpoint=mvp, methods=["GET"])
analysis_router.add_api_route("/mvp/{team_id}", endpoint=mvp, methods=["GET"])
if __name__ == "__main__":
with Session(engine) as session:

View File

@ -51,7 +51,7 @@ class Player(SQLModel, table=True):
disabled: bool | None = None
hashed_password: str | None = None
number: str | None = None
teams: list[Team] | None = Relationship(
teams: list[Team] = Relationship(
back_populates="players", link_model=PlayerTeamLink
)
scopes: str = ""
@ -60,17 +60,19 @@ class Player(SQLModel, table=True):
class Chemistry(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int | None = Field(default=None, foreign_key="player.id")
user: int = Field(default=None, foreign_key="player.id")
hate: list[int] = Field(sa_column=Column(ARRAY(Integer)))
undecided: list[int] = Field(sa_column=Column(ARRAY(Integer)))
love: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int | None = Field(default=None, foreign_key="player.id")
user: int = Field(default=None, foreign_key="player.id")
mvps: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class TokenDB(SQLModel, table=True):

View File

@ -2,23 +2,21 @@ from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine
from cutt.db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
Session,
func,
select,
)
from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router
from security import (
change_password,
from cutt.analysis import analysis_router
from cutt.security import (
get_current_active_user,
login_for_access_token,
logout,
read_player_me,
read_own_items,
set_first_password,
)
from cutt.player import player_router
C = Chemistry
R = MVPRanking
@ -46,50 +44,12 @@ def add_team(team: Team):
session.commit()
def add_player(player: Player):
with Session(engine) as session:
session.add(player)
session.commit()
def add_players(players: list[Player]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
async def list_players():
with Session(engine) as session:
statement = select(Player).order_by(Player.display_name)
players = session.exec(statement).fetchall()
return [
player.model_dump(include={"id", "display_name", "number"})
for player in players
]
async def read_teams_me(user: Annotated[Player, Depends(get_current_active_user)]):
with Session(engine) as session:
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0]
def list_teams():
with Session(engine) as session:
statement = select(Team)
return session.exec(statement).fetchall()
player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"])
player_router.add_api_route("/me/items", endpoint=read_own_items, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
)
team_router = APIRouter(
prefix="/team",
dependencies=[Security(get_current_active_user, scopes=["admin"])],
@ -102,6 +62,9 @@ wrong_user_id_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you're not who you think you are...",
)
somethings_fishy = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="something up..."
)
@api_router.put("/mvps")
@ -111,9 +74,15 @@ def submit_mvps(
):
if user.id == mvps.user:
with Session(engine) as session:
session.add(mvps)
session.commit()
return JSONResponse("success!")
statement = select(Team).where(Team.id == mvps.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= set(mvps.mvps):
session.add(mvps)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@ -148,9 +117,17 @@ def submit_chemistry(
):
if user.id == chemistry.user:
with Session(engine) as session:
session.add(chemistry)
session.commit()
return JSONResponse("success!")
statement = select(Team).where(Team.id == chemistry.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= (
set(chemistry.love) | set(chemistry.hate) | set(chemistry.undecided)
):
session.add(chemistry)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@ -189,10 +166,7 @@ api_router.include_router(
player_router, dependencies=[Depends(get_current_active_user)]
)
api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)])
api_router.include_router(
analysis_router,
dependencies=[Security(get_current_active_user, scopes=["analysis"])],
)
api_router.include_router(analysis_router)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])

47
cutt/player.py Normal file
View File

@ -0,0 +1,47 @@
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from cutt.db import Player, Team, engine
from cutt.security import change_password, get_current_active_user, read_player_me
P = Player
def add_player(player: P):
with Session(engine) as session:
session.add(player)
session.commit()
def add_players(players: list[P]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
async def list_players(team_id: int):
with Session(engine) as session:
statement = select(Team).where(Team.id == team_id)
players = [t.players for t in session.exec(statement)][0]
if players:
return [
player.model_dump(include={"id", "display_name", "number"})
for player in players
]
async def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
with Session(engine) as session:
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0]
player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list/{team_id}", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
)

View File

@ -6,7 +6,7 @@ from pydantic import BaseModel, ValidationError
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select
from db import TokenDB, engine, Player
from cutt.db import TokenDB, engine, Player
from fastapi.security import (
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
@ -141,8 +141,9 @@ async def get_current_user(
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
allowed_scopes = set(user.scopes.split())
for scope in security_scopes.scopes:
if scope not in token_data.scopes:
if scope not in allowed_scopes or scope not in token_data.scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
@ -159,6 +160,24 @@ async def get_current_active_user(
return current_user
class TeamScopedRequest(BaseModel):
user: Player
team_id: int
async def verify_team_scope(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
allowed_scopes = set(user.scopes.split())
if f"team:{team_id}" not in allowed_scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
)
else:
return TeamScopedRequest(user=user, team_id=team_id)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response
) -> Token:

View File

@ -408,15 +408,31 @@ button {
}
}
.avatars {
margin: 16px auto;
}
.avatar {
background-color: #f0f8ff88;
font-weight: bold;
font-size: 110%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 0 auto 16px auto;
margin: 4px auto;
}
.group-avatar {
background-color: #f0f8ff88;
color: inherit;
font-weight: bold;
font-size: 90%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
}
.user-info {
@ -532,6 +548,7 @@ button {
position: relative;
height: 12px;
width: 96%;
margin: auto;
border: 4px solid black;
overflow: hidden;
}

View File

@ -1,5 +1,5 @@
import { createRef, MouseEventHandler, useEffect, useState } from "react";
import { useSession } from "./Session";
import { TeamState, useSession } from "./Session";
import { User } from "./api";
import { useTheme } from "./ThemeProvider";
import { colourTheme, darkTheme, normalTheme, rainbowTheme } from "./themes";
@ -11,7 +11,7 @@ interface ContextMenuItem {
onClick: () => void;
}
const UserInfo = (user: User, teams: Team[] | undefined) => {
const UserInfo = (user: User, teams: TeamState | undefined) => {
return (
<div className="user-info">
<div>
@ -42,9 +42,9 @@ const UserInfo = (user: User, teams: Team[] | undefined) => {
textAlign: "left",
}}
>
{teams.map((team) => (
{teams.teams.map((team, index) => (
<li>
{team.name} (
{teams.activeTeam === index ? <b>{team.name}</b> : team.name} (
{team.location || team.country || "location unknown"})
</li>
))}
@ -56,7 +56,7 @@ const UserInfo = (user: User, teams: Team[] | undefined) => {
};
export default function Avatar() {
const { user, teams, onLogout } = useSession();
const { user, teams, setTeams, onLogout } = useSession();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const [contextMenu, setContextMenu] = useState<{
@ -147,20 +147,40 @@ export default function Avatar() {
}
return (
<div
className="avatar"
onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();
} else {
handleMenuClick(event);
}
}}
ref={avatarRef}
>
👤 {user?.username}
<>
<div className="avatars">
<div
className="avatar"
onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();
} else {
handleMenuClick(event);
}
}}
ref={avatarRef}
>
👤 {user?.username}
</div>
{teams && teams?.teams.length > 1 && (
<select
className="group-avatar"
value={teams.activeTeam}
onChange={(e) =>
setTeams({ ...teams, activeTeam: Number(e.target.value) })
}
>
{teams.teams.map((team) => (
<option key={team.id} value={team.id}>
👥 {team.name}
</option>
))}
</select>
)}
</div>
{contextMenu.open && (
<ul
className="context-menu"
@ -193,6 +213,6 @@ export default function Avatar() {
>
{dialog}
</dialog>
</div>
</>
);
}

View File

@ -1,36 +1,45 @@
import { useEffect, useState } from "react";
import { ReactNode, useEffect, useState } from "react";
import { apiAuth } from "./api";
import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart";
import { useSession } from "./Session";
const MVPChart = () => {
const [data, setData] = useState({} as PlayerRanking[]);
let initialData = {} as PlayerRanking[];
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [showStd, setShowStd] = useState(false);
const { teams } = useSession();
async function loadData() {
setLoading(true);
await apiAuth("analysis/mvp", null)
.then((json) => json as Promise<PlayerRanking[]>)
.then((json) => {
setData(json.sort((a, b) => a.rank - b.rank));
});
setLoading(false);
if (teams) {
await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null)
.then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<PlayerRanking[]>;
}
})
.then((data) => {
setData(data.sort((a, b) => a.rank - b.rank));
})
.catch(() => setError("no access"));
setLoading(false);
} else setError("team unknown");
}
useEffect(() => {
loadData();
}, []);
}, [teams]);
return (
<>
{loading ? (
<span className="loader" />
) : (
<RaceChart std={showStd} players={data} />
)}
</>
);
if (loading) return <span className="loader" />;
else if (error) return <span>{error}</span>;
else return <RaceChart std={showStd} players={data} />;
};
export default MVPChart;

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { apiAuth } from "./api";
import {
GraphCanvas,
@ -10,6 +10,7 @@ import {
useSelection,
} from "reagraph";
import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session";
interface NetworkData {
nodes: GraphNode[];
@ -36,26 +37,38 @@ const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
};
export const GraphComponent = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
let initialData = { nodes: [], edges: [] } as NetworkData;
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [threed, setThreed] = useState(false);
const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false);
const { teams } = useSession();
async function loadData() {
setLoading(true);
await apiAuth("analysis/graph_json", null)
.then((json) => json as Promise<NetworkData>)
.then((json) => {
setData(json);
});
setLoading(false);
if (teams) {
await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null)
.then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<NetworkData>;
}
})
.then((data) => setData(data))
.catch(() => setError("no access"));
setLoading(false);
} else setError("team unknown");
}
useEffect(() => {
loadData();
}, []);
}, [teams]);
const graphRef = useRef<GraphCanvasRef | null>(null);
@ -161,6 +174,74 @@ export const GraphComponent = () => {
type: "multiModifier",
});
let content: ReactNode;
if (loading) {
content = <span className="loader" />;
} else if (error) {
content = <span>{error}</span>;
} else {
content = (
<>
<GraphCanvas
draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef}
theme={customTheme}
nodes={data.nodes}
edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/>
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
</>
);
}
return (
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls">
@ -225,67 +306,7 @@ export const GraphComponent = () => {
</span>
</div>
)}
{loading ? (
<span className="loader" />
) : (
<GraphCanvas
draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef}
theme={customTheme}
nodes={data.nodes}
edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/>
)}
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
{content}
</div>
);
};

View File

@ -8,7 +8,7 @@ import {
} from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api";
import { useSession } from "./Session";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types";
import TabController from "./TabController";
@ -56,10 +56,11 @@ function filterSort(list: User[], ids: number[]): User[] {
interface PlayerInfoProps {
user: User;
teams: TeamState;
players: User[];
}
function ChemistryDnD({ user, players }: PlayerInfoProps) {
function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
@ -68,6 +69,11 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
useEffect(() => {
setPlayersMiddle(otherPlayers);
}, [players]);
useEffect(() => {
setPlayersLeft([]);
setPlayersMiddle(otherPlayers);
setPlayersRight([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
@ -78,7 +84,13 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.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,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
@ -163,7 +175,7 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
);
}
function MVPDnD({ user, players }: PlayerInfoProps) {
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
@ -171,6 +183,11 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
setAvailablePlayers(players);
}, [players]);
useEffect(() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
@ -178,7 +195,7 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps };
const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again");
}
@ -277,21 +294,27 @@ function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
}
export default function Rankings() {
const { user } = useSession();
const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null);
async function loadPlayers() {
try {
const data = await apiAuth("player/list", null, "GET");
setPlayers(data as User[]);
} catch (error) {
console.error(error);
if (teams) {
try {
const data = await apiAuth(
`player/list/${teams?.activeTeam}`,
null,
"GET"
);
setPlayers(data as User[]);
} catch (error) {
console.error(error);
}
}
}
useEffect(() => {
loadPlayers();
}, [user]);
}, [user, teams]);
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
@ -300,10 +323,10 @@ export default function Rankings() {
return (
<>
{user && players ? (
{user && teams && players ? (
<TabController tabs={tabs}>
<ChemistryDnD {...{ user, players }} />
<MVPDnD {...{ user, players }} />
<ChemistryDnD {...{ user, teams, players }} />
<MVPDnD {...{ user, teams, players }} />
</TabController>
) : (
<span className="loader" />

View File

@ -14,15 +14,22 @@ export interface SessionProviderProps {
children: ReactNode;
}
export interface TeamState {
teams: Team[];
activeTeam: number;
}
export interface Session {
user: User | null;
teams: Team[] | null;
teams: TeamState | null;
setTeams: (teams: TeamState) => void;
onLogout: () => void;
}
const sessionContext = createContext<Session>({
user: null,
teams: null,
setTeams: () => {},
onLogout: () => {},
});
@ -30,7 +37,7 @@ export function SessionProvider(props: SessionProviderProps) {
const { children } = props;
const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<Team[] | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null);
const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
@ -50,12 +57,12 @@ export function SessionProvider(props: SessionProviderProps) {
async function loadTeam() {
const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (teams) setTeams(teams);
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
}
useEffect(() => {
loadUser();
setTimeout(() => loadTeam(), 1500);
loadTeam();
}, []);
function onLogin(user: User) {
@ -87,7 +94,7 @@ export function SessionProvider(props: SessionProviderProps) {
content = <Login onLogin={onLogin} />;
} else
content = (
<sessionContext.Provider value={{ user, teams, onLogout }}>
<sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
{children}
</sessionContext.Provider>
);

View File

@ -1,5 +1,4 @@
import { useSession } from "./Session";
import { Team } from "./types";
export const baseUrl = import.meta.env.VITE_BASE_URL as string;

View File

@ -34,6 +34,7 @@ export interface MVPRanking {
}
export interface Team {
id: number;
name: string;
location: string;
country: string;