42 Commits

Author SHA1 Message Date
1626751083 feat: exponentially decreasing edge weight
and adjustment of negative weight
2025-06-20 14:01:04 +02:00
710b0770cc chore: remove old Analysis page 2025-05-26 07:53:58 +02:00
56c1ba11fc chore: remove unused 2025-05-26 07:51:44 +02:00
ad2b2993df fix: try to make sure order when mixed changes 2025-05-26 07:48:33 +02:00
638e8bf20c feat: gender-separated MVPChart in mixed teams 2025-05-26 07:33:31 +02:00
a4ea0dfc41 fix: don't show times of players not in the team 2025-05-25 21:13:28 +02:00
62ba89c599 feat: require gender when registering 2025-05-23 22:09:49 +02:00
05bdc5c44c feat: support mixed teams in MVP ranking 2025-05-23 22:01:08 +02:00
105b3778e1 fix: find display_name 2025-05-23 09:44:22 +02:00
003f401320 fix: go back to forms if not team captain 2025-05-22 12:45:49 +02:00
2195e7324d feat: exclude players from analysis that aren't in the team 2025-05-22 12:33:28 +02:00
ba26e7c9e6 feat: bigger "Events" 2025-05-21 15:18:01 +02:00
64d6edd9f5 feat: order players alphabetically 2025-05-21 15:17:34 +02:00
b781408c18 feat: disable dark mode for text inputs 2025-05-21 15:08:36 +02:00
a0c8e0cd18 feat: decrease calendar size 2025-05-21 15:04:06 +02:00
de8dc6b9b9 feat: load players in session 2025-05-21 14:55:13 +02:00
241f6fa7eb feat: show active player 2025-05-21 14:37:07 +02:00
a42fff807c feat: useSession for players 2025-05-21 14:36:51 +02:00
369cf0b727 feat: calendar display for latest submissions 2025-05-21 14:26:35 +02:00
a6dfab47d5 fix: props.list empty 2025-05-19 14:59:10 +02:00
4c78ede7c2 feat: increase dnd box 2025-05-19 14:50:24 +02:00
8c8a88e72c fix: handle players removed from team 2025-05-19 14:45:31 +02:00
b9efd4f7a3 feat: add player type survey 2025-05-19 14:32:30 +02:00
a6ebc28d47 fix: gender and previous state for DEMO 2025-05-18 16:01:05 +02:00
48f282423f feat: add gender in DEMO 2025-05-18 13:19:50 +02:00
881e015c1f Merge branch 'feat/demo' 2025-05-18 13:18:46 +02:00
4e2e0dd2a5 feat: add gender 2025-05-18 13:18:02 +02:00
b739246129 feat: load previously submitted by default 2025-05-18 11:59:07 +02:00
cb2b7db7a6 feat: demo MVP list 2025-05-08 10:11:00 +02:00
1c71df781c feat: adjustments for Demo Team (team_id = 42) 2025-05-08 07:07:14 +02:00
6378488fd0 feat: add demo players 2025-05-08 07:05:44 +02:00
6902ffdca6 fix: weigh dislike negatively for weighted degree 2025-04-16 12:59:50 +02:00
a6d0f528d0 fix: left out saving the password hash 2025-03-27 16:17:19 +01:00
77d292974c fix: group token cannot be one-time only 2025-03-27 08:04:41 +01:00
43f9b0d47c feat: register new user with team-specific token
revamp entire `SetPassword` page
2025-03-26 17:37:02 +01:00
bef5119a0b feat: more helpful error 2025-03-26 17:36:52 +01:00
ee13d06ab1 feat: no user -> no team 2025-03-26 16:31:27 +01:00
03ed843679 feat: check whether user has necessary scope 2025-03-25 19:09:33 +01:00
81d6a02229 feat: disallow non-members to list team members 2025-03-25 16:38:44 +01:00
11f3f9f440 fix: weird bug
cutt.0124816.xyz/team responded with 404 while all other routes were handled correctly
by react-router
2025-03-25 16:31:25 +01:00
0507b9f7c4 feat: default export TeamPanel 2025-03-25 09:13:26 +01:00
e701ebbb02 feat: remove unused / 2025-03-25 09:12:55 +01:00
22 changed files with 1526 additions and 776 deletions

View File

@@ -1,4 +1,6 @@
import io
import itertools
import random
import base64
from typing import Annotated
from fastapi import APIRouter, HTTPException, Security, status
@@ -6,12 +8,21 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from cutt.db import Chemistry, MVPRanking, Player, PlayerTeamLink, Team, engine
from cutt.db import (
Chemistry,
MVPRanking,
Player,
PlayerTeamLink,
PlayerType,
Team,
engine,
)
import networkx as nx
import numpy as np
import matplotlib
from cutt.security import TeamScopedRequest, verify_team_scope
from cutt.demo import demo_players
matplotlib.use("agg")
import matplotlib.pyplot as plt
@@ -22,6 +33,7 @@ analysis_router = APIRouter(prefix="/analysis", tags=["analysis"])
C = Chemistry
R = MVPRanking
PT = PlayerType
P = Player
@@ -55,13 +67,65 @@ def sociogram_json():
def graph_json(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
networkx_graph: bool = False,
):
nodes = []
edges = []
player_map = {}
if request.team_id == 42:
players = [request.user] + demo_players
random.seed(42)
for p in players:
nodes.append({"id": p.display_name, "label": p.display_name})
for p, other in itertools.permutations(players, 2):
value = random.random()
if value > 0.5:
edges.append(
{
"id": f"{p.display_name}->{other.display_name}",
"source": p.display_name,
"target": other.display_name,
"size": max(value, 0.3),
"data": {
"relation": 2,
"origSize": max(value, 0.3),
"origFill": "#bed4ff",
},
}
)
elif value < 0.1:
edges.append(
{
"id": f"{p.display_name}-x>{other.display_name}",
"source": p.display_name,
"target": other.display_name,
"size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
)
G = nx.DiGraph()
G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from(
[
(
e["source"],
e["target"],
e["size"] if e["data"]["relation"] == 2 else -e["size"],
)
for e in edges
]
)
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}})
for node in nodes
]
if networkx_graph:
return G
return JSONResponse({"nodes": nodes, "edges": edges})
with Session(engine) as session:
players = session.exec(
select(P)
@@ -70,7 +134,10 @@ def graph_json(
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no players found in your team",
)
for p in players:
player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name})
@@ -86,30 +153,37 @@ def graph_json(
)
for c in session.exec(statement2):
if c.user not in player_map:
continue
user = player_map[c.user]
for i, p_id in enumerate(c.love):
if p_id not in player_map:
continue
p = player_map[p_id]
weight = 0.9**i
edges.append(
{
"id": f"{user}->{p}",
"source": user,
"target": p,
"size": max(1.0 - 0.1 * i, 0.3),
"size": weight,
"data": {
"relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3),
"origSize": weight,
"origFill": "#bed4ff",
},
}
)
for p_id in c.hate:
if p_id not in player_map:
continue
p = player_map[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
"source": user,
"target": p,
"size": 0.3,
"size": 0.5,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
@@ -121,11 +195,22 @@ def graph_json(
)
G = nx.DiGraph()
G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
G.add_weighted_edges_from(
[
(
e["source"],
e["target"],
e["size"] if e["data"]["relation"] == 2 else -e["size"],
)
for e in edges
]
)
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
]
if networkx_graph:
return G
return JSONResponse({"nodes": nodes, "edges": edges})
@@ -224,12 +309,69 @@ async def render_sociogram(params: Params):
return {"image": encoded_image}
def mvp(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
translate_tablename = {
R.__tablename__: "🏆",
C.__tablename__: "🧪",
PT.__tablename__: "🃏",
}
def last_submissions(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
):
ranks = dict()
times = {}
with Session(engine) as session:
player_ids = session.exec(
select(P.id)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
for survey in [C, PT, R]:
subquery = (
select(survey.user, func.max(survey.time).label("latest"))
.where(survey.team == request.team_id)
.where(survey.user.in_(player_ids))
.group_by(survey.user)
.subquery()
)
statement2 = select(survey).join(
subquery,
(survey.user == subquery.c.user) & (survey.time == subquery.c.latest),
)
for r in session.exec(statement2):
if r.time.date() not in times:
times[r.time.date()] = {}
times[r.time.date()][r.user] = (
times[r.time.date()].get(r.user, "")
+ translate_tablename[survey.__tablename__]
)
return times
def mvp(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)], mixed=False
):
if request.team_id == 42:
ranks = {}
random.seed(42)
players = [request.user] + demo_players
for p in players:
random.shuffle(players)
for i, p in enumerate(players):
ranks[p.id] = ranks.get(p.id, []) + [i + 1]
return [
[
{
"p_id": p_id,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for i, (p_id, v) in enumerate(ranks.items())
]
]
with Session(engine) as session:
players = session.exec(
select(P)
@@ -239,7 +381,7 @@ def mvp(
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players}
player_map = {p.id: p for p in players}
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
@@ -249,23 +391,45 @@ def mvp(
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
if mixed:
all_ranks = []
for gender in ["fmp", "mmp"]:
ranks = {}
for r in session.exec(statement2):
mvps = [
p_id
for p_id in r.mvps
if p_id in player_map and player_map[p_id].gender == gender
]
for i, p_id in enumerate(mvps):
p = player_map[p_id]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks.append(ranks)
else:
ranks = {}
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
if p_id not in player_map:
continue
p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks = [ranks]
if not ranks:
if not all_ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [
[
{
"name": p,
"p_id": p_id,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
for p_id, v in ranks.items()
]
for ranks in all_ranks
]
@@ -340,6 +504,9 @@ analysis_router.add_api_route(
description="Request Most Valuable Players stats",
)
analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
analysis_router.add_api_route(
"/times/{team_id}", endpoint=last_submissions, methods=["GET"]
)
if __name__ == "__main__":
with Session(engine) as session:

View File

@@ -1,6 +1,7 @@
from datetime import datetime, timezone
from sqlmodel import (
ARRAY,
CHAR,
Column,
Integer,
Relationship,
@@ -37,6 +38,7 @@ class Team(SQLModel, table=True):
name: str
location: str | None
country: str | None
mixed: bool = False
players: list["Player"] | None = Relationship(
back_populates="teams", link_model=PlayerTeamLink
)
@@ -48,6 +50,7 @@ class Player(SQLModel, table=True):
display_name: str
email: str | None = None
full_name: str | None = None
gender: str | None = Field(default=None, sa_column=Column(CHAR(3)))
disabled: bool | None = None
hashed_password: str | None = None
number: str | None = None
@@ -67,6 +70,16 @@ class Chemistry(SQLModel, table=True):
team: int = Field(default=None, foreign_key="team.id")
class PlayerType(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id")
handlers: list[int] = Field(sa_column=Column(ARRAY(Integer)))
combis: list[int] = Field(sa_column=Column(ARRAY(Integer)))
cutters: 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)

28
cutt/demo.py Normal file
View File

@@ -0,0 +1,28 @@
import random
from cutt.db import Player
names = [
("August", "mmp"),
("Beate", "fmp"),
("Ceasar", "mmp"),
("Daedalus", "mmp"),
("Elli", "fmp"),
("Ford P.", ""),
("Gabriel", "mmp"),
("Hugo", "mmp"),
("Ivar Johansson", "mmp"),
("Jürgen Gordon Malinauskas", "mmp"),
]
demo_players = [
Player.model_validate(
{
"id": i + 4200,
"display_name": name,
"username": name.lower().replace(" ", "").replace(".", ""),
"gender": gender,
"number": str(random.randint(0, 100)),
"email": name.lower().replace(" ", "").replace(".", "") + "@example.org",
}
)
for i, (name, gender) in enumerate(names)
]

View File

@@ -1,8 +1,8 @@
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import JSONResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from cutt.db import Player, Team, Chemistry, MVPRanking, engine
from cutt.db import Player, PlayerType, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
Session,
func,
@@ -14,11 +14,13 @@ from cutt.security import (
get_current_active_user,
login_for_access_token,
logout,
register,
set_first_password,
)
from cutt.player import player_router
C = Chemistry
PT = PlayerType
R = MVPRanking
P = Player
@@ -75,6 +77,8 @@ def submit_mvps(
mvps: MVPRanking,
user: Annotated[Player, Depends(get_current_active_user)],
):
if mvps.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == mvps.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == mvps.team)
@@ -111,7 +115,7 @@ def get_mvps(
return mvps
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
@@ -120,6 +124,8 @@ def get_mvps(
def submit_chemistry(
chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)]
):
if chemistry.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == chemistry.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == chemistry.team)
@@ -153,11 +159,61 @@ def get_chemistry(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
chemistry = session.exec(statement2).one_or_none()
if chemistry:
if chemistry is not None:
return chemistry
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
@api_router.put("/playertype", tags=["analysis"])
def submit_playertype(
playertype: PlayerType, user: Annotated[Player, Depends(get_current_active_user)]
):
if playertype.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == playertype.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == playertype.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(playertype.handlers)
| set(playertype.combis)
| set(playertype.cutters)
):
session.add(playertype)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@api_router.get("/playertype/{team_id}", tags=["analysis"])
def get_playertype(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
with Session(engine) as session:
subquery = (
select(PT.user, func.max(PT.time).label("latest"))
.where(PT.user == user.id)
.where(PT.team == team_id)
.group_by(PT.user)
.subquery()
)
statement2 = select(PT).join(
subquery, (PT.user == subquery.c.user) & (PT.time == subquery.c.latest)
)
playertype = session.exec(statement2).one_or_none()
if playertype is not None:
return playertype
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
@@ -177,6 +233,20 @@ api_router.include_router(team_router, dependencies=[Depends(get_current_active_
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("/register", endpoint=register, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])
app.include_router(api_router)
app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
# app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
@app.get("/")
async def root():
return FileResponse("dist/index.html")
@app.exception_handler(404)
async def exception_404_handler(request, exc):
return FileResponse("dist/index.html")
app.mount("/", StaticFiles(directory="dist"), name="ui")

View File

@@ -12,6 +12,7 @@ from cutt.security import (
read_player_me,
verify_team_scope,
)
from cutt.demo import demo_players
P = Player
@@ -21,13 +22,20 @@ player_router = APIRouter(prefix="/player", tags=["player"])
class PlayerRequest(BaseModel):
display_name: str
username: str
gender: str | None
number: str
email: str
email: str | None
class AddPlayerRequest(PlayerRequest): ...
DEMO_TEAM_REQUEST = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DEMO Team, nothing happens",
)
def add_player(
r: AddPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
@@ -54,6 +62,7 @@ def add_player(
new_player = Player(
username=r.username,
display_name=r.display_name,
gender=r.gender if r.gender else None,
email=r.email if r.email else None,
number=r.number,
disabled=False,
@@ -72,6 +81,8 @@ def modify_player(
r: ModifyPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
if request.team_id == 42:
raise DEMO_TEAM_REQUEST
with Session(engine) as session:
player = session.exec(
select(P)
@@ -80,9 +91,11 @@ def modify_player(
.where(Team.id == request.team_id, P.id == r.id, P.username == r.username)
).one_or_none()
if player:
print(r)
player.display_name = r.display_name.strip()
player.number = r.number.strip()
player.email = r.email.strip()
player.gender = r.gender.strip() if r.gender else None
player.email = r.email.strip() if r.email else None
session.add(player)
session.commit()
return PlainTextResponse("modification successful")
@@ -101,6 +114,8 @@ def disable_player(
r: DisablePlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
if request.team_id == 42:
raise DEMO_TEAM_REQUEST
with Session(engine) as session:
player = session.exec(
select(P)
@@ -145,18 +160,47 @@ async def list_all_players():
return session.exec(select(P)).all()
async def list_players(team_id: int):
async def list_players(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
if team_id == 42:
return [
user.model_dump(
include={"id", "display_name", "gender", "username", "number", "email"}
)
] + demo_players
with Session(engine) as session:
current_user = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
).one_or_none()
if not current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="you're not in this team",
)
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False)
.order_by(P.display_name)
).all()
if players:
return [
player.model_dump(
include={"id", "display_name", "username", "number", "email"}
include={
"id",
"display_name",
"username",
"gender",
"number",
"email",
}
)
for player in players
if not player.disabled
@@ -165,7 +209,9 @@ async def list_players(team_id: int):
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]
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0] + [
{"country": "nowhere", "id": 42, "location": "everywhere", "name": "DEMO"}
]
player_router.add_api_route(
@@ -187,7 +233,6 @@ player_router.add_api_route(
"/{team_id}/list",
endpoint=list_players,
methods=["GET"],
dependencies=[Depends(get_current_active_user)],
)
player_router.add_api_route(
"/list",

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 cutt.db import TokenDB, engine, Player
from cutt.db import PlayerTeamLink, Team, TokenDB, engine, Player
from fastapi.security import (
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
@@ -16,6 +16,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError
P = Player
class Config(BaseSettings):
secret_key: str = ""
@@ -168,6 +170,8 @@ class TeamScopedRequest(BaseModel):
async def verify_team_scope(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
if team_id == 42:
return TeamScopedRequest(user=user, team_id=team_id)
allowed_scopes = set(user.scopes.split())
if f"team:{team_id}" not in allowed_scopes:
raise HTTPException(
@@ -208,72 +212,94 @@ async def logout(response: Response):
return {"message": "Successfully logged out"}
def generate_one_time_token(username):
def set_password_token(username: str):
user = get_user(username)
if user:
expire = timedelta(days=7)
expire = timedelta(days=30)
token = create_access_token(
data={"sub": username, "name": user.display_name},
data={
"sub": "set password",
"username": username,
"name": user.display_name,
},
expires_delta=expire,
)
return token
def register_token(team_id: int):
with Session(engine) as session:
team = session.exec(select(Team).where(Team.id == team_id)).one()
if team:
expire = timedelta(days=30)
token = create_access_token(
data={"sub": "register", "team_id": team_id, "name": team.name},
expires_delta=expire,
)
return token
def verify_one_time_token(token: str):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate token",
)
with Session(engine) as session:
token_in_db = session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == False)
).one_or_none()
if token_in_db:
try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
return payload
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="access token expired",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
elif session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token already used",
)
else:
raise credentials_exception
def invalidate_one_time_token(token: str):
with Session(engine) as session:
token_in_db = session.exec(select(TokenDB).where(TokenDB.token == token)).one()
token_in_db.used = True
session.add(token_in_db)
session.commit()
class FirstPassword(BaseModel):
token: str
password: str
async def set_first_password(req: FirstPassword):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate token",
)
with Session(engine) as session:
token_in_db = session.exec(
select(TokenDB)
.where(TokenDB.token == req.token)
.where(TokenDB.used == False)
).one_or_none()
if token_in_db:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate token",
)
try:
payload = jwt.decode(req.token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except ExpiredSignatureError:
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "set password":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token expired",
detail="wrong type of token.",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
username: str = payload.get("username")
with Session(engine) as session:
user = get_user(username)
if user:
user.hashed_password = get_password_hash(req.password)
session.add(user)
token_in_db.used = True
session.add(token_in_db)
session.commit()
return Response(
"Password set successfully", status_code=status.HTTP_200_OK
)
elif session.exec(
select(TokenDB)
.where(TokenDB.token == req.token)
.where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token already used",
)
else:
raise credentials_exception
invalidate_one_time_token(req.token)
return Response("password set successfully", status_code=status.HTTP_200_OK)
class ChangedPassword(BaseModel):
@@ -295,17 +321,74 @@ async def change_password(
session.add(user)
session.commit()
return PlainTextResponse(
"Password changed successfully",
"password changed successfully",
status_code=status.HTTP_200_OK,
media_type="text/plain",
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Wrong password",
detail="wrong password",
)
class RegisterRequest(BaseModel):
token: str
team_id: int
display_name: str
username: str
password: str
email: str | None
number: str | None
async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "register":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
team_id: int = payload.get("team_id")
if team_id != req.team_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong team",
)
with Session(engine) as session:
if session.exec(select(P).where(P.username == req.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username exists",
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.display_name == req.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == team_id)).one()
new_player = Player(
username=req.username,
hashed_password=get_password_hash(req.password),
display_name=req.display_name,
email=req.email if req.email else None,
number=req.number,
disabled=False,
teams=[team],
)
session.add(new_player)
session.commit()
# invalidate_one_time_token(req.token)
return PlainTextResponse(f"added {new_player.display_name}")
async def read_player_me(
current_user: Annotated[Player, Depends(get_current_active_user)],
):

View File

@@ -1,228 +0,0 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
//const debounce = <T extends (...args: any[]) => void>(
// func: T,
// delay: number
//): ((...args: Parameters<T>) => void) => {
// let timeoutId: number | null = null;
// return (...args: Parameters<T>) => {
// if (timeoutId !== null) {
// clearTimeout(timeoutId);
// }
// console.log(timeoutId);
// timeoutId = setTimeout(() => {
// func(...args);
// }, delay);
// };
//};
//
interface Params {
nodeSize: number;
edgeWidth: number;
arrowSize: number;
fontSize: number;
distance: number;
weighting: boolean;
popularity: boolean;
show: number;
}
let timeoutID: NodeJS.Timeout | null = null;
export default function Analysis() {
const [image, setImage] = useState("");
const [params, setParams] = useState<Params>({
nodeSize: 2000,
edgeWidth: 1,
arrowSize: 16,
fontSize: 10,
distance: 2,
weighting: true,
popularity: true,
show: 2,
});
const [showControlPanel, setShowControlPanel] = useState(false);
const [loading, setLoading] = useState(true);
// Function to generate and fetch the graph image
async function loadImage() {
setLoading(true);
await apiAuth("analysis/image", params, "POST")
.then((data) => {
setImage(data.image);
setLoading(false);
})
.catch((e) => {
console.log("best to just reload... ", e);
});
}
useEffect(() => {
if (timeoutID) {
clearTimeout(timeoutID);
}
timeoutID = setTimeout(() => {
loadImage();
}, 1000);
}, [params]);
function showLabel() {
switch (params.show) {
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
return (
<div className="stack column dropdown">
<button onClick={() => setShowControlPanel(!showControlPanel)}>
Parameters{" "}
<svg
viewBox="0 0 24 24"
height="1.2em"
style={{
fill: "#ffffff",
display: "inline",
top: "0.2em",
position: "relative",
transform: showControlPanel ? "rotate(180deg)" : "unset",
}}
>
{" "}
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"> </path>
</svg>
</button>
<div id="control-panel" className={showControlPanel ? "opened" : ""}>
<div className="control">
<datalist id="markers">
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
</datalist>
<div id="three-slider">
<label>😬</label>
<input
type="range"
list="markers"
min="0"
max="2"
step="1"
width="16px"
onChange={(evt) =>
setParams({ ...params, show: Number(evt.target.value) })
}
/>
<label>😍</label>
</div>
{showLabel()}
</div>
<div className="control">
<div className="checkBox">
<input
type="checkbox"
checked={params.weighting}
onChange={(evt) =>
setParams({ ...params, weighting: evt.target.checked })
}
/>
<label>weighting</label>
</div>
<div className="checkBox">
<input
type="checkbox"
checked={params.popularity}
onChange={(evt) =>
setParams({ ...params, popularity: evt.target.checked })
}
/>
<label>popularity</label>
</div>
</div>
<div className="control">
<label>distance between nodes</label>
<input
type="range"
min="0.01"
max="3.001"
step="0.05"
value={params.distance}
onChange={(evt) =>
setParams({ ...params, distance: Number(evt.target.value) })
}
/>
<span>{params.distance}</span>
</div>
<div className="control">
<label>node size</label>
<input
type="range"
min="500"
max="3000"
value={params.nodeSize}
onChange={(evt) =>
setParams({ ...params, nodeSize: Number(evt.target.value) })
}
/>
<span>{params.nodeSize}</span>
</div>
<div className="control">
<label>font size</label>
<input
type="range"
min="4"
max="24"
value={params.fontSize}
onChange={(evt) =>
setParams({ ...params, fontSize: Number(evt.target.value) })
}
/>
<span>{params.fontSize}</span>
</div>
<div className="control">
<label>edge width</label>
<input
type="range"
min="1"
max="5"
step="0.1"
value={params.edgeWidth}
onChange={(evt) =>
setParams({ ...params, edgeWidth: Number(evt.target.value) })
}
/>
<span>{params.edgeWidth}</span>
</div>
<div className="control">
<label>arrow size</label>
<input
type="range"
min="10"
max="50"
value={params.arrowSize}
onChange={(evt) =>
setParams({ ...params, arrowSize: Number(evt.target.value) })
}
/>
<span>{params.arrowSize}</span>
</div>
</div>
<button onClick={() => loadImage()}>reload </button>
{loading ? (
<span className="loader"></span>
) : (
<img src={"data:image/png;base64," + image} width="86%" />
)}
</div>
);
}

View File

@@ -29,7 +29,6 @@ dialog {
border-radius: 1em;
}
/*=========Network Controls=========*/
.infobutton {
@@ -61,7 +60,7 @@ dialog {
flex-wrap: wrap;
max-width: 240px;
margin: 0px;
background-color: #F0F8FFdd;
background-color: #f0f8ffdd;
.slider,
span {
@@ -103,8 +102,8 @@ dialog {
bottom: 0;
background-color: #ccc;
border-radius: 34px;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: 0.4s;
transition: 0.4s;
}
.slider:before {
@@ -116,16 +115,16 @@ dialog {
bottom: 3px;
background-color: white;
border-radius: 50%;
-webkit-transition: .4s;
transition: .4s;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
background-color: #2196F3;
background-color: #2196f3;
}
input:focus + .slider {
box-shadow: 0 0 1px #2196F3;
box-shadow: 0 0 1px #2196f3;
}
input:checked + .slider:before {
@@ -138,8 +137,6 @@ input:checked+.slider:before {
opacity: 66%;
}
.hint {
position: absolute;
font-size: 80%;
@@ -151,11 +148,14 @@ input:checked+.slider:before {
z-index: -1;
}
input {
input,
select {
padding: 0.2em 16px;
margin-top: 0.25em;
margin-bottom: 0.25em;
border-radius: 1em;
color: black;
background-color: white;
}
h1,
@@ -180,7 +180,6 @@ h3 {
flex-direction: column;
}
.container {
display: flex;
flex-wrap: nowrap;
@@ -201,6 +200,9 @@ h3 {
border-style: solid;
border-radius: 16px;
h4 {
margin: 4px;
}
&.one {
max-width: min(96%, 768px);
margin: 4px auto;
@@ -211,6 +213,7 @@ h3 {
}
.reservoir {
display: flex;
flex-direction: unset;
flex-wrap: wrap;
justify-content: space-around;
@@ -281,10 +284,8 @@ button {
font-size: 80%;
margin: 0px;
}
}
@media only screen and (max-width: 768px) {
#control-panel {
grid-template-columns: 1fr;
@@ -308,7 +309,6 @@ button {
font-size: xx-large;
margin-bottom: 16px;
margin-right: 16px;
}
.wavering {
@@ -317,11 +317,13 @@ button {
}
::backdrop {
background-image: linear-gradient(45deg,
background-image: linear-gradient(
45deg,
magenta,
rebeccapurple,
dodgerblue,
green);
green
);
opacity: 0.75;
}
@@ -488,7 +490,9 @@ button {
input {
max-width: 300px;
margin: 0.2em auto;
}
select {
max-width: 335px;
}
}
@@ -496,7 +500,7 @@ button {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.4em;
border-radius: 1.5em;
margin: 4px;
padding: 0.2em 0.5em;
@@ -533,21 +537,27 @@ button {
margin: auto 1em;
}
input {
input,
select {
width: 90%;
margin: 4px 0;
}
}
}
.mmp {
background-color: lightskyblue;
}
.fmp {
background-color: salmon;
}
@keyframes blink {
0% {
background-color: #8888;
}
13% {
background-color: #8888;
}
@@ -560,7 +570,6 @@ button {
background-color: #8888;
}
38% {
background-color: #8888;
}
@@ -638,3 +647,88 @@ button {
transform: translateX(0%);
}
}
.calendar-container {
position: relative;
margin: 20px auto;
font-size: small;
}
.month-navigation {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-top: 2px solid grey;
border-bottom: 2px solid grey;
}
.month-navigation button {
cursor: pointer;
padding: 4px 8px;
border: none;
color: black;
background-color: transparent;
}
.month-navigation span {
font-weight: bold;
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.day {
padding: 2px;
border: 1px solid grey;
cursor: pointer;
display: flex;
}
.selected-day {
border: 4px solid grey;
}
.weekday {
border-bottom: 3px solid black;
margin: 0 1em;
}
.day-circle {
text-align: center;
border-radius: 1.5em;
width: 1.5em;
height: 1.5em;
padding: 0;
margin: auto;
border: 2px solid transparent;
}
.today {
border-radius: 1.6em;
border: 4px solid red;
text-align: center;
}
.has-event {
border-radius: 1.5em;
background-color: lightskyblue;
}
.active-player {
border-radius: 1.5em;
border: 4px solid rebeccapurple;
}
.events {
font-size: large;
padding: 20px;
ul > li {
padding: 0;
margin: 0;
list-style-type: none;
}
}

View File

@@ -1,4 +1,3 @@
import Analysis from "./Analysis";
import "./App.css";
import Footer from "./Footer";
import Header from "./Header";
@@ -9,7 +8,7 @@ import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword";
import { ThemeProvider } from "./ThemeProvider";
import { TeamPanel } from "./TeamPanel";
import TeamPanel from "./TeamPanel";
const Maintenance = () => {
return (
@@ -34,11 +33,10 @@ function App() {
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="/network" element={<GraphComponent />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} />
<Route path="/changepassword" element={<SetPassword />} />
<Route path="/team" element={<TeamPanel />} />
<Route path="network" element={<GraphComponent />} />
<Route path="mvp" element={<MVPChart />} />
<Route path="changepassword" element={<SetPassword />} />
<Route path="team" element={<TeamPanel />} />
</Routes>
<Footer />
</SessionProvider>

View File

@@ -22,6 +22,10 @@ const UserInfo = (user: User, teams: TeamState | undefined) => {
<b>display name: </b>
</div>
<div>{user?.display_name}</div>
<div>
<b>gender: </b>
</div>
<div>{user?.gender?.toUpperCase() || "-"}</div>
<div>
<b>number: </b>
</div>
@@ -148,11 +152,10 @@ export default function Avatar() {
return (
<>
<div className="avatars">
<div className="avatars" style={{ display: user ? "block" : "none" }}>
<div
className="avatar"
onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();

View File

@@ -1,95 +0,0 @@
import { FC } from 'react';
import { PlayerRanking } from './types';
interface BarChartProps {
players: PlayerRanking[];
width: number;
height: number;
std: boolean;
}
const BarChart: FC<BarChartProps> = ({ players, width, height, std }) => {
const padding = 24;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barWidth = (width - 2 * padding) / players.length;
return (
<svg width={width} height={height}>
{players.map((player, index) => (
<rect
key={index}
x={index * barWidth + padding}
y={height - (1 - player.rank / maxValue) * height}
width={barWidth - 8} // subtract 2 for some spacing between bars
height={(1 - player.rank / maxValue) * height}
fill="#69f"
/>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 - 4 + padding}
y={height - (1 - player.rank / maxValue) * height - 5}
textAnchor="middle"
//transform='rotate(-27)'
//style={{ transformOrigin: "center", transformBox: "fill-box" }}
fontSize="16px"
fill="#404040"
>
{player.name}
</text>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 + padding - 4}
y={height - 8}
textAnchor="middle"
fontSize="12px"
fill="#404040"
>
{player.rank}
</text>
))}
{std && players.map((player, index) => (
<line
key={`error-${index}`}
x1={index * barWidth + barWidth / 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-top`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-bottom`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
</svg>
);
};
export default BarChart;

174
src/Calendar.tsx Normal file
View File

@@ -0,0 +1,174 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
import { useSession } from "./Session";
interface Datum {
[id: number]: string;
}
interface Events {
[key: string]: Datum;
}
const Calendar = ({ playerId }: { playerId: number }) => {
const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState<Events>();
const { teams, players } = useSession();
async function loadSubmissionDates() {
if (teams?.activeTeam) {
const data = await apiAuth(`analysis/times/${teams?.activeTeam}`, null);
if (data.detail) {
console.log(data.detail);
} else {
setEvents(data as Events);
}
}
}
useEffect(() => {
loadSubmissionDates();
}, [players]);
const getEventsForDay = (date: Date) => {
return events && events[date.toISOString().split("T")[0]];
};
// Handle day click
const handleDayClick = (date: Date) => {
setSelectedDate(date);
};
// Navigate to previous month
const handlePrevMonth = () => {
const date = new Date(selectedDate);
date.setMonth(date.getMonth() - 1);
setSelectedDate(date);
};
// Navigate to next month
const handleNextMonth = () => {
const date = new Date(selectedDate);
date.setMonth(date.getMonth() + 1);
setSelectedDate(date);
};
// Render month navigation
const renderMonthNavigation = () => {
return (
<div className="month-navigation">
<button onClick={handlePrevMonth}>&lt;</button>
<span>
<button onClick={() => setSelectedDate(new Date())}>📅</button>
{selectedDate.toLocaleString("default", {
month: "long",
year: "numeric",
})}
</span>
<button onClick={handleNextMonth}>&gt;</button>
</div>
);
};
// Render the calendar
const renderCalendar = () => {
const firstDayOfMonth = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
0
).getDay();
const lastDateOfMonth = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() + 1,
0
).getDate();
let days: JSX.Element[] = [];
let day = 1;
for (let i = 0; i < 7; i++) {
const date = new Date(0);
date.setDate(i + 5);
days.push(
<div key={"weekday_" + i} className="weekday">
{date.toLocaleString("default", {
weekday: "narrow",
})}
</div>
);
}
// Add empty cells for the first week
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={"prev" + i} className="empty"></div>);
}
// Render each day of the month
while (day <= lastDateOfMonth) {
const date = new Date(selectedDate);
date.setDate(day);
const todaysEvents = getEventsForDay(date);
days.push(
<div
key={date.getDate()}
className={
"day" +
(date.toDateString() === selectedDate.toDateString()
? " selected-day"
: "")
}
onClick={() => handleDayClick(date)}
>
<div
className={
"day-circle" +
(date.toDateString() === new Date().toDateString()
? " today"
: "") +
(todaysEvents ? " has-event" : "") +
(todaysEvents && playerId in todaysEvents ? " active-player" : "")
}
>
{day}
</div>
</div>
);
day++;
}
return <div className="calendar">{days}</div>;
};
// Render events for the selected day
const renderEvents = () => {
const eventsForDay = getEventsForDay(selectedDate);
return (
<div className="events">
{eventsForDay && (
<ul>
{Object.entries(eventsForDay).map(([id, sub]) => {
const name = players?.find((p) => p.id === Number(id));
return (
<li key={id}>
{name !== undefined ? name.display_name : ""}:{" "}
<span style={{ letterSpacing: 8 }}>{sub}</span>
</li>
);
})}
</ul>
)}
</div>
);
};
return (
<div className="calendar-container">
<h2>Latest Submissions</h2>
{renderMonthNavigation()}
{renderCalendar()}
{renderEvents()}
</div>
);
};
export default Calendar;

View File

@@ -4,17 +4,18 @@ import { useSession } from "./Session";
export default function Footer() {
const location = useLocation();
const { user } = useSession();
const { user, teams } = useSession();
return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
{user?.scopes.split(" ").includes("analysis") && (
{(user?.scopes.split(" ").includes("analysis") ||
teams?.activeTeam === 42) && (
<div className="navbar">
<Link to="/">
<span>Form</span>
</Link>
<span>|</span>
<Link to="/network">
<span>Trainer Analysis</span>
<span>Sociogram</span>
</Link>
<span>|</span>
<Link to="/mvp">

View File

@@ -3,30 +3,47 @@ import { apiAuth } from "./api";
import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart";
import { useSession } from "./Session";
import { useNavigate } from "react-router";
const MVPChart = () => {
let initialData = {} 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();
const { user, teams } = useSession();
const [mixed, setMixed] = useState(false);
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
useEffect(() => {
if (teams) {
const activeTeam = teams.teams.find(
(team) => team.id == teams.activeTeam
);
activeTeam && setMixed(activeTeam.mixed);
}
}, [teams]);
async function loadData() {
setLoading(true);
if (teams) {
await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null)
await apiAuth(`analysis/mvp/${teams?.activeTeam}?mixed=${mixed}`, null)
.then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<PlayerRanking[]>;
return data as Promise<PlayerRanking[][]>;
}
})
.then((data) => {
setData(data.sort((a, b) => a.rank - b.rank));
setData(data.map((_data) => _data.sort((a, b) => a.rank - b.rank)));
})
.catch(() => setError("no access"));
setLoading(false);
@@ -39,7 +56,8 @@ const MVPChart = () => {
if (loading) return <span className="loader" />;
else if (error) return <span>{error}</span>;
else return <RaceChart std={showStd} players={data} />;
else
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);
};
export default MVPChart;

View File

@@ -11,6 +11,7 @@ import {
} from "reagraph";
import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session";
import { useNavigate } from "react-router";
interface NetworkData {
nodes: GraphNode[];
@@ -45,7 +46,13 @@ export const GraphComponent = () => {
const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false);
const { teams } = useSession();
const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
async function loadData() {
setLoading(true);

View File

@@ -1,8 +1,9 @@
import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types";
import { useSession } from "./Session";
interface RaceChartProps {
players: PlayerRanking[];
playerRanks: PlayerRanking[];
std: boolean;
}
@@ -13,15 +14,14 @@ const determineNiceWidth = (width: number) => {
else return width * 0.96;
};
const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
const RaceChart: FC<RaceChartProps> = ({ playerRanks, std }) => {
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight);
const height = (players.length + 1) * 40;
const height = (playerRanks.length + 1) * 40;
const { players } = useSession();
useEffect(() => {
const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth));
//setHeight(window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
@@ -30,18 +30,18 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
}, []);
const padding = 24;
const gap = 8;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / players.length;
const maxValue = Math.max(...playerRanks.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / playerRanks.length;
const fontSize = Math.min(barHeight - 1.5 * gap, width / 22);
return (
<svg width={width} height={height} id="RaceChartSVG">
{players.map((player, index) => (
{playerRanks.map((playerRank, index) => (
<rect
key={String(index)}
x={4}
y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
stroke="aliceblue"
@@ -50,13 +50,15 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
/>
))}
{players.map((player, index) => (
{playerRanks.map((playerRank, index) => {
const player = players!.find((p) => p.id === playerRank.p_id);
return (
<g key={"group" + index}>
<text
key={index + "_name"}
x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={fontSize}
fill="aliceblue"
@@ -67,18 +69,19 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
fontFamily="monospace"
style={{ whiteSpace: "pre" }}
>
{`${String(index + 1).padStart(2)}. ${player.name}`}
{`${String(index + 1).padStart(2)}. ${player?.display_name}`}
</text>
<text
key={index + "_value"}
x={
8 +
(4 + Math.max(...players.map((p, _) => p.name.length))) *
(4 +
Math.max(...players!.map((p, _) => p.display_name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={0.8 * fontSize}
fill="aliceblue"
@@ -89,10 +92,11 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${String(player.rank).padStart(5)} ± ${player.std} N = ${player.n}`}
{`${String(playerRank.rank).padStart(5)} ± ${playerRank.std} N = ${playerRank.n}`}
</text>
</g>
))}
);
})}
</svg>
);
};

View File

@@ -1,28 +1,38 @@
import {
ButtonHTMLAttributes,
Fragment,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, loadPlayers, User } from "./api";
import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types";
import { Chemistry, MVPRanking, PlayerType } from "./types";
import TabController from "./TabController";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
gender?: boolean;
};
function PlayerList(props: PlayerListProps) {
const fmps = props.list?.filter((item) => item.gender === "fmp").length;
return (
<ReactSortable {...props} animation={200} swapThreshold={0.4}>
{props.list?.map((item, index) => (
<div key={item.id} className="item">
<ReactSortable
{...props}
animation={200}
swapThreshold={0.2}
style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }}
>
{props.list &&
props.list.map((item, index) => (
<div
key={item.id}
className={"item " + (props.gender ? item.gender : "")}
>
{props.orderedList
? index + 1 + ". " + item.display_name
? props.gender
? index +
1 -
(item.gender !== "fmp" ? fmps! : 0) +
". " +
item.display_name
: index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
@@ -65,15 +75,11 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
setPlayersMiddle(otherPlayers);
handleGet();
}, [players]);
useEffect(() => {
setPlayersLeft([]);
setPlayersMiddle(otherPlayers);
setPlayersRight([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
@@ -96,9 +102,14 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
if (data.detail) {
console.log(data.detail);
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
} else {
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(
@@ -110,6 +121,7 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
}
setLoading(false);
}
return (
@@ -122,6 +134,9 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
setPlayersLeft([]);
}}
/>
{loading ? (
<span className="loader" style={{ width: 300 }} />
) : (
<div className="container">
<div className="box three">
<h2>😬</h2>
@@ -162,6 +177,7 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
/>
</div>
</div>
)}
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
@@ -179,18 +195,160 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
);
}
function TypeDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [handlers, setHandlers] = useState<User[]>([]);
const [combis, setCombis] = useState<User[]>([]);
const [cutters, setCutters] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let handlerlist = handlers.map(({ id }) => id);
let combilist = combis.map(({ id }) => id);
let cutterlist = cutters.map(({ id }) => id);
const data = {
user: user.id,
handlers: handlerlist,
combis: combilist,
cutters: cutterlist,
team: teams.activeTeam,
};
const response = await apiAuth("playertype", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`playertype/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
} else {
const playertype = data as PlayerType;
setAvailablePlayers(
players.filter(
(player) =>
!playertype.handlers.includes(player.id) &&
!playertype.combis.includes(player.id) &&
!playertype.cutters.includes(player.id)
)
);
setHandlers(filterSort(players, playertype.handlers));
setCombis(filterSort(players, playertype.combis));
setCutters(filterSort(players, playertype.cutters));
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
}}
/>
<div className="container">
<div className="box one">
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={"type-shared"}
className="dragbox reservoir"
/>
</div>
</div>
<div className="container">
<div className="box three">
<h4>handler</h4>
{handlers.length < 1 && (
<span className="grey hint">
drag people here that you like to see as handlers
</span>
)}
<PlayerList
list={handlers}
setList={setHandlers}
group={"type-shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h4>combi</h4>
{combis.length < 1 && (
<span className="grey hint">
drag people here that switch between handling and cutting
</span>
)}
<PlayerList
list={combis}
setList={setCombis}
group={"type-shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h4>cutter</h4>
{cutters.length < 1 && (
<span className="grey hint">
drag people here that you think are the best cutters
</span>
)}
<PlayerList
list={cutters}
setList={setCutters}
group={"type-shared"}
className="dragbox"
/>
</div>
</div>
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="PlayerTypeDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [mixed, setMixed] = useState(false);
useEffect(() => {
setAvailablePlayers(players);
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed);
handleGet();
}, [players]);
useEffect(() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}, [teams]);
handleGet();
// setMixedList(rankedPlayers);
}, [mixed]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
@@ -204,16 +362,30 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
response ? setDialog(response) : setDialog("try sending again");
}
const setMixedList = (newList: User[]) =>
mixed
? setRankedPlayers(
newList.sort((a, b) =>
a.gender && b.gender ? a.gender.localeCompare(b.gender) : -1
)
)
: setRankedPlayers(newList);
async function handleGet() {
setLoading(true);
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setRankedPlayers([]);
} else {
const mvps = data as MVPRanking;
setRankedPlayers(filterSort(players, mvps.mvps));
setMixedList(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
}
setLoading(false);
}
return (
@@ -225,6 +397,9 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
setRankedPlayers([]);
}}
/>
{loading ? (
<span className="loader" style={{ width: 300 }} />
) : (
<div className="container">
<div className="box two">
<h2>🥏🏃</h2>
@@ -236,11 +411,9 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
gender={mixed}
/>
</div>
<div className="box two">
@@ -253,18 +426,17 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
)}
<PlayerList
list={rankedPlayers}
setList={setRankedPlayers}
setList={setMixedList}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
orderedList
gender={mixed}
/>
</div>
</div>
)}
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
@@ -290,8 +462,8 @@ function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
return (
<>
<div>
<LoadButton onClick={onLoad} />
<ClearButton onClick={onClear} />
<LoadButton onClick={onLoad} />
</div>
<div>
<span className="grey">
@@ -304,17 +476,11 @@ function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
}
export default function Rankings() {
const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null);
useEffect(() => {
if (teams) {
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [user, teams]);
const { user, teams, players } = useSession();
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
{ id: "Type", label: "🃏 Type" },
{ id: "MVP", label: "🏆 MVP" },
];
@@ -323,6 +489,7 @@ export default function Rankings() {
{user && teams && players ? (
<TabController tabs={tabs}>
<ChemistryDnD {...{ user, teams, players }} />
<TypeDnD {...{ user, teams, players }} />
<MVPDnD {...{ user, teams, players }} />
</TabController>
) : (

View File

@@ -5,7 +5,7 @@ import {
useEffect,
useState,
} from "react";
import { apiAuth, currentUser, logout, User } from "./api";
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login";
import Header from "./Header";
import { Team } from "./types";
@@ -23,6 +23,8 @@ export interface Session {
user: User | null;
teams: TeamState | null;
setTeams: (teams: TeamState) => void;
players: User[] | null;
reloadPlayers: () => void;
onLogout: () => void;
}
@@ -30,6 +32,8 @@ const sessionContext = createContext<Session>({
user: null,
teams: null,
setTeams: () => {},
players: null,
reloadPlayers: () => {},
onLogout: () => {},
});
@@ -38,6 +42,7 @@ export function SessionProvider(props: SessionProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null);
const [players, setPlayers] = useState<User[] | null>(null);
const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
@@ -60,10 +65,19 @@ export function SessionProvider(props: SessionProviderProps) {
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
}
async function reloadPlayers() {
teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data));
}
useEffect(() => {
loadUser();
loadTeam();
}, []);
useEffect(() => {
loadTeam();
}, [user]);
useEffect(() => {
reloadPlayers();
}, [teams]);
function onLogin(user: User) {
setUser(user);
@@ -94,7 +108,9 @@ export function SessionProvider(props: SessionProviderProps) {
content = <Login onLogin={onLogin} />;
} else
content = (
<sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
<sessionContext.Provider
value={{ user, teams, setTeams, players, reloadPlayers, onLogout }}
>
{children}
</sessionContext.Provider>
);

View File

@@ -1,17 +1,29 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import { useEffect, useState } from "react";
import { apiAuth, baseUrl } from "./api";
import { ReactNode, useEffect, useState } from "react";
import { apiAuth, baseUrl, Gender, User } from "./api";
import { useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
import { useSession } from "./Session";
import { relative } from "path";
import Header from "./Header";
interface SetPassToken extends JwtPayload {
interface PassToken extends JwtPayload {
username: string;
name: string;
team_id: number;
}
enum Mode {
register = "register",
set = "set password",
change = "change password",
}
export const SetPassword = () => {
const [mode, setMode] = useState<Mode>();
const [name, setName] = useState("after getting your token.");
const [username, setUsername] = useState("");
const [teamID, setTeamID] = useState<number>();
const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState("");
const [passwordr, setPasswordr] = useState("");
@@ -19,36 +31,22 @@ export const SetPassword = () => {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const newPlayerTemplate = {
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [player, setPlayer] = useState(newPlayerTemplate);
const navigate = useNavigate();
const { user } = useSession();
useEffect(() => {
if (user) {
setUsername(user.username);
setName(user.display_name);
} else {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<SetPassToken>(token);
if (payload.name) setName(payload.name);
else if (payload.sub) setName(payload.sub);
else setName("Mr. I-have-no Token");
payload.sub && setUsername(payload.sub);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password === passwordr) {
setLoading(true);
if (user) {
if (mode === Mode.change) {
//====CHANGING PASSWORD====
const resp = await apiAuth(
"player/change_password",
{ current_password: currentPassword, new_password: password },
@@ -60,7 +58,8 @@ export const SetPassword = () => {
setError(resp);
setTimeout(() => navigate("/"), 2000);
}
} else {
} else if (mode === Mode.set) {
//====SETTING PASSWORD====
const req = new Request(`${baseUrl}api/set_password`, {
method: "POST",
headers: {
@@ -92,43 +91,105 @@ export const SetPassword = () => {
throw new Error("Unauthorized");
}
}
} else if (mode === Mode.register) {
//====REGISTER NEW USER====
const req = new Request(`${baseUrl}api/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...player,
team_id: teamID,
token: token,
password: password,
}),
});
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
setLoading(false);
if (resp.ok) {
console.log(resp);
navigate("/", {
replace: true,
state: { username: player.username, password: password },
});
}
if (!resp.ok) {
const { detail } = await resp.json();
if (detail) setError(detail);
else setError("unauthorized");
throw new Error("Unauthorized");
}
}
} else setError("passwords are not the same");
}
return (
useEffect(() => {
if (user) {
setUsername(user.username);
setName(user.display_name);
setMode(Mode.change);
} else {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<PassToken>(token);
console.log(payload);
switch (payload.sub) {
case "register":
setMode(Mode.register);
if (payload.team_id) setTeamID(payload.team_id);
break;
case "set password":
setMode(Mode.set);
if (payload.username) setUsername(payload.username);
break;
}
if (payload.name) setName(payload.name);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}
}, []);
let header: ReactNode;
switch (mode) {
case Mode.change:
header = <h2>change your password, {name}</h2>;
break;
case Mode.set:
header = (
<>
{user ? (
<h2> change your password </h2>
) : (
<Header />
<h2>set your password, {name}</h2>
</>
);
break;
case Mode.register:
header = (
<>
<Header />
<h2>
set your password,
<br />
{name}
register as a member of <i>{name}</i>
</h2>
)}
{!user && username && (
<span>
your username is: <i>{username}</i>
</span>
)}
<form onSubmit={handleSubmit}>
<div
style={{
display: "flex",
alignItems: "center",
}}
>
<div
style={{
marginLeft: "48px",
marginRight: "8px",
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
{user && (
</>
);
}
let textInputs: ReactNode;
switch (mode) {
case Mode.change:
textInputs = (
<div>
<input
type={visible ? "text" : "password"}
@@ -143,15 +204,82 @@ export const SetPassword = () => {
setCurrentPassword(evt.target.value);
}}
/>
<hr
style={{
margin: "8px",
borderStyle: "inset",
display: "block",
<hr style={{ margin: "8px" }} />
</div>
);
break;
case Mode.register:
textInputs = (
<div className="new-player-inputs">
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
}}
/>
</div>
)}
<div>
<label>username</label>
<input
type="text"
required
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
}}
/>
</div>
<div>
<label>gender</label>
<select
name="gender"
required
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
}}
/>
</div>
<hr style={{ margin: "8px" }} />
</div>
);
break;
}
let passwordInputs = (
<>
<div>
<input
type={visible ? "text" : "password"}
@@ -182,16 +310,36 @@ export const SetPassword = () => {
}}
/>
</div>
</div>
</>
);
return mode ? (
<>
{header}
<hr style={{ width: "100%" }} />
<form onSubmit={handleSubmit}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}
>
{textInputs}
{passwordInputs}
<div
style={{
background: "unset",
fontSize: "xx-large",
fontSize: "medium",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
onClick={() => setVisible(!visible)}
>
{visible ? <Eye /> : <EyeSlash />}
{visible ? <Eye /> : <EyeSlash />} show passwords
</div>
</div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
@@ -201,5 +349,7 @@ export const SetPassword = () => {
{loading && <span className="loader" />}
</form>
</>
) : (
<span className="loader" />
);
};

View File

@@ -1,28 +1,29 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, loadPlayers, User } from "./api";
import { apiAuth, Gender, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
import Calendar from "./Calendar";
export const TeamPanel = () => {
const { teams } = useSession();
const TeamPanel = () => {
const { user, teams, players, reloadPlayers } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user, teams]);
const newPlayerTemplate = {
id: 0,
username: "",
display_name: "",
gender: undefined,
number: "",
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [players, setPlayers] = useState<User[] | null>(null);
const [player, setPlayer] = useState(newPlayerTemplate);
useEffect(() => {
if (teams) {
setError({ ok: true, message: "" });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [teams]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (teams) {
@@ -31,14 +32,14 @@ export const TeamPanel = () => {
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
reloadPlayers();
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
reloadPlayers();
}
}
}
@@ -58,7 +59,7 @@ export const TeamPanel = () => {
else {
setError({ ok: true, message: r });
setPlayer(newPlayerTemplate);
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
reloadPlayers();
}
}
}
@@ -86,10 +87,13 @@ export const TeamPanel = () => {
justifyContent: "center",
}}
>
{players &&
players.map((p) => (
{players.map((p) => (
<button
className="team-player"
className={
"team-player " +
p.gender +
(p.id === player.id ? " active-player" : "")
}
key={p.id}
onClick={() => {
setPlayer(p);
@@ -125,8 +129,10 @@ export const TeamPanel = () => {
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
...(player.id === 0 && {
username: e.target.value.toLowerCase().replace(/\W/g, ""),
}),
display_name: e.target.value,
});
setError({ ok: true, message: "" });
}}
@@ -145,6 +151,21 @@ export const TeamPanel = () => {
}}
/>
</div>
<div>
<label>gender</label>
<select
name="gender"
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
setError({ ok: true, message: "" });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
<div>
<label>number (optional)</label>
<input
@@ -195,7 +216,9 @@ export const TeamPanel = () => {
)}
</form>
</div>
<Calendar playerId={player.id} />
</div>
);
} else <span className="loader" />;
};
export default TeamPanel;

View File

@@ -43,12 +43,15 @@ export async function apiAuth(
}
}
export type Gender = "fmp" | "mmp" | undefined;
export type User = {
id: number;
username: string;
display_name: string;
email: string;
number: string;
gender: Gender;
scopes: string;
};

View File

@@ -13,7 +13,7 @@ export default interface NetworkData {
}
export interface PlayerRanking {
name: string;
p_id: number;
rank: number;
std: number;
n: number;
@@ -27,6 +27,14 @@ export interface Chemistry {
love: number[];
}
export interface PlayerType {
id: number;
user: number;
handlers: number[];
combis: number[];
cutters: number[];
}
export interface MVPRanking {
id: number;
user: number;
@@ -38,6 +46,7 @@ export interface Team {
name: string;
location: string;
country: string;
mixed: boolean;
}
export type ErrorState = {