46 Commits

Author SHA1 Message Date
cb2b7db7a6 feat: demo MVP list 2025-05-08 10:11:00 +02:00
1c71df781c feat: adjustments for Demo Team (team_id = 42) 2025-05-08 07:07:14 +02:00
6378488fd0 feat: add demo players 2025-05-08 07:05:44 +02:00
6902ffdca6 fix: weigh dislike negatively for weighted degree 2025-04-16 12:59:50 +02:00
a6d0f528d0 fix: left out saving the password hash 2025-03-27 16:17:19 +01:00
77d292974c fix: group token cannot be one-time only 2025-03-27 08:04:41 +01:00
43f9b0d47c feat: register new user with team-specific token
revamp entire `SetPassword` page
2025-03-26 17:37:02 +01:00
bef5119a0b feat: more helpful error 2025-03-26 17:36:52 +01:00
ee13d06ab1 feat: no user -> no team 2025-03-26 16:31:27 +01:00
03ed843679 feat: check whether user has necessary scope 2025-03-25 19:09:33 +01:00
81d6a02229 feat: disallow non-members to list team members 2025-03-25 16:38:44 +01:00
11f3f9f440 fix: weird bug
cutt.0124816.xyz/team responded with 404 while all other routes were handled correctly
by react-router
2025-03-25 16:31:25 +01:00
0507b9f7c4 feat: default export TeamPanel 2025-03-25 09:13:26 +01:00
e701ebbb02 feat: remove unused / 2025-03-25 09:12:55 +01:00
d3daa83d68 fix: /team -> /teams 2025-03-25 07:45:34 +01:00
90adb4fc9c fix: auto enable new players 2025-03-24 22:41:03 +01:00
19ae4a18ca fix: show players if they're not disabled 2025-03-24 22:37:59 +01:00
fc8592f8ab fix: bar height 2025-03-24 14:19:05 +01:00
195d240a87 feat: use better query 2025-03-24 14:18:40 +01:00
df16497476 feat: add Team nav link 2025-03-24 14:14:11 +01:00
8b4ee3b289 feat: Team management panel
the display name of a player is the same for all teams... change that?
2025-03-24 14:11:58 +01:00
e88eb02ef1 fix: allow for nodes without any edges (e.g. new player) 2025-03-24 13:35:36 +01:00
c04a1e03f2 feat: improve TeamPanel input placement 2025-03-24 11:57:51 +01:00
691b99daa8 feat: add a Team Panel 2025-03-23 15:01:26 +01:00
8c938a7ebc feat: fix RaceChart height when N<3 2025-03-22 21:04:17 +01:00
8bc38a10a4 fix: load previous state with correct team 2025-03-22 20:52:27 +01:00
0397725bda feat: who's missing? 2025-03-22 16:24:40 +01:00
a97eee842e feat: add player to team 2025-03-21 16:05:48 +01:00
ab3ed9b497 feat: add OpenAPI tags 2025-03-21 15:06:44 +01:00
d9ad903798 feat: add back files in new location 2025-03-21 14:48:55 +01:00
b28752830a feat: towards multi-team support
also testing at different points whether team association is correct
2025-03-21 14:44:55 +01:00
7f4f6142c9 feat: don't rely on secure JWT when it comes to scopes 2025-03-20 17:04:20 +01:00
ded2b79db7 feat: begin to add support for multiple teams 2025-03-19 15:08:18 +01:00
c246a0b264 fix: teams type 2025-03-17 19:27:32 +01:00
054508cf6a feat: show teams 2025-03-17 19:26:09 +01:00
3441e405a6 feat: keep states between tab switches 2025-03-17 11:09:29 +01:00
8f355c0cf3 fix: show all available players in undecided when restoring 2025-03-16 21:50:52 +01:00
4252e737d7 feat: add ability to change password 2025-03-16 18:11:28 +01:00
39630725a4 feat: simplify visible button placement
always 40px wide anyway
2025-03-16 13:30:06 +01:00
641ae50265 feat: improve on data privacy 2025-03-16 12:40:50 +01:00
2500a8d293 feat: round text inputs 2025-03-16 11:54:48 +01:00
719c57200d feat: username colour 2025-03-14 14:00:44 +01:00
a663b34500 feat: change style a little more 2025-03-14 13:58:28 +01:00
8191587115 feat: add themes 2025-03-14 12:08:07 +01:00
9ec457bb7a feat: playing with colours 2025-03-14 11:55:21 +01:00
953a166ec5 feat: button style/visibility 2025-03-13 21:31:52 +01:00
26 changed files with 2144 additions and 709 deletions

View File

@@ -1,237 +0,0 @@
import io
import base64
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from db import Chemistry, MVPRanking, Player, engine
import networkx as nx
import numpy as np
import matplotlib
matplotlib.use("agg")
import matplotlib.pyplot as plt
analysis_router = APIRouter(prefix="/analysis")
C = Chemistry
R = MVPRanking
P = Player
def sociogram_json():
nodes = []
necessary_nodes = set()
edges = []
players = {}
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.display_name, "label": p.display_name})
players[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
# G.add_node(c.user)
necessary_nodes.add(c.user)
for p in [players[p_id] for p_id in c.love]:
# G.add_edge(c.user, p)
# p_id = session.exec(select(P.id).where(P.name == p)).one()
necessary_nodes.add(p)
edges.append({"from": players[c.user], "to": p, "relation": "likes"})
for p in [players[p_id] for p_id in c.hate]:
edges.append({"from": players[c.user], "to": p, "relation": "dislikes"})
# nodes = [n for n in nodes if n["name"] in necessary_nodes]
return JSONResponse({"nodes": nodes, "edges": edges})
def graph_json():
nodes = []
edges = []
players = {}
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
players[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name})
subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
user = players[c.user]
for i, p_id in enumerate(c.love):
p = players[p_id]
edges.append(
{
"id": f"{user}->{p}",
"source": user,
"target": p,
"size": max(1.0 - 0.1 * i, 0.3),
"data": {
"relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3),
"origFill": "#bed4ff",
},
}
)
for p_id in c.hate:
p = players[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
"source": user,
"target": p,
"size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
)
G = nx.DiGraph()
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
]
return JSONResponse({"nodes": nodes, "edges": edges})
def sociogram_data(show: int | None = 2):
G = nx.DiGraph()
with Session(engine) as session:
players = {}
for p in session.exec(select(P)).fetchall():
G.add_node(p.display_name)
players[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
)
statement2 = (
select(C)
# .where(C.user.in_(["Kruse", "Franz", "ck"]))
.join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest))
)
for c in session.exec(statement2):
if show >= 1:
for i, p_id in enumerate(c.love):
p = players[p_id]
G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i)
if show <= 1:
for i, p_id in enumerate(c.hate):
p = players[p_id]
G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16)
return G
class Params(BaseModel):
node_size: int | None = Field(default=2400, alias="nodeSize")
font_size: int | None = Field(default=10, alias="fontSize")
arrow_size: int | None = Field(default=20, alias="arrowSize")
edge_width: float | None = Field(default=1, alias="edgeWidth")
distance: float | None = 0.2
weighting: bool | None = True
popularity: bool | None = True
show: int | None = 2
ARROWSTYLE = {"love": "-|>", "hate": "-|>"}
EDGESTYLE = {"love": "-", "hate": ":"}
EDGECOLOR = {"love": "#404040", "hate": "#cc0000"}
async def render_sociogram(params: Params):
plt.figure(figsize=(16, 10), facecolor="none")
ax = plt.gca()
ax.set_facecolor("none") # Set the axis face color to none (transparent)
ax.axis("off") # Turn off axis ticks and frames
G = sociogram_data(show=params.show)
pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None)
nodes = nx.draw_networkx_nodes(
G,
pos,
node_color=[
v for k, v in G.in_degree(weight="popularity" if params.weighting else None)
]
if params.popularity
else "#99ccff",
edgecolors="#404040",
linewidths=0,
# node_shape="8",
node_size=params.node_size,
cmap="coolwarm",
alpha=0.86,
)
if params.popularity:
cbar = plt.colorbar(nodes)
cbar.ax.set_xlabel("popularity")
nx.draw_networkx_labels(G, pos, font_size=params.font_size)
nx.draw_networkx_edges(
G,
pos,
arrows=True,
edge_color=[EDGECOLOR[G.edges()[*edge]["group"]] for edge in G.edges()],
arrowsize=params.arrow_size,
node_size=params.node_size,
width=params.edge_width,
style=[EDGESTYLE[G.edges()[*edge]["group"]] for edge in G.edges()],
arrowstyle=[ARROWSTYLE[G.edges()[*edge]["group"]] for edge in G.edges()],
connectionstyle="arc3,rad=0.12",
alpha=[1 - 0.08 * G.edges()[*edge]["rank"] for edge in G.edges()]
if params.weighting
else 1,
)
buf = io.BytesIO()
plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True)
buf.seek(0)
encoded_image = base64.b64encode(buf.read()).decode("UTF-8")
plt.close()
return {"image": encoded_image}
def mvp():
ranks = dict()
with Session(engine) as session:
players = {p.id: p.display_name for p in session.exec(select(P)).fetchall()}
subquery = (
select(R.user, func.max(R.time).label("latest")).group_by(R.user).subquery()
)
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
p = players[p_id]
ranks[p] = ranks.get(p, []) + [i + 1]
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"])
analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
analysis_router.add_api_route("/mvp", endpoint=mvp, methods=["GET"])
if __name__ == "__main__":
with Session(engine) as session:
statement: SelectOfScalar[P] = select(func.count(P.id))
print("players in DB: ", session.exec(statement).first())
G = sociogram_data()
pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42)

0
cutt/__init__.py Normal file
View File

433
cutt/analysis.py Normal file
View File

@@ -0,0 +1,433 @@
import io
import itertools
import random
import base64
from typing import Annotated
from fastapi import APIRouter, HTTPException, Security, status
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from cutt.db import Chemistry, MVPRanking, Player, PlayerTeamLink, Team, engine
import networkx as nx
import numpy as np
import matplotlib
from cutt.security import TeamScopedRequest, verify_team_scope
from cutt.demo import demo_players
matplotlib.use("agg")
import matplotlib.pyplot as plt
analysis_router = APIRouter(prefix="/analysis", tags=["analysis"])
C = Chemistry
R = MVPRanking
P = Player
def sociogram_json():
nodes = []
necessary_nodes = set()
edges = []
players = {}
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.display_name, "label": p.display_name})
players[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
# G.add_node(c.user)
necessary_nodes.add(c.user)
for p in [players[p_id] for p_id in c.love]:
# G.add_edge(c.user, p)
# p_id = session.exec(select(P.id).where(P.name == p)).one()
necessary_nodes.add(p)
edges.append({"from": players[c.user], "to": p, "relation": "likes"})
for p in [players[p_id] for p_id in c.hate]:
edges.append({"from": players[c.user], "to": p, "relation": "dislikes"})
# nodes = [n for n in nodes if n["name"] in necessary_nodes]
return JSONResponse({"nodes": nodes, "edges": edges})
def graph_json(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
networkx_graph: bool = False,
):
nodes = []
edges = []
player_map = {}
if request.team_id == 42:
players = [request.user] + demo_players
random.seed(42)
for p in players:
nodes.append({"id": p.display_name, "label": p.display_name})
for p, other in itertools.permutations(players, 2):
value = random.random()
if value > 0.5:
edges.append(
{
"id": f"{p.display_name}->{other.display_name}",
"source": p.display_name,
"target": other.display_name,
"size": max(value, 0.3),
"data": {
"relation": 2,
"origSize": max(value, 0.3),
"origFill": "#bed4ff",
},
}
)
elif value < 0.1:
edges.append(
{
"id": f"{p.display_name}-x>{other.display_name}",
"source": p.display_name,
"target": other.display_name,
"size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
)
G = nx.DiGraph()
G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from(
[
(
e["source"],
e["target"],
e["size"] if e["data"]["relation"] == 2 else -e["size"],
)
for e in edges
]
)
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}})
for node in nodes
]
if networkx_graph:
return G
return JSONResponse({"nodes": nodes, "edges": edges})
with Session(engine) as session:
players = session.exec(
select(P)
.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,
detail="no players found in your team",
)
for p in players:
player_map[p.id] = p.display_name
nodes.append({"id": p.display_name, "label": p.display_name})
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.team == request.team_id)
.group_by(C.user)
.subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
user = player_map[c.user]
for i, p_id in enumerate(c.love):
p = player_map[p_id]
edges.append(
{
"id": f"{user}->{p}",
"source": user,
"target": p,
"size": max(1.0 - 0.1 * i, 0.3),
"data": {
"relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3),
"origFill": "#bed4ff",
},
}
)
for p_id in c.hate:
p = player_map[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
"source": user,
"target": p,
"size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c",
}
)
if not edges:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
G = nx.DiGraph()
G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from(
[
(
e["source"],
e["target"],
e["size"] if e["data"]["relation"] == 2 else -e["size"],
)
for e in edges
]
)
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
]
if networkx_graph:
return G
return JSONResponse({"nodes": nodes, "edges": edges})
def sociogram_data(show: int | None = 2):
G = nx.DiGraph()
with Session(engine) as session:
players = {}
for p in session.exec(select(P)).fetchall():
G.add_node(p.display_name)
players[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
)
statement2 = (
select(C)
# .where(C.user.in_(["Kruse", "Franz", "ck"]))
.join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest))
)
for c in session.exec(statement2):
if show >= 1:
for i, p_id in enumerate(c.love):
p = players[p_id]
G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i)
if show <= 1:
for i, p_id in enumerate(c.hate):
p = players[p_id]
G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16)
return G
class Params(BaseModel):
node_size: int | None = Field(default=2400, alias="nodeSize")
font_size: int | None = Field(default=10, alias="fontSize")
arrow_size: int | None = Field(default=20, alias="arrowSize")
edge_width: float | None = Field(default=1, alias="edgeWidth")
distance: float | None = 0.2
weighting: bool | None = True
popularity: bool | None = True
show: int | None = 2
ARROWSTYLE = {"love": "-|>", "hate": "-|>"}
EDGESTYLE = {"love": "-", "hate": ":"}
EDGECOLOR = {"love": "#404040", "hate": "#cc0000"}
async def render_sociogram(params: Params):
plt.figure(figsize=(16, 10), facecolor="none")
ax = plt.gca()
ax.set_facecolor("none") # Set the axis face color to none (transparent)
ax.axis("off") # Turn off axis ticks and frames
G = sociogram_data(show=params.show)
pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None)
nodes = nx.draw_networkx_nodes(
G,
pos,
node_color=[
v for k, v in G.in_degree(weight="popularity" if params.weighting else None)
]
if params.popularity
else "#99ccff",
edgecolors="#404040",
linewidths=0,
# node_shape="8",
node_size=params.node_size,
cmap="coolwarm",
alpha=0.86,
)
if params.popularity:
cbar = plt.colorbar(nodes)
cbar.ax.set_xlabel("popularity")
nx.draw_networkx_labels(G, pos, font_size=params.font_size)
nx.draw_networkx_edges(
G,
pos,
arrows=True,
edge_color=[EDGECOLOR[G.edges()[*edge]["group"]] for edge in G.edges()],
arrowsize=params.arrow_size,
node_size=params.node_size,
width=params.edge_width,
style=[EDGESTYLE[G.edges()[*edge]["group"]] for edge in G.edges()],
arrowstyle=[ARROWSTYLE[G.edges()[*edge]["group"]] for edge in G.edges()],
connectionstyle="arc3,rad=0.12",
alpha=[1 - 0.08 * G.edges()[*edge]["rank"] for edge in G.edges()]
if params.weighting
else 1,
)
buf = io.BytesIO()
plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True)
buf.seek(0)
encoded_image = base64.b64encode(buf.read()).decode("UTF-8")
plt.close()
return {"image": encoded_image}
def mvp(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
):
ranks = dict()
if request.team_id == 42:
random.seed(42)
players = [request.user] + demo_players
for p in players:
random.shuffle(players)
for i, p in enumerate(players):
ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1]
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
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.display_name for p in players}
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
.group_by(R.user)
.subquery()
)
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1]
if not ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
async def turnout(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
):
player_map = {}
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)
for p in players:
player_map[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.team == request.team_id)
.group_by(C.user)
.subquery()
)
statement2 = select(C.user).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
chemistry_turnout = session.exec(statement2).all()
chemistry_missing = set(player_map) - set(chemistry_turnout)
chemistry_missing = [player_map[i] for i in chemistry_missing]
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
.group_by(R.user)
.subquery()
)
statement2 = select(R.user).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
mvp_turnout = session.exec(statement2).all()
mvp_missing = set(player_map) - set(mvp_turnout)
mvp_missing = [player_map[i] for i in mvp_missing]
return JSONResponse(
{
"players": len(player_map),
"chemistry": {
"turnout": len(chemistry_turnout),
"missing": sorted(list(chemistry_missing)),
},
"MVP": {
"turnout": len(mvp_turnout),
"missing": sorted(list(mvp_missing)),
},
}
)
# analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route(
"/graph_json/{team_id}", endpoint=graph_json, methods=["GET"]
)
# analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
analysis_router.add_api_route(
"/mvp/{team_id}",
endpoint=mvp,
methods=["GET"],
name="MVPs",
description="Request Most Valuable Players stats",
)
analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
if __name__ == "__main__":
with Session(engine) as session:
statement: SelectOfScalar[P] = select(func.count(P.id))
print("players in DB: ", session.exec(statement).first())
G = sociogram_data()
pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42)

View File

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

27
cutt/demo.py Normal file
View File

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

View File

@@ -1,30 +1,31 @@
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine from cutt.db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import ( from sqlmodel import (
Session, Session,
func, func,
select, select,
) )
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router from cutt.analysis import analysis_router
from security import ( from cutt.security import (
change_password,
get_current_active_user, get_current_active_user,
login_for_access_token, login_for_access_token,
logout, logout,
read_player_me, register,
read_own_items,
set_first_password, set_first_password,
) )
from cutt.player import player_router
C = Chemistry C = Chemistry
R = MVPRanking R = MVPRanking
P = Player P = Player
app = FastAPI(title="cutt") app = FastAPI(
title="cutt", swagger_ui_parameters={"syntaxHighlight": {"theme": "monokai"}}
)
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
origins = [ origins = [
"https://cutt.0124816.xyz", "https://cutt.0124816.xyz",
@@ -46,41 +47,17 @@ def add_team(team: Team):
session.commit() session.commit()
def add_player(player: Player):
with Session(engine) as session:
session.add(player)
session.commit()
def add_players(players: list[Player]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
def list_players():
with Session(engine) as session:
statement = select(Player).order_by(Player.display_name)
return session.exec(statement).fetchall()
def list_teams(): def list_teams():
with Session(engine) as session: with Session(engine) as session:
statement = select(Team) statement = select(Team)
return session.exec(statement).fetchall() return session.exec(statement).fetchall()
player_router = APIRouter(prefix="/player") team_router = APIRouter(
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"]) prefix="/teams",
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"]) dependencies=[Security(get_current_active_user, scopes=["admin"])],
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"]) tags=["team"],
player_router.add_api_route("/me/items", endpoint=read_own_items, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
) )
team_router = APIRouter(prefix="/team")
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"])
@@ -89,30 +66,43 @@ wrong_user_id_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="you're not who you think you are...", detail="you're not who you think you are...",
) )
somethings_fishy = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="something up..."
)
@api_router.put("/mvps") @api_router.put("/mvps", tags=["analysis"])
def submit_mvps( def submit_mvps(
mvps: MVPRanking, mvps: MVPRanking,
user: Annotated[Player, Depends(get_current_active_user)], user: Annotated[Player, Depends(get_current_active_user)],
): ):
if mvps.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == mvps.user: if user.id == mvps.user:
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == mvps.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= set(mvps.mvps):
session.add(mvps) session.add(mvps)
session.commit() session.commit()
return JSONResponse("success!") return JSONResponse("success!")
raise somethings_fishy
else: else:
raise wrong_user_id_exception raise wrong_user_id_exception
@api_router.get("/mvps") @api_router.get("/mvps/{team_id}", tags=["analysis"])
def get_mvps( def get_mvps(
team_id: int,
user: Annotated[Player, Depends(get_current_active_user)], user: Annotated[Player, Depends(get_current_active_user)],
): ):
with Session(engine) as session: with Session(engine) as session:
subquery = ( subquery = (
select(R.user, func.max(R.time).label("latest")) select(R.user, func.max(R.time).label("latest"))
.where(R.user == user.id) .where(R.user == user.id)
.where(R.team == team_id)
.group_by(R.user) .group_by(R.user)
.subquery() .subquery()
) )
@@ -129,25 +119,38 @@ def get_mvps(
) )
@api_router.put("/chemistry") @api_router.put("/chemistry", tags=["analysis"])
def submit_chemistry( def submit_chemistry(
chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)] chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)]
): ):
if chemistry.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == chemistry.user: if user.id == chemistry.user:
with Session(engine) as session: with Session(engine) as session:
statement = select(Team).where(Team.id == chemistry.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= (
set(chemistry.love) | set(chemistry.hate) | set(chemistry.undecided)
):
session.add(chemistry) session.add(chemistry)
session.commit() session.commit()
return JSONResponse("success!") return JSONResponse("success!")
raise somethings_fishy
else: else:
raise wrong_user_id_exception raise wrong_user_id_exception
@api_router.get("/chemistry") @api_router.get("/chemistry/{team_id}", tags=["analysis"])
def get_chemistry(user: Annotated[Player, Depends(get_current_active_user)]): def get_chemistry(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
with Session(engine) as session: with Session(engine) as session:
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest"))
.where(C.user == user.id) .where(C.user == user.id)
.where(C.team == team_id)
.group_by(C.user) .group_by(C.user)
.subquery() .subquery()
) )
@@ -176,12 +179,23 @@ api_router.include_router(
player_router, dependencies=[Depends(get_current_active_user)] player_router, dependencies=[Depends(get_current_active_user)]
) )
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( api_router.include_router(analysis_router)
analysis_router,
dependencies=[Security(get_current_active_user, scopes=["analysis"])],
)
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("/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("/logout", endpoint=logout, methods=["POST"]) api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])
app.include_router(api_router) app.include_router(api_router)
app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
# app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
@app.get("/")
async def root():
return FileResponse("dist/index.html")
@app.exception_handler(404)
async def exception_404_handler(request, exc):
return FileResponse("dist/index.html")
app.mount("/", StaticFiles(directory="dist"), name="ui")

241
cutt/player.py Normal file
View File

@@ -0,0 +1,241 @@
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from cutt.db import Player, PlayerTeamLink, Team, engine
from cutt.security import (
TeamScopedRequest,
change_password,
get_current_active_user,
read_player_me,
verify_team_scope,
)
from cutt.demo import demo_players
P = Player
player_router = APIRouter(prefix="/player", tags=["player"])
class PlayerRequest(BaseModel):
display_name: str
username: str
number: str
email: str
class AddPlayerRequest(PlayerRequest): ...
DEMO_TEAM_REQUEST = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DEMO Team, nothing happens",
)
def add_player(
r: AddPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
with Session(engine) as session:
if session.exec(select(P).where(P.username == r.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="username not available"
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.display_name == r.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == request.team_id)).one()
new_player = Player(
username=r.username,
display_name=r.display_name,
email=r.email if r.email else None,
number=r.number,
disabled=False,
teams=[team],
)
session.add(new_player)
session.commit()
return PlainTextResponse(f"added {new_player.display_name}")
class ModifyPlayerRequest(PlayerRequest):
id: int
def modify_player(
r: ModifyPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
if request.team_id == 42:
raise DEMO_TEAM_REQUEST
with Session(engine) as session:
player = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.id == r.id, P.username == r.username)
).one_or_none()
if player:
player.display_name = r.display_name.strip()
player.number = r.number.strip()
player.email = r.email.strip()
session.add(player)
session.commit()
return PlainTextResponse("modification successful")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no such player found in your team",
)
class DisablePlayerRequest(BaseModel):
player_id: int
def disable_player(
r: DisablePlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
if request.team_id == 42:
raise DEMO_TEAM_REQUEST
with Session(engine) as session:
player = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.id == r.player_id)
).one_or_none()
if player:
player.disabled = True
session.add(player)
session.commit()
return PlainTextResponse(f"disabled {player.display_name}")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no such player found in your team",
)
def add_player_to_team(player_id: int, team_id: int):
with Session(engine) as session:
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}"
)
def add_players(players: list[P]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
async def list_all_players():
with Session(engine) as session:
return session.exec(select(P)).all()
async def list_players(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
if team_id == 42:
return [
user.model_dump(
include={"id", "display_name", "username", "number", "email"}
)
] + demo_players
with Session(engine) as session:
current_user = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
).one_or_none()
if not current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="you're not in this team",
)
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False)
).all()
if players:
return [
player.model_dump(
include={"id", "display_name", "username", "number", "email"}
)
for player in players
if not player.disabled
]
def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
with Session(engine) as session:
return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0] + [
{"country": "nowhere", "id": 42, "location": "everywhere", "name": "DEMO"}
]
player_router.add_api_route(
"/{team_id}",
endpoint=add_player,
methods=["POST"],
)
player_router.add_api_route(
"/{team_id}",
endpoint=modify_player,
methods=["PUT"],
)
player_router.add_api_route(
"/{team_id}",
endpoint=disable_player,
methods=["DELETE"],
)
player_router.add_api_route(
"/{team_id}/list",
endpoint=list_players,
methods=["GET"],
)
player_router.add_api_route(
"/list",
endpoint=list_all_players,
methods=["GET"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
player_router.add_api_route(
"/add/{team_id}/{player_id}",
endpoint=add_player_to_team,
methods=["GET"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
)

View File

@@ -6,7 +6,7 @@ from pydantic import BaseModel, ValidationError
import jwt import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select from sqlmodel import Session, select
from db import TokenDB, engine, Player from cutt.db import PlayerTeamLink, Team, TokenDB, engine, Player
from fastapi.security import ( from fastapi.security import (
OAuth2PasswordBearer, OAuth2PasswordBearer,
OAuth2PasswordRequestForm, OAuth2PasswordRequestForm,
@@ -16,6 +16,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
P = Player
class Config(BaseSettings): class Config(BaseSettings):
secret_key: str = "" secret_key: str = ""
@@ -60,6 +62,7 @@ oauth2_scheme = CookieOAuth2(
tokenUrl="api/token", tokenUrl="api/token",
scopes={ scopes={
"analysis": "Access the results.", "analysis": "Access the results.",
"admin": "Maintain DB etc.",
}, },
) )
@@ -140,8 +143,9 @@ async def get_current_user(
user = get_user(username=token_data.username) user = get_user(username=token_data.username)
if user is None: if user is None:
raise credentials_exception raise credentials_exception
allowed_scopes = set(user.scopes.split())
for scope in security_scopes.scopes: for scope in security_scopes.scopes:
if scope not in token_data.scopes: if scope not in allowed_scopes or scope not in token_data.scopes:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions", detail="Not enough permissions",
@@ -158,6 +162,26 @@ async def get_current_active_user(
return current_user return current_user
class TeamScopedRequest(BaseModel):
user: Player
team_id: int
async def verify_team_scope(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
if team_id == 42:
return TeamScopedRequest(user=user, team_id=team_id)
allowed_scopes = set(user.scopes.split())
if f"team:{team_id}" not in allowed_scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
)
else:
return TeamScopedRequest(user=user, team_id=team_id)
async def login_for_access_token( async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response
) -> Token: ) -> Token:
@@ -188,102 +212,187 @@ async def logout(response: Response):
return {"message": "Successfully logged out"} return {"message": "Successfully logged out"}
def generate_one_time_token(username): def set_password_token(username: str):
user = get_user(username) user = get_user(username)
if user: if user:
expire = timedelta(days=7) expire = timedelta(days=30)
token = create_access_token( token = create_access_token(
data={"sub": username, "name": user.display_name}, data={
"sub": "set password",
"username": username,
"name": user.display_name,
},
expires_delta=expire, expires_delta=expire,
) )
return token return token
def register_token(team_id: int):
with Session(engine) as session:
team = session.exec(select(Team).where(Team.id == team_id)).one()
if team:
expire = timedelta(days=30)
token = create_access_token(
data={"sub": "register", "team_id": team_id, "name": team.name},
expires_delta=expire,
)
return token
def verify_one_time_token(token: str):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate token",
)
with Session(engine) as session:
token_in_db = session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == False)
).one_or_none()
if token_in_db:
try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
return payload
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="access token expired",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
elif session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token already used",
)
else:
raise credentials_exception
def invalidate_one_time_token(token: str):
with Session(engine) as session:
token_in_db = session.exec(select(TokenDB).where(TokenDB.token == token)).one()
token_in_db.used = True
session.add(token_in_db)
session.commit()
class FirstPassword(BaseModel): class FirstPassword(BaseModel):
token: str token: str
password: str password: str
async def set_first_password(req: FirstPassword): async def set_first_password(req: FirstPassword):
credentials_exception = HTTPException( payload = verify_one_time_token(req.token)
status_code=status.HTTP_401_UNAUTHORIZED, action: str = payload.get("sub")
detail="Could not validate token", if action != "set password":
)
with Session(engine) as session:
token_in_db = session.exec(
select(TokenDB)
.where(TokenDB.token == req.token)
.where(TokenDB.used == False)
).one_or_none()
if token_in_db:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate token",
)
try:
payload = jwt.decode(req.token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except ExpiredSignatureError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token expired", detail="wrong type of token.",
) )
except (InvalidTokenError, ValidationError): username: str = payload.get("username")
raise credentials_exception with Session(engine) as session:
user = get_user(username) user = get_user(username)
if user: if user:
user.hashed_password = get_password_hash(req.password) user.hashed_password = get_password_hash(req.password)
session.add(user) session.add(user)
token_in_db.used = True
session.add(token_in_db)
session.commit() session.commit()
return Response( invalidate_one_time_token(req.token)
"Password set successfully", status_code=status.HTTP_200_OK return Response("password set successfully", status_code=status.HTTP_200_OK)
)
elif session.exec(
select(TokenDB) class ChangedPassword(BaseModel):
.where(TokenDB.token == req.token) current_password: str
.where(TokenDB.used == True) new_password: str
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token already used",
)
else:
raise credentials_exception
async def change_password( async def change_password(
current_password: str, request: ChangedPassword,
new_password: str,
user: Annotated[Player, Depends(get_current_active_user)], user: Annotated[Player, Depends(get_current_active_user)],
): ):
if ( if (
new_password request.new_password
and user.hashed_password and user.hashed_password
and verify_password(current_password, user.hashed_password) and verify_password(request.current_password, user.hashed_password)
): ):
with Session(engine) as session: with Session(engine) as session:
user.hashed_password = get_password_hash(new_password) user.hashed_password = get_password_hash(request.new_password)
session.add(user) session.add(user)
session.commit() session.commit()
return PlainTextResponse( return PlainTextResponse(
"Password changed successfully", status_code=status.HTTP_200_OK "password changed successfully",
status_code=status.HTTP_200_OK,
media_type="text/plain",
) )
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Wrong password", detail="wrong password",
) )
class RegisterRequest(BaseModel):
token: str
team_id: int
display_name: str
username: str
password: str
email: str | None
number: str | None
async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "register":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
team_id: int = payload.get("team_id")
if team_id != req.team_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong team",
)
with Session(engine) as session:
if session.exec(select(P).where(P.username == req.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username exists",
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.display_name == req.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == team_id)).one()
new_player = Player(
username=req.username,
hashed_password=get_password_hash(req.password),
display_name=req.display_name,
email=req.email if req.email else None,
number=req.number,
disabled=False,
teams=[team],
)
session.add(new_player)
session.commit()
# invalidate_one_time_token(req.token)
return PlainTextResponse(f"added {new_player.display_name}")
async def read_player_me( async def read_player_me(
current_user: Annotated[Player, Depends(get_current_active_user)], current_user: Annotated[Player, Depends(get_current_active_user)],
): ):
return current_user return current_user.model_dump(exclude={"hashed_password", "disabled"})
async def read_own_items( async def read_own_items(

View File

@@ -1,7 +1,3 @@
* {
border-radius: 16px;
}
body { body {
background-color: aliceblue; background-color: aliceblue;
position: relative; position: relative;
@@ -29,6 +25,11 @@ footer {
left: 8px; left: 8px;
} }
dialog {
border-radius: 1em;
}
/*=========Network Controls=========*/ /*=========Network Controls=========*/
.infobutton { .infobutton {
@@ -46,6 +47,7 @@ footer {
.controls { .controls {
z-index: 9; z-index: 9;
position: absolute; position: absolute;
color: black;
top: 1vh; top: 1vh;
right: 0px; right: 0px;
padding: 8px; padding: 8px;
@@ -133,9 +135,11 @@ input:checked+.slider:before {
} }
.grey { .grey {
color: #444; opacity: 66%;
} }
.hint { .hint {
position: absolute; position: absolute;
font-size: 80%; font-size: 80%;
@@ -149,7 +153,9 @@ input:checked+.slider:before {
input { input {
padding: 0.2em 16px; padding: 0.2em 16px;
margin-bottom: 0.5em; margin-top: 0.25em;
margin-bottom: 0.25em;
border-radius: 1em;
} }
h1, h1,
@@ -191,6 +197,9 @@ h3 {
.box { .box {
position: relative; position: relative;
flex: 1; flex: 1;
border-width: 3px;
border-style: solid;
border-radius: 16px;
&.one { &.one {
max-width: min(96%, 768px); max-width: min(96%, 768px);
@@ -199,8 +208,6 @@ h3 {
padding: 4px; padding: 4px;
margin: 4px 0.5%; margin: 4px 0.5%;
border-style: solid;
border-color: black;
} }
.reservoir { .reservoir {
@@ -210,24 +217,13 @@ h3 {
width: 100%; width: 100%;
} }
.user {
max-width: 240px;
min-width: 100px;
margin: 4px auto;
.item {
font-weight: bold;
border-style: solid;
}
}
.item { .item {
cursor: pointer; cursor: pointer;
font-size: small; font-size: medium;
border: 3px dashed black; border: 2px solid;
border-radius: 1.2em; border-radius: 1em;
margin: 8px auto; margin: 3px auto;
padding: 4px 16px; padding: 5px 0.8em;
} }
.extra-margin { .extra-margin {
@@ -244,7 +240,7 @@ button {
z-index: 1; z-index: 1;
&:hover { &:hover {
opacity: 75%; opacity: 80%;
} }
} }
@@ -263,6 +259,7 @@ button {
.control { .control {
display: flex; display: flex;
border-radius: 16px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -307,10 +304,15 @@ button {
bottom: 16px; bottom: 16px;
padding: 0.4em; padding: 0.4em;
border-radius: 1em; border-radius: 1em;
background-color: rgba(0, 0, 0, 0.3); background-color: #36c8;
font-size: xx-large; font-size: xx-large;
margin-bottom: 16px; margin-bottom: 16px;
margin-right: 16px; margin-right: 16px;
}
.wavering {
animation: blink 40s infinite;
} }
} }
@@ -324,12 +326,13 @@ button {
} }
.tab-button { .tab-button {
color: black;
flex: 1; flex: 1;
background-color: grey; background-color: #bfbfbf;
border: none; border: none;
margin: 4px auto; margin: 4px auto;
cursor: pointer; cursor: pointer;
opacity: 50%; opacity: 80%;
} }
.tab-button.active { .tab-button.active {
@@ -352,7 +355,7 @@ button {
opacity: 50%; opacity: 50%;
&:hover { &:hover {
opacity: 75%; opacity: 80%;
} }
} }
} }
@@ -405,21 +408,36 @@ button {
} }
} }
.avatar { .avatars {
background-color: lightsteelblue; margin: 16px auto;
padding: 3px 8px;
width: fit-content;
border: 3px solid black;
margin: 0 auto 16px auto;
ul {
min-width: 100px;
} }
.avatar {
background-color: #f0f8ff88;
font-weight: bold;
font-size: 110%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
}
.group-avatar {
background-color: #f0f8ff88;
color: inherit;
font-weight: bold;
font-size: 90%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
} }
.user-info { .user-info {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 8em 12em;
gap: 2px 16px; gap: 2px 16px;
div { div {
@@ -427,6 +445,31 @@ button {
} }
} }
/*=======CONTEXT MENU=======*/
.context-menu {
z-index: 3;
min-width: 8em;
position: absolute;
background: aliceblue;
box-shadow: 4px 4px black;
color: black;
border: 3px solid black;
border-radius: 16px;
padding: 0;
margin: 0;
list-style: none;
li {
padding: 4px 0.5em;
border-bottom: 2px solid #0008;
border-radius: 0;
cursor: pointer;
}
li:last-child {
border-bottom: none;
}
}
.networkroute { .networkroute {
z-index: 3; z-index: 3;
@@ -435,13 +478,139 @@ button {
left: 48px; left: 48px;
} }
/*========TEAM PANEL========*/
.team-panel {
max-width: 800px;
padding: 1em;
border: 3px solid black;
box-shadow: 8px 8px black;
margin: 1em;
input {
max-width: 300px;
margin: 0.2em auto;
}
}
.team-player {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.4em;
margin: 4px;
padding: 0.2em 0.5em;
&:hover {
background-color: #36c8;
}
&.new-player {
background-color: #3838;
}
&.disable-player {
background-color: #e338;
}
}
.new-player-inputs {
display: flex;
flex-direction: column;
margin: auto;
div {
display: grid;
grid-template-columns: 20ch auto;
@media only screen and (max-width: 768px) {
grid-template-columns: auto;
place-items: center;
}
label {
text-align: left;
width: 20ch;
margin: auto 1em;
}
input {
width: 90%;
margin: 4px 0;
}
}
}
@keyframes blink {
0% {
background-color: #8888;
}
13% {
background-color: #8888;
}
15% {
background-color: #f00a;
}
17% {
background-color: #8888;
}
38% {
background-color: #8888;
}
40% {
background-color: #ff0a;
}
42% {
background-color: #8888;
}
63% {
background-color: #8888;
}
65% {
background-color: #248f24aa;
}
67% {
background-color: #8888;
}
88% {
background-color: #8888;
}
90% {
background-color: #4700b3aa;
}
92% {
background-color: #8888;
}
100% {
background-color: #8888;
}
}
/*======SPINNER=======*/ /*======SPINNER=======*/
.loader { .loader {
display: block; display: block;
border-radius: 16px;
position: relative; position: relative;
height: 12px; height: 12px;
width: 96%; width: 96%;
margin: auto;
border: 4px solid black; border: 4px solid black;
overflow: hidden; overflow: hidden;
} }

View File

@@ -8,6 +8,8 @@ import { SessionProvider } from "./Session";
import { GraphComponent } from "./Network"; import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart"; import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword"; import { SetPassword } from "./SetPassword";
import { ThemeProvider } from "./ThemeProvider";
import TeamPanel from "./TeamPanel";
const Maintenance = () => { const Maintenance = () => {
return ( return (
@@ -21,6 +23,7 @@ const Maintenance = () => {
function App() { function App() {
return ( return (
<ThemeProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/password" element={<SetPassword />} /> <Route path="/password" element={<SetPassword />} />
@@ -31,9 +34,11 @@ function App() {
<Header /> <Header />
<Routes> <Routes>
<Route index element={<Rankings />} /> <Route index element={<Rankings />} />
<Route path="/network" element={<GraphComponent />} /> <Route path="network" element={<GraphComponent />} />
<Route path="/analysis" element={<Analysis />} /> <Route path="analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} /> <Route path="mvp" element={<MVPChart />} />
<Route path="changepassword" element={<SetPassword />} />
<Route path="team" element={<TeamPanel />} />
</Routes> </Routes>
<Footer /> <Footer />
</SessionProvider> </SessionProvider>
@@ -41,6 +46,7 @@ function App() {
/> />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ThemeProvider>
); );
} }
export default App; export default App;

View File

@@ -1,13 +1,17 @@
import { createRef, MouseEventHandler, useEffect, useState } from "react"; import { createRef, MouseEventHandler, useEffect, useState } from "react";
import { useSession } from "./Session"; import { TeamState, useSession } from "./Session";
import { User } from "./api"; import { User } from "./api";
import { useTheme } from "./ThemeProvider";
import { colourTheme, darkTheme, normalTheme, rainbowTheme } from "./themes";
import { useNavigate } from "react-router";
import { Team } from "./types";
interface ContextMenuItem { interface ContextMenuItem {
label: string; label: string;
onClick: () => void; onClick: () => void;
} }
const UserInfo = (user: User) => { const UserInfo = (user: User, teams: TeamState | undefined) => {
return ( return (
<div className="user-info"> <div className="user-info">
<div> <div>
@@ -21,17 +25,40 @@ const UserInfo = (user: User) => {
<div> <div>
<b>number: </b> <b>number: </b>
</div> </div>
<div>{user?.number ? user?.number : "-"}</div> <div>{user?.number || "-"}</div>
<div> <div>
<b>email: </b> <b>email: </b>
</div> </div>
<div>{user?.email ? user?.email : "-"}</div> <div>{user?.email || "-"}</div>
{teams && (
<>
<div>
<b>teams: </b>
</div>
<ul
style={{
margin: 0,
padding: 0,
textAlign: "left",
}}
>
{teams.teams.map((team, index) => (
<li>
{<b>{team.name}</b>} (
{team.location || team.country || "location unknown"})
</li>
))}
</ul>
</>
)}
</div> </div>
); );
}; };
export default function Avatar() { export default function Avatar() {
const { user, onLogout } = useSession(); const { user, teams, setTeams, onLogout } = useSession();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const [contextMenu, setContextMenu] = useState<{ const [contextMenu, setContextMenu] = useState<{
open: boolean; open: boolean;
allowOpen: boolean; allowOpen: boolean;
@@ -42,8 +69,27 @@ export default function Avatar() {
const avatarRef = createRef<HTMLDivElement>(); const avatarRef = createRef<HTMLDivElement>();
const contextMenuItems: ContextMenuItem[] = [ const contextMenuItems: ContextMenuItem[] = [
{ label: "View Profile", onClick: handleViewProfile }, { label: "view Profile", onClick: handleViewProfile },
{ label: "Logout", onClick: onLogout }, { label: "change password", onClick: () => navigate("/changepassword") },
{
label: "change theme",
onClick: () => {
switch (theme) {
case darkTheme:
setTheme(colourTheme);
break;
case colourTheme:
setTheme(rainbowTheme);
break;
case rainbowTheme:
setTheme(normalTheme);
break;
case normalTheme:
setTheme(darkTheme);
}
},
},
{ label: "logout", onClick: onLogout },
]; ];
const handleMenuClick: MouseEventHandler<HTMLDivElement> = (event) => { const handleMenuClick: MouseEventHandler<HTMLDivElement> = (event) => {
@@ -94,18 +140,18 @@ export default function Avatar() {
const dialogRef = createRef<HTMLDialogElement>(); const dialogRef = createRef<HTMLDialogElement>();
function handleViewProfile() { function handleViewProfile() {
handleMenuClose(); if (user && teams) {
if (user) {
dialogRef.current?.showModal(); dialogRef.current?.showModal();
setDialog(UserInfo(user)); setDialog(UserInfo(user, teams));
} }
} }
return ( return (
<>
<div className="avatars" style={{ display: user ? "block" : "none" }}>
<div <div
className="avatar" className="avatar"
onContextMenu={handleMenuClick} onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => { onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) { if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose(); handleMenuClose();
@@ -116,30 +162,36 @@ export default function Avatar() {
ref={avatarRef} ref={avatarRef}
> >
👤 {user?.username} 👤 {user?.username}
</div>
{teams && teams?.teams.length > 1 && (
<select
className="group-avatar"
value={teams.activeTeam}
onChange={(e) =>
setTeams({ ...teams, activeTeam: Number(e.target.value) })
}
>
{teams.teams.map((team) => (
<option key={team.id} value={team.id}>
👥 {team.name}
</option>
))}
</select>
)}
</div>
{contextMenu.open && ( {contextMenu.open && (
<ul <ul
className="context-menu" className="context-menu"
ref={contextMenuRef} ref={contextMenuRef}
style={{ style={{
zIndex: 3,
position: "absolute",
top: contextMenu.mouseY, top: contextMenu.mouseY,
left: contextMenu.mouseX, left: contextMenu.mouseX,
background: "white",
border: "1px solid #ddd",
padding: 0,
margin: 0,
listStyle: "none",
}} }}
> >
{contextMenuItems.map((item, index) => ( {contextMenuItems.map((item, index) => (
<li <li
key={index} key={index}
style={{
padding: "10px",
borderBottom: "1px solid #ddd",
cursor: "pointer",
}}
onClick={() => { onClick={() => {
item.onClick(); item.onClick();
handleMenuClose(); handleMenuClose();
@@ -160,6 +212,6 @@ export default function Avatar() {
> >
{dialog} {dialog}
</dialog> </dialog>
</div> </>
); );
} }

View File

@@ -4,10 +4,11 @@ import { useSession } from "./Session";
export default function Footer() { export default function Footer() {
const location = useLocation(); const location = useLocation();
const { user } = useSession(); const { user, teams } = useSession();
return ( return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}> <footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
{user?.scopes.split(" ").includes("analysis") && ( {(user?.scopes.split(" ").includes("analysis") ||
teams?.activeTeam === 42) && (
<div className="navbar"> <div className="navbar">
<Link to="/"> <Link to="/">
<span>Form</span> <span>Form</span>
@@ -20,6 +21,10 @@ export default function Footer() {
<Link to="/mvp"> <Link to="/mvp">
<span>MVP</span> <span>MVP</span>
</Link> </Link>
<span>|</span>
<Link to="/team">
<span>Team</span>
</Link>
</div> </div>
)} )}
<p className="grey extra-margin"> <p className="grey extra-margin">

View File

@@ -114,7 +114,7 @@ export const Login = ({ onLogin }: LoginProps) => {
{visible ? <Eye /> : <EyeSlash />} {visible ? <Eye /> : <EyeSlash />}
</div> </div>
</div> </div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div> {error && <span style={{ color: "red" }}>{error}</span>}
<button type="submit" value="login" style={{ fontSize: "small" }}> <button type="submit" value="login" style={{ fontSize: "small" }}>
login login
</button> </button>

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { Theme } from "reagraph"; import { Theme } from "reagraph";
export const customTheme: Theme = { export var customTheme: Theme = {
canvas: { canvas: {
background: "aliceblue", background: "aliceblue",
}, },

View File

@@ -14,12 +14,10 @@ const determineNiceWidth = (width: number) => {
}; };
const RaceChart: FC<RaceChartProps> = ({ players, std }) => { const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
// State to store window's width and height
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth)); const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight); //const [height, setHeight] = useState(window.innerHeight);
const height = players.length * 40; const height = (players.length + 1) * 40;
// Update state on resize
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth)); setWidth(determineNiceWidth(window.innerWidth));
@@ -41,11 +39,14 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
{players.map((player, index) => ( {players.map((player, index) => (
<rect <rect
key={String(index)} key={String(index)}
x={0} x={4}
y={index * barHeight + padding} y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width} width={(1 - player.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c" fill="#36c"
stroke="aliceblue"
strokeWidth={4}
paintOrder={"stroke fill"}
/> />
))} ))}
@@ -53,7 +54,7 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
<g key={"group" + index}> <g key={"group" + index}>
<text <text
key={index + "_name"} key={index + "_name"}
x={4} x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2} y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width} width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars height={barHeight - 8} // subtract 2 for some spacing between bars
@@ -71,7 +72,7 @@ const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
<text <text
key={index + "_value"} key={index + "_value"}
x={ x={
4 + 8 +
(4 + Math.max(...players.map((p, _) => p.name.length))) * (4 + Math.max(...players.map((p, _) => p.name.length))) *
fontSize * fontSize *
0.66 0.66

View File

@@ -1,8 +1,16 @@
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react"; import {
ButtonHTMLAttributes,
Fragment,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs"; import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api"; import { apiAuth, loadPlayers, User } from "./api";
import { useSession } from "./Session"; import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types"; import { Chemistry, MVPRanking } from "./types";
import TabController from "./TabController";
type PlayerListProps = Partial<ReactSortableProps<any>> & { type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean; orderedList?: boolean;
@@ -10,7 +18,7 @@ type PlayerListProps = Partial<ReactSortableProps<any>> & {
function PlayerList(props: PlayerListProps) { function PlayerList(props: PlayerListProps) {
return ( return (
<ReactSortable {...props} animation={200}> <ReactSortable {...props} animation={200} swapThreshold={0.4}>
{props.list?.map((item, index) => ( {props.list?.map((item, index) => (
<div key={item.id} className="item"> <div key={item.id} className="item">
{props.orderedList {props.orderedList
@@ -48,10 +56,11 @@ function filterSort(list: User[], ids: number[]): User[] {
interface PlayerInfoProps { interface PlayerInfoProps {
user: User; user: User;
teams: TeamState;
players: User[]; players: User[];
} }
function ChemistryDnD({ user, players }: PlayerInfoProps) { function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id); var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]); const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers); const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
@@ -60,6 +69,11 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
useEffect(() => { useEffect(() => {
setPlayersMiddle(otherPlayers); setPlayersMiddle(otherPlayers);
}, [players]); }, [players]);
useEffect(() => {
setPlayersLeft([]);
setPlayersMiddle(otherPlayers);
setPlayersRight([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
@@ -70,17 +84,33 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
let left = playersLeft.map(({ id }) => id); let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.map(({ id }) => id); let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ id }) => id); let right = playersRight.map(({ id }) => id);
const data = { user: user.id, hate: left, undecided: middle, love: right }; const data = {
user: user.id,
hate: left,
undecided: middle,
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT"); const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again"); setDialog(response || "try sending again");
} }
async function handleGet() { async function handleGet() {
const chemistry = (await apiAuth("chemistry", null, "GET")) as Chemistry; const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate)); setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(filterSort(otherPlayers, chemistry.undecided)); setPlayersMiddle(
otherPlayers.filter(
(player) =>
!chemistry.hate.includes(player.id) &&
!chemistry.love.includes(player.id)
)
);
setPlayersRight(filterSort(otherPlayers, chemistry.love)); setPlayersRight(filterSort(otherPlayers, chemistry.love));
} }
}
return ( return (
<> <>
@@ -133,7 +163,7 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
</div> </div>
</div> </div>
<button className="submit" onClick={() => handleSubmit()}> <button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span> 💾 <span className="submit_text">submit</span>
</button> </button>
<dialog <dialog
@@ -149,7 +179,7 @@ function ChemistryDnD({ user, players }: PlayerInfoProps) {
); );
} }
function MVPDnD({ user, players }: PlayerInfoProps) { function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]); const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
@@ -157,6 +187,11 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
setAvailablePlayers(players); setAvailablePlayers(players);
}, [players]); }, [players]);
useEffect(() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null); const dialogRef = useRef<HTMLDialogElement>(null);
@@ -164,15 +199,21 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
if (dialogRef.current) dialogRef.current.showModal(); if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending..."); setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id); let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps }; const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT"); const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again"); response ? setDialog(response) : setDialog("try sending again");
} }
async function handleGet() { async function handleGet() {
const mvps = (await apiAuth("mvps", null, "GET")) as MVPRanking; const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
const mvps = data as MVPRanking;
setRankedPlayers(filterSort(players, mvps.mvps)); setRankedPlayers(filterSort(players, mvps.mvps));
setAvailablePlayers(players.filter((user) => !mvps.mvps.includes(user.id))); setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
}
} }
return ( return (
@@ -225,7 +266,7 @@ function MVPDnD({ user, players }: PlayerInfoProps) {
</div> </div>
</div> </div>
<button className="submit" onClick={() => handleSubmit()}> <button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span> 💾 <span className="submit_text">submit</span>
</button> </button>
<dialog <dialog
@@ -263,22 +304,14 @@ function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
} }
export default function Rankings() { export default function Rankings() {
const { user } = useSession(); const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null); const [players, setPlayers] = useState<User[] | null>(null);
const [openTab, setOpenTab] = useState("Chemistry");
async function loadPlayers() {
try {
const data = await apiAuth("player/list", null, "GET");
setPlayers(data as User[]);
} catch (error) {
console.error(error);
}
}
useEffect(() => { useEffect(() => {
loadPlayers(); if (teams) {
}, []); loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [user, teams]);
const tabs = [ const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" }, { id: "Chemistry", label: "🧪 Chemistry" },
@@ -287,33 +320,13 @@ export default function Rankings() {
return ( return (
<> <>
{user && players && ( {user && teams && players ? (
<div> <TabController tabs={tabs}>
<div className="container navbar"> <ChemistryDnD {...{ user, teams, players }} />
{tabs.map((tab) => ( <MVPDnD {...{ user, teams, players }} />
<button </TabController>
key={tab.id} ) : (
className={ <span className="loader" />
openTab === tab.id ? "tab-button active" : "tab-button"
}
onClick={() => setOpenTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab) => {
if (openTab !== tab.id) return null;
switch (tab.id) {
case "Chemistry":
return <ChemistryDnD key={tab.id} {...{ user, players }} />;
case "MVP":
return <MVPDnD key={tab.id} {...{ user, players }} />;
default:
return null;
}
})}
</div>
)} )}
</> </>
); );

View File

@@ -5,21 +5,31 @@ import {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { currentUser, logout, User } from "./api"; import { apiAuth, currentUser, logout, User } from "./api";
import { Login } from "./Login"; import { Login } from "./Login";
import Header from "./Header"; import Header from "./Header";
import { Team } from "./types";
export interface SessionProviderProps { export interface SessionProviderProps {
children: ReactNode; children: ReactNode;
} }
export interface TeamState {
teams: Team[];
activeTeam: number;
}
export interface Session { export interface Session {
user: User | null; user: User | null;
teams: TeamState | null;
setTeams: (teams: TeamState) => void;
onLogout: () => void; onLogout: () => void;
} }
const sessionContext = createContext<Session>({ const sessionContext = createContext<Session>({
user: null, user: null,
teams: null,
setTeams: () => {},
onLogout: () => {}, onLogout: () => {},
}); });
@@ -27,6 +37,7 @@ export function SessionProvider(props: SessionProviderProps) {
const { children } = props; const { children } = props;
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null);
const [err, setErr] = useState<unknown>(null); const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -44,9 +55,17 @@ export function SessionProvider(props: SessionProviderProps) {
.finally(() => setLoading(false)); .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 });
}
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
}, []); }, []);
useEffect(() => {
loadTeam();
}, [user]);
function onLogin(user: User) { function onLogin(user: User) {
setUser(user); setUser(user);
@@ -77,7 +96,7 @@ export function SessionProvider(props: SessionProviderProps) {
content = <Login onLogin={onLogin} />; content = <Login onLogin={onLogin} />;
} else } else
content = ( content = (
<sessionContext.Provider value={{ user, onLogout }}> <sessionContext.Provider value={{ user, teams, setTeams, onLogout }}>
{children} {children}
</sessionContext.Provider> </sessionContext.Provider>
); );

View File

@@ -1,47 +1,65 @@
import { jwtDecode, JwtPayload } from "jwt-decode"; import { jwtDecode, JwtPayload } from "jwt-decode";
import { useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { baseUrl } from "./api"; import { apiAuth, baseUrl, User } from "./api";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import eye from "./eye.svg";
import { Eye, EyeSlash } from "./Icons"; import { Eye, EyeSlash } from "./Icons";
import { useSession } from "./Session";
import { relative } from "path"; import { relative } from "path";
import Header from "./Header";
interface SetPassToken extends JwtPayload { interface PassToken extends JwtPayload {
username: string;
name: string; name: string;
team_id: number;
}
enum Mode {
register = "register",
set = "set password",
change = "change password",
} }
export const SetPassword = () => { export const SetPassword = () => {
const [mode, setMode] = useState<Mode>();
const [name, setName] = useState("after getting your token."); const [name, setName] = useState("after getting your token.");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [teamID, setTeamID] = useState<number>();
const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [passwordr, setPasswordr] = useState(""); const [passwordr, setPasswordr] = useState("");
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 [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const newPlayerTemplate = {
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [player, setPlayer] = useState(newPlayerTemplate);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useSession();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<SetPassToken>(token);
if (payload.name) setName(payload.name);
else if (payload.sub) setName(payload.sub);
else setName("Mr. I-have-no Token");
payload.sub && setUsername(payload.sub);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}, []);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (password === passwordr) { if (password === passwordr) {
setLoading(true); setLoading(true);
if (mode === Mode.change) {
//====CHANGING PASSWORD====
const resp = await apiAuth(
"player/change_password",
{ current_password: currentPassword, new_password: password },
"POST"
);
setLoading(false);
if (resp.detail) setError(resp.detail);
else {
setError(resp);
setTimeout(() => navigate("/"), 2000);
}
} else if (mode === Mode.set) {
//====SETTING PASSWORD====
const req = new Request(`${baseUrl}api/set_password`, { const req = new Request(`${baseUrl}api/set_password`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -73,38 +91,180 @@ export const SetPassword = () => {
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
} }
} else if (mode === Mode.register) {
//====REGISTER NEW USER====
const req = new Request(`${baseUrl}api/register`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...player,
team_id: teamID,
token: token,
password: password,
}),
});
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
setLoading(false);
if (resp.ok) {
console.log(resp);
navigate("/", {
replace: true,
state: { username: player.username, password: password },
});
}
if (!resp.ok) {
const { detail } = await resp.json();
if (detail) setError(detail);
else setError("unauthorized");
throw new Error("Unauthorized");
}
}
} else setError("passwords are not the same"); } else setError("passwords are not the same");
} }
return ( useEffect(() => {
if (user) {
setUsername(user.username);
setName(user.display_name);
setMode(Mode.change);
} else {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<PassToken>(token);
console.log(payload);
switch (payload.sub) {
case "register":
setMode(Mode.register);
if (payload.team_id) setTeamID(payload.team_id);
break;
case "set password":
setMode(Mode.set);
if (payload.username) setUsername(payload.username);
break;
}
if (payload.name) setName(payload.name);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}
}, []);
let header: ReactNode;
switch (mode) {
case Mode.change:
header = <h2>change your password, {name}</h2>;
break;
case Mode.set:
header = (
<> <>
<Header />
<h2>set your password, {name}</h2>
</>
);
break;
case Mode.register:
header = (
<>
<Header />
<h2> <h2>
set your password, register as a member of <i>{name}</i>
<br />
{name}
</h2> </h2>
{username && ( </>
<span> );
your username is: <i>{username}</i> }
</span>
)} let textInputs: ReactNode;
<form onSubmit={handleSubmit}> switch (mode) {
<div case Mode.change:
style={{ textInputs = (
position: "relative", <div>
display: "flex", <input
alignItems: "center", type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="current password"
minLength={8}
value={currentPassword}
required
onChange={(evt) => {
setError("");
setCurrentPassword(evt.target.value);
}} }}
> />
<div <hr style={{ margin: "8px" }} />
style={{ </div>
width: "100%", );
marginRight: "8px", break;
display: "flex", case Mode.register:
justifyContent: "center", textInputs = (
flexDirection: "column", <div className="new-player-inputs">
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
}} }}
> />
</div>
<div>
<label>username</label>
<input
type="text"
required
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
}}
/>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
}}
/>
</div>
<hr style={{ margin: "8px" }} />
</div>
);
break;
}
let passwordInputs = (
<>
<div> <div>
<input <input
type={visible ? "text" : "password"} type={visible ? "text" : "password"}
@@ -135,27 +295,46 @@ export const SetPassword = () => {
}} }}
/> />
</div> </div>
</div> </>
);
return mode ? (
<>
{header}
<hr style={{ width: "100%" }} />
<form onSubmit={handleSubmit}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}
>
{textInputs}
{passwordInputs}
<div <div
style={{ style={{
position: "absolute",
right: 0,
margin: "auto 4px",
background: "unset", background: "unset",
fontSize: "xx-large", fontSize: "medium",
cursor: "pointer", cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}} }}
onClick={() => setVisible(!visible)} onClick={() => setVisible(!visible)}
> >
{visible ? <Eye /> : <EyeSlash />} {visible ? <Eye /> : <EyeSlash />} show passwords
</div> </div>
</div> </div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div> <div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
<button type="submit" value="login" style={{ fontSize: "small" }}> <button type="submit" value="login" style={{ fontSize: "small" }}>
login submit
</button> </button>
{loading && <span className="loader" />} {loading && <span className="loader" />}
</form> </form>
</> </>
) : (
<span className="loader" />
); );
}; };

45
src/TabController.tsx Normal file
View File

@@ -0,0 +1,45 @@
import { Fragment, ReactNode, useState } from "react";
interface TabProps {
id: string;
label: string;
}
interface TabControllerProps {
tabs: TabProps[];
children: ReactNode[];
}
export default function TabController({ tabs, children }: TabControllerProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const handleTabClick = (index: number) => {
setCurrentIndex(index);
};
return (
<div>
<div>
<div className="container navbar">
{tabs.map((tab, index) => (
<button
key={tab.id}
className={
currentIndex === index ? "tab-button active" : "tab-button"
}
onClick={() => handleTabClick(index)}
>
{tab.label}
</button>
))}
</div>
</div>
{children.map((child, index) => (
<Fragment key={index}>
<div style={{ display: currentIndex === index ? "block" : "none" }}>
{child}
</div>
</Fragment>
))}
</div>
);
}

209
src/TeamPanel.tsx Normal file
View File

@@ -0,0 +1,209 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, loadPlayers, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
const TeamPanel = () => {
const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
const newPlayerTemplate = {
id: 0,
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [players, setPlayers] = useState<User[] | null>(null);
const [player, setPlayer] = useState(newPlayerTemplate);
useEffect(() => {
if (teams) {
setError({ ok: true, message: "" });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [teams]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (teams) {
if (player.id === 0) {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "POST");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}
}
}
async function handleDisable(e: FormEvent) {
e.preventDefault();
if (teams && player.id !== 0) {
var confirmation = confirm("are you sure?");
if (confirmation) {
const r = await apiAuth(
`player/${teams?.activeTeam}`,
{ player_id: player.id },
"DELETE"
);
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
setPlayer(newPlayerTemplate);
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}
}
}
if (teams && players) {
const activeTeam = teams.teams.filter(
(team) => team.id == teams?.activeTeam
)[0];
return (
<div className="team-panel">
<h1>{activeTeam.name}</h1>
<div>
<input type="text" value={activeTeam.location || ""} disabled />
<br />
<input type="text" value={activeTeam.country || ""} disabled />
<hr style={{ width: "100%" }} />
<h2>players</h2>
{players ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{players &&
players.map((p) => (
<button
className="team-player"
key={p.id}
onClick={() => {
setPlayer(p);
setError({ ok: true, message: "" });
}}
>
{p.display_name}
</button>
))}
<button
className="team-player new-player"
key="add-player"
onClick={() => {
setPlayer(newPlayerTemplate);
setError({ ok: true, message: "" });
}}
>
+
</button>
</div>
) : (
<span className="loader" />
)}
<hr style={{ width: "100%" }} />
<form className="new-player-inputs" onSubmit={handleSubmit}>
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>username</label>
<input
type="text"
required
disabled={player.id !== 0}
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div style={{ margin: "auto" }}>
{error?.message && (
<span
style={{
color: error.ok ? "green" : "red",
}}
>
{error.message}
</span>
)}
</div>
<div style={{ margin: "auto" }}>
<button className="team-player new-player">
{player.id === 0 ? "add player" : "modify player"}
</button>
</div>
{player.id !== 0 && (
<div style={{ margin: "auto" }}>
<button
className="team-player disable-player"
onClick={handleDisable}
>
remove player
</button>
</div>
)}
</form>
</div>
</div>
);
} else <span className="loader" />;
};
export default TeamPanel;

49
src/ThemeProvider.tsx Normal file
View File

@@ -0,0 +1,49 @@
import { normalTheme, Theme } from "./themes";
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
interface ThemeContextProps {
children: ReactNode;
}
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const themeContext = createContext<ThemeContextValue>({
theme: normalTheme,
setTheme: () => {},
});
const ThemeProvider = ({ children }: ThemeContextProps) => {
const [theme, setTheme] = useState(normalTheme);
useEffect(() => {
if (theme.backgroundColor) {
document.body.style.backgroundColor = theme.backgroundColor;
document.body.style.backgroundImage = "unset";
} else if (theme.backgroundImage) {
document.body.style.backgroundColor = "unset";
document.body.style.backgroundImage = theme.backgroundImage;
}
document.body.style.color = theme.textColor;
document.body.style.borderColor = theme.textColor;
}, [theme]);
return (
<themeContext.Provider value={{ theme, setTheme }}>
{children}
</themeContext.Provider>
);
};
function useTheme() {
return useContext(themeContext);
}
export { ThemeProvider, useTheme };

View File

@@ -29,16 +29,25 @@ export async function apiAuth(
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} }
} }
const contentType = resp.headers.get("Content-Type");
switch (contentType) {
case "application/json":
return resp.json(); return resp.json();
case "text/plain":
case "text/plain; charset=utf-8":
return resp.text();
case null:
return null;
default:
throw new Error(`Unsupported content type: ${contentType}`);
}
} }
export type User = { export type User = {
id: number; id: number;
username: string; username: string;
display_name: string; display_name: string;
full_name: string;
email: string; email: string;
player_id: number;
number: string; number: string;
scopes: string; scopes: string;
}; };
@@ -67,6 +76,16 @@ export async function currentUser(): Promise<User> {
return resp.json() as Promise<User>; return resp.json() as Promise<User>;
} }
export async function loadPlayers(teamId: number) {
try {
const data = await apiAuth(`player/${teamId}/list`, null, "GET");
return data as User[];
} catch (error) {
console.error(error);
return null;
}
}
export type LoginRequest = { export type LoginRequest = {
username: string; username: string;
password: string; password: string;

24
src/themes.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface Theme {
backgroundColor?: string;
backgroundImage?: string;
textColor: string;
}
export const normalTheme: Theme = {
backgroundColor: "aliceblue",
textColor: "black",
};
export const darkTheme: Theme = {
backgroundColor: "#444",
textColor: "white",
};
export const colourTheme: Theme = {
backgroundImage:
"linear-gradient(45deg, magenta, rebeccapurple, dodgerblue, green)",
textColor: "white",
};
export const rainbowTheme: Theme = {
backgroundImage:
"linear-gradient(135deg, #FF0000, #FFA500, #888800, #008000, #0000FF, #4B0082, #800080 ",
textColor: "white",
};

View File

@@ -32,3 +32,15 @@ export interface MVPRanking {
user: number; user: number;
mvps: number[]; mvps: number[];
} }
export interface Team {
id: number;
name: string;
location: string;
country: string;
}
export type ErrorState = {
ok: boolean;
message: string;
};