163 Commits

Author SHA1 Message Date
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
5405c3e12f fix: trying to fix minification error 2025-02-20 12:07:12 +01:00
1eab163e10 feat: remove vis.js 2025-02-20 11:55:27 +01:00
7c054d6ba3 feat: re-implement Graph with Reagraph 2025-02-20 11:46:03 +01:00
4a46cd505d feat: Network Graph with vis.js 2025-02-20 08:54:56 +01:00
1fa91a7228 feat: change API output 2025-02-20 08:54:03 +01:00
8e91724462 feat: features for bad internet connections 2025-02-20 08:52:48 +01:00
1a1b44743a fix: clicking on logo navigates to root 2025-02-18 08:07:04 +01:00
827eceed2b Merge pull request 'feat/security' (#1) from feat/security into main
Reviewed-on: #1
2025-02-18 07:06:28 +00:00
96f04e6d90 fix: add Session and Login 2025-02-17 23:11:06 +01:00
df94b151a6 feat: improve navigation in footer 2025-02-17 23:06:18 +01:00
9647e890f6 feat: simple OAuth2 login with JWT token 2025-02-17 22:28:48 +01:00
15c9a64de2 feat: inelegant and buggy version of auth 2025-02-16 17:22:36 +01:00
fbe17479f7 feat: react-router-dom -> react-router 2025-02-16 16:55:49 +01:00
18e693bd2d feat: server-side security 2025-02-16 16:38:55 +01:00
c1ff2120ad feat: add hint to submit 2025-02-15 16:16:40 +01:00
8a9af450d4 feat: add security: OAuth2 with JWT 2025-02-14 20:10:21 +01:00
9c54eaf59b fix: simplify heading 2025-02-14 17:43:18 +01:00
b1e5de086c feat: update info 2025-02-14 17:42:23 +01:00
eb4fa02327 feat: add hint 2025-02-13 08:44:41 +01:00
d37c6f7158 feat: option to show likes, dislikes or both 2025-02-12 17:54:07 +01:00
06fd18ef4c feat: add option to show dislike 2025-02-12 17:23:18 +01:00
44bc27b567 feat: deferred loadImage 2025-02-12 16:53:06 +01:00
dee40ebdb6 feat: decouple popularity and weighting in node_color 2025-02-12 16:08:43 +01:00
3ec065aaf9 feat: introduce weighting and popularity 2025-02-12 15:39:52 +01:00
a34c88c18c feat: async render_sociogram 2025-02-12 12:23:17 +01:00
0c830c1f8f feat: drop-down icon 2025-02-11 20:55:15 +01:00
686fb3a5a4 feat: simple working drop down menu 2025-02-11 16:30:53 +01:00
94bee44cb6 fix: drop-down symbols 2025-02-11 14:19:21 +01:00
e89a2eea20 feat: change API router structure 2025-02-11 14:14:23 +01:00
55b7b6f206 feat: drop-down parameters 2025-02-11 14:14:01 +01:00
c64f93e912 feat: make submit button more visible 2025-02-10 16:43:22 +01:00
501811a0b5 feat: out-source footer and header 2025-02-10 16:40:19 +01:00
25bda2bc4d feat: simple analysis page with sociogram 2025-02-10 16:12:31 +01:00
8def52fbf2 feat: make entire logo clickable 2025-01-29 17:29:00 +01:00
16a6814d69 feat: open automatically 2025-01-29 17:28:17 +01:00
bb7f795175 useEffect -> useMemo 2025-01-29 17:19:30 +01:00
af28539a02 make logo smaller 2025-01-29 15:19:53 +01:00
11bd3c4849 add gitea logo 2025-01-29 15:14:44 +01:00
e8c788832c feat: order players by name 2025-01-29 15:11:43 +01:00
2d760cda16 feat: revamp a little 2025-01-29 15:04:07 +01:00
2256fbfdf9 feat: make chemistry and MVP buttons more button-like 2025-01-29 12:06:31 +01:00
d5e684eb98 make buttons/clickables round 2025-01-28 18:31:38 +01:00
41 changed files with 5031 additions and 567 deletions

1
.env
View File

@@ -1 +0,0 @@
VITE_BASE_URL=

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
VITE_BASE_URL=
SECRET_KEY="tg07Cp0daCpcbeeFw+lI4RG2lEuT8oq5xOwB5ETs9YaJTylG1euC4ogjTK4zym/d"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

View File

@@ -1,3 +1,3 @@
# cutt
# cutt - cool ultimate team tool
cool ultimate team tool
app to survey the chemistry between the players in your team and determine the most valued players in your team

0
cutt/__init__.py Normal file
View File

484
cutt/analysis.py Normal file
View File

@@ -0,0 +1,484 @@
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):
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]
edges.append(
{
"id": f"{user}->{p}",
"source": user,
"target": p,
"size": max(1.0 - 0.1 * i, 0.3),
"data": {
"relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3),
"origFill": "#bed4ff",
},
}
)
for p_id in c.hate:
if p_id not in player_map:
continue
p = player_map[p_id]
edges.append(
{
"id": f"{user}-x>{p}",
"source": user,
"target": p,
"size": 0.3,
"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:
for survey in [C, PT, R]:
subquery = (
select(survey.user, func.max(survey.time).label("latest"))
.where(survey.team == request.team_id)
.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)],
):
ranks = dict()
if request.team_id == 42:
random.seed(42)
players = [request.user] + demo_players
for p in players:
random.shuffle(players)
for i, p in enumerate(players):
ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1]
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
with Session(engine) as session:
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players}
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
.group_by(R.user)
.subquery()
)
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps):
if p_id not in player_map:
continue
p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1]
if not ranks:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
async def turnout(
request: Annotated[
TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"])
],
):
player_map = {}
with Session(engine) as session:
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
for p in players:
player_map[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.team == request.team_id)
.group_by(C.user)
.subquery()
)
statement2 = select(C.user).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
chemistry_turnout = session.exec(statement2).all()
chemistry_missing = set(player_map) - set(chemistry_turnout)
chemistry_missing = [player_map[i] for i in chemistry_missing]
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id)
.group_by(R.user)
.subquery()
)
statement2 = select(R.user).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
mvp_turnout = session.exec(statement2).all()
mvp_missing = set(player_map) - set(mvp_turnout)
mvp_missing = [player_map[i] for i in mvp_missing]
return JSONResponse(
{
"players": len(player_map),
"chemistry": {
"turnout": len(chemistry_turnout),
"missing": sorted(list(chemistry_missing)),
},
"MVP": {
"turnout": len(mvp_turnout),
"missing": sorted(list(mvp_missing)),
},
}
)
# analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route(
"/graph_json/{team_id}", endpoint=graph_json, methods=["GET"]
)
# analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
analysis_router.add_api_route(
"/mvp/{team_id}",
endpoint=mvp,
methods=["GET"],
name="MVPs",
description="Request Most Valuable Players stats",
)
analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
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)

98
cutt/db.py Normal file
View File

@@ -0,0 +1,98 @@
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
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,
"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}]

57
db.py
View File

@@ -1,57 +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)
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)))
SQLModel.metadata.create_all(engine)

84
main.py
View File

@@ -1,84 +0,0 @@
from fastapi import APIRouter, 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
app = FastAPI(title="cutt")
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)
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()
app.include_router(player_router)
app.include_router(team_router)
app.mount("/", StaticFiles(directory="dist", html=True), name="site")

View File

@@ -10,25 +10,32 @@
"preview": "vite preview"
},
"dependencies": {
"d3": "^7.9.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"jwt-decode": "^4.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-sortablejs": "^6.1.4",
"reagraph": "^4.21.2",
"sortablejs": "^1.15.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/d3": "^7.4.3",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/node": "^22.13.10",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"react-router": "^7.1.5",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true
}
}

1
public/gitea.svg Normal file
View File

@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,49 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- 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>
<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>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -1,15 +1,19 @@
[project]
name = "cutt"
version = "0.1.0"
description = "Add your description here"
version = "0.1.1"
description = "cool ultimate team tool"
author = "julius"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"argon2-cffi>=23.1.0",
"fastapi[standard]>=0.115.7",
"matplotlib>=3.10.0",
"networkx>=3.4.2",
"passlib>=1.7.4",
"psycopg>=3.2.4",
"pydantic-settings>=2.7.1",
"pyjwt>=2.10.1",
"pyqt6>=6.8.0",
"sqlmodel>=0.0.22",
"uvicorn>=0.34.0",

228
src/Analysis.tsx Normal file
View File

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

View File

@@ -1,35 +1,146 @@
* {
border-radius: 8px;
}
body {
background-color: aliceblue;
position: relative;
z-index: 0;
color: black;
text-align: center;
overflow-wrap: anywhere;
height: 100%;
}
footer {
font-size: x-small;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 8px;
text-align: center;
}
footer {
margin-top: 24px;
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 {
color: #444;
opacity: 66%;
}
.hint {
position: absolute;
font-size: 80%;
padding: 4px;
padding: 8px;
top: auto;
left: 4px;
bottom: auto;
@@ -37,19 +148,42 @@ footer {
z-index: -1;
}
input,
select {
padding: 0.2em 16px;
margin-top: 0.25em;
margin-bottom: 0.25em;
border-radius: 1em;
color: black;
background-color: white;
}
h1,
h2,
h3 {
margin-top: 0px;
margin-bottom: 0px;
padding: 4px 16px;
padding: 8px 16px;
}
.stack {
display: flex;
button,
img {
padding: 0px 1em 4px 1em;
margin: 3px auto;
}
}
.column {
flex-direction: column;
}
.container {
display: flex;
flex-wrap: nowrap;
justify-content: space-evenly;
min-width: 737px;
width: min(96vw, 900px);
}
.dragbox {
@@ -59,85 +193,126 @@ h3 {
height: 92%;
}
.box {
position: relative;
flex: 1;
border-width: 3px;
border-style: solid;
border-radius: 16px;
h4 {
margin: 4px;
}
&.one {
max-width: min(96%, 768px);
margin: 4px auto;
}
padding: 4px;
margin: 4px 0.5%;
}
.reservoir {
display: flex;
flex-direction: unset;
flex-wrap: wrap;
justify-content: space-around;
width: 100%;
}
.box {
position: relative;
&.one {
max-width: min(80vw, 500px);
}
&.two {
min-width: 43%;
max-width: 20vw;
}
&.three {
min-width: 27%;
max-width: 10vw;
}
padding: 4px;
margin: 4px auto;
border-style: solid;
border-color: black;
}
.user {
max-width: 400px;
min-width: 200px;
margin: 4px auto;
.item {
font-weight: bold;
border-style: solid;
}
}
.item {
cursor: pointer;
font-size: small;
border: 3px dashed black;
border-radius: 4px;
margin: 8px auto;
padding: 4px 8px;
font-size: medium;
border: 2px solid;
border-radius: 1em;
margin: 3px auto;
padding: 5px 0.8em;
}
.extra-margin {
padding: 0px 8px;
margin: auto;
}
button {
margin: 4px;
font-weight: bold;
font-size: large;
color: ghostwhite;
color: aliceblue;
background-color: black;
border-radius: 1.2em;
z-index: 1;
&:focus {
outline: black;
}
&:hover {
border-color: black;
opacity: 80%;
}
}
#control-panel {
display: none;
overflow: hidden;
margin: auto;
gap: 16px;
grid-template-columns: repeat(3, 1fr);
transition: display 1s ease-out 0s;
}
#control-panel.opened {
display: grid;
}
.control {
display: flex;
border-radius: 16px;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #404040;
padding: 8px 16px;
}
#three-slider input {
margin: 4px;
width: 50%;
}
@media only screen and (max-width: 1000px) {
#control-panel {
grid-template-columns: repeat(2, 1fr);
}
.control {
font-size: 80%;
margin: 0px;
}
}
@media only screen and (max-width: 768px) {
.container {
min-width: 96vw;
#control-panel {
grid-template-columns: 1fr;
}
.networkroute {
display: none;
}
.submit_text {
display: none;
}
.submit {
position: fixed;
right: 16px;
bottom: 16px;
padding: 0px;
background-color: unset;
padding: 0.4em;
border-radius: 1em;
background-color: #36c8;
font-size: xx-large;
margin-bottom: 20px;
margin-right: 20px;
margin-bottom: 16px;
margin-right: 16px;
}
.wavering {
animation: blink 40s infinite;
}
}
@@ -152,16 +327,39 @@ button {
opacity: 0.75;
}
.tablink {
background-color: unset;
font-weight: unset;
.tab-button {
color: black;
border: 2px solid black;
border-radius: unset;
outline: black;
flex: 1;
background-color: #bfbfbf;
border: none;
margin: 4px auto;
cursor: pointer;
padding: 8px 16px;
width: 50%;
opacity: 80%;
}
.tab-button.active {
opacity: unset;
font-weight: bold;
background-color: black;
color: white;
}
.navbar {
span {
padding: 4px;
}
button {
font-size: medium;
margin: 4px 0.5%;
padding-top: 4px;
padding-bottom: 4px;
opacity: 50%;
&:hover {
opacity: 80%;
}
}
}
/* Style the tab content (and add height:100% for full page content) */
@@ -179,18 +377,27 @@ button {
font-size: 150%;
}
/*======LOGO=======*/
.logo {
position: relative;
text-align: center;
height: 196px;
margin: auto;
height: 140px;
span {
display: block;
margin: 2px;
}
img {
display: block;
margin: auto;
}
h3 {
position: absolute;
width: 200px;
font-size: medium;
width: 140px;
top: 33%;
left: 50%;
transform: translate(-50%, -50%);
@@ -203,14 +410,218 @@ 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;
}
&.mmp {
background-color: lightskyblue;
}
&.fmp {
background-color: salmon;
}
}
.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;
}
}
}
@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 {
display: block;
border-radius: 16px;
position: relative;
height: 12px;
width: 96%;
margin: auto;
border: 4px solid black;
overflow: hidden;
}
.loader::after {
content: "";
width: 32%;
@@ -228,8 +639,94 @@ button {
left: 0;
transform: translateX(-100%);
}
100% {
left: 100%;
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,31 +1,52 @@
import { baseUrl } from "./api";
import Analysis from "./Analysis";
import "./App.css";
import Footer from "./Footer";
import Header from "./Header";
import Rankings from "./Rankings";
import { BrowserRouter, Routes, Route } from "react-router";
import { SessionProvider } from "./Session";
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() {
return (
<>
<div className="logo">
<a href={baseUrl}>
<img alt="logo" height="66%" src="logo.svg" />
</a>
<h3 className="centered">cutt</h3>
<span className="grey">cool ultimate team tool</span>
</div>
<Rankings />
<footer>
<p className="grey">
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>
</>
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route path="/password" element={<SetPassword />} />
<Route
path="/*"
element={
<SessionProvider>
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="network" element={<GraphComponent />} />
<Route path="analysis" element={<Analysis />} />
<Route path="mvp" element={<MVPChart />} />
<Route path="changepassword" element={<SetPassword />} />
<Route path="team" element={<TeamPanel />} />
</Routes>
<Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter>
</ThemeProvider>
);
}
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>
</>
);
}

95
src/BarChart.tsx Normal file
View File

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

174
src/Calendar.tsx Normal file
View File

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

42
src/Footer.tsx Normal file
View File

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

18
src/Header.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Link, useLocation } from "react-router";
import Avatar from "./Avatar";
export default function Header() {
const location = useLocation();
return (
<div className={location.pathname === "/network" ? "networkroute" : ""}>
<div className="logo">
<Link to="/">
<img alt="logo" height="66%" src="logo.svg" />
<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>
);

125
src/Login.tsx Normal file
View File

@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { currentUser, login, User } from "./api";
import Header from "./Header";
import { useLocation, useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
export interface LoginProps {
onLogin: (user: User) => void;
}
export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const navigate = useNavigate();
const location = useLocation();
async function doLogin() {
setLoading(true);
setError("");
const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User;
try {
await login({ username, password });
user = await currentUser();
} catch (e) {
await timeout;
setError("failed");
setLoading(false);
return;
}
await timeout;
onLogin(user);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
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 (
<>
<Header />
<form onSubmit={handleSubmit}>
<div
style={{
position: "relative",
display: "flex",
alignItems: "end",
}}
>
<div
style={{
width: "100%",
marginRight: "8px",
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
<div>
<input
type="text"
id="username"
name="username"
placeholder="username"
required
value={username}
onChange={(evt) => {
setError("");
setUsername(evt.target.value);
}}
/>
</div>
<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>
<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>
</>
);
};

52
src/MVPChart.tsx Normal file
View File

@@ -0,0 +1,52 @@
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 navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
async function loadData() {
setLoading(true);
if (teams) {
await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null)
.then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<PlayerRanking[]>;
}
})
.then((data) => {
setData(data.sort((a, b) => a.rank - b.rank));
})
.catch(() => setError("no access"));
setLoading(false);
} else setError("team unknown");
}
useEffect(() => {
loadData();
}, [teams]);
if (loading) return <span className="loader" />;
else if (error) return <span>{error}</span>;
else return <RaceChart std={showStd} players={data} />;
};
export default MVPChart;

319
src/Network.tsx Normal file
View File

@@ -0,0 +1,319 @@
import { ReactNode, useEffect, useRef, useState } from "react";
import { apiAuth } from "./api";
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 {
nodes: GraphNode[];
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 = () => {
let initialData = { nodes: [], edges: [] } as NetworkData;
const [data, setData] = useState(initialData);
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() {
setLoading(true);
if (teams) {
await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null)
.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();
}, [teams]);
const graphRef = useRef<GraphCanvasRef | null>(null);
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,
nodes: data.nodes,
edges: data.edges.filter((edge) => edge.data.relation === likes),
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 (
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls">
<div className="control" onClick={handleMutuality}>
<div className="switch">
<input type="checkbox" checked={mutuality} onChange={() => {}} />
<span className="slider round"></span>
</div>
<span>mutuality</span>
</div>
<div 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",
},
},
};

99
src/RaceChart.tsx Normal file
View File

@@ -0,0 +1,99 @@
import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types";
interface RaceChartProps {
players: 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> = ({ players, std }) => {
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight);
const height = (players.length + 1) * 40;
useEffect(() => {
const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth));
//setHeight(window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const padding = 24;
const gap = 8;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / players.length;
const fontSize = Math.min(barHeight - 1.5 * gap, width / 22);
return (
<svg width={width} height={height} id="RaceChartSVG">
{players.map((player, index) => (
<rect
key={String(index)}
x={4}
y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
stroke="aliceblue"
strokeWidth={4}
paintOrder={"stroke fill"}
/>
))}
{players.map((player, index) => (
<g key={"group" + index}>
<text
key={index + "_name"}
x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.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.name}`}
</text>
<text
key={index + "_value"}
x={
8 +
(4 + Math.max(...players.map((p, _) => p.name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.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(player.rank).padStart(5)} ± ${player.std} N = ${player.n}`}
</text>
</g>
))}
</svg>
);
};
export default RaceChart;

View File

@@ -1,12 +1,9 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import api, { baseUrl } from "./api";
interface Player {
id: number;
name: string;
number: string | null;
}
import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking, PlayerType } from "./types";
import TabController from "./TabController";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
@@ -14,170 +11,167 @@ type PlayerListProps = Partial<ReactSortableProps<any>> & {
function PlayerList(props: PlayerListProps) {
return (
<ReactSortable {...props} animation={200}>
<ReactSortable
{...props}
animation={200}
swapThreshold={0.2}
style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }}
>
{props.list?.map((item, index) => (
<div key={item.id} className="item">
{props.orderedList ? index + 1 + ". " + item.name : item.name}
{props.orderedList
? index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
</ReactSortable>
);
}
interface SelectUserProps {
user: Player[];
setUser: Dispatch<SetStateAction<Player[]>>;
players: Player[];
setPlayers: Dispatch<SetStateAction<Player[]>>;
}
export function SelectUser({
user,
setUser,
players,
setPlayers,
}: SelectUserProps) {
const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<>
<div className="box user">
{user.length < 1 ? (
<>
<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>
)}
</>
<button {...props} style={{ padding: "4px 16px" }}>
🗃 restore previous
</button>
);
};
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 {
user: Player[];
players: Player[];
user: User;
teams: TeamState;
players: User[];
}
export function Chemistry({ user, players }: PlayerInfoProps) {
const index = players.indexOf(user[0]);
var otherPlayers = players.slice();
otherPlayers.splice(index, 1);
const [playersLeft, setPlayersLeft] = useState<Player[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<Player[]>([]);
function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
const dialog = document.querySelector("dialog[id='ChemistryDialog']");
(dialog as HTMLDialogElement).showModal();
if (user.length < 1) {
setDialog("who are you?");
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let left = playersLeft.map(({ id }) => id);
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 {
setDialog("sending...");
let _user = user.map(({ name }) => name)[0];
let left = playersLeft.map(({ name }) => name);
let middle = playersMiddle.map(({ name }) => name);
let right = playersRight.map(({ name }) => name);
const data = { user: _user, hate: left, undecided: middle, love: right };
const response = await api("chemistry", data);
response.ok ? setDialog("success!") : setDialog("try sending again");
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(
otherPlayers.filter(
(player) =>
!chemistry.hate.includes(player.id) &&
!chemistry.love.includes(player.id)
)
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
}
setLoading(false);
}
return (
<>
<div className="container">
<div className="box three">
<h2>😬</h2>
{playersLeft.length < 1 && (
<span className="grey hint">
drag people here that you'd rather not play with from worst to ...
ok
</span>
)}
<PlayerList
list={playersLeft}
setList={setPlayersLeft}
group={"shared"}
className="dragbox"
orderedList
/>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
}}
/>
{loading ? (
<span className="loader" style={{ width: 300 }} />
) : (
<div className="container">
<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 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>
</button>
<dialog
ref={dialogRef}
id="ChemistryDialog"
onClick={(event) => {
event.currentTarget.close();
@@ -189,74 +183,241 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
);
}
export function MVP({ user, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]);
function TypeDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [handlers, setHandlers] = useState<User[]>([]);
const [combis, setCombis] = useState<User[]>([]);
const [cutters, setCutters] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
const dialog = document.querySelector("dialog[id='MVPDialog']");
(dialog as HTMLDialogElement).showModal();
if (user.length < 1) {
setDialog("who are you?");
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let handlerlist = handlers.map(({ id }) => id);
let combilist = combis.map(({ id }) => id);
let cutterlist = cutters.map(({ id }) => id);
const data = {
user: user.id,
handlers: handlerlist,
combis: combilist,
cutters: cutterlist,
team: teams.activeTeam,
};
const response = await apiAuth("playertype", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`playertype/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
} else {
setDialog("sending...");
let _user = user.map(({ name }) => name)[0];
let mvps = rankedPlayers.map(({ name }) => name);
const data = { user: _user, mvps: mvps };
const response = await api("mvps", data);
response.ok ? setDialog("success!") : setDialog("try sending again");
const playertype = data as PlayerType;
setAvailablePlayers(
players.filter(
(player) =>
!playertype.handlers.includes(player.id) &&
!playertype.combis.includes(player.id) &&
!playertype.cutters.includes(player.id)
)
);
setHandlers(filterSort(players, playertype.handlers));
setCombis(filterSort(players, playertype.combis));
setCutters(filterSort(players, playertype.cutters));
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
}}
/>
<div className="container">
<div className="box two">
<h2>🥏🏃</h2>
{availablePlayers.length < 1 && (
<span className="grey hint">all sorted 👍</span>
)}
<div className="box one">
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
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
group={"type-shared"}
className="dragbox reservoir"
/>
</div>
</div>
<button className="submit" onClick={() => handleSubmit()}>
<div className="container">
<div className="box three">
<h4>handler</h4>
{handlers.length < 1 && (
<span className="grey hint">
drag people here that you like to see as handlers
</span>
)}
<PlayerList
list={handlers}
setList={setHandlers}
group={"type-shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h4>combi</h4>
{combis.length < 1 && (
<span className="grey hint">
drag people here that switch between handling and cutting
</span>
)}
<PlayerList
list={combis}
setList={setCombis}
group={"type-shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h4>cutter</h4>
{cutters.length < 1 && (
<span className="grey hint">
drag people here that you think are the best cutters
</span>
)}
<PlayerList
list={cutters}
setList={setCutters}
group={"type-shared"}
className="dragbox"
/>
</div>
</div>
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="PlayerTypeDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let 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");
}
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;
setRankedPlayers(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",
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>
)}
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="MVPDialog"
onClick={(event) => {
event.currentTarget.close();
@@ -268,80 +429,46 @@ export function MVP({ user, players }: PlayerInfoProps) {
);
}
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.backgroundColor = "unset";
button.style.textDecoration = "unset";
button.style.fontWeight = "unset";
button.style.color = "unset";
}
// 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.textDecoration = "underline";
activeButton.style.fontWeight = "bold";
activeButton.style.backgroundColor = "#3366cc";
activeButton.style.color = "white";
document.body.style.backgroundColor = color;
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() {
const [user, setUser] = useState<Player[]>([]);
const [players, setPlayers] = useState<Player[]>([]);
const { user, teams, players } = useSession();
async function loadPlayers() {
const response = await fetch(`${baseUrl}player/list`, {
method: "GET",
});
const data = await response.json();
setPlayers(data as Player[]);
}
useEffect(() => {
loadPlayers();
}, []);
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
{ id: "Type", label: "🃏 Type" },
{ id: "MVP", label: "🏆 MVP" },
];
return (
<>
<SelectUser {...{ user, setUser, players, setPlayers }} />
{user.length === 1 && (
<>
<div className="container">
<button
className="tablink"
id="ChemistryButton"
onClick={() => openPage("Chemistry", "aliceblue")}
>
Chemistry
</button>
<button
className="tablink"
id="MVPButton"
onClick={() => openPage("MVP", "aliceblue")}
>
MVP
</button>
</div>
<div id="Chemistry" className="tabcontent">
<Chemistry {...{ user, players }} />
</div>
<div id="MVP" className="tabcontent">
<MVP {...{ user, players }} />
</div>
</>
{user && teams && players ? (
<TabController tabs={tabs}>
<ChemistryDnD {...{ user, teams, players }} />
<TypeDnD {...{ user, teams, players }} />
<MVPDnD {...{ user, teams, players }} />
</TabController>
) : (
<span className="loader" />
)}
</>
);

123
src/Session.tsx Normal file
View File

@@ -0,0 +1,123 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login";
import Header from "./Header";
import { Team } from "./types";
export interface SessionProviderProps {
children: ReactNode;
}
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) {
const { children } = props;
const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null);
const [players, setPlayers] = useState<User[] | null>(null);
const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
function loadUser() {
setLoading(true);
currentUser()
.then((user) => {
setUser(user);
setErr(null);
})
.catch((err) => {
setUser(null);
setErr(err);
})
.finally(() => setLoading(false));
}
async function loadTeam() {
const teams: Team[] = await apiAuth("player/me/teams", null, "GET");
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
}
async function reloadPlayers() {
teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data));
}
useEffect(() => {
loadUser();
}, []);
useEffect(() => {
loadTeam();
}, [user]);
useEffect(() => {
reloadPlayers();
}, [teams]);
function onLogin(user: User) {
setUser(user);
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;
if (loading || (!err && !user))
content = (
<>
<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;
}
export function useSession() {
return useContext(sessionContext);
}

340
src/SetPassword.tsx Normal file
View File

@@ -0,0 +1,340 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import { ReactNode, useEffect, useState } from "react";
import { apiAuth, baseUrl, 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>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>
);
}

225
src/TeamPanel.tsx Normal file
View File

@@ -0,0 +1,225 @@
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]);
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"
required
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,17 +1,125 @@
import { useSession } from "./Session";
export const baseUrl = import.meta.env.VITE_BASE_URL as string;
export default async function api(path: string, data: any): Promise<any> {
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: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
credentials: "include",
...(data && { body: JSON.stringify(data) }),
});
let response: Response;
let resp: Response;
try {
response = await fetch(request);
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
return response;
if (!resp.ok) {
if (resp.status === 401) {
const { onLogout } = useSession();
onLogout();
throw new Error("Unauthorized");
}
}
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 = {
id: number;
username: string;
display_name: string;
email: string;
number: string;
gender: Gender;
scopes: string;
};
export async function currentUser(): Promise<User> {
const req = new Request(`${baseUrl}api/player/me`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
if (!resp.ok) {
if (resp.status === 401) {
logout();
throw new Error("Unauthorized");
}
}
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 = {
username: string;
password: string;
};
export const login = async (req: LoginRequest): Promise<void> => {
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 logout = async () => {
try {
await fetch(`${baseUrl}api/logout`, {
method: "POST",
credentials: "include",
});
} catch (e) {
console.error(e);
}
};

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

54
src/types.ts Normal file
View File

@@ -0,0 +1,54 @@
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 {
name: string;
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;
}
export type ErrorState = {
ok: boolean;
message: string;
};

View File

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

View File

@@ -2,17 +2,17 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"lib": [
"ES2023"
],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
@@ -20,5 +20,7 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
"include": [
"vite.config.ts"
]
}