100 Commits

Author SHA1 Message Date
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
31 changed files with 2817 additions and 368 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

237
analysis.py Normal file
View File

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

45
db.py
View File

@@ -1,10 +1,23 @@
from datetime import datetime, timezone
from sqlmodel import ARRAY, Column, Relationship, SQLModel, Field, create_engine, String
from sqlmodel import (
ARRAY,
Column,
Integer,
Relationship,
SQLModel,
Field,
create_engine,
)
with open("db.secrets", "r") as f:
db_secrets = f.readline().strip()
engine = create_engine(db_secrets)
engine = create_engine(
db_secrets,
pool_timeout=20,
pool_size=2,
connect_args={"connect_timeout": 8},
)
del db_secrets
@@ -31,27 +44,41 @@ class Team(SQLModel, table=True):
class Player(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
name: str
username: str = Field(default=None, unique=True)
display_name: str
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
hashed_password: str | None = None
number: str | None = None
teams: list[Team] | None = 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: 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)))
user: int | None = 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)))
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)))
user: int | None = Field(default=None, foreign_key="player.id")
mvps: list[int] = Field(sa_column=Column(ARRAY(Integer)))
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)

137
main.py
View File

@@ -1,19 +1,34 @@
from fastapi import APIRouter, FastAPI, status
from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
Session,
func,
select,
)
from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router
from security import (
change_password,
get_current_active_user,
login_for_access_token,
logout,
read_player_me,
read_own_items,
set_first_password,
)
C = Chemistry
R = MVPRanking
P = Player
app = FastAPI(title="cutt")
api_router = APIRouter(prefix="/api")
origins = [
"*",
"http://localhost",
"http://localhost:3000",
"http://localhost:8000",
"https://cutt.0124816.xyz",
"http://localhost:5173",
]
app.add_middleware(
@@ -46,7 +61,7 @@ def add_players(players: list[Player]):
def list_players():
with Session(engine) as session:
statement = select(Player)
statement = select(Player).order_by(Player.display_name)
return session.exec(statement).fetchall()
@@ -59,26 +74,114 @@ def list_teams():
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"])
player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"])
player_router.add_api_route("/me/items", endpoint=read_own_items, methods=["GET"])
player_router.add_api_route(
"/change_password", endpoint=change_password, methods=["POST"]
)
team_router = APIRouter(prefix="/team")
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
@app.post("/mvps/", status_code=status.HTTP_200_OK)
def submit_mvps(mvps: MVPRanking):
wrong_user_id_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you're not who you think you are...",
)
@api_router.put("/mvps")
def submit_mvps(
mvps: MVPRanking,
user: Annotated[Player, Depends(get_current_active_user)],
):
if user.id == mvps.user:
with Session(engine) as session:
session.add(mvps)
session.commit()
return JSONResponse("success!")
else:
raise wrong_user_id_exception
@api_router.get("/mvps")
def get_mvps(
user: Annotated[Player, Depends(get_current_active_user)],
):
with Session(engine) as session:
session.add(mvps)
session.commit()
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.user == user.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_404_NOT_FOUND,
detail="no previous state was found",
)
@app.post("/chemistry/", status_code=status.HTTP_200_OK)
def submit_chemistry(chemistry: Chemistry):
@api_router.put("/chemistry")
def submit_chemistry(
chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)]
):
if user.id == chemistry.user:
with Session(engine) as session:
session.add(chemistry)
session.commit()
return JSONResponse("success!")
else:
raise wrong_user_id_exception
@api_router.get("/chemistry")
def get_chemistry(user: Annotated[Player, Depends(get_current_active_user)]):
with Session(engine) as session:
session.add(chemistry)
session.commit()
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.user == user.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:
return chemistry
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no previous state was found",
)
app.include_router(player_router)
app.include_router(team_router)
app.mount("/", StaticFiles(directory="dist", html=True), name="site")
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,
dependencies=[Security(get_current_active_user, scopes=["analysis"])],
)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/set_password", endpoint=set_first_password, 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")

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

292
security.py Normal file
View File

@@ -0,0 +1,292 @@
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 db import 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
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.",
},
)
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
for scope in security_scopes.scopes:
if 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
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 generate_one_time_token(username):
user = get_user(username)
if user:
expire = timedelta(days=7)
token = create_access_token(
data={"sub": username, "name": user.display_name},
expires_delta=expire,
)
return token
class FirstPassword(BaseModel):
token: str
password: str
async def set_first_password(req: FirstPassword):
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 == req.token)
.where(TokenDB.used == False)
).one_or_none()
if token_in_db:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate token",
)
try:
payload = jwt.decode(req.token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Access token expired",
)
except (InvalidTokenError, ValidationError):
raise credentials_exception
user = get_user(username)
if user:
user.hashed_password = get_password_hash(req.password)
session.add(user)
token_in_db.used = True
session.add(token_in_db)
session.commit()
return Response(
"Password set successfully", status_code=status.HTTP_200_OK
)
elif session.exec(
select(TokenDB)
.where(TokenDB.token == req.token)
.where(TokenDB.used == True)
).one_or_none():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token already used",
)
else:
raise credentials_exception
async def change_password(
current_password: str,
new_password: str,
user: Annotated[Player, Depends(get_current_active_user)],
):
if (
new_password
and user.hashed_password
and verify_password(current_password, user.hashed_password)
):
with Session(engine) as session:
user.hashed_password = get_password_hash(new_password)
session.add(user)
session.commit()
return PlainTextResponse(
"Password changed successfully", status_code=status.HTTP_200_OK
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Wrong password",
)
async def read_player_me(
current_user: Annotated[Player, Depends(get_current_active_user)],
):
return current_user
async def read_own_items(
current_user: Annotated[Player, Depends(get_current_active_user)],
):
return [{"item_id": "Foo", "owner": current_user.username}]

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,5 +1,5 @@
* {
border-radius: 8px;
border-radius: 16px;
}
body {
@@ -7,19 +7,129 @@ body {
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;
}
/*=========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;
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: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
-webkit-transition: .4s;
transition: .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 {
@@ -29,7 +139,7 @@ footer {
.hint {
position: absolute;
font-size: 80%;
padding: 4px;
padding: 8px;
top: auto;
left: 4px;
bottom: auto;
@@ -37,19 +147,38 @@ footer {
z-index: -1;
}
input {
padding: 0.2em 16px;
margin-bottom: 0.5em;
}
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,6 +188,21 @@ h3 {
height: 92%;
}
.box {
position: relative;
flex: 1;
&.one {
max-width: min(96%, 768px);
margin: 4px auto;
}
padding: 4px;
margin: 4px 0.5%;
border-style: solid;
border-color: black;
}
.reservoir {
flex-direction: unset;
flex-wrap: wrap;
@@ -66,29 +210,11 @@ h3 {
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;
max-width: 240px;
min-width: 100px;
margin: 4px auto;
.item {
font-weight: bold;
border-style: solid;
@@ -99,69 +225,136 @@ h3 {
cursor: pointer;
font-size: small;
border: 3px dashed black;
border-radius: 4px;
border-radius: 1.2em;
margin: 8px auto;
padding: 4px 8px;
padding: 4px 16px;
}
.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: 75%;
}
}
@media only screen and (max-width: 768px) {
.container {
min-width: 96vw;
#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;
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) {
#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: rgba(0, 0, 0, 0.3);
font-size: xx-large;
margin-bottom: 20px;
margin-right: 20px;
margin-bottom: 16px;
margin-right: 16px;
}
}
::backdrop {
background-image: linear-gradient(
45deg,
magenta,
rebeccapurple,
dodgerblue,
green
);
background-image: linear-gradient(45deg,
magenta,
rebeccapurple,
dodgerblue,
green);
opacity: 0.75;
}
.tablink {
background-color: unset;
font-weight: unset;
color: black;
border: 2px solid black;
border-radius: unset;
outline: black;
.tab-button {
flex: 1;
background-color: grey;
border: none;
margin: 4px auto;
cursor: pointer;
padding: 8px 16px;
width: 50%;
opacity: 50%;
}
.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: 75%;
}
}
}
/* Style the tab content (and add height:100% for full page content) */
@@ -179,18 +372,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,6 +405,38 @@ button {
}
}
.avatar {
background-color: lightsteelblue;
padding: 3px 8px;
width: fit-content;
border: 3px solid black;
margin: 0 auto 16px auto;
ul {
min-width: 100px;
}
}
.user-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px 16px;
div {
text-align: left;
}
}
.networkroute {
z-index: 3;
position: absolute;
top: 24px;
left: 48px;
}
/*======SPINNER=======*/
.loader {
display: block;
position: relative;
@@ -211,6 +445,7 @@ button {
border: 4px solid black;
overflow: hidden;
}
.loader::after {
content: "";
width: 32%;
@@ -228,6 +463,7 @@ button {
left: 0;
transform: translateX(-100%);
}
100% {
left: 100%;
transform: translateX(0%);

View File

@@ -1,31 +1,46 @@
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";
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>
</>
<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 />} />
</Routes>
<Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter>
);
}
export default App;

165
src/Avatar.tsx Normal file
View File

@@ -0,0 +1,165 @@
import { createRef, MouseEventHandler, useEffect, useState } from "react";
import { useSession } from "./Session";
import { User } from "./api";
interface ContextMenuItem {
label: string;
onClick: () => void;
}
const UserInfo = (user: User) => {
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>number: </b>
</div>
<div>{user?.number ? user?.number : "-"}</div>
<div>
<b>email: </b>
</div>
<div>{user?.email ? user?.email : "-"}</div>
</div>
);
};
export default function Avatar() {
const { user, onLogout } = useSession();
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: "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() {
handleMenuClose();
if (user) {
dialogRef.current?.showModal();
setDialog(UserInfo(user));
}
}
return (
<div
className="avatar"
onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={(event) => {
if (contextMenu.open && event.target === avatarRef.current) {
handleMenuClose();
} else {
handleMenuClick(event);
}
}}
ref={avatarRef}
>
👤 {user?.username}
{contextMenu.open && (
<ul
className="context-menu"
ref={contextMenuRef}
style={{
zIndex: 3,
position: "absolute",
top: contextMenu.mouseY,
left: contextMenu.mouseX,
background: "white",
border: "1px solid #ddd",
padding: 0,
margin: 0,
listStyle: "none",
}}
>
{contextMenuItems.map((item, index) => (
<li
key={index}
style={{
padding: "10px",
borderBottom: "1px solid #ddd",
cursor: "pointer",
}}
onClick={() => {
item.onClick();
handleMenuClose();
}}
>
{item.label}
</li>
))}
</ul>
)}
<dialog
id="AvatarDialog"
ref={dialogRef}
onClick={(event) => {
event.stopPropagation();
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</div>
);
}

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;

37
src/Footer.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { useLocation } from "react-router";
import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() {
const location = useLocation();
const { user } = useSession();
return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
{user?.scopes.split(" ").includes("analysis") && (
<div className="navbar">
<Link to="/">
<span>Form</span>
</Link>
<span>|</span>
<Link to="/network">
<span>Trainer Analysis</span>
</Link>
<span>|</span>
<Link to="/mvp">
<span>MVP</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>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>
{loading && <span className="loader" />}
</form>
</>
);
};

36
src/MVPChart.tsx Normal file
View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart";
const MVPChart = () => {
const [data, setData] = useState({} as PlayerRanking[]);
const [loading, setLoading] = useState(true);
const [showStd, setShowStd] = useState(false);
async function loadData() {
setLoading(true);
await apiAuth("analysis/mvp", null)
.then((json) => json as Promise<PlayerRanking[]>)
.then((json) => {
setData(json.sort((a, b) => a.rank - b.rank));
});
setLoading(false);
}
useEffect(() => {
loadData();
}, []);
return (
<>
{loading ? (
<span className="loader" />
) : (
<RaceChart std={showStd} players={data} />
)}
</>
);
};
export default MVPChart;

291
src/Network.tsx Normal file
View File

@@ -0,0 +1,291 @@
import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api";
import {
GraphCanvas,
GraphCanvasRef,
GraphEdge,
GraphNode,
SelectionProps,
SelectionResult,
useSelection,
} from "reagraph";
import { customTheme } from "./NetworkTheme";
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 = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
const [loading, setLoading] = useState(true);
const [threed, setThreed] = useState(false);
const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false);
async function loadData() {
setLoading(true);
await apiAuth("analysis/graph_json", null)
.then((json) => json as Promise<NetworkData>)
.then((json) => {
setData(json);
});
setLoading(false);
}
useEffect(() => {
loadData();
}, []);
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",
});
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>
)}
{loading ? (
<span className="loader" />
) : (
<GraphCanvas
draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef}
theme={customTheme}
nodes={data.nodes}
edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/>
)}
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
</div>
);
};

59
src/NetworkTheme.tsx Normal file
View File

@@ -0,0 +1,59 @@
import { Theme } from "reagraph";
export const 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",
},
},
};

98
src/RaceChart.tsx Normal file
View File

@@ -0,0 +1,98 @@
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 }) => {
// State to store window's width and height
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight);
const height = players.length * 40;
// Update state on resize
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={0}
y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
/>
))}
{players.map((player, index) => (
<g key={"group" + index}>
<text
key={index + "_name"}
x={4}
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={
4 +
(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,8 @@
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 { useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
@@ -17,127 +13,91 @@ function PlayerList(props: PlayerListProps) {
<ReactSortable {...props} animation={200}>
{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;
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, 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[]>([]);
useEffect(() => {
setPlayersMiddle(otherPlayers);
}, [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?");
} 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");
}
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 };
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
const chemistry = (await apiAuth("chemistry", null, "GET")) as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(filterSort(otherPlayers, chemistry.undecided));
setPlayersRight(filterSort(otherPlayers, chemistry.love));
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
}}
/>
<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
drag people here that you'd rather not play with
</span>
)}
<PlayerList
@@ -145,7 +105,6 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
setList={setPlayersLeft}
group={"shared"}
className="dragbox"
orderedList
/>
</div>
<div className="box three">
@@ -178,6 +137,7 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="ChemistryDialog"
onClick={(event) => {
event.currentTarget.close();
@@ -189,29 +149,41 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
);
}
export function MVP({ user, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]);
function MVPDnD({ user, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
useEffect(() => {
setAvailablePlayers(players);
}, [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?");
} 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");
}
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps };
const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again");
}
async function handleGet() {
const mvps = (await apiAuth("mvps", null, "GET")) as MVPRanking;
setRankedPlayers(filterSort(players, mvps.mvps));
setAvailablePlayers(players.filter((user) => !mvps.mvps.includes(user.id)));
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}}
/>
<div className="container">
<div className="box two">
<h2>🥏🏃</h2>
@@ -257,6 +229,7 @@ export function MVP({ user, players }: PlayerInfoProps) {
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="MVPDialog"
onClick={(event) => {
event.currentTarget.close();
@@ -268,80 +241,79 @@ 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>
<LoadButton onClick={onLoad} />
<ClearButton onClick={onClear} />
</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 } = useSession();
const [players, setPlayers] = useState<User[] | null>(null);
const [openTab, setOpenTab] = useState("Chemistry");
async function loadPlayers() {
const response = await fetch(`${baseUrl}player/list`, {
method: "GET",
});
const data = await response.json();
setPlayers(data as Player[]);
try {
const data = await apiAuth("player/list", null, "GET");
setPlayers(data as User[]);
} catch (error) {
console.error(error);
}
}
useEffect(() => {
loadPlayers();
}, []);
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
{ 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>
{user && players && (
<div>
<div className="container navbar">
{tabs.map((tab) => (
<button
key={tab.id}
className={
openTab === tab.id ? "tab-button active" : "tab-button"
}
onClick={() => setOpenTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div id="Chemistry" className="tabcontent">
<Chemistry {...{ user, players }} />
</div>
<div id="MVP" className="tabcontent">
<MVP {...{ user, players }} />
</div>
</>
{tabs.map((tab) => {
if (openTab !== tab.id) return null;
switch (tab.id) {
case "Chemistry":
return <ChemistryDnD key={tab.id} {...{ user, players }} />;
case "MVP":
return <MVPDnD key={tab.id} {...{ user, players }} />;
default:
return null;
}
})}
</div>
)}
</>
);

90
src/Session.tsx Normal file
View File

@@ -0,0 +1,90 @@
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { currentUser, logout, User } from "./api";
import { Login } from "./Login";
import Header from "./Header";
export interface SessionProviderProps {
children: ReactNode;
}
export interface Session {
user: User | null;
onLogout: () => void;
}
const sessionContext = createContext<Session>({
user: null,
onLogout: () => {},
});
export function SessionProvider(props: SessionProviderProps) {
const { children } = props;
const [user, setUser] = 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));
}
useEffect(() => {
loadUser();
}, []);
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, onLogout }}>
{children}
</sessionContext.Provider>
);
return content;
}
export function useSession() {
return useContext(sessionContext);
}

161
src/SetPassword.tsx Normal file
View File

@@ -0,0 +1,161 @@
import { jwtDecode, JwtPayload } from "jwt-decode";
import { useEffect, useState } from "react";
import { baseUrl } from "./api";
import { useNavigate } from "react-router";
import eye from "./eye.svg";
import { Eye, EyeSlash } from "./Icons";
import { relative } from "path";
interface SetPassToken extends JwtPayload {
name: string;
}
export const SetPassword = () => {
const [name, setName] = useState("after getting your token.");
const [username, setUsername] = 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 navigate = useNavigate();
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const token = params.get("token");
if (token) {
setToken(token);
try {
const payload = jwtDecode<SetPassToken>(token);
if (payload.name) setName(payload.name);
else if (payload.sub) setName(payload.sub);
else setName("Mr. I-have-no Token");
payload.sub && setUsername(payload.sub);
} catch (InvalidTokenError) {
setName("Mr. I-have-no-valid Token");
}
}
}, []);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password === passwordr) {
setLoading(true);
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 setError("passwords are not the same");
}
return (
<>
<h2>
set your password,
<br />
{name}
</h2>
{username && (
<span>
your username is: <i>{username}</i>
</span>
)}
<form onSubmit={handleSubmit}>
<div
style={{
position: "relative",
display: "flex",
alignItems: "center",
}}
>
<div
style={{
width: "100%",
marginRight: "8px",
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
<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>
</div>
<div
style={{
position: "absolute",
right: 0,
margin: "auto 4px",
background: "unset",
fontSize: "xx-large",
cursor: "pointer",
}}
onClick={() => setVisible(!visible)}
>
{visible ? <Eye /> : <EyeSlash />}
</div>
</div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>
{loading && <span className="loader" />}
</form>
</>
);
};

View File

@@ -1,17 +1,103 @@
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");
}
}
return resp.json();
}
export type User = {
id: number;
username: string;
display_name: string;
full_name: string;
email: string;
player_id: number;
number: string;
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 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);
}
};

34
src/types.ts Normal file
View File

@@ -0,0 +1,34 @@
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 MVPRanking {
id: number;
user: number;
mvps: number[];
}

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"
]
}