Compare commits

..

132 Commits

Author SHA1 Message Date
1626751083
feat: exponentially decreasing edge weight
and adjustment of negative weight
2025-06-20 14:01:04 +02:00
710b0770cc
chore: remove old Analysis page 2025-05-26 07:53:58 +02:00
56c1ba11fc
chore: remove unused 2025-05-26 07:51:44 +02:00
ad2b2993df
fix: try to make sure order when mixed changes 2025-05-26 07:48:33 +02:00
638e8bf20c
feat: gender-separated MVPChart in mixed teams 2025-05-26 07:33:31 +02:00
a4ea0dfc41
fix: don't show times of players not in the team 2025-05-25 21:13:28 +02:00
62ba89c599
feat: require gender when registering 2025-05-23 22:09:49 +02:00
05bdc5c44c
feat: support mixed teams in MVP ranking 2025-05-23 22:01:08 +02:00
105b3778e1
fix: find display_name 2025-05-23 09:44:22 +02:00
003f401320
fix: go back to forms if not team captain 2025-05-22 12:45:49 +02:00
2195e7324d
feat: exclude players from analysis that aren't in the team 2025-05-22 12:33:28 +02:00
ba26e7c9e6
feat: bigger "Events" 2025-05-21 15:18:01 +02:00
64d6edd9f5
feat: order players alphabetically 2025-05-21 15:17:34 +02:00
b781408c18
feat: disable dark mode for text inputs 2025-05-21 15:08:36 +02:00
a0c8e0cd18
feat: decrease calendar size 2025-05-21 15:04:06 +02:00
de8dc6b9b9
feat: load players in session 2025-05-21 14:55:13 +02:00
241f6fa7eb
feat: show active player 2025-05-21 14:37:07 +02:00
a42fff807c
feat: useSession for players 2025-05-21 14:36:51 +02:00
369cf0b727
feat: calendar display for latest submissions 2025-05-21 14:26:35 +02:00
a6dfab47d5
fix: props.list empty 2025-05-19 14:59:10 +02:00
4c78ede7c2
feat: increase dnd box 2025-05-19 14:50:24 +02:00
8c8a88e72c
fix: handle players removed from team 2025-05-19 14:45:31 +02:00
b9efd4f7a3
feat: add player type survey 2025-05-19 14:32:30 +02:00
a6ebc28d47
fix: gender and previous state for DEMO 2025-05-18 16:01:05 +02:00
48f282423f
feat: add gender in DEMO 2025-05-18 13:19:50 +02:00
881e015c1f
Merge branch 'feat/demo' 2025-05-18 13:18:46 +02:00
4e2e0dd2a5
feat: add gender 2025-05-18 13:18:02 +02:00
b739246129
feat: load previously submitted by default 2025-05-18 11:59:07 +02:00
cb2b7db7a6
feat: demo MVP list 2025-05-08 10:11:00 +02:00
1c71df781c
feat: adjustments for Demo Team (team_id = 42) 2025-05-08 07:07:14 +02:00
6378488fd0
feat: add demo players 2025-05-08 07:05:44 +02:00
6902ffdca6
fix: weigh dislike negatively for weighted degree 2025-04-16 12:59:50 +02:00
a6d0f528d0
fix: left out saving the password hash 2025-03-27 16:17:19 +01:00
77d292974c
fix: group token cannot be one-time only 2025-03-27 08:04:41 +01:00
43f9b0d47c
feat: register new user with team-specific token
revamp entire `SetPassword` page
2025-03-26 17:37:02 +01:00
bef5119a0b
feat: more helpful error 2025-03-26 17:36:52 +01:00
ee13d06ab1
feat: no user -> no team 2025-03-26 16:31:27 +01:00
03ed843679
feat: check whether user has necessary scope 2025-03-25 19:09:33 +01:00
81d6a02229
feat: disallow non-members to list team members 2025-03-25 16:38:44 +01:00
11f3f9f440
fix: weird bug
cutt.0124816.xyz/team responded with 404 while all other routes were handled correctly
by react-router
2025-03-25 16:31:25 +01:00
0507b9f7c4
feat: default export TeamPanel 2025-03-25 09:13:26 +01:00
e701ebbb02
feat: remove unused / 2025-03-25 09:12:55 +01:00
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
453d7ca951
feat: loads of improvements (see comments)
1) check whether submitting user is submitting for himself
2) some refactoring of the tabs in `Ranking`
3) get chemistry and mvps from DB
4) restore previous
5) start over
6) (hopefully) improve logout
2025-03-13 20:11:34 +01:00
9afa4a88a8
feat: querySelector -> useRef 2025-03-13 15:01:22 +01:00
630986d49c
feat: rewrite of tabs component 2025-03-13 13:11:52 +01:00
4f30888c5c
feat: show popularity score 2025-03-12 14:39:42 +01:00
5b8f476997
feat: button to make password visible 2025-03-11 19:14:34 +01:00
e4c95c37ee
feat: add Maintenance page, just in case 2025-03-11 15:46:03 +01:00
2a396457aa
chore: remove unused stuff 2025-03-11 15:43:36 +01:00
34c030c1e9
feat: adjust submission function to new DB 2025-03-11 13:52:54 +01:00
6eb2563068
feat: implement one-time token 2025-03-11 13:37:23 +01:00
1067b12be8
feat: adjust mvp function to new DB 2025-03-11 12:40:12 +01:00
c42231907d
feat: adjust Sociogram to new DB 2025-03-11 12:37:16 +01:00
95e66e5d73
feat: adjust graph_json 2025-03-11 12:34:45 +01:00
6d2bf057a5
feat: more robust context menu 2025-03-11 12:33:35 +01:00
b07c2fd8ab
fix: remove un-controlled checked warning 2025-03-11 11:57:58 +01:00
82ffa06a00
feat: view profile 2025-03-11 11:45:48 +01:00
00442be4b5
fix: context menu and dialog didn't play nice 2025-03-11 11:14:07 +01:00
26ee4b84a9
feat: update context menu to use references 2025-03-11 11:04:42 +01:00
aa3c3df5da
fix: enable all sub-routes 2025-03-11 10:35:39 +01:00
401ac316c1
feat: adjust Rankings to new auth + User 2025-03-11 10:34:58 +01:00
53fc8bb6e3
feat: add more User props 2025-03-11 10:34:39 +01:00
92a98064e5
feat: add jwt token handler 2025-03-11 10:34:18 +01:00
1773a9885a
fix: remove max-age seconds 2025-03-11 10:32:49 +01:00
9996752d94
fix: check location.state first 2025-03-11 08:42:44 +01:00
b386ee365f
feat: automatically switch to index and fill in newly given creds 2025-03-11 08:25:33 +01:00
045c26d258
feat: setup for setting first password 2025-03-11 08:12:29 +01:00
a37971ed86
feat: set first password page 2025-03-10 13:16:41 +01:00
f3e6382101
feat: set first password with token 2025-03-10 13:15:41 +01:00
59e2fc4502
feat: User -> Player 2025-03-10 11:24:03 +01:00
33c505fee4
feat: change DB, represent players by id 2025-03-10 11:23:19 +01:00
cfe2df01f7
feat: Cookie and Header auth 2025-03-09 18:47:22 +01:00
7580a4f1e6
feat: remove unnecessary info in User response 2025-03-09 16:53:11 +01:00
7bf35b65fb
fix: logout bug 2025-03-09 16:34:05 +01:00
d3f5c3cb82
feat: roll back refresh tokens, use single token only 2025-03-07 18:24:25 +01:00
8b092fed51
feat: implement login auth for all
also show the username underneath the logo
2025-03-06 13:29:10 +01:00
99e80c8077
feat: clickable username with context menu 2025-03-06 09:28:55 +01:00
854bd03c40
feat: add unique key prop to g 2025-03-05 12:59:09 +01:00
bc6c2a4a98
fix: give the token all available scopes for user 2025-03-05 11:45:47 +01:00
b7c8136b1e
feat: add correct keys to User interface 2025-03-05 09:57:03 +01:00
b8c4190072
feat: implement auth scopes 2025-03-05 09:56:04 +01:00
d61bea3c86
feat: adjust formatting slightly 2025-03-03 16:31:32 +01:00
70a4ece5bc
feat: add numbers to RaceChart 2025-03-03 16:05:28 +01:00
406ea9ffdd
feat: remove unnecessary CORS origins 2025-03-03 16:02:44 +01:00
104ec70695
feat: add footer back to Network page 2025-03-03 11:52:48 +01:00
9d65c1d1df
feat: add sample size 2025-03-03 09:40:16 +01:00
de79970987
feat: add value ± standard deviation to RaceChart 2025-03-02 20:09:54 +01:00
a52dae5605
feat: require auth to add players/teams 2025-03-02 09:28:16 +01:00
a46427c6b8
fix: in case no edge size is set 2025-02-25 18:40:50 +01:00
fd323db6d0
feat: implement highlighting of mutuality 2025-02-25 18:36:58 +01:00
c2d94c0400
fix: don't show nodes that are target of ignored edges 2025-02-25 08:55:44 +01:00
f94c3402c2
fix: text stroke 2025-02-24 18:17:53 +01:00
5c21cf1fc3
feat: add vertical BarChart 2025-02-24 18:04:20 +01:00
5cd793b278
fix: re-add utility types 2025-02-24 18:00:09 +01:00
de8688133f
feat: add MPV to nav 2025-02-24 17:56:38 +01:00
d6e5d0334c
feat: add RaceChart 2025-02-24 17:53:01 +01:00
5fef47f692
feat: implement BarChart for MVP 2025-02-24 16:51:50 +01:00
978aafc204
feat: add popularity option 2025-02-23 17:10:18 +01:00
47fd9bd859
feat: working WebGL graph with selection/highlighting 2025-02-23 16:50:34 +01:00
13bb965b28
feat: add 3D toggle and adjust theme 2025-02-20 17:26:27 +01:00
37 changed files with 4554 additions and 1313 deletions

View File

@ -1,195 +0,0 @@
from datetime import datetime
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, Player, engine
import networkx as nx
import matplotlib
matplotlib.use("agg")
import matplotlib.pyplot as plt
analysis_router = APIRouter(prefix="/analysis")
C = Chemistry
P = Player
def sociogram_json():
nodes = []
necessary_nodes = set()
edges = []
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "label": p.name})
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.time > datetime(2025, 2, 1, 10))
.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 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": c.user, "to": p, "relation": "likes"})
for p in c.hate:
edges.append({"from": 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 = []
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "label": p.name})
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.time > datetime(2025, 2, 1, 10))
.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):
for p in c.love:
edges.append(
{
"id": f"{c.user}->{p}",
"source": c.user,
"target": p,
"relation": "likes",
}
)
continue
for p in c.hate:
edges.append(
{
id: f"{c.user}-x>{p}",
"source": c.user,
"target": p,
"relation": "dislikes",
}
)
return JSONResponse({"nodes": nodes, "edges": edges})
def sociogram_data(show: int | None = 2):
G = nx.DiGraph()
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
G.add_node(p.name)
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.time > datetime(2025, 2, 1, 10))
.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 in enumerate(c.love):
G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i)
if show <= 1:
for i, p in enumerate(c.hate):
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}
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"])
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

516
cutt/analysis.py Normal file
View File

@ -0,0 +1,516 @@
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,
PlayerType,
Team,
engine,
)
import networkx as nx
import numpy as np
import matplotlib
from cutt.security import TeamScopedRequest, verify_team_scope
from cutt.demo import demo_players
matplotlib.use("agg")
import matplotlib.pyplot as plt
analysis_router = APIRouter(prefix="/analysis", tags=["analysis"])
C = Chemistry
R = MVPRanking
PT = PlayerType
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):
if c.user not in player_map:
continue
user = player_map[c.user]
for i, p_id in enumerate(c.love):
if p_id not in player_map:
continue
p = player_map[p_id]
weight = 0.9**i
edges.append(
{
"id": f"{user}->{p}",
"source": user,
"target": p,
"size": weight,
"data": {
"relation": 2,
"origSize": weight,
"origFill": "#bed4ff",
},
}
)
for p_id in c.hate:
if p_id not in player_map:
continue
p = player_map[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
"source": user,
"target": p,
"size": 0.5,
"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}
translate_tablename = {
R.__tablename__: "🏆",
C.__tablename__: "🧪",
PT.__tablename__: "🃏",
}
def last_submissions(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
):
times = {}
with Session(engine) as session:
player_ids = session.exec(
select(P.id)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
for survey in [C, PT, R]:
subquery = (
select(survey.user, func.max(survey.time).label("latest"))
.where(survey.team == request.team_id)
.where(survey.user.in_(player_ids))
.group_by(survey.user)
.subquery()
)
statement2 = select(survey).join(
subquery,
(survey.user == subquery.c.user) & (survey.time == subquery.c.latest),
)
for r in session.exec(statement2):
if r.time.date() not in times:
times[r.time.date()] = {}
times[r.time.date()][r.user] = (
times[r.time.date()].get(r.user, "")
+ translate_tablename[survey.__tablename__]
)
return times
def mvp(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)], mixed=False
):
if request.team_id == 42:
ranks = {}
random.seed(42)
players = [request.user] + demo_players
for p in players:
random.shuffle(players)
for i, p in enumerate(players):
ranks[p.id] = ranks.get(p.id, []) + [i + 1]
return [
[
{
"p_id": p_id,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for i, (p_id, v) in enumerate(ranks.items())
]
]
with Session(engine) as session:
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p for p in players}
subquery = (
select(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)
)
if mixed:
all_ranks = []
for gender in ["fmp", "mmp"]:
ranks = {}
for r in session.exec(statement2):
mvps = [
p_id
for p_id in r.mvps
if p_id in player_map and player_map[p_id].gender == gender
]
for i, p_id in enumerate(mvps):
p = player_map[p_id]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks.append(ranks)
else:
ranks = {}
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
if p_id not in player_map:
continue
p = player_map[p_id]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks = [ranks]
if not all_ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [
[
{
"p_id": p_id,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p_id, v in ranks.items()
]
for ranks in all_ranks
]
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"])
analysis_router.add_api_route(
"/times/{team_id}", endpoint=last_submissions, 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)

99
cutt/db.py Normal file
View File

@ -0,0 +1,99 @@
from datetime import datetime, timezone
from sqlmodel import (
ARRAY,
CHAR,
Column,
Integer,
Relationship,
SQLModel,
Field,
create_engine,
)
with open("db.secrets", "r") as f:
db_secrets = f.readline().strip()
engine = create_engine(
db_secrets,
pool_timeout=20,
pool_size=2,
connect_args={"connect_timeout": 8},
)
del db_secrets
def utctime():
return datetime.now(tz=timezone.utc)
class PlayerTeamLink(SQLModel, table=True):
team_id: int | None = Field(default=None, foreign_key="team.id", primary_key=True)
player_id: int | None = Field(
default=None, foreign_key="player.id", primary_key=True
)
class Team(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
location: str | None
country: str | None
mixed: bool = False
players: list["Player"] | None = Relationship(
back_populates="teams", link_model=PlayerTeamLink
)
class Player(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
username: str = Field(default=None, unique=True)
display_name: str
email: str | None = None
full_name: str | None = None
gender: str | None = Field(default=None, sa_column=Column(CHAR(3)))
disabled: bool | None = None
hashed_password: str | None = None
number: str | None = None
teams: list[Team] = Relationship(
back_populates="players", link_model=PlayerTeamLink
)
scopes: str = ""
class Chemistry(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id")
hate: list[int] = Field(sa_column=Column(ARRAY(Integer)))
undecided: list[int] = Field(sa_column=Column(ARRAY(Integer)))
love: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class PlayerType(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id")
handlers: list[int] = Field(sa_column=Column(ARRAY(Integer)))
combis: list[int] = Field(sa_column=Column(ARRAY(Integer)))
cutters: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id")
mvps: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class TokenDB(SQLModel, table=True):
token: str = Field(index=True, primary_key=True)
used: bool | None = False
updated_at: datetime | None = Field(
default_factory=utctime, sa_column_kwargs={"onupdate": utctime}
)
SQLModel.metadata.create_all(engine)

28
cutt/demo.py Normal file
View File

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

252
cutt/main.py Normal file
View File

@ -0,0 +1,252 @@
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from cutt.db import Player, PlayerType, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
Session,
func,
select,
)
from fastapi.middleware.cors import CORSMiddleware
from cutt.analysis import analysis_router
from cutt.security import (
get_current_active_user,
login_for_access_token,
logout,
register,
set_first_password,
)
from cutt.player import player_router
C = Chemistry
PT = PlayerType
R = MVPRanking
P = Player
app = FastAPI(
title="cutt", swagger_ui_parameters={"syntaxHighlight": {"theme": "monokai"}}
)
api_router = APIRouter(prefix="/api")
origins = [
"https://cutt.0124816.xyz",
"http://localhost:5173",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def add_team(team: Team):
with Session(engine) as session:
session.add(team)
session.commit()
def list_teams():
with Session(engine) as session:
statement = select(Team)
return session.exec(statement).fetchall()
team_router = APIRouter(
prefix="/teams",
dependencies=[Security(get_current_active_user, scopes=["admin"])],
tags=["team"],
)
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
wrong_user_id_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you're not who you think you are...",
)
somethings_fishy = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="something up..."
)
@api_router.put("/mvps", tags=["analysis"])
def submit_mvps(
mvps: MVPRanking,
user: Annotated[Player, Depends(get_current_active_user)],
):
if mvps.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == mvps.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == mvps.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= set(mvps.mvps):
session.add(mvps)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@api_router.get("/mvps/{team_id}", tags=["analysis"])
def get_mvps(
team_id: int,
user: Annotated[Player, Depends(get_current_active_user)],
):
with Session(engine) as session:
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.user == user.id)
.where(R.team == team_id)
.group_by(R.user)
.subquery()
)
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
mvps = session.exec(statement2).one_or_none()
if mvps:
return mvps
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
@api_router.put("/chemistry", tags=["analysis"])
def submit_chemistry(
chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)]
):
if chemistry.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == chemistry.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == chemistry.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= (
set(chemistry.love) | set(chemistry.hate) | set(chemistry.undecided)
):
session.add(chemistry)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@api_router.get("/chemistry/{team_id}", tags=["analysis"])
def get_chemistry(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
with Session(engine) as session:
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.user == user.id)
.where(C.team == team_id)
.group_by(C.user)
.subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
chemistry = session.exec(statement2).one_or_none()
if chemistry is not None:
return chemistry
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
@api_router.put("/playertype", tags=["analysis"])
def submit_playertype(
playertype: PlayerType, user: Annotated[Player, Depends(get_current_active_user)]
):
if playertype.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == playertype.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == playertype.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= (
set(playertype.handlers)
| set(playertype.combis)
| set(playertype.cutters)
):
session.add(playertype)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@api_router.get("/playertype/{team_id}", tags=["analysis"])
def get_playertype(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
with Session(engine) as session:
subquery = (
select(PT.user, func.max(PT.time).label("latest"))
.where(PT.user == user.id)
.where(PT.team == team_id)
.group_by(PT.user)
.subquery()
)
statement2 = select(PT).join(
subquery, (PT.user == subquery.c.user) & (PT.time == subquery.c.latest)
)
playertype = session.exec(statement2).one_or_none()
if playertype is not None:
return playertype
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
if response.status_code == 404:
response = await super().get_response(".", scope)
return response
api_router.include_router(
player_router, dependencies=[Depends(get_current_active_user)]
)
api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)])
api_router.include_router(analysis_router)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"])
api_router.add_api_route("/register", endpoint=register, methods=["POST"])
api_router.add_api_route("/logout", endpoint=logout, methods=["POST"])
app.include_router(api_router)
# app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")
@app.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")

253
cutt/player.py Normal file
View File

@ -0,0 +1,253 @@
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
gender: str | None
number: str
email: str | None
class AddPlayerRequest(PlayerRequest): ...
DEMO_TEAM_REQUEST = HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="DEMO Team, nothing happens",
)
def add_player(
r: AddPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
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,
gender=r.gender if r.gender else None,
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:
print(r)
player.display_name = r.display_name.strip()
player.number = r.number.strip()
player.gender = r.gender.strip() if r.gender else None
player.email = r.email.strip() if r.email else None
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", "gender", "username", "number", "email"}
)
] + demo_players
with Session(engine) as session:
current_user = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False, P.id == user.id)
).one_or_none()
if not current_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="you're not in this team",
)
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False)
.order_by(P.display_name)
).all()
if players:
return [
player.model_dump(
include={
"id",
"display_name",
"username",
"gender",
"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"]
)

401
cutt/security.py Normal file
View File

@ -0,0 +1,401 @@
from datetime import timedelta, timezone, datetime
from typing import Annotated
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, ValidationError
import jwt
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select
from cutt.db import PlayerTeamLink, Team, TokenDB, engine, Player
from fastapi.security import (
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
SecurityScopes,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError
P = Player
class Config(BaseSettings):
secret_key: str = ""
access_token_expire_minutes: int = 15
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
config = Config()
class Token(BaseModel):
access_token: str
class TokenData(BaseModel):
username: str | None = None
scopes: list[str] = []
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
class CookieOAuth2(OAuth2PasswordBearer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
async def __call__(self, request: Request):
cookie_token = request.cookies.get("access_token")
if cookie_token:
return cookie_token
else:
header_token = await super().__call__(request)
if header_token:
return header_token
else:
raise HTTPException(status_code=401)
oauth2_scheme = CookieOAuth2(
tokenUrl="api/token",
scopes={
"analysis": "Access the results.",
"admin": "Maintain DB etc.",
},
)
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def get_user(username: str | None):
if username:
try:
with Session(engine) as session:
return session.exec(
select(Player).where(Player.username == username)
).one_or_none()
except OperationalError:
return
def authenticate_user(username: str, password: str):
user = get_user(username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=config.access_token_expire_minutes
)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256")
return encoded_jwt
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
security_scopes: SecurityScopes,
):
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
authenticate_value = "Bearer"
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": authenticate_value},
)
# access_token = request.cookies.get("access_token")
access_token = token
if not access_token:
raise credentials_exception
try:
payload = jwt.decode(access_token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_scopes = payload.get("scopes", [])
token_data = TokenData(username=username, scopes=token_scopes)
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token expired",
headers={"WWW-Authenticate": authenticate_value},
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
allowed_scopes = set(user.scopes.split())
for scope in security_scopes.scopes:
if scope not in allowed_scopes or scope not in token_data.scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return user
async def get_current_active_user(
current_user: Annotated[Player, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive 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(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response
) -> Token:
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
allowed_scopes = set(user.scopes.split())
requested_scopes = set(form_data.scopes)
access_token = create_access_token(
data={"sub": user.username, "scopes": list(allowed_scopes)}
)
response.set_cookie(
"access_token",
value=access_token,
httponly=True,
samesite="strict",
max_age=config.access_token_expire_minutes * 60,
)
return Token(access_token=access_token)
async def logout(response: Response):
response.set_cookie("access_token", "", expires=0, httponly=True, samesite="strict")
return {"message": "Successfully logged out"}
def set_password_token(username: str):
user = get_user(username)
if user:
expire = timedelta(days=30)
token = create_access_token(
data={
"sub": "set password",
"username": username,
"name": user.display_name,
},
expires_delta=expire,
)
return token
def register_token(team_id: int):
with Session(engine) as session:
team = session.exec(select(Team).where(Team.id == team_id)).one()
if team:
expire = timedelta(days=30)
token = create_access_token(
data={"sub": "register", "team_id": team_id, "name": team.name},
expires_delta=expire,
)
return token
def verify_one_time_token(token: str):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="could not validate token",
)
with Session(engine) as session:
token_in_db = session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == False)
).one_or_none()
if token_in_db:
try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
return payload
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="access token expired",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
elif session.exec(
select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="token already used",
)
else:
raise credentials_exception
def invalidate_one_time_token(token: str):
with Session(engine) as session:
token_in_db = session.exec(select(TokenDB).where(TokenDB.token == token)).one()
token_in_db.used = True
session.add(token_in_db)
session.commit()
class FirstPassword(BaseModel):
token: str
password: str
async def set_first_password(req: FirstPassword):
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "set password":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
username: str = payload.get("username")
with Session(engine) as session:
user = get_user(username)
if user:
user.hashed_password = get_password_hash(req.password)
session.add(user)
session.commit()
invalidate_one_time_token(req.token)
return Response("password set successfully", status_code=status.HTTP_200_OK)
class ChangedPassword(BaseModel):
current_password: str
new_password: str
async def change_password(
request: ChangedPassword,
user: Annotated[Player, Depends(get_current_active_user)],
):
if (
request.new_password
and user.hashed_password
and verify_password(request.current_password, user.hashed_password)
):
with Session(engine) as session:
user.hashed_password = get_password_hash(request.new_password)
session.add(user)
session.commit()
return PlainTextResponse(
"password changed successfully",
status_code=status.HTTP_200_OK,
media_type="text/plain",
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="wrong password",
)
class RegisterRequest(BaseModel):
token: str
team_id: int
display_name: str
username: str
password: str
email: str | None
number: str | None
async def register(req: RegisterRequest):
payload = verify_one_time_token(req.token)
action: str = payload.get("sub")
if action != "register":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong type of token.",
)
team_id: int = payload.get("team_id")
if team_id != req.team_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="wrong team",
)
with Session(engine) as session:
if session.exec(select(P).where(P.username == req.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="username exists",
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.display_name == req.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == team_id)).one()
new_player = Player(
username=req.username,
hashed_password=get_password_hash(req.password),
display_name=req.display_name,
email=req.email if req.email else None,
number=req.number,
disabled=False,
teams=[team],
)
session.add(new_player)
session.commit()
# invalidate_one_time_token(req.token)
return PlainTextResponse(f"added {new_player.display_name}")
async def read_player_me(
current_user: Annotated[Player, Depends(get_current_active_user)],
):
return current_user.model_dump(exclude={"hashed_password", "disabled"})
async def read_own_items(
current_user: Annotated[Player, Depends(get_current_active_user)],
):
return [{"item_id": "Foo", "owner": current_user.username}]

74
db.py
View File

@ -1,74 +0,0 @@
from datetime import datetime, timezone
from sqlmodel import (
ARRAY,
Column,
Relationship,
SQLModel,
Field,
create_engine,
String,
)
with open("db.secrets", "r") as f:
db_secrets = f.readline().strip()
engine = create_engine(db_secrets, connect_args={"connect_timeout": 8})
del db_secrets
def utctime():
return datetime.now(tz=timezone.utc)
class PlayerTeamLink(SQLModel, table=True):
team_id: int | None = Field(default=None, foreign_key="team.id", primary_key=True)
player_id: int | None = Field(
default=None, foreign_key="player.id", primary_key=True
)
class Team(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
location: str | None
country: str | None
players: list["Player"] | None = Relationship(
back_populates="teams", link_model=PlayerTeamLink
)
class Player(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
number: str | None = None
teams: list[Team] | None = Relationship(
back_populates="players", link_model=PlayerTeamLink
)
class Chemistry(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: str
love: list[str] = Field(sa_column=Column(ARRAY(String)))
hate: list[str] = Field(sa_column=Column(ARRAY(String)))
undecided: list[str] = Field(sa_column=Column(ARRAY(String)))
class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: str
mvps: list[str] = Field(sa_column=Column(ARRAY(String)))
class User(SQLModel, table=True):
username: str = Field(default=None, primary_key=True)
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
hashed_password: str | None = None
player_id: int | None = Field(default=None, foreign_key="player.id")
SQLModel.metadata.create_all(engine)

107
main.py
View File

@ -1,107 +0,0 @@
from fastapi import APIRouter, Depends, FastAPI, status
from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
Session,
select,
)
from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router
from security import (
get_current_active_user,
login_for_access_token,
read_users_me,
read_own_items,
)
app = FastAPI(title="cutt")
api_router = APIRouter(prefix="/api")
origins = [
"*",
"http://localhost",
"http://localhost:3000",
"http://localhost:8000",
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def add_team(team: Team):
with Session(engine) as session:
session.add(team)
session.commit()
def add_player(player: Player):
with Session(engine) as session:
session.add(player)
session.commit()
def add_players(players: list[Player]):
with Session(engine) as session:
for player in players:
session.add(player)
session.commit()
def list_players():
with Session(engine) as session:
statement = select(Player).order_by(Player.name)
return session.exec(statement).fetchall()
def list_teams():
with Session(engine) as session:
statement = select(Team)
return session.exec(statement).fetchall()
player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
team_router = APIRouter(prefix="/team")
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
@app.post("/mvps/", status_code=status.HTTP_200_OK)
def submit_mvps(mvps: MVPRanking):
with Session(engine) as session:
session.add(mvps)
session.commit()
@app.post("/chemistry/", status_code=status.HTTP_200_OK)
def submit_chemistry(chemistry: Chemistry):
with Session(engine) as session:
session.add(chemistry)
session.commit()
class SPAStaticFiles(StaticFiles):
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
if response.status_code == 404:
response = await super().get_response(".", scope)
return response
api_router.include_router(player_router)
api_router.include_router(team_router)
api_router.include_router(
analysis_router, dependencies=[Depends(get_current_active_user)]
)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"])
api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"])
app.include_router(api_router)
app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site")

View File

@ -10,16 +10,18 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"react": "^18.3.1", "jwt-decode": "^4.0.0",
"react-dom": "^18.3.1", "react": "18.3.1",
"react-dom": "18.3.1",
"react-sortablejs": "^6.1.4", "react-sortablejs": "^6.1.4",
"reagraph": "^4.21.2", "reagraph": "^4.21.2",
"sortablejs": "^1.15.6" "sortablejs": "^1.15.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/react": "^18.3.18", "@types/node": "^22.13.10",
"@types/react-dom": "^18.3.5", "@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0", "eslint": "^9.17.0",

View File

@ -1,41 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="128" height="128" viewBox="0 0 2560 2560"><path d="m1569.914 2282.76-484.616-232.952c-47.736-22.913-68.358-80.96-45.063-129.078l232.952-484.617c22.913-47.736 80.96-68.358 129.078-45.062 65.685 31.696 103.492 49.645 103.492 49.645l-.382-417.022 63.776-.382.381 447.191s219.204 92.417 317.35 153.138c14.13 8.783 38.952 25.968 49.263 54.992 8.02 23.295 7.638 50.027-3.818 73.704l-232.952 484.617c-23.678 48.5-81.725 69.121-129.46 45.826z" style="fill:#fff;stroke-width:3.81889"/><path d="M2436.037 1005.725c-15.657-15.657-36.66-15.276-36.66-15.276s-447.574 25.205-679.38 30.552c-50.792 1.145-101.201 2.29-151.228 2.673v447.573c-21.004-9.929-42.39-20.24-63.394-30.17 0-139.007-.382-417.021-.382-417.021-110.747 1.527-340.644-8.402-340.644-8.402s-539.99-27.114-598.802-32.46c-37.425-2.292-85.924-8.02-148.936 5.728-33.224 6.874-127.933 28.26-205.456 102.728-171.85 153.137-127.933 396.782-122.586 433.443 6.492 44.681 26.35 168.795 121.058 276.87 174.905 214.239 551.447 209.275 551.447 209.275s46.209 110.365 116.858 211.948c95.472 126.405 193.618 224.932 289.09 236.77 240.59 0 721.387-.381 721.387-.381s45.827.382 108.075-39.335c53.464-32.46 101.2-89.362 101.2-89.362s49.264-52.7 118.004-172.995c21.004-37.043 38.57-72.941 53.846-106.93 0 0 210.803-447.19 210.803-882.543-4.201-131.752-36.662-155.047-44.3-162.685M537.67 1785.159c-98.91-32.46-140.917-71.413-140.917-71.413s-72.94-51.173-109.602-151.991c-63.012-168.795-5.347-271.905-5.347-271.905s32.079-85.925 147.027-114.567c52.701-14.13 118.386-11.838 118.386-11.838s27.114 226.842 59.956 359.739c27.496 111.511 94.709 296.727 94.709 296.727s-99.673-11.838-164.212-34.752m1146.81 410.912s-23.294 55.374-74.85 58.811c-22.149 1.528-39.334-4.582-39.334-4.582s-1.145-.382-20.24-8.02l-431.152-210.039s-41.626-21.767-48.882-59.574c-8.401-30.933 10.311-69.122 10.311-69.122l207.366-427.333s18.33-37.044 46.59-49.646c2.291-1.146 8.784-3.819 17.185-5.728 30.933-8.02 68.74 10.693 68.74 10.693l422.75 205.074s48.119 21.767 58.43 61.866c7.255 28.26-1.91 53.464-6.874 65.685-24.06 58.81-210.04 431.916-210.04 431.916z" style="fill:#609926;stroke-width:3.81889"/><path d="M1306.029 1885.214c-31.314.382-58.81 22.15-66.066 52.7-7.256 30.552 7.637 62.249 34.751 76.379 29.406 15.275 66.83 6.874 86.69-20.622 19.476-27.114 16.42-64.54-6.875-88.217l91.653-187.507c5.729.382 14.13.764 23.677-1.91 15.658-3.436 27.115-13.747 27.115-13.747 16.039 6.874 32.842 14.511 50.409 23.295 18.33 9.165 35.516 18.712 51.173 27.878 3.437 1.91 6.874 4.2 10.693 7.256 6.11 4.964 12.984 11.838 17.949 21.003 7.255 21.004-7.256 56.902-7.256 56.902-8.784 29.023-70.268 155.047-70.268 155.047-30.933-.764-58.429 19.094-67.594 47.736-9.93 30.933 4.2 66.066 33.988 81.342 29.787 15.275 66.449 6.492 85.925-20.24 19.094-25.969 17.567-62.248-4.2-86.307 7.255-14.13 14.129-28.26 21.385-43.153 19.094-39.717 51.555-116.094 51.555-116.094 3.437-6.493 21.768-39.335 10.31-81.343-9.546-43.535-48.117-63.775-48.117-63.775-46.59-30.17-111.512-58.047-111.512-58.047s0-15.658-4.2-27.114c-4.201-11.839-10.693-19.477-14.894-24.06 17.949-37.042 35.897-73.704 53.846-110.747a2648 2648 0 0 1-46.59-23.295c-18.33 37.425-37.043 75.232-55.374 112.657-25.587-.382-49.264 13.366-61.484 35.898-12.984 24.058-10.311 53.846 7.256 75.613z" style="fill:#609926;stroke-width:3.81889"/></svg>
<svg
xml:space="preserve"
width="128"
height="128"
viewBox="0 0 2560 2560"
version="1.1"
id="svg3"
sodipodi:docname="gitea.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs3" /><sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.4221483"
inkscape:cx="89.58989"
inkscape:cy="-60.483497"
inkscape:window-width="1408"
inkscape:window-height="1727"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg3" /><path
d="m 1569.914,2282.76 -484.616,-232.952 c -47.736,-22.913 -68.358,-80.96 -45.063,-129.078 l 232.952,-484.617 c 22.913,-47.736 80.96,-68.358 129.078,-45.062 65.685,31.696 103.492,49.645 103.492,49.645 l -0.382,-417.022 63.776,-0.382 0.381,447.191 c 0,0 219.204,92.417 317.35,153.138 14.13,8.783 38.952,25.968 49.263,54.992 8.02,23.295 7.638,50.027 -3.818,73.704 l -232.952,484.617 c -23.678,48.5 -81.725,69.121 -129.46,45.826 z"
style="fill:#ffffff;stroke-width:3.81889"
id="path1" /><path
d="m 2436.037,1005.725 c -15.657,-15.657 -36.66,-15.276 -36.66,-15.276 0,0 -447.574,25.205 -679.38,30.552 -50.792,1.145 -101.201,2.29 -151.228,2.673 v 447.573 c -21.004,-9.929 -42.39,-20.24 -63.394,-30.17 0,-139.007 -0.382,-417.021 -0.382,-417.021 -110.747,1.527 -340.644,-8.402 -340.644,-8.402 0,0 -539.99,-27.114 -598.802,-32.46 -37.425,-2.292 -85.924,-8.02 -148.936,5.728 -33.224,6.874 -127.933,28.26 -205.456,102.728 -171.85,153.137 -127.933,396.782 -122.586,433.443 6.492,44.681 26.35,168.795 121.058,276.87 174.905,214.239 551.447,209.275 551.447,209.275 0,0 46.209,110.365 116.858,211.948 95.472,126.405 193.618,224.932 289.09,236.77 240.59,0 721.387,-0.381 721.387,-0.381 0,0 45.827,0.382 108.075,-39.335 53.464,-32.46 101.2,-89.362 101.2,-89.362 0,0 49.264,-52.7 118.004,-172.995 21.004,-37.043 38.57,-72.941 53.846,-106.93 0,0 210.803,-447.19 210.803,-882.543 -4.201,-131.752 -36.662,-155.047 -44.3,-162.685 z M 537.67,1785.159 c -98.91,-32.46 -140.917,-71.413 -140.917,-71.413 0,0 -72.94,-51.173 -109.602,-151.991 -63.012,-168.795 -5.347,-271.905 -5.347,-271.905 0,0 32.079,-85.925 147.027,-114.567 52.701,-14.13 118.386,-11.838 118.386,-11.838 0,0 27.114,226.842 59.956,359.739 27.496,111.511 94.709,296.727 94.709,296.727 0,0 -99.673,-11.838 -164.212,-34.752 z m 1146.81,410.912 c 0,0 -23.294,55.374 -74.85,58.811 -22.149,1.528 -39.334,-4.582 -39.334,-4.582 0,0 -1.145,-0.382 -20.24,-8.02 l -431.152,-210.039 c 0,0 -41.626,-21.767 -48.882,-59.574 -8.401,-30.933 10.311,-69.122 10.311,-69.122 l 207.366,-427.333 c 0,0 18.33,-37.044 46.59,-49.646 2.291,-1.146 8.784,-3.819 17.185,-5.728 30.933,-8.02 68.74,10.693 68.74,10.693 l 422.75,205.074 c 0,0 48.119,21.767 58.43,61.866 7.255,28.26 -1.91,53.464 -6.874,65.685 -24.06,58.81 -210.04,431.916 -210.04,431.916 z"
style="fill:#609926;stroke-width:3.81889"
id="path2" /><path
d="m 1306.029,1885.214 c -31.314,0.382 -58.81,22.15 -66.066,52.7 -7.256,30.552 7.637,62.249 34.751,76.379 29.406,15.275 66.83,6.874 86.69,-20.622 19.476,-27.114 16.42,-64.54 -6.875,-88.217 l 91.653,-187.507 c 5.729,0.382 14.13,0.764 23.677,-1.91 15.658,-3.436 27.115,-13.747 27.115,-13.747 16.039,6.874 32.842,14.511 50.409,23.295 18.33,9.165 35.516,18.712 51.173,27.878 3.437,1.91 6.874,4.2 10.693,7.256 6.11,4.964 12.984,11.838 17.949,21.003 7.255,21.004 -7.256,56.902 -7.256,56.902 -8.784,29.023 -70.268,155.047 -70.268,155.047 -30.933,-0.764 -58.429,19.094 -67.594,47.736 -9.93,30.933 4.2,66.066 33.988,81.342 29.787,15.275 66.449,6.492 85.925,-20.24 19.094,-25.969 17.567,-62.248 -4.2,-86.307 7.255,-14.13 14.129,-28.26 21.385,-43.153 19.094,-39.717 51.555,-116.094 51.555,-116.094 3.437,-6.493 21.768,-39.335 10.31,-81.343 -9.546,-43.535 -48.117,-63.775 -48.117,-63.775 -46.59,-30.17 -111.512,-58.047 -111.512,-58.047 0,0 0,-15.658 -4.2,-27.114 -4.201,-11.839 -10.693,-19.477 -14.894,-24.06 17.949,-37.042 35.897,-73.704 53.846,-110.747 a 2647.928,2647.928 0 0 1 -46.59,-23.295 c -18.33,37.425 -37.043,75.232 -55.374,112.657 -25.587,-0.382 -49.264,13.366 -61.484,35.898 -12.984,24.058 -10.311,53.846 7.256,75.613 z"
style="fill:#609926;stroke-width:3.81889"
id="path3" /></svg>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,49 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 12.7 12.7"><ellipse cx="6.35" cy="6.35" rx="4.509" ry="4.592" style="fill:#c7d6f1;stroke:#36c;stroke-width:1.94357;fill-opacity:1"/></svg>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="logo.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="14.329304"
inkscape:cx="17.167617"
inkscape:cy="25.088448"
inkscape:window-width="1408"
inkscape:window-height="1727"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<ellipse
style="fill:#c7d6f1;stroke:#3366cc;stroke-width:1.94357;fill-opacity:1"
id="path2"
cx="6.3500028"
cy="6.3500109"
rx="4.5089426"
ry="4.5918198" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 214 B

View File

@ -1,137 +0,0 @@
from datetime import timedelta, timezone, datetime
from typing import Annotated
from fastapi import Depends, HTTPException, Response, status
from pydantic import BaseModel
import jwt
from jwt.exceptions import InvalidTokenError
from sqlmodel import Session, select
from db import engine, User
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError
class Config(BaseSettings):
secret_key: str = ""
access_token_expire_minutes: int = 30
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
config = Config()
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def get_user(username: str | None):
if username:
try:
with Session(engine) as session:
return session.exec(
select(User).where(User.username == username)
).one_or_none()
except OperationalError:
return
def authenticate_user(username: str, password: str):
user = get_user(username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: timedelta | None = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256")
return encoded_jwt
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
raise credentials_exception
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
return current_user
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response
) -> Token:
user = authenticate_user(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=config.access_token_expire_minutes)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
response.set_cookie(
"Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none"
)
return Token(access_token=access_token, token_type="bearer")
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)],
):
return current_user
async def read_own_items(
current_user: Annotated[User, Depends(get_current_active_user)],
):
return [{"item_id": "Foo", "owner": current_user.username}]

View File

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

View File

@ -1,7 +1,3 @@
* {
border-radius: 16px;
}
body { body {
background-color: aliceblue; background-color: aliceblue;
position: relative; position: relative;
@ -23,9 +19,122 @@ footer {
font-size: x-small; font-size: x-small;
} }
.fixed-footer {
position: absolute;
bottom: 4px;
left: 8px;
}
dialog {
border-radius: 1em;
}
/*=========Network Controls=========*/
.infobutton {
position: fixed;
right: 8px;
bottom: 8px;
padding: 0.4em;
border-radius: 1em;
background-color: rgba(0, 0, 0, 0.3);
font-size: medium;
margin-bottom: 16px;
margin-right: 16px;
}
.controls {
z-index: 9;
position: absolute;
color: black;
top: 1vh;
right: 0px;
padding: 8px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.control {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 240px;
margin: 0px;
background-color: #f0f8ffdd;
.slider,
span {
padding-left: 4px;
padding-right: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
margin: auto;
justify-content: center;
align-items: center;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 48px;
height: 24px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 34px;
-webkit-transition: 0.4s;
transition: 0.4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
-webkit-transition: 0.4s;
transition: 0.4s;
}
input:checked + .slider {
background-color: #2196f3;
}
input:focus + .slider {
box-shadow: 0 0 1px #2196f3;
}
input:checked + .slider:before {
-webkit-transform: translateX(24px);
-ms-transform: translateX(24px);
transform: translateX(24px);
}
.grey { .grey {
color: #444; opacity: 66%;
} }
.hint { .hint {
@ -39,9 +148,14 @@ footer {
z-index: -1; z-index: -1;
} }
input { input,
select {
padding: 0.2em 16px; padding: 0.2em 16px;
margin-bottom: 0.5em; margin-top: 0.25em;
margin-bottom: 0.25em;
border-radius: 1em;
color: black;
background-color: white;
} }
h1, h1,
@ -66,7 +180,6 @@ h3 {
flex-direction: column; flex-direction: column;
} }
.container { .container {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -83,7 +196,13 @@ h3 {
.box { .box {
position: relative; position: relative;
flex: 1; flex: 1;
border-width: 3px;
border-style: solid;
border-radius: 16px;
h4 {
margin: 4px;
}
&.one { &.one {
max-width: min(96%, 768px); max-width: min(96%, 768px);
margin: 4px auto; margin: 4px auto;
@ -91,35 +210,23 @@ h3 {
padding: 4px; padding: 4px;
margin: 4px 0.5%; margin: 4px 0.5%;
border-style: solid;
border-color: black;
} }
.reservoir { .reservoir {
display: flex;
flex-direction: unset; flex-direction: unset;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-around; justify-content: space-around;
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 {
@ -127,14 +234,17 @@ h3 {
margin: auto; margin: auto;
} }
button, button {
.button { margin: 4px;
font-weight: bold; font-weight: bold;
font-size: large;
color: aliceblue; color: aliceblue;
background-color: black; background-color: black;
border-radius: 1.2em; border-radius: 1.2em;
z-index: 1; z-index: 1;
&:hover {
opacity: 80%;
}
} }
#control-panel { #control-panel {
@ -152,6 +262,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;
@ -168,6 +279,11 @@ button,
#control-panel { #control-panel {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.control {
font-size: 80%;
margin: 0px;
}
} }
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
@ -175,6 +291,10 @@ button,
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.networkroute {
display: none;
}
.submit_text { .submit_text {
display: none; display: none;
} }
@ -185,27 +305,43 @@ 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;
}
} }
::backdrop { ::backdrop {
background-image: linear-gradient(45deg, background-image: linear-gradient(
magenta, 45deg,
rebeccapurple, magenta,
dodgerblue, rebeccapurple,
green); dodgerblue,
green
);
opacity: 0.75; opacity: 0.75;
} }
.tablink { .tab-button {
color: white; color: black;
cursor: pointer;
flex: 1; flex: 1;
background-color: #bfbfbf;
border: none;
margin: 4px auto; margin: 4px auto;
cursor: pointer;
opacity: 80%;
}
.tab-button.active {
opacity: unset;
font-weight: bold;
background-color: black;
color: white;
} }
.navbar { .navbar {
@ -221,7 +357,7 @@ button,
opacity: 50%; opacity: 50%;
&:hover { &:hover {
opacity: 75%; opacity: 80%;
} }
} }
} }
@ -241,11 +377,17 @@ button,
font-size: 150%; font-size: 150%;
} }
/*======LOGO=======*/
.logo { .logo {
position: relative; position: relative;
text-align: center; text-align: center;
height: 140px; height: 140px;
margin-bottom: 20px;
span {
display: block;
margin: 2px;
}
img { img {
display: block; display: block;
@ -268,11 +410,216 @@ button,
} }
} }
.avatars {
margin: 16px auto;
}
.avatar {
background-color: #f0f8ff88;
font-weight: bold;
font-size: 110%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 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 {
display: grid;
grid-template-columns: 8em 12em;
gap: 2px 16px;
div {
text-align: left;
}
}
/*=======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 {
z-index: 3;
position: absolute;
top: 24px;
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;
}
select {
max-width: 335px;
}
}
.team-player {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.5em;
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,
select {
width: 90%;
margin: 4px 0;
}
}
}
.mmp {
background-color: lightskyblue;
}
.fmp {
background-color: salmon;
}
@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=======*/
.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;
} }
@ -300,3 +647,88 @@ button,
transform: translateX(0%); transform: translateX(0%);
} }
} }
.calendar-container {
position: relative;
margin: 20px auto;
font-size: small;
}
.month-navigation {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-top: 2px solid grey;
border-bottom: 2px solid grey;
}
.month-navigation button {
cursor: pointer;
padding: 4px 8px;
border: none;
color: black;
background-color: transparent;
}
.month-navigation span {
font-weight: bold;
}
.calendar {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px;
}
.day {
padding: 2px;
border: 1px solid grey;
cursor: pointer;
display: flex;
}
.selected-day {
border: 4px solid grey;
}
.weekday {
border-bottom: 3px solid black;
margin: 0 1em;
}
.day-circle {
text-align: center;
border-radius: 1.5em;
width: 1.5em;
height: 1.5em;
padding: 0;
margin: auto;
border: 2px solid transparent;
}
.today {
border-radius: 1.6em;
border: 4px solid red;
text-align: center;
}
.has-event {
border-radius: 1.5em;
background-color: lightskyblue;
}
.active-player {
border-radius: 1.5em;
border: 4px solid rebeccapurple;
}
.events {
font-size: large;
padding: 20px;
ul > li {
padding: 0;
margin: 0;
list-style-type: none;
}
}

View File

@ -1,4 +1,3 @@
import Analysis from "./Analysis";
import "./App.css"; import "./App.css";
import Footer from "./Footer"; import Footer from "./Footer";
import Header from "./Header"; import Header from "./Header";
@ -6,26 +5,46 @@ import Rankings from "./Rankings";
import { BrowserRouter, Routes, Route } from "react-router"; import { BrowserRouter, Routes, Route } from "react-router";
import { SessionProvider } from "./Session"; import { SessionProvider } from "./Session";
import { GraphComponent } from "./Network"; import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword";
import { ThemeProvider } from "./ThemeProvider";
import TeamPanel from "./TeamPanel";
const Maintenance = () => {
return (
<div style={{ textAlign: "center", padding: "20px" }}>
<h2>We are under maintenance.</h2>
<p>Please check back later. Thank you for your patience.</p>
<span style={{ fontSize: "xx-large" }}>🚧</span>
</div>
);
};
function App() { function App() {
return ( return (
<BrowserRouter> <ThemeProvider>
<Header /> <BrowserRouter>
<Routes> <Routes>
<Route index element={<Rankings />} /> <Route path="/password" element={<SetPassword />} />
<Route path="/network" element={ <Route
<SessionProvider> path="/*"
<GraphComponent /> element={
</SessionProvider> <SessionProvider>
} /> <Header />
<Route path="/analysis" element={ <Routes>
<SessionProvider> <Route index element={<Rankings />} />
<Analysis /> <Route path="network" element={<GraphComponent />} />
</SessionProvider> <Route path="mvp" element={<MVPChart />} />
} /> <Route path="changepassword" element={<SetPassword />} />
</Routes> <Route path="team" element={<TeamPanel />} />
<Footer /> </Routes>
</BrowserRouter> <Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter>
</ThemeProvider>
); );
} }
export default App; export default App;

221
src/Avatar.tsx Normal file
View File

@ -0,0 +1,221 @@
import { createRef, MouseEventHandler, useEffect, useState } from "react";
import { TeamState, useSession } from "./Session";
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 {
label: string;
onClick: () => void;
}
const UserInfo = (user: User, teams: TeamState | undefined) => {
return (
<div className="user-info">
<div>
<b>username: </b>
</div>
<div>{user?.username}</div>
<div>
<b>display name: </b>
</div>
<div>{user?.display_name}</div>
<div>
<b>gender: </b>
</div>
<div>{user?.gender?.toUpperCase() || "-"}</div>
<div>
<b>number: </b>
</div>
<div>{user?.number || "-"}</div>
<div>
<b>email: </b>
</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>
);
};
export default function Avatar() {
const { user, teams, setTeams, onLogout } = useSession();
const { theme, setTheme } = useTheme();
const navigate = useNavigate();
const [contextMenu, setContextMenu] = useState<{
open: boolean;
allowOpen: boolean;
mouseX: number;
mouseY: number;
}>({ open: false, allowOpen: true, mouseX: 0, mouseY: 0 });
const contextMenuRef = createRef<HTMLUListElement>();
const avatarRef = createRef<HTMLDivElement>();
const contextMenuItems: ContextMenuItem[] = [
{ label: "view Profile", onClick: handleViewProfile },
{ 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) => {
if (!contextMenu.allowOpen) return;
event.preventDefault();
setContextMenu({
open: !contextMenu.open,
allowOpen: contextMenu.allowOpen,
mouseX: event.clientX + 4,
mouseY: event.clientY + 2,
});
};
useEffect(() => {
if (contextMenu.open) {
document.addEventListener("click", handleCloseContextMenuOutside);
}
return () => {
document.removeEventListener("click", handleCloseContextMenuOutside);
};
}, [contextMenu.open]);
const handleMenuClose = () => {
setContextMenu({ ...contextMenu, open: false });
setContextMenu((prevContextMenu) => ({
...prevContextMenu,
allowOpen: false,
}));
setTimeout(() => {
setContextMenu((prevContextMenu) => ({
...prevContextMenu,
allowOpen: true,
}));
}, 100);
};
const handleCloseContextMenuOutside: (event: MouseEvent) => void = (ev) => {
if (
!(
contextMenuRef.current?.contains(ev.target as Node) ||
avatarRef.current?.contains(ev.target as Node)
)
)
handleMenuClose();
};
const [dialog, setDialog] = useState(<></>);
const dialogRef = createRef<HTMLDialogElement>();
function handleViewProfile() {
if (user && teams) {
dialogRef.current?.showModal();
setDialog(UserInfo(user, teams));
}
}
return (
<>
<div className="avatars" style={{ display: user ? "block" : "none" }}>
<div
className="avatar"
onContextMenu={handleMenuClick}
onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();
} else {
handleMenuClick(event);
}
}}
ref={avatarRef}
>
👤 {user?.username}
</div>
{teams && teams?.teams.length > 1 && (
<select
className="group-avatar"
value={teams.activeTeam}
onChange={(e) =>
setTeams({ ...teams, activeTeam: Number(e.target.value) })
}
>
{teams.teams.map((team) => (
<option key={team.id} value={team.id}>
👥 {team.name}
</option>
))}
</select>
)}
</div>
{contextMenu.open && (
<ul
className="context-menu"
ref={contextMenuRef}
style={{
top: contextMenu.mouseY,
left: contextMenu.mouseX,
}}
>
{contextMenuItems.map((item, index) => (
<li
key={index}
onClick={() => {
item.onClick();
handleMenuClose();
}}
>
{item.label}
</li>
))}
</ul>
)}
<dialog
id="AvatarDialog"
ref={dialogRef}
onClick={(event) => {
event.stopPropagation();
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}

174
src/Calendar.tsx Normal file
View File

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

View File

@ -1,21 +1,42 @@
import { useLocation } from "react-router";
import { Link } from "react-router"; import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() { export default function Footer() {
return <footer> const location = useLocation();
<div className="navbar"> const { user, teams } = useSession();
<Link to="/" ><span>Form</span></Link> return (
<span>|</span> <footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
<Link to="/network" ><span>Trainer Analysis</span></Link> {(user?.scopes.split(" ").includes("analysis") ||
</div> teams?.activeTeam === 42) && (
<p className="grey extra-margin"> <div className="navbar">
something not working? <Link to="/">
<br /> <span>Form</span>
message <a href="https://t.me/x0124816">me</a>. </Link>
<br /> <span>|</span>
or fix it here:{" "} <Link to="/network">
<a href="https://git.0124816.xyz/julius/cutt" key="gitea"> <span>Sociogram</span>
<img src="gitea.svg" alt="gitea" height="16" /> </Link>
</a> <span>|</span>
</p> <Link to="/mvp">
</footer> <span>MVP</span>
</Link>
<span>|</span>
<Link to="/team">
<span>Team</span>
</Link>
</div>
)}
<p className="grey extra-margin">
something not working?
<br />
message <a href="https://t.me/x0124816">me</a>.
<br />
or fix it here:{" "}
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
<img src="gitea.svg" alt="gitea" height="16" />
</a>
</p>
</footer>
);
} }

View File

@ -1,11 +1,18 @@
import { baseUrl } from "./api"; import { Link, useLocation } from "react-router";
import Avatar from "./Avatar";
export default function Header() { export default function Header() {
return <div className="logo"> const location = useLocation();
<a href={"/"}> return (
<img alt="logo" height="66%" src="logo.svg" /> <div className={location.pathname === "/network" ? "networkroute" : ""}>
<h3 className="centered">cutt</h3> <div className="logo">
</a> <Link to="/">
<span className="grey">cool ultimate team tool</span> <img alt="logo" height="66%" src="logo.svg" />
</div> <h3 className="centered">cutt</h3>
</Link>
<span className="grey">cool ultimate team tool</span>
</div>
<Avatar />
</div>
);
} }

72
src/Icons.tsx Normal file
View File

@ -0,0 +1,72 @@
export const Eye = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
>
<path
fill="#E1E8ED"
d="M35.059 18c0 3.304-7.642 11-17.067 11S.925 22.249.925 18c0-3.314 34.134-3.314 34.134 0"
/>
<path
fill="#292F33"
d="M35.059 18H.925c0-3.313 7.642-11 17.067-11s17.067 7.686 17.067 11"
/>
<path
fill="#F5F8FA"
d="M33.817 18c0 2.904-7.087 9.667-15.826 9.667S2.166 21.732 2.166 18c0-2.912 7.085-9.666 15.825-9.666C26.73 8.333 33.817 15.088 33.817 18"
/>
<circle
cx="18"
cy="18"
r="8.458"
fill="#8B5E3C"
style={{ fill: "#919191", fillOpacity: 1 }}
/>
<circle cx="18" cy="18" r="4.708" fill="#292F33" />
<circle cx="14.983" cy="15" r="2" fill="#F5F8FA" />
</svg>
);
export const EyeSlash = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 36 36"
>
<path
fill="#e1e8ed"
d="M35.059 18c0 3.304-7.642 11-17.067 11S.925 22.249.925 18c0-3.314 34.134-3.314 34.134 0"
/>
<path
fill="#292f33"
d="M35.059 18H.925c0-3.313 7.642-11 17.067-11s17.067 7.686 17.067 11"
/>
<path
fill="#f5f8fa"
d="M33.817 18c0 2.904-7.087 9.667-15.826 9.667S2.166 21.732 2.166 18c0-2.912 7.085-9.666 15.825-9.666C26.73 8.333 33.817 15.088 33.817 18"
/>
<circle
cx="18"
cy="18"
r="8.458"
fill="#8B5E3C"
style={{ fill: "#919191", fillOpacity: 1 }}
/>
<circle cx="18" cy="18" r="4.708" fill="#292f33" />
<circle cx="14.983" cy="15" r="2" fill="#f5f8fa" />
<path
d="m-2.97 30.25 41.94-24.5"
style={{
fill: "#404040",
fillOpacity: 1,
stroke: "#404040",
strokeWidth: 3,
strokeDasharray: "none",
strokeOpacity: 1,
}}
/>
</svg>
);

View File

@ -1,6 +1,8 @@
import { FormEvent, useContext, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router"; import { currentUser, login, User } from "./api";
import { currentUser, login, LoginRequest, User } from "./api"; import Header from "./Header";
import { useLocation, useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
export interface LoginProps { export interface LoginProps {
onLogin: (user: User) => void; onLogin: (user: User) => void;
@ -9,78 +11,115 @@ export interface LoginProps {
export const Login = ({ onLogin }: LoginProps) => { export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<unknown>(null); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const navigate = useNavigate();
const location = useLocation();
async function doLogin() { async function doLogin() {
setLoading(true); setLoading(true);
setError(null); setError("");
const timeout = new Promise((r) => setTimeout(r, 1500)); const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User; let user: User;
try { try {
login({ username, password }); await login({ username, password });
user = await currentUser(); user = await currentUser();
} catch (e) { } catch (e) {
await timeout; await timeout;
setError(e); setError("failed");
setLoading(false); setLoading(false);
return return;
} }
await timeout; await timeout;
onLogin(user); onLogin(user);
} }
function handleClick() {
doLogin();
}
function handleSubmit(e: React.FormEvent) { function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
doLogin(); doLogin();
} }
useEffect(() => {
if (location.state) {
const queryUsername = location.state.username;
const queryPassword = location.state.password;
if (queryUsername) setUsername(queryUsername);
if (queryPassword) setPassword(queryPassword);
navigate(location.pathname, { replace: true });
}
}, []);
return ( return (
<form onSubmit={handleSubmit}> <>
<div> <Header />
<input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} /> <form onSubmit={handleSubmit}>
</div> <div
<div> style={{
<input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} /> position: "relative",
</div> display: "flex",
<button type="submit" value="login" style={{ fontSize: "small" }} onClick={handleClick} >login</button> alignItems: "end",
{loading && <span className="loader" />} }}
</form> >
) <div
} style={{
width: "100%",
/* marginRight: "8px",
export default function Login(props: { onLogin: (user: User) => void }) { display: "flex",
const { onLogin } = props; justifyContent: "center",
const [username, setUsername] = useState(""); flexDirection: "column",
const [password, setPassword] = useState(""); }}
>
async function handleLogin(e: FormEvent) { <div>
e.preventDefault() <input
const timeout = new Promise((r) => setTimeout(r, 1500)); type="text"
let user: User; id="username"
try { name="username"
login({ username, password }) placeholder="username"
user = await currentUser() required
} catch (e) { await timeout; return } value={username}
await timeout; onChange={(evt) => {
onLogin(user); setError("");
} setUsername(evt.target.value);
}}
return <div> />
<form onSubmit={handleLogin}> </div>
<div> <div>
<input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} /> <input
</div> type={visible ? "text" : "password"}
<div> id="password"
<input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} /> name="password"
</div> placeholder="password"
<input className="button" type="submit" value="login" onSubmit={handleLogin} /> minLength={8}
</form> value={password}
</div> required
} */ onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</div>
</div>
<div
style={{
position: "absolute",
right: "-1em",
margin: "auto 4px",
background: "unset",
fontSize: "x-large",
cursor: "pointer",
}}
onClick={() => setVisible(!visible)}
>
{visible ? <Eye /> : <EyeSlash />}
</div>
</div>
{error && <span style={{ color: "red" }}>{error}</span>}
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>
{loading && <span className="loader" />}
</form>
</>
);
};

63
src/MVPChart.tsx Normal file
View File

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

View File

@ -1,46 +1,319 @@
import { useEffect, useRef, useState } from "react"; import { ReactNode, useEffect, useRef, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, useSelection } from "reagraph"; import {
GraphCanvas,
GraphCanvasRef,
GraphEdge,
GraphNode,
SelectionProps,
SelectionResult,
useSelection,
} from "reagraph";
import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session";
import { useNavigate } from "react-router";
interface NetworkData { interface NetworkData {
nodes: GraphNode[], nodes: GraphNode[];
edges: GraphEdge[], edges: GraphEdge[];
}
interface CustomSelectionProps extends SelectionProps {
ignore: (GraphEdge | undefined)[];
} }
const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
var result = useSelection(props);
result.actives = result.actives.filter(
(s) => !props.ignore.map((edge) => edge?.id).includes(s)
);
const ignored_nodes = props.ignore.map((edge) =>
edge &&
result.selections?.includes(edge.source) &&
!result.selections?.includes(edge.target)
? edge.target
: ""
);
result.actives = result.actives.filter((s) => !ignored_nodes.includes(s));
return result;
};
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 [likes, setLikes] = useState(2);
const [popularity, setPopularity] = 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>).then(json => { setData(json) }) await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null)
setLoading(false); .then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<NetworkData>;
}
})
.then((data) => setData(data))
.catch(() => setError("no access"));
setLoading(false);
} else setError("team unknown");
} }
useEffect(() => { loadData() }, []) useEffect(() => {
loadData();
}, [teams]);
const graphRef = useRef<GraphCanvasRef | null>(null); const graphRef = useRef<GraphCanvasRef | null>(null);
const { selections, actives, onNodeClick, onCanvasClick } = useSelection({ function handleThreed() {
setThreed(!threed);
graphRef.current?.fitNodesInView();
graphRef.current?.centerGraph();
graphRef.current?.resetControls();
}
function handlePopularity() {
popularityLabel(!popularity);
setPopularity(!popularity);
}
function handleMutuality() {
colorMatches(!mutuality);
setMutuality(!mutuality);
}
function showLabel() {
switch (likes) {
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
function findMatches(edges: GraphEdge[]) {
const adjacencyList = edges.map(
(edge) => edge.source + edge.target + edge.data.relation
);
return edges.filter((edge) =>
adjacencyList.includes(edge.target + edge.source + edge.data.relation)
);
}
//const matches = useMemo(() => findMatches(data.edges), [])
function colorMatches(mutuality: boolean) {
const matches = findMatches(data.edges);
const newEdges = data.edges;
if (mutuality) {
newEdges.forEach((edge) => {
if (
(likes === 1 || edge.data.relation === likes) &&
matches.map((edge) => edge.id).includes(edge.id)
) {
edge.fill = "#9c3";
if (edge.size) edge.size = edge.size * 1.5;
}
});
} else {
newEdges.forEach((edge) => {
if (
(likes === 1 || edge.data.relation === likes) &&
matches.map((edge) => edge.id).includes(edge.id)
) {
edge.fill = edge.data.origFill;
edge.size = edge.data.origSize;
}
});
}
setData({ nodes: data.nodes, edges: newEdges });
}
function popularityLabel(popularity: boolean) {
const newNodes = data.nodes;
console.log(data.nodes);
if (popularity) {
newNodes.forEach(
(node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`)
);
} else {
newNodes.forEach((node) => (node.subLabel = undefined));
}
setData({ nodes: newNodes, edges: data.edges });
}
useEffect(() => {
if (mutuality) colorMatches(false);
colorMatches(mutuality);
}, [likes]);
const {
selections,
actives,
onNodeClick,
onCanvasClick,
onNodePointerOver,
onNodePointerOut,
} = useCustomSelection({
ref: graphRef, ref: graphRef,
nodes: data.nodes, nodes: data.nodes,
edges: data.edges, edges: data.edges.filter((edge) => edge.data.relation === likes),
pathSelectionType: 'out' ignore: data.edges.map((edge) => {
if (likes === 1 && edge.data.relation !== 2) return edge;
}),
pathSelectionType: "out",
pathHoverType: "in",
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 (
<GraphCanvas <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
draggable <div className="controls">
ref={graphRef} <div className="control" onClick={handleMutuality}>
nodes={data.nodes} <div className="switch">
edges={data.edges} <input type="checkbox" checked={mutuality} onChange={() => {}} />
selections={selections} <span className="slider round"></span>
actives={actives} </div>
onCanvasClick={onCanvasClick} <span>mutuality</span>
onNodeClick={onNodeClick} </div>
/>
<div className="control" onClick={handleThreed}>
<span>2D</span>
<div className="switch">
<input type="checkbox" checked={threed} onChange={() => {}} />
<span className="slider round"></span>
</div>
<span>3D</span>
</div>
<div className="control">
<div className="stack column">
<datalist id="markers">
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
</datalist>
<div id="three-slider">
<label>😬</label>
<input
type="range"
list="markers"
min="0"
max="2"
step="1"
width="16px"
onChange={(evt) => setLikes(Number(evt.target.value))}
/>
<label>😍</label>
</div>
{showLabel()}
</div>
</div>
<div className="control" onClick={handlePopularity}>
<div className="switch">
<input type="checkbox" checked={popularity} onChange={() => {}} />
<span className="slider round"></span>
</div>
<span>
popularity<sup>*</sup>
</span>
</div>
</div>
{popularity && (
<div
style={{ position: "absolute", bottom: 0, right: "10px", zIndex: 10 }}
>
<span className="grey" style={{ fontSize: "70%" }}>
<sup>*</sup>popularity meassured by rank-weighted in-degree
</span>
</div>
)}
{content}
</div>
); );
};
}

59
src/NetworkTheme.ts Normal file
View File

@ -0,0 +1,59 @@
import { Theme } from "reagraph";
export var customTheme: Theme = {
canvas: {
background: "aliceblue",
},
node: {
fill: "#69F",
activeFill: "#36C",
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.333,
label: {
color: "#404040",
stroke: "white",
activeColor: "black",
},
subLabel: {
color: "#404040",
stroke: "white",
activeColor: "black",
},
},
lasso: {
border: "1px solid #55aaff",
background: "rgba(75, 160, 255, 0.1)",
},
ring: {
fill: "#69F",
activeFill: "#36C",
},
edge: {
fill: "#bed4ff",
activeFill: "#36C",
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.333,
label: {
stroke: "#fff",
color: "#2A6475",
activeColor: "#1DE9AC",
fontSize: 6,
},
},
arrow: {
fill: "#bed4ff",
activeFill: "#36C",
},
cluster: {
stroke: "#D8E6EA",
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.1,
label: {
stroke: "#fff",
color: "#2A6475",
},
},
};

103
src/RaceChart.tsx Normal file
View File

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

View File

@ -1,181 +1,189 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs"; import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import api, { baseUrl } from "./api"; import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session";
interface Player { import { Chemistry, MVPRanking, PlayerType } from "./types";
id: number; import TabController from "./TabController";
name: string;
number: string | null;
}
type PlayerListProps = Partial<ReactSortableProps<any>> & { type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean; orderedList?: boolean;
gender?: boolean;
}; };
function PlayerList(props: PlayerListProps) { function PlayerList(props: PlayerListProps) {
const fmps = props.list?.filter((item) => item.gender === "fmp").length;
return ( return (
<ReactSortable {...props} animation={200}> <ReactSortable
{props.list?.map((item, index) => ( {...props}
<div key={item.id} className="item"> animation={200}
{props.orderedList ? index + 1 + ". " + item.name : item.name} swapThreshold={0.2}
</div> style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }}
))} >
{props.list &&
props.list.map((item, index) => (
<div
key={item.id}
className={"item " + (props.gender ? item.gender : "")}
>
{props.orderedList
? props.gender
? index +
1 -
(item.gender !== "fmp" ? fmps! : 0) +
". " +
item.display_name
: index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
</ReactSortable> </ReactSortable>
); );
} }
interface SelectUserProps { const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
user: Player[];
setUser: Dispatch<SetStateAction<Player[]>>;
players: Player[];
setPlayers: Dispatch<SetStateAction<Player[]>>;
}
export function SelectUser({
user,
setUser,
players,
setPlayers,
}: SelectUserProps) {
return ( return (
<> <button {...props} style={{ padding: "4px 16px" }}>
<div className="box user"> 🗃 restore previous
{user.length < 1 ? ( </button>
<>
<span>your name?</span>
<br /> <span className="grey hint">drag your name here</span>
</>
) : (
<>
<span
className="renew"
onClick={() => {
setUser([]);
}}
>
{" ✕"}
</span>
</>
)}
<PlayerList
list={user}
setList={setUser}
group={{
name: "user-shared",
put: function (to) {
return to.el.children.length < 1;
},
}}
className="dragbox"
/>
</div>
{user.length < 1 && (
<div className="box one">
<h2>🥏🏃</h2>
<ReactSortable
list={players}
setList={setPlayers}
group={{ name: "user-shared", pull: "clone" }}
className="dragbox reservoir"
animation={200}
>
{players.length < 1 ? (
<span className="loader"></span>
) : (
players.map((item, _) => (
<div key={"extra" + item.id} className="extra-margin">
<div key={item.id} className="item">
{item.name}
</div>
</div>
))
)}
</ReactSortable>
</div>
)}
</>
); );
};
const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button {...props} style={{ padding: "4px 16px" }}>
🗑 start over
</button>
);
};
function filterSort(list: User[], ids: number[]): User[] {
const objectMap = new Map(list.map((obj) => [obj.id, obj]));
const filteredAndSortedObjects = ids
.map((id) => objectMap.get(id))
.filter((obj) => obj !== undefined);
return filteredAndSortedObjects;
} }
interface PlayerInfoProps { interface PlayerInfoProps {
user: Player[]; user: User;
players: Player[]; teams: TeamState;
players: User[];
} }
export function Chemistry({ user, players }: PlayerInfoProps) { function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
const index = players.indexOf(user[0]); var otherPlayers = players.filter((player) => player.id !== user.id);
var otherPlayers = players.slice(); const [playersLeft, setPlayersLeft] = useState<User[]>([]);
otherPlayers.splice(index, 1); const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersLeft, setPlayersLeft] = useState<Player[]>([]); const [playersRight, setPlayersRight] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers); const [loading, setLoading] = useState(false);
const [playersRight, setPlayersRight] = useState<Player[]>([]);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() { async function handleSubmit() {
const dialog = document.querySelector("dialog[id='ChemistryDialog']"); if (dialogRef.current) dialogRef.current.showModal();
(dialog as HTMLDialogElement).showModal(); setDialog("sending...");
if (user.length < 1) { let left = playersLeft.map(({ id }) => id);
setDialog("who are you?"); let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ id }) => id);
const data = {
user: user.id,
hate: left,
undecided: middle,
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
} else { } else {
setDialog("sending..."); const chemistry = data as Chemistry;
let _user = user.map(({ name }) => name)[0]; setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
let left = playersLeft.map(({ name }) => name); setPlayersMiddle(
let middle = playersMiddle.map(({ name }) => name); otherPlayers.filter(
let right = playersRight.map(({ name }) => name); (player) =>
const data = { user: _user, hate: left, undecided: middle, love: right }; !chemistry.hate.includes(player.id) &&
const response = await api("chemistry", data); !chemistry.love.includes(player.id)
response.ok ? setDialog("success!") : setDialog("try sending again"); )
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
} }
setLoading(false);
} }
return ( return (
<> <>
<div className="container"> <HeaderControl
<div className="box three"> onLoad={handleGet}
<h2>😬</h2> onClear={() => {
{playersLeft.length < 1 && ( setPlayersRight([]);
<span className="grey hint"> setPlayersMiddle(otherPlayers);
drag people here that you'd rather not play with setPlayersLeft([]);
</span> }}
)} />
<PlayerList {loading ? (
list={playersLeft} <span className="loader" style={{ width: 300 }} />
setList={setPlayersLeft} ) : (
group={"shared"} <div className="container">
className="dragbox" <div className="box three">
/> <h2>😬</h2>
{playersLeft.length < 1 && (
<span className="grey hint">
drag people here that you'd rather not play with
</span>
)}
<PlayerList
list={playersLeft}
setList={setPlayersLeft}
group={"shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h2>🤷</h2>
<PlayerList
list={playersMiddle}
setList={setPlayersMiddle}
group={"shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h2>😍</h2>
{playersRight.length < 1 && (
<span className="grey hint">
drag people here that you love playing with from best to ... ok
</span>
)}
<PlayerList
list={playersRight}
setList={setPlayersRight}
group={"shared"}
className="dragbox"
orderedList
/>
</div>
</div> </div>
<div className="box three"> )}
<h2>🤷</h2>
<PlayerList
list={playersMiddle}
setList={setPlayersMiddle}
group={"shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h2>😍</h2>
{playersRight.length < 1 && (
<span className="grey hint">
drag people here that you love playing with from best to ... ok
</span>
)}
<PlayerList
list={playersRight}
setList={setPlayersRight}
group={"shared"}
className="dragbox"
orderedList
/>
</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
ref={dialogRef}
id="ChemistryDialog" id="ChemistryDialog"
onClick={(event) => { onClick={(event) => {
event.currentTarget.close(); event.currentTarget.close();
@ -187,74 +195,254 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
); );
} }
export function MVP({ user, players }: PlayerInfoProps) { function TypeDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players); const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]); const [handlers, setHandlers] = useState<User[]>([]);
const [combis, setCombis] = useState<User[]>([]);
const [cutters, setCutters] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() { async function handleSubmit() {
const dialog = document.querySelector("dialog[id='MVPDialog']"); if (dialogRef.current) dialogRef.current.showModal();
(dialog as HTMLDialogElement).showModal(); setDialog("sending...");
if (user.length < 1) { let handlerlist = handlers.map(({ id }) => id);
setDialog("who are you?"); let combilist = combis.map(({ id }) => id);
let cutterlist = cutters.map(({ id }) => id);
const data = {
user: user.id,
handlers: handlerlist,
combis: combilist,
cutters: cutterlist,
team: teams.activeTeam,
};
const response = await apiAuth("playertype", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`playertype/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
} else { } else {
setDialog("sending..."); const playertype = data as PlayerType;
let _user = user.map(({ name }) => name)[0]; setAvailablePlayers(
let mvps = rankedPlayers.map(({ name }) => name); players.filter(
const data = { user: _user, mvps: mvps }; (player) =>
const response = await api("mvps", data); !playertype.handlers.includes(player.id) &&
response.ok ? setDialog("success!") : setDialog("try sending again"); !playertype.combis.includes(player.id) &&
!playertype.cutters.includes(player.id)
)
);
setHandlers(filterSort(players, playertype.handlers));
setCombis(filterSort(players, playertype.combis));
setCutters(filterSort(players, playertype.cutters));
} }
setLoading(false);
} }
return ( return (
<> <>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
}}
/>
<div className="container"> <div className="container">
<div className="box two"> <div className="box one">
<h2>🥏🏃</h2>
{availablePlayers.length < 1 && (
<span className="grey hint">all sorted 👍</span>
)}
<PlayerList <PlayerList
list={availablePlayers} list={availablePlayers}
setList={setAvailablePlayers} setList={setAvailablePlayers}
group={{ group={"type-shared"}
name: "mvp-shared", className="dragbox reservoir"
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
/>
</div>
<div className="box two">
<h1>🏆</h1>
{rankedPlayers.length < 1 && (
<span className="grey hint">
carefully place as many of the <i>Most Valuable Players</i>{" "}
(according to your humble opinion) in this box
</span>
)}
<PlayerList
list={rankedPlayers}
setList={setRankedPlayers}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
orderedList
/> />
</div> </div>
</div> </div>
<div className="container">
<button className="submit" onClick={() => handleSubmit()}> <div className="box three">
<h4>handler</h4>
{handlers.length < 1 && (
<span className="grey hint">
drag people here that you like to see as handlers
</span>
)}
<PlayerList
list={handlers}
setList={setHandlers}
group={"type-shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h4>combi</h4>
{combis.length < 1 && (
<span className="grey hint">
drag people here that switch between handling and cutting
</span>
)}
<PlayerList
list={combis}
setList={setCombis}
group={"type-shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h4>cutter</h4>
{cutters.length < 1 && (
<span className="grey hint">
drag people here that you think are the best cutters
</span>
)}
<PlayerList
list={cutters}
setList={setCutters}
group={"type-shared"}
className="dragbox"
/>
</div>
</div>
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span> 💾 <span className="submit_text">submit</span>
</button> </button>
<dialog <dialog
ref={dialogRef}
id="PlayerTypeDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [mixed, setMixed] = useState(false);
useEffect(() => {
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed);
handleGet();
}, [players]);
useEffect(() => {
handleGet();
// setMixedList(rankedPlayers);
}, [mixed]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again");
}
const setMixedList = (newList: User[]) =>
mixed
? setRankedPlayers(
newList.sort((a, b) =>
a.gender && b.gender ? a.gender.localeCompare(b.gender) : -1
)
)
: setRankedPlayers(newList);
async function handleGet() {
setLoading(true);
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setRankedPlayers([]);
} else {
const mvps = data as MVPRanking;
setMixedList(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}}
/>
{loading ? (
<span className="loader" style={{ width: 300 }} />
) : (
<div className="container">
<div className="box two">
<h2>🥏🏃</h2>
{availablePlayers.length < 1 && (
<span className="grey hint">all sorted 👍</span>
)}
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
}}
className="dragbox"
gender={mixed}
/>
</div>
<div className="box two">
<h1>🏆</h1>
{rankedPlayers.length < 1 && (
<span className="grey hint">
carefully place as many of the <i>Most Valuable Players</i>{" "}
(according to your humble opinion) in this box
</span>
)}
<PlayerList
list={rankedPlayers}
setList={setMixedList}
group={{
name: "mvp-shared",
}}
className="dragbox"
orderedList
gender={mixed}
/>
</div>
</div>
)}
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="MVPDialog" id="MVPDialog"
onClick={(event) => { onClick={(event) => {
event.currentTarget.close(); event.currentTarget.close();
@ -266,84 +454,46 @@ export function MVP({ user, players }: PlayerInfoProps) {
); );
} }
interface HeaderControlProps {
onLoad: () => void;
onClear: () => void;
}
function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
return (
<>
<div>
<ClearButton onClick={onClear} />
<LoadButton onClick={onLoad} />
</div>
<div>
<span className="grey">
assign as many or as few players as you want and don't forget to{" "}
<b>submit</b> 💾 when you're done :)
</span>
</div>
</>
);
}
export default function Rankings() { export default function Rankings() {
const [user, setUser] = useState<Player[]>([]); const { user, teams, players } = useSession();
const [players, setPlayers] = useState<Player[]>([]);
const [openTab, setOpenTab] = useState("Chemistry");
async function loadPlayers() { const tabs = [
const response = await fetch(`${baseUrl}api/player/list`, { { id: "Chemistry", label: "🧪 Chemistry" },
method: "GET", { id: "Type", label: "🃏 Type" },
}); { id: "MVP", label: "🏆 MVP" },
const data = await response.json(); ];
setPlayers(data as Player[]);
}
useMemo(() => {
loadPlayers();
}, []);
useEffect(() => {
user.length === 1 && openPage(openTab, "aliceblue");
}, [user]);
function openPage(pageName: string, color: string) {
// Hide all elements with class="tabcontent" by default */
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
(tabcontent[i] as HTMLElement).style.display = "none";
}
// Remove the background color of all tablinks/buttons
tablinks = document.getElementsByClassName("tablink");
for (i = 0; i < tablinks.length; i++) {
let button = tablinks[i] as HTMLElement;
button.style.opacity = "50%";
}
// Show the specific tab content
(document.getElementById(pageName) as HTMLElement).style.display = "block";
// Add the specific color to the button used to open the tab content
let activeButton = document.getElementById(
pageName + "Button"
) as HTMLElement;
activeButton.style.fontWeight = "bold";
activeButton.style.opacity = "100%";
document.body.style.backgroundColor = color;
setOpenTab(pageName);
}
return ( return (
<> <>
<SelectUser {...{ user, setUser, players, setPlayers }} /> {user && teams && players ? (
{user.length === 1 && ( <TabController tabs={tabs}>
<> <ChemistryDnD {...{ user, teams, players }} />
<div className="container navbar"> <TypeDnD {...{ user, teams, players }} />
<button <MVPDnD {...{ user, teams, players }} />
className="tablink" </TabController>
id="ChemistryButton" ) : (
onClick={() => openPage("Chemistry", "aliceblue")} <span className="loader" />
>
🧪 Chemistry
</button>
<button
className="tablink"
id="MVPButton"
onClick={() => openPage("MVP", "aliceblue")}
>
🏆 MVP
</button>
</div>
<span className="grey">assign as many or as few players as you want<br />
and don't forget to <b>submit</b> (💾) when you're done :)</span>
<div id="Chemistry" className="tabcontent">
<Chemistry {...{ user, players }} />
</div>
<div id="MVP" className="tabcontent">
<MVP {...{ user, players }} />
</div>
</>
)} )}
</> </>
); );

View File

@ -1,36 +1,119 @@
import { createContext, ReactNode, useContext, useLayoutEffect, useState } from "react"; import {
import { currentUser, User } from "./api"; createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login"; import { Login } from "./Login";
import Header from "./Header";
import { Team } from "./types";
export interface SessionProviderProps { export interface SessionProviderProps {
children: ReactNode; children: ReactNode;
} }
const sessionContext = createContext<User | null>(null); export interface TeamState {
teams: Team[];
activeTeam: number;
}
export interface Session {
user: User | null;
teams: TeamState | null;
setTeams: (teams: TeamState) => void;
players: User[] | null;
reloadPlayers: () => void;
onLogout: () => void;
}
const sessionContext = createContext<Session>({
user: null,
teams: null,
setTeams: () => {},
players: null,
reloadPlayers: () => {},
onLogout: () => {},
});
export function SessionProvider(props: SessionProviderProps) { 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 [players, setPlayers] = useState<User[] | null>(null);
const [err, setErr] = useState<unknown>(null); const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
function loadUser() { function loadUser() {
setLoading(true);
currentUser() currentUser()
.then((user) => { setUser(user); setErr(null); }) .then((user) => {
.catch((err) => { setUser(null); setErr(err); }); setUser(user);
setErr(null);
})
.catch((err) => {
setUser(null);
setErr(err);
})
.finally(() => setLoading(false));
} }
useLayoutEffect(() => { loadUser(); }, [err]); async function loadTeam() {
const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
}
async function reloadPlayers() {
teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data));
}
useEffect(() => {
loadUser();
}, []);
useEffect(() => {
loadTeam();
}, [user]);
useEffect(() => {
reloadPlayers();
}, [teams]);
function onLogin(user: User) { function onLogin(user: User) {
setUser(user); setUser(user);
setErr(null); setErr(null);
} }
async function onLogout() {
try {
logout();
setUser(null);
setErr({ message: "Logged out successfully" });
console.log("logged out.");
} catch (e) {
console.error(e);
setErr(e);
}
}
let content: ReactNode; let content: ReactNode;
if (!err && !user) content = <span className="loader" />; if (loading || (!err && !user))
else if (err) content = <Login onLogin={onLogin} />; content = (
else content = <sessionContext.Provider value={user}>{children}</sessionContext.Provider>; <>
<Header />
<span className="loader" />
</>
);
else if (err) {
content = <Login onLogin={onLogin} />;
} else
content = (
<sessionContext.Provider
value={{ user, teams, setTeams, players, reloadPlayers, onLogout }}
>
{children}
</sessionContext.Provider>
);
return content; return content;
} }

355
src/SetPassword.tsx Normal file
View File

@ -0,0 +1,355 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import { ReactNode, useEffect, useState } from "react";
import { apiAuth, baseUrl, Gender, User } from "./api";
import { useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
import { useSession } from "./Session";
import { relative } from "path";
import Header from "./Header";
interface PassToken extends JwtPayload {
username: string;
name: string;
team_id: number;
}
enum Mode {
register = "register",
set = "set password",
change = "change password",
}
export const SetPassword = () => {
const [mode, setMode] = useState<Mode>();
const [name, setName] = useState("after getting your token.");
const [username, setUsername] = useState("");
const [teamID, setTeamID] = useState<number>();
const [currentPassword, setCurrentPassword] = useState("");
const [password, setPassword] = useState("");
const [passwordr, setPasswordr] = useState("");
const [token, setToken] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const newPlayerTemplate = {
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [player, setPlayer] = useState(newPlayerTemplate);
const navigate = useNavigate();
const { user } = useSession();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password === passwordr) {
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`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ 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: username, password: password },
});
}
if (!resp.ok) {
if (resp.status === 401) {
const { detail } = await resp.json();
if (detail) setError(detail);
else setError("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");
}
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>
register as a member of <i>{name}</i>
</h2>
</>
);
}
let textInputs: ReactNode;
switch (mode) {
case Mode.change:
textInputs = (
<div>
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="current password"
minLength={8}
value={currentPassword}
required
onChange={(evt) => {
setError("");
setCurrentPassword(evt.target.value);
}}
/>
<hr style={{ margin: "8px" }} />
</div>
);
break;
case Mode.register:
textInputs = (
<div className="new-player-inputs">
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
}}
/>
</div>
<div>
<label>username</label>
<input
type="text"
required
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
}}
/>
</div>
<div>
<label>gender</label>
<select
name="gender"
required
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
}}
/>
</div>
<hr style={{ margin: "8px" }} />
</div>
);
break;
}
let passwordInputs = (
<>
<div>
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</div>
<div>
<input
type={visible ? "text" : "password"}
id="password-repeat"
name="password-repeat"
placeholder="repeat password"
minLength={8}
value={passwordr}
required
onChange={(evt) => {
setError("");
setPasswordr(evt.target.value);
}}
/>
</div>
</>
);
return mode ? (
<>
{header}
<hr style={{ width: "100%" }} />
<form onSubmit={handleSubmit}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
flexDirection: "column",
}}
>
{textInputs}
{passwordInputs}
<div
style={{
background: "unset",
fontSize: "medium",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: "8px",
}}
onClick={() => setVisible(!visible)}
>
{visible ? <Eye /> : <EyeSlash />} show passwords
</div>
</div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
<button type="submit" value="login" style={{ fontSize: "small" }}>
submit
</button>
{loading && <span className="loader" />}
</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>
);
}

224
src/TeamPanel.tsx Normal file
View File

@ -0,0 +1,224 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, Gender, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
import Calendar from "./Calendar";
const TeamPanel = () => {
const { user, teams, players, reloadPlayers } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user, teams]);
const newPlayerTemplate = {
id: 0,
username: "",
display_name: "",
gender: undefined,
number: "",
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [player, setPlayer] = useState(newPlayerTemplate);
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 });
reloadPlayers();
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
reloadPlayers();
}
}
}
}
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);
reloadPlayers();
}
}
}
}
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.map((p) => (
<button
className={
"team-player " +
p.gender +
(p.id === player.id ? " active-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,
...(player.id === 0 && {
username: e.target.value.toLowerCase().replace(/\W/g, ""),
}),
display_name: e.target.value,
});
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>gender</label>
<select
name="gender"
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
setError({ ok: true, message: "" });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
<div>
<label>number (optional)</label>
<input
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>
<Calendar playerId={player.id} />
</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

@ -1,35 +1,20 @@
export const baseUrl = import.meta.env.VITE_BASE_URL as string; import { useSession } from "./Session";
export const token = () => localStorage.getItem("access_token") as string;
export default async function api(path: string, data: any): Promise<any> { export const baseUrl = import.meta.env.VITE_BASE_URL as string;
const request = new Request(`${baseUrl}${path}/`, {
method: "POST", export async function apiAuth(
path: string,
data: any,
method: string = "GET"
): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`, {
method: method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(data), credentials: "include",
...(data && { body: JSON.stringify(data) }),
}); });
let response: Response;
try {
response = await fetch(request);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
return response;
}
export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`,
{
method: method,
headers: {
"Authorization": `Bearer ${token()} `,
'Content-Type': 'application/json'
},
...(data && { body: JSON.stringify(data) })
}
);
let resp: Response; let resp: Response;
try { try {
resp = await fetch(req); resp = await fetch(req);
@ -39,25 +24,44 @@ export async function apiAuth(path: string, data: any, method: string = "GET"):
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) { if (resp.status === 401) {
logout() const { onLogout } = useSession();
throw new Error('Unauthorized'); onLogout();
throw new Error("Unauthorized");
} }
} }
return resp.json() const contentType = resp.headers.get("Content-Type");
switch (contentType) {
case "application/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 Gender = "fmp" | "mmp" | undefined;
export type User = { export type User = {
id: number;
username: string; username: string;
fullName: string; display_name: string;
} email: string;
number: string;
gender: Gender;
scopes: string;
};
export async function currentUser(): Promise<User> { export async function currentUser(): Promise<User> {
if (!token()) throw new Error("you have no access token") const req = new Request(`${baseUrl}api/player/me`, {
const req = new Request(`${baseUrl}api/users/me/`, { method: "GET",
method: "GET", headers: { headers: {
"Authorization": `Bearer ${token()} `, "Content-Type": "application/json",
'Content-Type': 'application/json' },
} credentials: "include",
}); });
let resp: Response; let resp: Response;
try { try {
@ -68,29 +72,54 @@ export async function currentUser(): Promise<User> {
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) { if (resp.status === 401) {
logout() logout();
throw new Error('Unauthorized'); throw new Error("Unauthorized");
} }
} }
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;
}; };
export type Token = {
access_token: string; export const login = async (req: LoginRequest): Promise<void> => {
token_type: string; try {
const response = await fetch(`${baseUrl}api/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(req).toString(),
credentials: "include",
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
} catch (e) {
console.error(e);
throw e; // rethrow the error so it can be caught by the caller
}
}; };
export const login = (req: LoginRequest) => { export const logout = async () => {
fetch(`${baseUrl}api/token`, { try {
method: "POST", headers: { await fetch(`${baseUrl}api/logout`, {
'Content-Type': 'application/x-www-form-urlencoded', method: "POST",
}, body: new URLSearchParams(req).toString() credentials: "include",
}).then(resp => resp.json() as Promise<Token>).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login")); });
return Promise<void> } catch (e) {
} console.error(e);
}
export const logout = () => localStorage.removeItem("access_token"); };

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",
};

55
src/types.ts Normal file
View File

@ -0,0 +1,55 @@
export interface Edge {
from: string;
to: string;
color: string;
relation: "likes" | "dislikes";
}
export interface Node {
id: string;
}
export default interface NetworkData {
nodes: Node[];
edges: Edge[];
}
export interface PlayerRanking {
p_id: number;
rank: number;
std: number;
n: number;
}
export interface Chemistry {
id: number;
user: number;
hate: number[];
undecided: number[];
love: number[];
}
export interface PlayerType {
id: number;
user: number;
handlers: number[];
combis: number[];
cutters: number[];
}
export interface MVPRanking {
id: number;
user: number;
mvps: number[];
}
export interface Team {
id: number;
name: string;
location: string;
country: string;
mixed: boolean;
}
export type ErrorState = {
ok: boolean;
message: string;
};

View File

@ -3,10 +3,13 @@
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
@ -14,7 +17,6 @@
"moduleDetection": "force", "moduleDetection": "force",
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true, "strict": true,
"noUnusedLocals": false, "noUnusedLocals": false,
@ -22,5 +24,7 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true "noUncheckedSideEffectImports": true
}, },
"include": ["src"] "include": [
"src"
]
} }