24 Commits

Author SHA1 Message Date
df84c798be show submissions for selected player 2026-01-29 23:32:57 +01:00
052065acf9 display player type in Sociogram in RGB 2026-01-24 11:42:20 +01:00
039b43be8e fix type warning 2026-01-03 17:04:58 +01:00
5c76a60df1 refactor navbar links 2026-01-03 17:03:09 +01:00
bb41513571 join (or register) with invite link 2026-01-03 17:02:44 +01:00
aff3b9f7be no header during login 2026-01-03 14:00:58 +01:00
be3cf4175e refactor 2026-01-03 14:00:50 +01:00
051202edc9 appoint team manager in UI 2026-01-03 13:59:53 +01:00
12b42e4a46 team invite link handling login state
one can register a new account or login and join
2026-01-03 13:57:46 +01:00
ed460f63d6 use same Loading progress bar everywhere 2026-01-03 09:23:10 +01:00
7ec6a5b45f remove dev .env 2026-01-03 09:10:00 +01:00
03134b2f03 minify logo svg 2026-01-03 09:09:17 +01:00
1b6ad04148 show analyses for DEMO team 2026-01-03 09:08:48 +01:00
0c65aae718 err -> error 2026-01-03 09:07:57 +01:00
6408a3fee1 Sociogram help dialog 2025-12-26 11:02:35 +01:00
635105c1b7 correct term in email 2025-12-26 08:25:09 +01:00
caa56a3484 fix: switch likes/dislikes button 2025-12-26 08:24:45 +01:00
66422bd4d9 show control overlay 2025-12-26 07:54:48 +01:00
2645bb054c better spacing 2025-12-23 11:28:45 +01:00
8e11a2fb56 bold active tab 2025-12-23 11:28:16 +01:00
92d6f451ec clean up sociogram controls 2025-12-23 11:27:58 +01:00
46fd498c32 easier disabling players 2025-12-23 08:54:02 +01:00
4022136970 forgotten password routine 2025-12-23 08:53:46 +01:00
d0140f4cfb implement reset password email 2025-12-22 11:51:54 +01:00
26 changed files with 982 additions and 333 deletions

View File

@@ -1 +0,0 @@
VITE_BASE_URL=http://localhost:8000/

View File

@@ -126,6 +126,7 @@ def graph_json(
return G return G
return JSONResponse({"nodes": nodes, "edges": edges}) return JSONResponse({"nodes": nodes, "edges": edges})
playertypes = playertype(request)
with Session(engine) as session: with Session(engine) as session:
players = session.exec( players = session.exec(
select(P) select(P)
@@ -140,7 +141,18 @@ def graph_json(
) )
for p in players: for p in players:
player_map[p.id] = p.display_name 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 = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest"))
@@ -207,7 +219,8 @@ def graph_json(
) )
in_degrees = G.in_degree(weight="weight") in_degrees = G.in_degree(weight="weight")
nodes = [ 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: if networkx_graph:
return G 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( async def turnout(
request: Annotated[ request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"]) TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
@@ -504,6 +563,9 @@ analysis_router.add_api_route(
name="MVPs", name="MVPs",
description="Request Most Valuable Players stats", 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("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
analysis_router.add_api_route( analysis_router.add_api_route(
"/times/{team_id}", endpoint=last_submissions, methods=["GET"] "/times/{team_id}", endpoint=last_submissions, methods=["GET"]

View 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: &quot;Sans Bold&quot;;
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: &quot;Sans Bold&quot;;
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
View 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."
)

View File

@@ -10,8 +10,10 @@ from sqlmodel import (
) )
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from cutt.analysis import analysis_router from cutt.analysis import analysis_router
from cutt.mail import send_forgotten_password_link
from cutt.security import ( from cutt.security import (
get_current_active_user, get_current_active_user,
join_team_token,
login_for_access_token, login_for_access_token,
logout, logout,
register, register,
@@ -58,6 +60,9 @@ team_router = APIRouter(
) )
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"]) team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"]) team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
team_router.add_api_route(
"/join_link/{team_id}", endpoint=join_team_token, methods=["GET"]
)
wrong_user_id_exception = HTTPException( 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(team_router, dependencies=[Depends(get_current_active_user)])
api_router.include_router(analysis_router) api_router.include_router(analysis_router)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) 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("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/register", endpoint=register, methods=["POST"]) api_router.add_api_route("/register", endpoint=register, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"]) api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])

View File

@@ -10,6 +10,7 @@ from cutt.security import (
change_password, change_password,
get_current_active_user, get_current_active_user,
read_player_me, read_player_me,
verify_one_time_token,
verify_team_scope, verify_team_scope,
) )
from cutt.demo import demo_players from cutt.demo import demo_players
@@ -19,12 +20,24 @@ P = Player
player_router = APIRouter(prefix="/player", tags=["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): class PlayerRequest(BaseModel):
display_name: str display_name: str
username: str username: str
gender: str | None gender: str | None
number: str number: str
email: str | None email: str | None
is_manager: bool | None
class AddPlayerRequest(PlayerRequest): ... class AddPlayerRequest(PlayerRequest): ...
@@ -96,6 +109,10 @@ def modify_player(
player.number = r.number.strip() player.number = r.number.strip()
player.gender = r.gender.strip() if r.gender else None player.gender = r.gender.strip() if r.gender else None
player.email = r.email.strip() if r.email 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.add(player)
session.commit() session.commit()
return PlainTextResponse("modification successful") 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: 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: if player:
player.disabled = True player.disabled = True
session.add(player) 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() player = session.exec(select(P).where(P.id == player_id)).one()
team = session.exec(select(Team).where(Team.id == team_id)).one() team = session.exec(select(Team).where(Team.id == team_id)).one()
if player and team: if player and team:
team.players.append(player) if player in team.players:
session.add(team) return PlainTextResponse(
session.commit() f"{player.display_name} ({player.username}) is already part of {team.name}"
return PlainTextResponse( )
f"added {player.display_name} ({player.username}) to {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]): def add_players(players: list[P]):
with Session(engine) as session: with Session(engine) as session:
for player in players: for player in players:
@@ -202,7 +251,7 @@ async def list_all_players():
async def list_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: if team_id == 42:
return [ return [
@@ -212,6 +261,8 @@ async def list_players(
] + demo_players ] + demo_players
allowed_scopes = set(user.scopes.split()) 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: with Session(engine) as session:
current_user = session.exec( current_user = session.exec(
@@ -220,7 +271,7 @@ async def list_players(
.join(Team) .join(Team)
.where(Team.id == team_id, P.disabled == False, P.id == user.id) .where(Team.id == team_id, P.disabled == False, P.id == user.id)
).one_or_none() ).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( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="you're not in this team", detail="you're not in this team",
@@ -233,21 +284,26 @@ async def list_players(
.where(Team.id == team_id, P.disabled == False) .where(Team.id == team_id, P.disabled == False)
.order_by(P.display_name) .order_by(P.display_name)
).all() ).all()
if players: if players:
return [ players_dump = []
player.model_dump( for player in players:
include={ if not player.disabled:
"id", player_dump = player.model_dump(
"display_name", include={
"username", "id",
"gender", "display_name",
"number", "username",
"email", "gender",
} "number",
) }
for player in players )
if not player.disabled 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)]): 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, endpoint=remove_player_from_team,
methods=["DELETE"], methods=["DELETE"],
) )
player_router.add_api_route(
"/{team_id}/join",
endpoint=join_team,
methods=["POST"],
)
player_router.add_api_route( player_router.add_api_route(
"/{team_id}/list", "/{team_id}/list",
endpoint=list_players, endpoint=list_players,
@@ -308,7 +369,7 @@ player_router.add_api_route(
dependencies=[Security(get_current_active_user, scopes=["admin"])], dependencies=[Security(get_current_active_user, scopes=["admin"])],
) )
player_router.add_api_route( player_router.add_api_route(
"/disable", "/disable/{player_id}",
endpoint=disable_player, endpoint=disable_player,
methods=["DELETE"], methods=["DELETE"],
dependencies=[Security(get_current_active_user, scopes=["admin"])], dependencies=[Security(get_current_active_user, scopes=["admin"])],

View File

@@ -212,31 +212,32 @@ async def logout(response: Response):
return {"message": "Successfully logged out"} return {"message": "Successfully logged out"}
def set_password_token(username: str): def set_password_token(user: Player):
user = get_user(username) expire = timedelta(days=30)
if user: token = create_access_token(
expire = timedelta(days=30) data={
token = create_access_token( "sub": "set password",
data={ "username": user.username,
"sub": "set password", "name": user.display_name,
"username": username, },
"name": user.display_name, expires_delta=expire,
}, )
expires_delta=expire, return token
)
return token
def register_token(team_id: int): def join_team_token(team_id: int):
with Session(engine) as session: with Session(engine) as session:
team = session.exec(select(Team).where(Team.id == team_id)).one() team = session.exec(select(Team).where(Team.id == team_id)).one()
if team: if team:
expire = timedelta(days=30) expire = timedelta(days=30)
token = create_access_token( 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, 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): def verify_one_time_token(token: str):
@@ -346,7 +347,7 @@ class RegisterRequest(BaseModel):
async def register(req: RegisterRequest): async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token) payload = verify_one_time_token(req.token)
action: str = payload.get("sub") action: str = payload.get("sub")
if action != "register": if action != "join":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.", detail="wrong type of token.",

View File

@@ -7,7 +7,7 @@ from cutt.db import Player, TokenDB, engine
if len(sys.argv) > 1: if len(sys.argv) > 1:
with Session(engine) as session: with Session(engine) as session:
for p in session.exec( 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) print(p)
token = set_password_token(p) token = set_password_token(p)

View File

@@ -1,58 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <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:&quot;Sans Bold&quot;;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:&quot;Sans Bold&quot;;letter-spacing:2.83px;fill:#fff;stroke:#fff;stroke-width:.655;stroke-dasharray:none;stroke-opacity:1">CUTT</tspan></text></svg>
<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>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -9,6 +9,8 @@ import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart"; import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword"; import { SetPassword } from "./SetPassword";
import { Register } from "./Register"; import { Register } from "./Register";
import { ForgotPassword } from "./Login";
import { Join } from "./JoinTeam";
const Maintenance = () => { const Maintenance = () => {
return ( return (
@@ -28,6 +30,7 @@ function App() {
<Routes> <Routes>
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/setpassword" element={<SetPassword />} /> <Route path="/setpassword" element={<SetPassword />} />
<Route path="/forgotten_password" element={<ForgotPassword />} />
<Route <Route
path="/*" path="/*"
element={ element={
@@ -38,6 +41,7 @@ function App() {
<Route path="network" element={<GraphComponent />} /> <Route path="network" element={<GraphComponent />} />
<Route path="mvp" element={<MVPChart />} /> <Route path="mvp" element={<MVPChart />} />
<Route path="team" element={<TeamPanel />} /> <Route path="team" element={<TeamPanel />} />
<Route path="join" element={<Join />} />
</Routes> </Routes>
<Footer /> <Footer />
</SessionProvider> </SessionProvider>

View File

@@ -1,34 +1,18 @@
import { JSX, useEffect, useState } from "react"; import { JSX, useEffect, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { Events } from "./types";
interface Datum { const Calendar = ({
[id: number]: string; playerId,
} events,
interface Events { }: {
[key: string]: Datum; playerId: number;
} events: Events | undefined;
}) => {
const Calendar = ({ playerId }: { playerId: number }) => {
const [selectedDate, setSelectedDate] = useState(new Date()); const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState<Events>();
const { teams, players } = useSession(); 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) => { const getEventsForDay = (date: Date) => {
return events && events[date.toISOString().split("T")[0]]; return events && events[date.toISOString().split("T")[0]];
}; };

View File

@@ -4,6 +4,7 @@ import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session"; import { TeamState, useSession } from "./Session";
import TabController from "./TabController"; import TabController from "./TabController";
import { Chemistry, MVPRanking, PlayerType } from "./types"; import { Chemistry, MVPRanking, PlayerType } from "./types";
import Loading from "./Loading";
type PlayerListProps = Partial<ReactSortableProps<any>> & { type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean; orderedList?: boolean;
@@ -103,7 +104,7 @@ function TypeDnD({ user, teams, players }: PlayerInfoProps) {
useEffect(() => { useEffect(() => {
handleGet(); handleGet();
}, [players]); }, [players, teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null); 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); const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed); activeTeam && setMixed(activeTeam.mixed);
handleGet(); handleGet();
}, [players]); }, [players, teams]);
useEffect(() => { useEffect(() => {
handleGet(); handleGet();
@@ -300,9 +301,7 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
{loading ? ( {loading ? (
<div className="container"> Loading
<progress className="progress is-primary" max="100"></progress>
</div>
) : ( ) : (
<div className="columns container is-mobile is-1-mobile"> <div className="columns container is-mobile is-1-mobile">
<div className="column"> <div className="column">
@@ -365,7 +364,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
useEffect(() => { useEffect(() => {
handleGet(); handleGet();
}, [players]); }, [players, teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
@@ -422,9 +421,7 @@ function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
/> />
{loading ? ( {loading ? (
<div className="container"> Loading
<progress className="progress is-primary" max="100"></progress>
</div>
) : ( ) : (
<div className="columns container is-multiline is-mobile is-1-mobile"> <div className="columns container is-multiline is-mobile is-1-mobile">
<div className="column is-full is-flex is-justify-content-center"> <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="column">
<div className="box"> <div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light"> <p className="subtitle is-6 is-uppercase has-text-weight-light">
yes, please
</p> </p>
<PlayerList <PlayerList
list={playersRight} list={playersRight}
@@ -513,22 +510,22 @@ export default function Rankings() {
]; ];
return ( return (
<div className="container block"> <section className="section">
<p className="notification is-warning is-light"> <div className="container block">
assign as many or as few players as you want and don't forget to 💾 <p className="notification is-warning is-light">
<strong> submit</strong> when you're done :) assign as many or as few players as you want and don't forget to 💾
</p> <strong> submit</strong> when you're done :)
{user && teams && players ? ( </p>
<TabController tabs={tabs}> {user && teams && players ? (
<ChemistryDnDMobile {...{ user, teams, players }} /> <TabController tabs={tabs}>
<MVPDnD {...{ user, teams, players }} /> <ChemistryDnDMobile {...{ user, teams, players }} />
<TypeDnD {...{ user, teams, players }} /> <MVPDnD {...{ user, teams, players }} />
</TabController> <TypeDnD {...{ user, teams, players }} />
) : ( </TabController>
<div className="container"> ) : (
<progress className="progress is-primary" max="100"></progress> Loading
</div> )}
)} </div>
</div> </section>
); );
} }

View File

@@ -1,10 +1,16 @@
import { Link } from "react-router"; import { Link, useLocation } from "react-router";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { useState } from "react"; import { useState } from "react";
export default function Header() { export default function Header() {
const { user, teams, setTeams, players, onLogout } = useSession(); const { user, teams, setTeams, players, onLogout } = useSession();
const [burgerActive, setBurgerActive] = useState(false); const [burgerActive, setBurgerActive] = useState(false);
const location = useLocation();
const pages = [
{ name: "Sociogram", path: "/network" },
{ name: "MVP", path: "/mvp" },
{ name: "Team", path: "/team" },
];
return ( return (
<nav className="navbar" role="navigation" aria-label="main navigation"> <nav className="navbar" role="navigation" aria-label="main navigation">
<div className="navbar-brand"> <div className="navbar-brand">
@@ -36,29 +42,23 @@ export default function Header() {
className={"navbar-menu" + (burgerActive ? " is-active" : "")} className={"navbar-menu" + (burgerActive ? " is-active" : "")}
id="navbar" id="navbar"
> >
{user?.scopes.includes(`team:${teams?.activeTeam}`) && ( {(user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42) && (
<div className="navbar-start"> <div className="navbar-start">
<Link {pages.map((p) => (
onClick={() => setBurgerActive(false)} <Link
className="navbar-item" onClick={() => setBurgerActive(false)}
to="/network" className={
> "navbar-item" +
<span>Sociogram</span> (location.pathname === p.path
</Link> ? " has-text-weight-extrabold"
<Link : "")
onClick={() => setBurgerActive(false)} }
className="navbar-item" to={p.path}
to="/mvp" >
> <span>{p.name}</span>
<span>MVP</span> </Link>
</Link> ))}
<Link
onClick={() => setBurgerActive(false)}
className="navbar-item"
to="/team"
>
<span>Team</span>
</Link>
</div> </div>
)} )}
<div className="navbar-end"> <div className="navbar-end">

64
frontend/src/JoinTeam.tsx Normal file
View 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
View File

@@ -0,0 +1,6 @@
const Loading = (
<div className="container">
<progress className="progress is-primary" max="100"></progress>
</div>
);
export default Loading;

View File

@@ -1,8 +1,7 @@
import { useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { currentUser, login, User } from "./api"; import { baseUrl, currentUser, login, User } from "./api";
import Header from "./Header";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons"; import Loading from "./Loading";
export interface LoginProps { export interface LoginProps {
onLogin: (user: User) => void; onLogin: (user: User) => void;
@@ -35,7 +34,7 @@ export const Login = ({ onLogin }: LoginProps) => {
onLogin(user); onLogin(user);
} }
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
doLogin(); doLogin();
} }
@@ -86,6 +85,9 @@ export const Login = ({ onLogin }: LoginProps) => {
}} }}
/> />
</p> </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>} {error && <p className="help is-danger">{error}</p>}
</div> </div>
<div className="control"> <div className="control">
@@ -98,7 +100,85 @@ export const Login = ({ onLogin }: LoginProps) => {
login login
</button> </button>
</div> </div>
{loading && <span className="loader" />} {loading && Loading}
</form> </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>
);
};

View File

@@ -4,6 +4,7 @@ import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart"; import RaceChart from "./RaceChart";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import Loading from "./Loading";
const MVPChart = () => { const MVPChart = () => {
let initialData = {} as PlayerRanking[][]; let initialData = {} as PlayerRanking[][];
@@ -54,7 +55,7 @@ const MVPChart = () => {
loadData(); loadData();
}, [teams]); }, [teams]);
if (loading) return <span className="loader" />; if (loading) return Loading;
else if (error) return <span>{error}</span>; else if (error) return <span>{error}</span>;
else else
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />); return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);

View File

@@ -12,6 +12,7 @@ import {
import { customTheme } from "./NetworkTheme"; import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { ChevronDown, ChevronUp, Info, Settings2 } from "lucide-react";
interface NetworkData { interface NetworkData {
nodes: GraphNode[]; nodes: GraphNode[];
@@ -43,9 +44,14 @@ export const GraphComponent = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [threed, setThreed] = useState(false); const [threed, setThreed] = useState(false);
const [showControls, setShowControls] = useState(true);
const [likes, setLikes] = useState(2); const [likes, setLikes] = useState(2);
const [showLikes, setShowLikes] = useState(true);
const [showDislikes, setShowDislikes] = useState(false);
const [popularity, setPopularity] = useState(false); const [popularity, setPopularity] = useState(false);
const [showPlayerType, setShowPlayerType] = useState(false);
const [mutuality, setMutuality] = useState(false); const [mutuality, setMutuality] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const { user, teams } = useSession(); const { user, teams } = useSession();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
@@ -90,23 +96,16 @@ export const GraphComponent = () => {
popularityLabel(!popularity); popularityLabel(!popularity);
setPopularity(!popularity); setPopularity(!popularity);
} }
function handlePlayerType() {
playerType(!showPlayerType);
setShowPlayerType(!showPlayerType);
}
function handleMutuality() { function handleMutuality() {
colorMatches(!mutuality); colorMatches(!mutuality);
setMutuality(!mutuality); setMutuality(!mutuality);
} }
function showLabel() {
switch (likes) {
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
function findMatches(edges: GraphEdge[]) { function findMatches(edges: GraphEdge[]) {
const adjacencyList = edges.map( const adjacencyList = edges.map(
(edge) => edge.source + edge.target + edge.data.relation (edge) => edge.source + edge.target + edge.data.relation
@@ -144,19 +143,35 @@ export const GraphComponent = () => {
setData({ nodes: data.nodes, edges: newEdges }); 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) { function popularityLabel(popularity: boolean) {
const newNodes = data.nodes; const newNodes = data.nodes;
console.log(data.nodes);
if (popularity) { if (popularity) {
newNodes.forEach( newNodes.forEach((node) => {
(node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`) node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`;
); node.fill = node.data.playertype;
});
} else { } else {
newNodes.forEach((node) => (node.subLabel = undefined)); newNodes.forEach((node) => (node.subLabel = undefined));
} }
setData({ nodes: newNodes, edges: data.edges }); setData({ nodes: newNodes, edges: data.edges });
} }
useEffect(() => {
setLikes(+showLikes + +!showDislikes);
}, [showLikes, showDislikes]);
useEffect(() => { useEffect(() => {
if (mutuality) colorMatches(false); if (mutuality) colorMatches(false);
colorMatches(mutuality); colorMatches(mutuality);
@@ -183,7 +198,9 @@ export const GraphComponent = () => {
let content: ReactNode; let content: ReactNode;
if (loading) { if (loading) {
content = <span className="loader" />; <div className="container">
<progress className="progress is-primary" max="100"></progress>
</div>;
} else if (error) { } else if (error) {
content = <span>{error}</span>; content = <span>{error}</span>;
} else { } else {
@@ -213,106 +230,203 @@ export const GraphComponent = () => {
onNodePointerOut={onNodePointerOut} onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver} 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 ( return (
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}> <div>
<div className="controls"> <div className="card network-controls">
<div className="control" onClick={handleMutuality}> <header
<div className="switch"> className="card-header"
<input type="checkbox" checked={mutuality} onChange={() => {}} /> style={{ cursor: "pointer" }}
<span className="slider round"></span> onClick={() => setShowControls(!showControls)}
</div> >
<span>mutuality</span> <p className="card-header-title">
</div> <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}> <div className="control">
<span>2D</span> <label className="checkbox">
<div className="switch"> <input
<input type="checkbox" checked={threed} onChange={() => {}} /> type="checkbox"
<span className="slider round"></span> checked={mutuality}
</div> onClick={handleMutuality}
<span>3D</span> />
</div> <span className="ml-1">mutuality</span>
</label>
</div>
<div className="control"> <div className="control">
<div className="stack column"> <label className="radio">
<datalist id="markers"> <input
<option value="0"></option> type="radio"
<option value="1"></option> checked={!threed}
<option value="2"></option> name="3D"
</datalist> onClick={handleThreed}
<div id="three-slider"> />
<label>😬</label> <span className="m-1">2D</span>
<input </label>
type="range" <label className="radio">
list="markers" <input
min="0" type="radio"
max="2" checked={threed}
step="1" name="3D"
width="16px" onClick={handleThreed}
onChange={(evt) => setLikes(Number(evt.target.value))} />
/> <span className="m-1">3D</span>
<label>😍</label> </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> </div>
{showLabel()}
</div> </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={"modal" + (showHelp ? " is-active" : "")}>
<div className="switch"> <div
<input type="checkbox" checked={popularity} onChange={() => {}} /> onClick={() => setShowHelp(false)}
<span className="slider round"></span> className="modal-background"
</div> ></div>
<span> <div className="modal-content">
popularity<sup>*</sup> <article className="message is-info">
</span> <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>
</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} {content}
</div> </div>
); );

View File

@@ -1,13 +1,7 @@
import { jwtDecode, JwtPayload } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { baseUrl, Gender } from "./api"; import { baseUrl, Gender, PassToken } from "./api";
import { useNavigate } from "react-router"; import { useLocation, useNavigate } from "react-router";
interface PassToken extends JwtPayload {
username: string;
name: string;
team_id: number;
}
export const Register = () => { export const Register = () => {
const [name, setName] = useState(""); const [name, setName] = useState("");
@@ -23,16 +17,17 @@ export const Register = () => {
const [token, setToken] = useState(""); const [token, setToken] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(location.search);
const token = params.get("token"); const token = params.get("token");
if (token) { if (token) {
setToken(token); setToken(token);
try { try {
const payload = jwtDecode<PassToken>(token); const payload = jwtDecode<PassToken>(token);
if (payload.sub === "register") { if (payload.sub === "join") {
if (payload.team_id) setTeamID(payload.team_id); if (payload.team_id) setTeamID(payload.team_id);
} else { } else {
setError("not a valid token for registration"); setError("not a valid token for registration");

View File

@@ -7,8 +7,10 @@ import {
} from "react"; } from "react";
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api"; import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login"; import { Login } from "./Login";
import Header from "./Header";
import { Team } from "./types"; import { Team } from "./types";
import Loading from "./Loading";
import { Link, useLocation } from "react-router";
import { TriangleAlert } from "lucide-react";
export interface SessionProviderProps { export interface SessionProviderProps {
children: ReactNode; children: ReactNode;
@@ -43,26 +45,31 @@ export function SessionProvider(props: SessionProviderProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null); const [teams, setTeams] = useState<TeamState | null>(null);
const [players, setPlayers] = useState<User[] | 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 [loading, setLoading] = useState(false);
const location = useLocation();
function loadUser() { function loadUser() {
setLoading(true); setLoading(true);
currentUser() currentUser()
.then((user) => { .then((user) => {
setUser(user); setUser(user);
setErr(null); setError(null);
}) })
.catch((err) => { .catch((err) => {
setUser(null); setUser(null);
setErr(err); setError(err);
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
} }
async function loadTeam() { async function loadTeam() {
const teams: Team[] = await apiAuth("player/me/teams", null, "GET"); const loaded_teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id }); if (loaded_teams)
setTeams({
teams: loaded_teams,
activeTeam: teams?.activeTeam || loaded_teams[0].id,
});
} }
async function reloadPlayers() { async function reloadPlayers() {
@@ -81,30 +88,25 @@ export function SessionProvider(props: SessionProviderProps) {
function onLogin(user: User) { function onLogin(user: User) {
setUser(user); setUser(user);
setErr(null); setError(null);
} }
async function onLogout() { async function onLogout() {
try { try {
logout(); logout();
setUser(null); setUser(null);
setErr({ message: "Logged out successfully" }); setError({ message: "Logged out successfully" });
console.log("logged out."); console.log("logged out.");
} catch (e) { } catch (e) {
console.error(e); console.error(e);
setErr(e); setError(e);
} }
} }
let content: ReactNode; let content: ReactNode;
if (loading || (!err && !user)) if (loading || (!error && !user))
content = ( content = <section className="section is-medium">{Loading}</section>;
<> else if (error) {
<Header />
<span className="loader" />
</>
);
else if (err) {
content = ( content = (
<section className="section is-medium"> <section className="section is-medium">
<div className="container is-max-tablet"> <div className="container is-max-tablet">
@@ -118,6 +120,19 @@ export function SessionProvider(props: SessionProviderProps) {
/> />
</p> </p>
</div> </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} /> <Login onLogin={onLogin} />
</div> </div>
</section> </section>

View File

@@ -21,8 +21,17 @@ export default function TabController({ tabs, children }: TabControllerProps) {
<div className="tabs is-boxed is-centered"> <div className="tabs is-boxed is-centered">
<ul> <ul>
{tabs.map((tab, index) => ( {tabs.map((tab, index) => (
<li className={currentIndex === index ? "is-active" : ""}> <li
<a onClick={() => handleTabClick(index)}>{tab.label}</a> className={
currentIndex === index ? "is-active has-text-weight-bold" : ""
}
>
<a
className="has-text-black"
onClick={() => handleTabClick(index)}
>
{tab.label}
</a>
</li> </li>
))} ))}
</ul> </ul>

View File

@@ -1,18 +1,38 @@
import { FormEvent, useEffect, useState } from "react"; import { FormEvent, useEffect, useState } from "react";
import { apiAuth, Gender, User } from "./api"; import { apiAuth, Gender, User } from "./api";
import { useSession } from "./Session"; import { useSession } from "./Session";
import { ErrorState } from "./types"; import { ErrorState, Events } from "./types";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import Calendar from "./Calendar"; import Calendar from "./Calendar";
import { Info, Star, StarHalf, StarOff, UserPen } from "lucide-react";
import Loading from "./Loading";
const TeamPanel = () => { const TeamPanel = () => {
const { user, teams, players, reloadPlayers } = useSession(); const { user, teams, players, reloadPlayers } = useSession();
const [events, setEvents] = useState<Events>();
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) || user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 || teams?.activeTeam === 42 ||
navigate("/", { replace: true }); navigate("/", { replace: true });
}, [user, teams]); }, [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 = { const newPlayerTemplate = {
id: 0, id: 0,
username: "", username: "",
@@ -20,10 +40,21 @@ const TeamPanel = () => {
gender: undefined, gender: undefined,
number: "", number: "",
email: "", email: "",
is_manager: false,
} as User; } as User;
const [error, setError] = useState<ErrorState>(); const [error, setError] = useState<ErrorState>();
const [player, setPlayer] = useState(newPlayerTemplate); 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) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (teams) { if (teams) {
@@ -95,7 +126,12 @@ const TeamPanel = () => {
setError({ ok: true, message: "" }); 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>
))} ))}
<button <button
@@ -110,7 +146,7 @@ const TeamPanel = () => {
</button> </button>
</div> </div>
) : ( ) : (
<span className="loader" /> Loading
)} )}
</div> </div>
@@ -196,7 +232,7 @@ const TeamPanel = () => {
</div> </div>
<div className="field"> <div className="field">
<label className="label"> <label className="label">
email <small>(optional)</small> email <small>(optional, but helpful)</small>
</label> </label>
<div className="control"> <div className="control">
<input <input
@@ -209,18 +245,67 @@ const TeamPanel = () => {
}} }}
/> />
</div> </div>
{error?.message && (
<p
className={
"help" + (error.ok ? " is-success" : " is-danger")
}
>
{error.message}
</p>
)}
</div> </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>
<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 <button
className={ className={
"button is-light" + "button is-light" +
@@ -245,11 +330,11 @@ const TeamPanel = () => {
</section> </section>
<section className="section"> <section className="section">
<div className="container"> <div className="container">
<Calendar playerId={player.id} /> <Calendar playerId={player.id} events={events} />
</div> </div>
</section> </section>
</> </>
); );
} else <span className="loader" />; } else Loading;
}; };
export default TeamPanel; export default TeamPanel;

View File

@@ -1,3 +1,4 @@
import { JwtPayload } from "jwt-decode";
import { useSession } from "./Session"; import { useSession } from "./Session";
export const baseUrl = ""; export const baseUrl = "";
@@ -53,6 +54,7 @@ export type User = {
number: string; number: string;
gender: Gender; gender: Gender;
scopes: string; scopes: string;
is_manager: boolean;
}; };
export async function currentUser(): Promise<User> { export async function currentUser(): Promise<User> {
@@ -124,3 +126,9 @@ export const logout = async () => {
console.error(e); console.error(e);
} }
}; };
export interface PassToken extends JwtPayload {
username: string;
name: string;
team_id: number;
}

View File

@@ -14,3 +14,31 @@
overflow-y: auto; overflow-y: auto;
max-height: 30vh; 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;
}

View File

@@ -53,3 +53,10 @@ export type ErrorState = {
ok: boolean; ok: boolean;
message: string; message: string;
}; };
export interface Datum {
[id: number]: string;
}
export interface Events {
[key: string]: Datum;
}

View File

@@ -21,4 +21,5 @@ dependencies = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pyqt6>=6.10.1", "pyqt6>=6.10.1",
"python-dotenv>=1.2.1",
] ]