Compare commits
24 Commits
14c34066f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
df84c798be
|
|||
|
052065acf9
|
|||
|
039b43be8e
|
|||
|
5c76a60df1
|
|||
|
bb41513571
|
|||
|
aff3b9f7be
|
|||
|
be3cf4175e
|
|||
|
051202edc9
|
|||
|
12b42e4a46
|
|||
|
ed460f63d6
|
|||
|
7ec6a5b45f
|
|||
|
03134b2f03
|
|||
|
1b6ad04148
|
|||
|
0c65aae718
|
|||
|
6408a3fee1
|
|||
|
635105c1b7
|
|||
|
caa56a3484
|
|||
|
66422bd4d9
|
|||
|
2645bb054c
|
|||
|
8e11a2fb56
|
|||
|
92d6f451ec
|
|||
|
46fd498c32
|
|||
|
4022136970
|
|||
|
d0140f4cfb
|
@@ -1 +0,0 @@
|
||||
VITE_BASE_URL=http://localhost:8000/
|
||||
@@ -126,6 +126,7 @@ def graph_json(
|
||||
return G
|
||||
return JSONResponse({"nodes": nodes, "edges": edges})
|
||||
|
||||
playertypes = playertype(request)
|
||||
with Session(engine) as session:
|
||||
players = session.exec(
|
||||
select(P)
|
||||
@@ -140,7 +141,18 @@ def graph_json(
|
||||
)
|
||||
for p in players:
|
||||
player_map[p.id] = p.display_name
|
||||
nodes.append({"id": p.display_name, "label": p.display_name})
|
||||
playertype_colour = "#%02x%02x%02x" % (
|
||||
int(playertypes[p.id]["handler"] * 255),
|
||||
int(playertypes[p.id]["combi"] * 255),
|
||||
int(playertypes[p.id]["cutter"] * 255),
|
||||
)
|
||||
nodes.append(
|
||||
{
|
||||
"id": p.display_name,
|
||||
"label": p.display_name,
|
||||
"data": {"playertype": playertype_colour},
|
||||
}
|
||||
)
|
||||
|
||||
subquery = (
|
||||
select(C.user, func.max(C.time).label("latest"))
|
||||
@@ -207,7 +219,8 @@ def graph_json(
|
||||
)
|
||||
in_degrees = G.in_degree(weight="weight")
|
||||
nodes = [
|
||||
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
|
||||
dict(node, **{"data": {"inDegree": in_degrees[node["id"]], **node["data"]}})
|
||||
for node in nodes
|
||||
]
|
||||
if networkx_graph:
|
||||
return G
|
||||
@@ -434,6 +447,52 @@ def mvp(
|
||||
]
|
||||
|
||||
|
||||
def playertype(request: Annotated[TeamScopedRequest, Security(verify_team_scope)]):
|
||||
with Session(engine) as session:
|
||||
players = session.exec(
|
||||
select(P)
|
||||
.join(PlayerTeamLink)
|
||||
.join(Team)
|
||||
.where(Team.id == request.team_id, P.disabled == False)
|
||||
).all()
|
||||
if not players:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
|
||||
player_map = {p.id: p for p in players}
|
||||
subquery = (
|
||||
select(PT.user, func.max(PT.time).label("latest"))
|
||||
.where(PT.team == request.team_id)
|
||||
.group_by(PT.user)
|
||||
.subquery()
|
||||
)
|
||||
statement2 = select(PT).join(
|
||||
subquery, (PT.user == subquery.c.user) & (PT.time == subquery.c.latest)
|
||||
)
|
||||
playertypes = {}
|
||||
for pt in session.exec(statement2):
|
||||
for i, p_id in enumerate(pt.handlers):
|
||||
if p_id not in player_map:
|
||||
continue
|
||||
playertypes[p_id] = playertypes.get(p_id, []) + [-1]
|
||||
for i, p_id in enumerate(pt.combis):
|
||||
if p_id not in player_map:
|
||||
continue
|
||||
playertypes[p_id] = playertypes.get(p_id, []) + [0]
|
||||
for i, p_id in enumerate(pt.cutters):
|
||||
if p_id not in player_map:
|
||||
continue
|
||||
playertypes[p_id] = playertypes.get(p_id, []) + [1]
|
||||
playertype_analysis = {}
|
||||
for p_id, v in playertypes.items():
|
||||
v = np.array(v)
|
||||
playertype_analysis[p_id] = {
|
||||
"mean": np.mean(v),
|
||||
"handler": (v == -1).sum() / len(v),
|
||||
"combi": (v == 0).sum() / len(v),
|
||||
"cutter": (v == 1).sum() / len(v),
|
||||
}
|
||||
return playertype_analysis
|
||||
|
||||
|
||||
async def turnout(
|
||||
request: Annotated[
|
||||
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
|
||||
@@ -504,6 +563,9 @@ analysis_router.add_api_route(
|
||||
name="MVPs",
|
||||
description="Request Most Valuable Players stats",
|
||||
)
|
||||
analysis_router.add_api_route(
|
||||
"/playertype/{team_id}", endpoint=playertype, methods=["GET"]
|
||||
)
|
||||
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"]
|
||||
|
||||
111
cutt/forgotten_password.html
Normal file
111
cutt/forgotten_password.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>cutt - set password</title>
|
||||
</head>
|
||||
<body
|
||||
style="
|
||||
margin: auto;
|
||||
max-width: 800px;
|
||||
padding: 2rem 1.5rem;
|
||||
background-color: white;
|
||||
"
|
||||
>
|
||||
<p
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" viewBox="0 0 80 50">
|
||||
<ellipse
|
||||
cx="40"
|
||||
cy="25"
|
||||
rx="20.263"
|
||||
ry="20.633"
|
||||
style="
|
||||
fill: #c7d6f1;
|
||||
fill-opacity: 1;
|
||||
stroke: #36c;
|
||||
stroke-width: 8.73336;
|
||||
"
|
||||
/>
|
||||
<path
|
||||
d="M0 17.67h80v14.66H0Z"
|
||||
style="
|
||||
fill: #000;
|
||||
stroke-width: 3.56018;
|
||||
paint-order: stroke fill markers;
|
||||
"
|
||||
/>
|
||||
<text
|
||||
x="39.788"
|
||||
y="29.819"
|
||||
style="
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
font-weight: 700;
|
||||
font-stretch: normal;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
font-family: Sans;
|
||||
-inkscape-font-specification: "Sans Bold";
|
||||
text-align: center;
|
||||
letter-spacing: 2.83px;
|
||||
writing-mode: lr-tb;
|
||||
direction: ltr;
|
||||
text-anchor: middle;
|
||||
fill: #fff;
|
||||
stroke: #fff;
|
||||
stroke-width: 0.655;
|
||||
stroke-dasharray: none;
|
||||
stroke-opacity: 1;
|
||||
paint-order: stroke fill markers;
|
||||
"
|
||||
>
|
||||
<tspan
|
||||
x="39.788"
|
||||
y="29.819"
|
||||
style="
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
font-weight: 700;
|
||||
font-stretch: normal;
|
||||
font-size: 14px;
|
||||
font-family: Sans;
|
||||
-inkscape-font-specification: "Sans Bold";
|
||||
letter-spacing: 2.83px;
|
||||
fill: #fff;
|
||||
stroke: #fff;
|
||||
stroke-width: 0.655;
|
||||
stroke-dasharray: none;
|
||||
stroke-opacity: 1;
|
||||
"
|
||||
>
|
||||
CUTT
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
</p>
|
||||
<p>Hello USER,</p>
|
||||
<p>click on the following button to set yourself a new password.</p>
|
||||
<p style="text-align: center; padding: 2rem">
|
||||
<a
|
||||
href="LINK"
|
||||
style="
|
||||
color: ghostwhite;
|
||||
font-weight: bold;
|
||||
background-color: #36c;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
text-decoration: none;
|
||||
"
|
||||
>reset password</a
|
||||
>
|
||||
</p>
|
||||
<p>Cheers,<br />Julius</p>
|
||||
</body>
|
||||
</html>
|
||||
66
cutt/mail.py
Normal file
66
cutt/mail.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
from random import random
|
||||
import smtplib
|
||||
import os
|
||||
import ssl
|
||||
from time import sleep
|
||||
from dotenv import load_dotenv
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
from cutt.db import Player, TokenDB, engine
|
||||
from cutt.security import set_password_token
|
||||
|
||||
P = Player
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def generate_password_link(user: Player):
|
||||
with Session(engine) as session:
|
||||
token = set_password_token(user)
|
||||
if token:
|
||||
session.add(TokenDB(token=token))
|
||||
session.commit()
|
||||
return f"https://cutt.0124816.xyz/setpassword?token={token}"
|
||||
|
||||
|
||||
class EmailRequest(BaseModel):
|
||||
email: str
|
||||
|
||||
|
||||
def send_forgotten_password_link(email: EmailRequest):
|
||||
with Session(engine) as session:
|
||||
user = session.exec(
|
||||
select(P).where(P.email == email.email, P.disabled != True)
|
||||
).one_or_none()
|
||||
if user and user.email:
|
||||
link = generate_password_link(user)
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = "CUTT - reset password"
|
||||
msg["From"] = "CUTT - cool ultimate team tool <cutt@0124816.xyz>"
|
||||
msg["To"] = formataddr((user.display_name, user.email))
|
||||
msg.set_content(
|
||||
f"Hello {user.display_name},\nclick on the following link to set yourself a new password.\n\n{link}\n\nCheers,\nJulius"
|
||||
)
|
||||
with open("cutt/forgotten_password.html") as f:
|
||||
html_body = (
|
||||
f.read().replace("USER", user.display_name).replace("LINK", link)
|
||||
)
|
||||
msg.add_alternative(html_body, subtype="html")
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP(
|
||||
host=os.environ["SMTP_HOST"],
|
||||
port=int(os.environ["SMTP_PORT"]),
|
||||
timeout=20,
|
||||
) as server:
|
||||
server.starttls(context=context)
|
||||
server.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
|
||||
server.send_message(msg)
|
||||
else:
|
||||
sleep(random())
|
||||
|
||||
return PlainTextResponse(
|
||||
"a link will be sent to this email, if it belongs to an existing user."
|
||||
)
|
||||
@@ -10,8 +10,10 @@ from sqlmodel import (
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from cutt.analysis import analysis_router
|
||||
from cutt.mail import send_forgotten_password_link
|
||||
from cutt.security import (
|
||||
get_current_active_user,
|
||||
join_team_token,
|
||||
login_for_access_token,
|
||||
logout,
|
||||
register,
|
||||
@@ -58,6 +60,9 @@ team_router = APIRouter(
|
||||
)
|
||||
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
|
||||
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
|
||||
team_router.add_api_route(
|
||||
"/join_link/{team_id}", endpoint=join_team_token, methods=["GET"]
|
||||
)
|
||||
|
||||
|
||||
wrong_user_id_exception = HTTPException(
|
||||
@@ -229,6 +234,9 @@ api_router.include_router(
|
||||
api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)])
|
||||
api_router.include_router(analysis_router)
|
||||
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
|
||||
api_router.add_api_route(
|
||||
"/forgotten_password", endpoint=send_forgotten_password_link, 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"])
|
||||
|
||||
109
cutt/player.py
109
cutt/player.py
@@ -10,6 +10,7 @@ from cutt.security import (
|
||||
change_password,
|
||||
get_current_active_user,
|
||||
read_player_me,
|
||||
verify_one_time_token,
|
||||
verify_team_scope,
|
||||
)
|
||||
from cutt.demo import demo_players
|
||||
@@ -19,12 +20,24 @@ P = Player
|
||||
player_router = APIRouter(prefix="/player", tags=["player"])
|
||||
|
||||
|
||||
def update_team_manager(scopes_str: str, team_id: int, state: bool = True):
|
||||
scopes = set(scopes_str.split())
|
||||
if state:
|
||||
scopes.add(f"team:{team_id}")
|
||||
else:
|
||||
scopes.remove(f"team:{team_id}")
|
||||
|
||||
print("new scopestr", " ".join(scopes))
|
||||
return " ".join(sorted(scopes))
|
||||
|
||||
|
||||
class PlayerRequest(BaseModel):
|
||||
display_name: str
|
||||
username: str
|
||||
gender: str | None
|
||||
number: str
|
||||
email: str | None
|
||||
is_manager: bool | None
|
||||
|
||||
|
||||
class AddPlayerRequest(PlayerRequest): ...
|
||||
@@ -96,6 +109,10 @@ def modify_player(
|
||||
player.number = r.number.strip()
|
||||
player.gender = r.gender.strip() if r.gender else None
|
||||
player.email = r.email.strip() if r.email else None
|
||||
if r.is_manager is not None:
|
||||
player.scopes = update_team_manager(
|
||||
player.scopes, request.team_id, r.is_manager
|
||||
)
|
||||
session.add(player)
|
||||
session.commit()
|
||||
return PlainTextResponse("modification successful")
|
||||
@@ -161,9 +178,9 @@ def disable_player_team(
|
||||
)
|
||||
|
||||
|
||||
def disable_player(r: DisablePlayerRequest):
|
||||
def disable_player(player_id: int):
|
||||
with Session(engine) as session:
|
||||
player = session.exec(select(P).where(P.id == r.player_id)).one_or_none()
|
||||
player = session.exec(select(P).where(P.id == player_id)).one_or_none()
|
||||
if player:
|
||||
player.disabled = True
|
||||
session.add(player)
|
||||
@@ -181,14 +198,46 @@ def add_player_to_team(player_id: int, team_id: int):
|
||||
player = session.exec(select(P).where(P.id == player_id)).one()
|
||||
team = session.exec(select(Team).where(Team.id == team_id)).one()
|
||||
if player and team:
|
||||
team.players.append(player)
|
||||
session.add(team)
|
||||
session.commit()
|
||||
return PlainTextResponse(
|
||||
f"added {player.display_name} ({player.username}) to {team.name}"
|
||||
if player in team.players:
|
||||
return PlainTextResponse(
|
||||
f"{player.display_name} ({player.username}) is already part of {team.name}"
|
||||
)
|
||||
else:
|
||||
team.players.append(player)
|
||||
session.add(team)
|
||||
session.commit()
|
||||
return PlainTextResponse(
|
||||
f"added {player.display_name} ({player.username}) to {team.name}"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="something went wrong",
|
||||
)
|
||||
|
||||
|
||||
class JoinRequest(BaseModel):
|
||||
token: str
|
||||
team_id: int
|
||||
|
||||
|
||||
def join_team(r: JoinRequest, user: Annotated[P, Depends(get_current_active_user)]):
|
||||
payload = verify_one_time_token(r.token)
|
||||
action: str = payload.get("sub")
|
||||
if action != "join":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="wrong type of token.",
|
||||
)
|
||||
team_id: int = payload.get("team_id")
|
||||
if team_id != r.team_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="wrong team",
|
||||
)
|
||||
return add_player_to_team(user.id, r.team_id)
|
||||
|
||||
|
||||
def add_players(players: list[P]):
|
||||
with Session(engine) as session:
|
||||
for player in players:
|
||||
@@ -202,7 +251,7 @@ async def list_all_players():
|
||||
|
||||
|
||||
async def list_players(
|
||||
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
|
||||
team_id: int, user: Annotated[P, Depends(get_current_active_user)]
|
||||
):
|
||||
if team_id == 42:
|
||||
return [
|
||||
@@ -212,6 +261,8 @@ async def list_players(
|
||||
] + demo_players
|
||||
|
||||
allowed_scopes = set(user.scopes.split())
|
||||
team_manager_scope = f"team:{team_id}"
|
||||
is_team_manager = team_manager_scope in allowed_scopes
|
||||
|
||||
with Session(engine) as session:
|
||||
current_user = session.exec(
|
||||
@@ -220,7 +271,7 @@ async def list_players(
|
||||
.join(Team)
|
||||
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
|
||||
).one_or_none()
|
||||
if not current_user and f"team:{team_id}" not in allowed_scopes:
|
||||
if not current_user and not is_team_manager:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="you're not in this team",
|
||||
@@ -233,21 +284,26 @@ async def list_players(
|
||||
.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",
|
||||
"gender",
|
||||
"number",
|
||||
"email",
|
||||
}
|
||||
)
|
||||
for player in players
|
||||
if not player.disabled
|
||||
]
|
||||
players_dump = []
|
||||
for player in players:
|
||||
if not player.disabled:
|
||||
player_dump = player.model_dump(
|
||||
include={
|
||||
"id",
|
||||
"display_name",
|
||||
"username",
|
||||
"gender",
|
||||
"number",
|
||||
}
|
||||
)
|
||||
if is_team_manager:
|
||||
player_dump["email"] = player.email
|
||||
if team_manager_scope in player.scopes:
|
||||
player_dump["is_manager"] = True
|
||||
players_dump.append(player_dump)
|
||||
return players_dump
|
||||
|
||||
|
||||
def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
|
||||
@@ -290,6 +346,11 @@ player_router.add_api_route(
|
||||
endpoint=remove_player_from_team,
|
||||
methods=["DELETE"],
|
||||
)
|
||||
player_router.add_api_route(
|
||||
"/{team_id}/join",
|
||||
endpoint=join_team,
|
||||
methods=["POST"],
|
||||
)
|
||||
player_router.add_api_route(
|
||||
"/{team_id}/list",
|
||||
endpoint=list_players,
|
||||
@@ -308,7 +369,7 @@ player_router.add_api_route(
|
||||
dependencies=[Security(get_current_active_user, scopes=["admin"])],
|
||||
)
|
||||
player_router.add_api_route(
|
||||
"/disable",
|
||||
"/disable/{player_id}",
|
||||
endpoint=disable_player,
|
||||
methods=["DELETE"],
|
||||
dependencies=[Security(get_current_active_user, scopes=["admin"])],
|
||||
|
||||
@@ -212,31 +212,32 @@ async def logout(response: Response):
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
def set_password_token(username: str):
|
||||
user = get_user(username)
|
||||
if user:
|
||||
expire = timedelta(days=30)
|
||||
token = create_access_token(
|
||||
data={
|
||||
"sub": "set password",
|
||||
"username": username,
|
||||
"name": user.display_name,
|
||||
},
|
||||
expires_delta=expire,
|
||||
)
|
||||
return token
|
||||
def set_password_token(user: Player):
|
||||
expire = timedelta(days=30)
|
||||
token = create_access_token(
|
||||
data={
|
||||
"sub": "set password",
|
||||
"username": user.username,
|
||||
"name": user.display_name,
|
||||
},
|
||||
expires_delta=expire,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
def register_token(team_id: int):
|
||||
def join_team_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},
|
||||
data={"sub": "join", "team_id": team_id, "name": team.name},
|
||||
expires_delta=expire,
|
||||
)
|
||||
return token
|
||||
if token:
|
||||
session.add(TokenDB(token=token))
|
||||
session.commit()
|
||||
return PlainTextResponse(f"https://cutt.0124816.xyz/join?token={token}")
|
||||
|
||||
|
||||
def verify_one_time_token(token: str):
|
||||
@@ -346,7 +347,7 @@ class RegisterRequest(BaseModel):
|
||||
async def register(req: RegisterRequest):
|
||||
payload = verify_one_time_token(req.token)
|
||||
action: str = payload.get("sub")
|
||||
if action != "register":
|
||||
if action != "join":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="wrong type of token.",
|
||||
|
||||
@@ -7,7 +7,7 @@ from cutt.db import Player, TokenDB, engine
|
||||
if len(sys.argv) > 1:
|
||||
with Session(engine) as session:
|
||||
for p in session.exec(
|
||||
select(Player.username).where(Player.username == sys.argv[1].strip())
|
||||
select(Player).where(Player.username == sys.argv[1].strip())
|
||||
):
|
||||
print(p)
|
||||
token = set_password_token(p)
|
||||
|
||||
@@ -1,58 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="80"
|
||||
height="50"
|
||||
viewBox="0 0 80 50"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="cutt.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
inkscape:export-filename="cutt.svg"
|
||||
inkscape:export-xdpi="362.84"
|
||||
inkscape:export-ydpi="362.84"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs1" />
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="12.413459"
|
||||
inkscape:cx="38.909381"
|
||||
inkscape:cy="55.786224"
|
||||
inkscape:window-width="1408"
|
||||
inkscape:window-height="1727"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg1" />
|
||||
<ellipse
|
||||
cx="40"
|
||||
cy="25"
|
||||
rx="20.262579"
|
||||
ry="20.632982"
|
||||
style="fill:#c7d6f1;fill-opacity:1;stroke:#3366cc;stroke-width:8.73336"
|
||||
id="ellipse1" />
|
||||
<path
|
||||
d="m -3.4e-4,17.669765 h 80.00068 v 14.66047 H -3.4e-4 Z"
|
||||
style="fill:#000000;stroke-width:3.56018;paint-order:stroke fill markers"
|
||||
id="path1" />
|
||||
<text
|
||||
xml:space="preserve"
|
||||
x="39.788086"
|
||||
y="29.819336"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14px;line-height:1;font-family:Sans;-inkscape-font-specification:'Sans Bold';text-align:center;letter-spacing:2.83px;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#ffffff;stroke:#ffffff;stroke-width:0.655;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
|
||||
id="text1"><tspan
|
||||
x="39.788086"
|
||||
y="29.819336"
|
||||
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14px;font-family:Sans;-inkscape-font-specification:'Sans Bold';letter-spacing:2.83px;fill:#ffffff;stroke:#ffffff;stroke-width:0.655;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="tspan1">CUTT</tspan></text>
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="50" viewBox="0 0 80 50"><ellipse cx="40" cy="25" rx="20.263" ry="20.633" style="fill:#c7d6f1;fill-opacity:1;stroke:#36c;stroke-width:8.73336"/><path d="M0 17.67h80v14.66H0Z" style="fill:#000;stroke-width:3.56018;paint-order:stroke fill markers"/><text xml:space="preserve" x="39.788" y="29.819" style="font-style:normal;font-variant:normal;font-weight:700;font-stretch:normal;font-size:14px;line-height:1;font-family:Sans;-inkscape-font-specification:"Sans Bold";text-align:center;letter-spacing:2.83px;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#fff;stroke:#fff;stroke-width:.655;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"><tspan x="39.788" y="29.819" style="font-style:normal;font-variant:normal;font-weight:700;font-stretch:normal;font-size:14px;font-family:Sans;-inkscape-font-specification:"Sans Bold";letter-spacing:2.83px;fill:#fff;stroke:#fff;stroke-width:.655;stroke-dasharray:none;stroke-opacity:1">CUTT</tspan></text></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.0 KiB |
@@ -9,6 +9,8 @@ import { GraphComponent } from "./Network";
|
||||
import MVPChart from "./MVPChart";
|
||||
import { SetPassword } from "./SetPassword";
|
||||
import { Register } from "./Register";
|
||||
import { ForgotPassword } from "./Login";
|
||||
import { Join } from "./JoinTeam";
|
||||
|
||||
const Maintenance = () => {
|
||||
return (
|
||||
@@ -28,6 +30,7 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/setpassword" element={<SetPassword />} />
|
||||
<Route path="/forgotten_password" element={<ForgotPassword />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
@@ -38,6 +41,7 @@ function App() {
|
||||
<Route path="network" element={<GraphComponent />} />
|
||||
<Route path="mvp" element={<MVPChart />} />
|
||||
<Route path="team" element={<TeamPanel />} />
|
||||
<Route path="join" element={<Join />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</SessionProvider>
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
import { JSX, useEffect, useState } from "react";
|
||||
import { apiAuth } from "./api";
|
||||
import { useSession } from "./Session";
|
||||
import { Events } from "./types";
|
||||
|
||||
interface Datum {
|
||||
[id: number]: string;
|
||||
}
|
||||
interface Events {
|
||||
[key: string]: Datum;
|
||||
}
|
||||
|
||||
const Calendar = ({ playerId }: { playerId: number }) => {
|
||||
const Calendar = ({
|
||||
playerId,
|
||||
events,
|
||||
}: {
|
||||
playerId: number;
|
||||
events: Events | undefined;
|
||||
}) => {
|
||||
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]];
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { apiAuth, User } from "./api";
|
||||
import { TeamState, useSession } from "./Session";
|
||||
import TabController from "./TabController";
|
||||
import { Chemistry, MVPRanking, PlayerType } from "./types";
|
||||
import Loading from "./Loading";
|
||||
|
||||
type PlayerListProps = Partial<ReactSortableProps<any>> & {
|
||||
orderedList?: boolean;
|
||||
@@ -103,7 +104,7 @@ function TypeDnD({ user, teams, players }: PlayerInfoProps) {
|
||||
|
||||
useEffect(() => {
|
||||
handleGet();
|
||||
}, [players]);
|
||||
}, [players, teams]);
|
||||
|
||||
const [dialog, setDialog] = useState("dialog");
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
@@ -244,7 +245,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
|
||||
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
|
||||
activeTeam && setMixed(activeTeam.mixed);
|
||||
handleGet();
|
||||
}, [players]);
|
||||
}, [players, teams]);
|
||||
|
||||
useEffect(() => {
|
||||
handleGet();
|
||||
@@ -300,9 +301,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="container">
|
||||
<progress className="progress is-primary" max="100"></progress>
|
||||
</div>
|
||||
Loading
|
||||
) : (
|
||||
<div className="columns container is-mobile is-1-mobile">
|
||||
<div className="column">
|
||||
@@ -365,7 +364,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
|
||||
|
||||
useEffect(() => {
|
||||
handleGet();
|
||||
}, [players]);
|
||||
}, [players, teams]);
|
||||
|
||||
const [dialog, setDialog] = useState("dialog");
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
@@ -422,9 +421,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{loading ? (
|
||||
<div className="container">
|
||||
<progress className="progress is-primary" max="100"></progress>
|
||||
</div>
|
||||
Loading
|
||||
) : (
|
||||
<div className="columns container is-multiline is-mobile is-1-mobile">
|
||||
<div className="column is-full is-flex is-justify-content-center">
|
||||
@@ -456,7 +453,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
|
||||
<div className="column">
|
||||
<div className="box">
|
||||
<p className="subtitle is-6 is-uppercase has-text-weight-light">
|
||||
yes, please ♥️
|
||||
♥️
|
||||
</p>
|
||||
<PlayerList
|
||||
list={playersRight}
|
||||
@@ -513,22 +510,22 @@ export default function Rankings() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container block">
|
||||
<p className="notification is-warning is-light">
|
||||
assign as many or as few players as you want and don't forget to 💾
|
||||
<strong> submit</strong> when you're done :)
|
||||
</p>
|
||||
{user && teams && players ? (
|
||||
<TabController tabs={tabs}>
|
||||
<ChemistryDnDMobile {...{ user, teams, players }} />
|
||||
<MVPDnD {...{ user, teams, players }} />
|
||||
<TypeDnD {...{ user, teams, players }} />
|
||||
</TabController>
|
||||
) : (
|
||||
<div className="container">
|
||||
<progress className="progress is-primary" max="100"></progress>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<section className="section">
|
||||
<div className="container block">
|
||||
<p className="notification is-warning is-light">
|
||||
assign as many or as few players as you want and don't forget to 💾
|
||||
<strong> submit</strong> when you're done :)
|
||||
</p>
|
||||
{user && teams && players ? (
|
||||
<TabController tabs={tabs}>
|
||||
<ChemistryDnDMobile {...{ user, teams, players }} />
|
||||
<MVPDnD {...{ user, teams, players }} />
|
||||
<TypeDnD {...{ user, teams, players }} />
|
||||
</TabController>
|
||||
) : (
|
||||
Loading
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { Link } from "react-router";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { useSession } from "./Session";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function Header() {
|
||||
const { user, teams, setTeams, players, onLogout } = useSession();
|
||||
const [burgerActive, setBurgerActive] = useState(false);
|
||||
const location = useLocation();
|
||||
const pages = [
|
||||
{ name: "Sociogram", path: "/network" },
|
||||
{ name: "MVP", path: "/mvp" },
|
||||
{ name: "Team", path: "/team" },
|
||||
];
|
||||
return (
|
||||
<nav className="navbar" role="navigation" aria-label="main navigation">
|
||||
<div className="navbar-brand">
|
||||
@@ -36,29 +42,23 @@ export default function Header() {
|
||||
className={"navbar-menu" + (burgerActive ? " is-active" : "")}
|
||||
id="navbar"
|
||||
>
|
||||
{user?.scopes.includes(`team:${teams?.activeTeam}`) && (
|
||||
{(user?.scopes.includes(`team:${teams?.activeTeam}`) ||
|
||||
teams?.activeTeam === 42) && (
|
||||
<div className="navbar-start">
|
||||
<Link
|
||||
onClick={() => setBurgerActive(false)}
|
||||
className="navbar-item"
|
||||
to="/network"
|
||||
>
|
||||
<span>Sociogram</span>
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => setBurgerActive(false)}
|
||||
className="navbar-item"
|
||||
to="/mvp"
|
||||
>
|
||||
<span>MVP</span>
|
||||
</Link>
|
||||
<Link
|
||||
onClick={() => setBurgerActive(false)}
|
||||
className="navbar-item"
|
||||
to="/team"
|
||||
>
|
||||
<span>Team</span>
|
||||
</Link>
|
||||
{pages.map((p) => (
|
||||
<Link
|
||||
onClick={() => setBurgerActive(false)}
|
||||
className={
|
||||
"navbar-item" +
|
||||
(location.pathname === p.path
|
||||
? " has-text-weight-extrabold"
|
||||
: "")
|
||||
}
|
||||
to={p.path}
|
||||
>
|
||||
<span>{p.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="navbar-end">
|
||||
|
||||
64
frontend/src/JoinTeam.tsx
Normal file
64
frontend/src/JoinTeam.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { apiAuth, PassToken } from "./api";
|
||||
import { useEffect, useState } from "react";
|
||||
import { UsersIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useSession } from "./Session";
|
||||
|
||||
export const Join = () => {
|
||||
const [token, setToken] = useState("");
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [teamID, setTeamID] = useState<number>();
|
||||
const [error, setError] = useState("");
|
||||
const { teams, setTeams } = useSession();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
setToken(token);
|
||||
try {
|
||||
const payload = jwtDecode<PassToken>(token);
|
||||
if (payload.sub === "join") {
|
||||
if (payload.team_id) setTeamID(payload.team_id);
|
||||
} else {
|
||||
setError("not a valid token for joining");
|
||||
}
|
||||
if (payload.name) setTeamName(payload.name);
|
||||
} catch (InvalidTokenError) {
|
||||
setError("not a valid token");
|
||||
}
|
||||
} else setError("no token found");
|
||||
}, []);
|
||||
|
||||
async function handleJoin() {
|
||||
const r = await apiAuth(
|
||||
`player/${teamID}/join`,
|
||||
{ token: token, team_id: teamID },
|
||||
"POST"
|
||||
);
|
||||
if (r.detail) setError(r.detail);
|
||||
else {
|
||||
teamID && teams && setTeams({ ...teams, activeTeam: teamID });
|
||||
navigate("/", { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section is-medium">
|
||||
<div className="container is-max-tablet has-text-centered">
|
||||
<h1 className="title">Join "{teamName}"</h1>
|
||||
<div className="field is-grouped is-grouped-centered">
|
||||
<button className="button is-dark is-large" onClick={handleJoin}>
|
||||
<span className="icon">
|
||||
<UsersIcon />
|
||||
</span>
|
||||
<span> join team</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="help is-danger">{error}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
6
frontend/src/Loading.tsx
Normal file
6
frontend/src/Loading.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
const Loading = (
|
||||
<div className="container">
|
||||
<progress className="progress is-primary" max="100"></progress>
|
||||
</div>
|
||||
);
|
||||
export default Loading;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { currentUser, login, User } from "./api";
|
||||
import Header from "./Header";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { baseUrl, currentUser, login, User } from "./api";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { Eye, EyeSlash } from "./Icons";
|
||||
import Loading from "./Loading";
|
||||
|
||||
export interface LoginProps {
|
||||
onLogin: (user: User) => void;
|
||||
@@ -35,7 +34,7 @@ export const Login = ({ onLogin }: LoginProps) => {
|
||||
onLogin(user);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
doLogin();
|
||||
}
|
||||
@@ -86,6 +85,9 @@ export const Login = ({ onLogin }: LoginProps) => {
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<p className="help is-link has-text-right">
|
||||
<a href="forgotten_password">forgot password?</a>
|
||||
</p>
|
||||
{error && <p className="help is-danger">{error}</p>}
|
||||
</div>
|
||||
<div className="control">
|
||||
@@ -98,7 +100,85 @@ export const Login = ({ onLogin }: LoginProps) => {
|
||||
login
|
||||
</button>
|
||||
</div>
|
||||
{loading && <span className="loader" />}
|
||||
{loading && Loading}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const ForgotPassword = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const req = new Request(`${baseUrl}api/forgotten_password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: decodeURI(email) }),
|
||||
});
|
||||
let resp: Response;
|
||||
try {
|
||||
resp = await fetch(req);
|
||||
} catch (e) {
|
||||
throw new Error(`request failed: ${e}`);
|
||||
}
|
||||
if (resp.ok) {
|
||||
const content = await resp.text();
|
||||
if (content) setError(content);
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
const content = await resp.text();
|
||||
if (content) setError(content);
|
||||
else setError("unauthorized");
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="section is-medium">
|
||||
<div className="container is-max-tablet">
|
||||
<div className="block">
|
||||
<p className="level-item has-text-centered">
|
||||
<img
|
||||
className="image"
|
||||
alt="cool ultimate team tool"
|
||||
src="cutt.svg"
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="title">Forgot password</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="field">
|
||||
<div className="field">
|
||||
<label className="label">email</label>
|
||||
<div className="control">
|
||||
<input
|
||||
className="input"
|
||||
required
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<p className={"help" + (error ? " is-danger" : " is-success")}>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<div className="field is-grouped is-grouped-centered">
|
||||
<button className="button is-light is-primary">send email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PlayerRanking } from "./types";
|
||||
import RaceChart from "./RaceChart";
|
||||
import { useSession } from "./Session";
|
||||
import { useNavigate } from "react-router";
|
||||
import Loading from "./Loading";
|
||||
|
||||
const MVPChart = () => {
|
||||
let initialData = {} as PlayerRanking[][];
|
||||
@@ -54,7 +55,7 @@ const MVPChart = () => {
|
||||
loadData();
|
||||
}, [teams]);
|
||||
|
||||
if (loading) return <span className="loader" />;
|
||||
if (loading) return Loading;
|
||||
else if (error) return <span>{error}</span>;
|
||||
else
|
||||
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { customTheme } from "./NetworkTheme";
|
||||
import { useSession } from "./Session";
|
||||
import { useNavigate } from "react-router";
|
||||
import { ChevronDown, ChevronUp, Info, Settings2 } from "lucide-react";
|
||||
|
||||
interface NetworkData {
|
||||
nodes: GraphNode[];
|
||||
@@ -43,9 +44,14 @@ export const GraphComponent = () => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [threed, setThreed] = useState(false);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [likes, setLikes] = useState(2);
|
||||
const [showLikes, setShowLikes] = useState(true);
|
||||
const [showDislikes, setShowDislikes] = useState(false);
|
||||
const [popularity, setPopularity] = useState(false);
|
||||
const [showPlayerType, setShowPlayerType] = useState(false);
|
||||
const [mutuality, setMutuality] = useState(false);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const { user, teams } = useSession();
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
@@ -90,23 +96,16 @@ export const GraphComponent = () => {
|
||||
popularityLabel(!popularity);
|
||||
setPopularity(!popularity);
|
||||
}
|
||||
function handlePlayerType() {
|
||||
playerType(!showPlayerType);
|
||||
setShowPlayerType(!showPlayerType);
|
||||
}
|
||||
|
||||
function handleMutuality() {
|
||||
colorMatches(!mutuality);
|
||||
setMutuality(!mutuality);
|
||||
}
|
||||
|
||||
function showLabel() {
|
||||
switch (likes) {
|
||||
case 0:
|
||||
return "dislike";
|
||||
case 1:
|
||||
return "both";
|
||||
case 2:
|
||||
return "like";
|
||||
}
|
||||
}
|
||||
|
||||
function findMatches(edges: GraphEdge[]) {
|
||||
const adjacencyList = edges.map(
|
||||
(edge) => edge.source + edge.target + edge.data.relation
|
||||
@@ -144,19 +143,35 @@ export const GraphComponent = () => {
|
||||
setData({ nodes: data.nodes, edges: newEdges });
|
||||
}
|
||||
|
||||
function playerType(popularity: boolean) {
|
||||
const newNodes = data.nodes;
|
||||
if (popularity) {
|
||||
newNodes.forEach((node) => {
|
||||
node.fill = node.data.playertype;
|
||||
});
|
||||
} else {
|
||||
newNodes.forEach((node) => (node.fill = undefined));
|
||||
}
|
||||
setData({ nodes: newNodes, edges: data.edges });
|
||||
}
|
||||
|
||||
function popularityLabel(popularity: boolean) {
|
||||
const newNodes = data.nodes;
|
||||
console.log(data.nodes);
|
||||
if (popularity) {
|
||||
newNodes.forEach(
|
||||
(node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`)
|
||||
);
|
||||
newNodes.forEach((node) => {
|
||||
node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`;
|
||||
node.fill = node.data.playertype;
|
||||
});
|
||||
} else {
|
||||
newNodes.forEach((node) => (node.subLabel = undefined));
|
||||
}
|
||||
setData({ nodes: newNodes, edges: data.edges });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLikes(+showLikes + +!showDislikes);
|
||||
}, [showLikes, showDislikes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mutuality) colorMatches(false);
|
||||
colorMatches(mutuality);
|
||||
@@ -183,7 +198,9 @@ export const GraphComponent = () => {
|
||||
|
||||
let content: ReactNode;
|
||||
if (loading) {
|
||||
content = <span className="loader" />;
|
||||
<div className="container">
|
||||
<progress className="progress is-primary" max="100"></progress>
|
||||
</div>;
|
||||
} else if (error) {
|
||||
content = <span>{error}</span>;
|
||||
} else {
|
||||
@@ -213,106 +230,203 @@ export const GraphComponent = () => {
|
||||
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">
|
||||
<div className="control" onClick={handleMutuality}>
|
||||
<div className="switch">
|
||||
<input type="checkbox" checked={mutuality} onChange={() => {}} />
|
||||
<span className="slider round"></span>
|
||||
</div>
|
||||
<span>mutuality</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="card network-controls">
|
||||
<header
|
||||
className="card-header"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setShowControls(!showControls)}
|
||||
>
|
||||
<p className="card-header-title">
|
||||
<span className="icon-text">
|
||||
<span className="icon">
|
||||
<Settings2 />
|
||||
</span>
|
||||
<span>controls</span>
|
||||
</span>
|
||||
</p>
|
||||
<button className="card-header-icon">
|
||||
{showControls ? <ChevronUp /> : <ChevronDown />}
|
||||
</button>
|
||||
</header>
|
||||
{showControls && (
|
||||
<div className="card-content">
|
||||
<div className="field">
|
||||
<div className="field is-grouped">
|
||||
<div className="control">
|
||||
<button
|
||||
className={
|
||||
"button is-primary" + (showLikes ? "" : " is-outlined")
|
||||
}
|
||||
onClick={() => setShowLikes(!showLikes)}
|
||||
>
|
||||
<span className="icon">👍</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="control">
|
||||
<button
|
||||
className={
|
||||
"button is-primary" + (showDislikes ? "" : " is-outlined")
|
||||
}
|
||||
onClick={() => setShowDislikes(!showDislikes)}
|
||||
>
|
||||
<span className="icon">👎</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control" onClick={handleThreed}>
|
||||
<span>2D</span>
|
||||
<div className="switch">
|
||||
<input type="checkbox" checked={threed} onChange={() => {}} />
|
||||
<span className="slider round"></span>
|
||||
</div>
|
||||
<span>3D</span>
|
||||
</div>
|
||||
<div className="control">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mutuality}
|
||||
onClick={handleMutuality}
|
||||
/>
|
||||
<span className="ml-1">mutuality</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="control">
|
||||
<div className="stack column">
|
||||
<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) => setLikes(Number(evt.target.value))}
|
||||
/>
|
||||
<label>😍</label>
|
||||
<div className="control">
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
checked={!threed}
|
||||
name="3D"
|
||||
onClick={handleThreed}
|
||||
/>
|
||||
<span className="m-1">2D</span>
|
||||
</label>
|
||||
<label className="radio">
|
||||
<input
|
||||
type="radio"
|
||||
checked={threed}
|
||||
name="3D"
|
||||
onClick={handleThreed}
|
||||
/>
|
||||
<span className="m-1">3D</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="control">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={popularity}
|
||||
onClick={handlePopularity}
|
||||
/>
|
||||
<span className="ml-1">popularity</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="control">
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showPlayerType}
|
||||
onClick={handlePlayerType}
|
||||
/>
|
||||
<span className="ml-1">player type</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="control pl-4">
|
||||
{showPlayerType && (
|
||||
<>
|
||||
<p>RGB:</p>
|
||||
<p
|
||||
style={{
|
||||
color: "red",
|
||||
fontWeight: "bold",
|
||||
paddingLeft: "1rem",
|
||||
}}
|
||||
>
|
||||
handler
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "green",
|
||||
fontWeight: "bold",
|
||||
paddingLeft: "1rem",
|
||||
}}
|
||||
>
|
||||
combi
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
color: "blue",
|
||||
fontWeight: "bold",
|
||||
paddingLeft: "1rem",
|
||||
}}
|
||||
>
|
||||
cutter
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showLabel()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showControls && (
|
||||
<footer className="card-footer">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowHelp(true);
|
||||
}}
|
||||
className="card-footer-item"
|
||||
>
|
||||
<p className="icon-text is-small">
|
||||
<span className="icon">
|
||||
<Info />
|
||||
</span>
|
||||
<span>help</span>
|
||||
</p>
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="control" onClick={handlePopularity}>
|
||||
<div className="switch">
|
||||
<input type="checkbox" checked={popularity} onChange={() => {}} />
|
||||
<span className="slider round"></span>
|
||||
</div>
|
||||
<span>
|
||||
popularity<sup>*</sup>
|
||||
</span>
|
||||
<div className={"modal" + (showHelp ? " is-active" : "")}>
|
||||
<div
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="modal-background"
|
||||
></div>
|
||||
<div className="modal-content">
|
||||
<article className="message is-info">
|
||||
<div className="message-header">
|
||||
<p>Help</p>
|
||||
<button
|
||||
onClick={() => setShowHelp(false)}
|
||||
className="delete"
|
||||
aria-label="delete"
|
||||
></button>
|
||||
</div>
|
||||
<div className="message-body">
|
||||
<p>scroll to zoom</p>
|
||||
<p>
|
||||
<strong>hover</strong>: show inbound links
|
||||
</p>
|
||||
<p>
|
||||
<strong>click</strong>: show outward links
|
||||
</p>
|
||||
<hr className="has-background-info" />
|
||||
<p>
|
||||
multi-selection is possible with
|
||||
<br />
|
||||
<i>Ctrl</i> or <i>Shift</i>
|
||||
</p>
|
||||
<br />
|
||||
<p>drag to pan/rotate</p>
|
||||
<hr className="has-background-info" />
|
||||
<p>popularity is meassured by rank-weighted in-degree</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{popularity && (
|
||||
<div
|
||||
style={{ position: "absolute", bottom: 0, right: "10px", zIndex: 10 }}
|
||||
>
|
||||
<span className="grey" style={{ fontSize: "70%" }}>
|
||||
<sup>*</sup>popularity meassured by rank-weighted in-degree
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { jwtDecode, JwtPayload } from "jwt-decode";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { baseUrl, Gender } from "./api";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface PassToken extends JwtPayload {
|
||||
username: string;
|
||||
name: string;
|
||||
team_id: number;
|
||||
}
|
||||
import { baseUrl, Gender, PassToken } from "./api";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
|
||||
export const Register = () => {
|
||||
const [name, setName] = useState("");
|
||||
@@ -23,16 +17,17 @@ export const Register = () => {
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const params = new URLSearchParams(location.search);
|
||||
const token = params.get("token");
|
||||
if (token) {
|
||||
setToken(token);
|
||||
try {
|
||||
const payload = jwtDecode<PassToken>(token);
|
||||
if (payload.sub === "register") {
|
||||
if (payload.sub === "join") {
|
||||
if (payload.team_id) setTeamID(payload.team_id);
|
||||
} else {
|
||||
setError("not a valid token for registration");
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
} from "react";
|
||||
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
|
||||
import { Login } from "./Login";
|
||||
import Header from "./Header";
|
||||
import { Team } from "./types";
|
||||
import Loading from "./Loading";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
|
||||
export interface SessionProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -43,26 +45,31 @@ 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 [error, setError] = useState<unknown>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
function loadUser() {
|
||||
setLoading(true);
|
||||
currentUser()
|
||||
.then((user) => {
|
||||
setUser(user);
|
||||
setErr(null);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setUser(null);
|
||||
setErr(err);
|
||||
setError(err);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
async function loadTeam() {
|
||||
const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
|
||||
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
|
||||
const loaded_teams: Team[] = await apiAuth("player/me/teams", null, "GET");
|
||||
if (loaded_teams)
|
||||
setTeams({
|
||||
teams: loaded_teams,
|
||||
activeTeam: teams?.activeTeam || loaded_teams[0].id,
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadPlayers() {
|
||||
@@ -81,30 +88,25 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
|
||||
function onLogin(user: User) {
|
||||
setUser(user);
|
||||
setErr(null);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
async function onLogout() {
|
||||
try {
|
||||
logout();
|
||||
setUser(null);
|
||||
setErr({ message: "Logged out successfully" });
|
||||
setError({ message: "Logged out successfully" });
|
||||
console.log("logged out.");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setErr(e);
|
||||
setError(e);
|
||||
}
|
||||
}
|
||||
|
||||
let content: ReactNode;
|
||||
if (loading || (!err && !user))
|
||||
content = (
|
||||
<>
|
||||
<Header />
|
||||
<span className="loader" />
|
||||
</>
|
||||
);
|
||||
else if (err) {
|
||||
if (loading || (!error && !user))
|
||||
content = <section className="section is-medium">{Loading}</section>;
|
||||
else if (error) {
|
||||
content = (
|
||||
<section className="section is-medium">
|
||||
<div className="container is-max-tablet">
|
||||
@@ -118,6 +120,19 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
{location.pathname === "/join" && (
|
||||
<div className="notification is-warning">
|
||||
<div className="icon-text">
|
||||
<span className="icon">
|
||||
<TriangleAlert />
|
||||
</span>
|
||||
<span>
|
||||
If you don't already have an account,{" "}
|
||||
<Link to={`/register${location.search}`}>register here</Link>.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Login onLogin={onLogin} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -21,8 +21,17 @@ export default function TabController({ tabs, children }: TabControllerProps) {
|
||||
<div className="tabs is-boxed is-centered">
|
||||
<ul>
|
||||
{tabs.map((tab, index) => (
|
||||
<li className={currentIndex === index ? "is-active" : ""}>
|
||||
<a onClick={() => handleTabClick(index)}>{tab.label}</a>
|
||||
<li
|
||||
className={
|
||||
currentIndex === index ? "is-active has-text-weight-bold" : ""
|
||||
}
|
||||
>
|
||||
<a
|
||||
className="has-text-black"
|
||||
onClick={() => handleTabClick(index)}
|
||||
>
|
||||
{tab.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { apiAuth, Gender, User } from "./api";
|
||||
import { useSession } from "./Session";
|
||||
import { ErrorState } from "./types";
|
||||
import { ErrorState, Events } from "./types";
|
||||
import { useNavigate } from "react-router";
|
||||
import Calendar from "./Calendar";
|
||||
import { Info, Star, StarHalf, StarOff, UserPen } from "lucide-react";
|
||||
import Loading from "./Loading";
|
||||
|
||||
const TeamPanel = () => {
|
||||
const { user, teams, players, reloadPlayers } = useSession();
|
||||
const [events, setEvents] = useState<Events>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
|
||||
teams?.activeTeam === 42 ||
|
||||
navigate("/", { replace: true });
|
||||
}, [user, teams]);
|
||||
|
||||
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 newPlayerTemplate = {
|
||||
id: 0,
|
||||
username: "",
|
||||
@@ -20,10 +40,21 @@ const TeamPanel = () => {
|
||||
gender: undefined,
|
||||
number: "",
|
||||
email: "",
|
||||
is_manager: false,
|
||||
} as User;
|
||||
const [error, setError] = useState<ErrorState>();
|
||||
const [player, setPlayer] = useState(newPlayerTemplate);
|
||||
|
||||
const getPlayerSubmissions = () => {
|
||||
var submissions = [];
|
||||
if (events) {
|
||||
for (const [date, obj] of Object.entries(events))
|
||||
for (const [id, emoji] of Object.entries(obj))
|
||||
if (player.id === Number(id)) submissions.push({ emoji, date });
|
||||
}
|
||||
return submissions;
|
||||
};
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (teams) {
|
||||
@@ -95,7 +126,12 @@ const TeamPanel = () => {
|
||||
setError({ ok: true, message: "" });
|
||||
}}
|
||||
>
|
||||
{p.display_name}
|
||||
<span>{p.display_name}</span>
|
||||
{p.is_manager && (
|
||||
<span className="icon">
|
||||
<Star size={16} fill="gold" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
@@ -110,7 +146,7 @@ const TeamPanel = () => {
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="loader" />
|
||||
Loading
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -196,7 +232,7 @@ const TeamPanel = () => {
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">
|
||||
email <small>(optional)</small>
|
||||
email <small>(optional, but helpful)</small>
|
||||
</label>
|
||||
<div className="control">
|
||||
<input
|
||||
@@ -209,18 +245,67 @@ const TeamPanel = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error?.message && (
|
||||
<p
|
||||
className={
|
||||
"help" + (error.ok ? " is-success" : " is-danger")
|
||||
}
|
||||
>
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<label className="label">
|
||||
manager{" "}
|
||||
<span className="tooltip">
|
||||
<Info size={16} />
|
||||
<span className="tooltiptext notification is-primary is-light has-text-centered">
|
||||
managers are able to see the analyses and modify
|
||||
the players
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={"button"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setPlayer({
|
||||
...player,
|
||||
is_manager: !player.is_manager,
|
||||
});
|
||||
setError({ ok: true, message: "" });
|
||||
}}
|
||||
>
|
||||
<span className="icon">
|
||||
{player.is_manager ? (
|
||||
<Star fill="gold" />
|
||||
) : (
|
||||
<StarOff />
|
||||
)}
|
||||
</span>
|
||||
<span>{player.is_manager ? "yes" : "no"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{player.id !== 0 && (
|
||||
<div className="field">
|
||||
<div className="control">
|
||||
<label className="label">submissions</label>
|
||||
{getPlayerSubmissions().map((submission) => (
|
||||
<span className="tooltip">
|
||||
{submission.emoji}
|
||||
<span className="tooltiptext notification is-primary is-light has-text-centered">
|
||||
{submission.date}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="field is-grouped is-grouped-centered">
|
||||
{error?.message && (
|
||||
<p
|
||||
className={
|
||||
"help" + (error.ok ? " is-success" : " is-danger")
|
||||
}
|
||||
>
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="field is-grouped is-grouped-multiline is-grouped-centered">
|
||||
<button
|
||||
className={
|
||||
"button is-light" +
|
||||
@@ -245,11 +330,11 @@ const TeamPanel = () => {
|
||||
</section>
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<Calendar playerId={player.id} />
|
||||
<Calendar playerId={player.id} events={events} />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
} else <span className="loader" />;
|
||||
} else Loading;
|
||||
};
|
||||
export default TeamPanel;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { JwtPayload } from "jwt-decode";
|
||||
import { useSession } from "./Session";
|
||||
|
||||
export const baseUrl = "";
|
||||
@@ -53,6 +54,7 @@ export type User = {
|
||||
number: string;
|
||||
gender: Gender;
|
||||
scopes: string;
|
||||
is_manager: boolean;
|
||||
};
|
||||
|
||||
export async function currentUser(): Promise<User> {
|
||||
@@ -124,3 +126,9 @@ export const logout = async () => {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
export interface PassToken extends JwtPayload {
|
||||
username: string;
|
||||
name: string;
|
||||
team_id: number;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,31 @@
|
||||
overflow-y: auto;
|
||||
max-height: 30vh;
|
||||
}
|
||||
|
||||
.network-controls {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tooltip text */
|
||||
.tooltiptext {
|
||||
visibility: hidden;
|
||||
top: 1.5rem;
|
||||
left: -0.5rem;
|
||||
width: 8rem;
|
||||
padding: 0.25rem;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Show the tooltip text on hover */
|
||||
.tooltip:hover .tooltiptext {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@@ -53,3 +53,10 @@ export type ErrorState = {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export interface Datum {
|
||||
[id: number]: string;
|
||||
}
|
||||
export interface Events {
|
||||
[key: string]: Datum;
|
||||
}
|
||||
|
||||
@@ -21,4 +21,5 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pyqt6>=6.10.1",
|
||||
"python-dotenv>=1.2.1",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user