62 Commits

Author SHA1 Message Date
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
26 changed files with 1912 additions and 518 deletions

View File

@@ -1,4 +1,3 @@
from datetime import datetime
import io
import base64
from fastapi import APIRouter
@@ -6,8 +5,9 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from db import Chemistry, Player, engine
from db import Chemistry, MVPRanking, Player, engine
import networkx as nx
import numpy as np
import matplotlib
matplotlib.use("agg")
@@ -18,21 +18,21 @@ analysis_router = APIRouter(prefix="/analysis")
C = Chemistry
R = MVPRanking
P = Player
def sociogram_json():
nodes = []
necessary_nodes = set()
links = []
edges = []
players = {}
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "appearance": 1})
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"))
.where(C.time > datetime(2025, 2, 1, 10))
.group_by(C.user)
.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)
@@ -40,25 +40,79 @@ def sociogram_json():
for c in session.exec(statement2):
# G.add_node(c.user)
necessary_nodes.add(c.user)
for p in c.love:
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)
links.append({"source": c.user, "target": 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, "links": links})
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.name)
G.add_node(p.display_name)
players[p.id] = p.display_name
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.time > datetime(2025, 2, 1, 10))
.group_by(C.user)
.subquery()
select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery()
)
statement2 = (
select(C)
@@ -67,10 +121,12 @@ def sociogram_data(show: int | None = 2):
)
for c in session.exec(statement2):
if show >= 1:
for i, p in enumerate(c.love):
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 in enumerate(c.hate):
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
@@ -143,8 +199,35 @@ async def render_sociogram(params: Params):
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:

42
db.py
View File

@@ -2,17 +2,22 @@ from datetime import datetime, timezone
from sqlmodel import (
ARRAY,
Column,
Integer,
Relationship,
SQLModel,
Field,
create_engine,
String,
)
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
@@ -39,36 +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 User(SQLModel, table=True):
username: str = Field(default=None, primary_key=True)
email: str | None = None
full_name: str | None = None
disabled: bool | None = None
hashed_password: str | None = None
player_id: int | None = Field(default=None, foreign_key="player.id")
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)

40
main.py
View File

@@ -1,4 +1,5 @@
from fastapi import APIRouter, Depends, FastAPI, status
from fastapi import APIRouter, Depends, FastAPI, Security
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
@@ -8,20 +9,21 @@ from sqlmodel import (
from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router
from security import (
change_password,
get_current_active_user,
login_for_access_token,
read_users_me,
logout,
read_player_me,
read_own_items,
set_first_password,
)
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(
@@ -54,7 +56,7 @@ def add_players(players: list[Player]):
def list_players():
with Session(engine) as session:
statement = select(Player).order_by(Player.name)
statement = select(Player).order_by(Player.display_name)
return session.exec(statement).fetchall()
@@ -67,24 +69,31 @@ 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)
@api_router.post("/mvps", dependencies=[Depends(get_current_active_user)])
def submit_mvps(mvps: MVPRanking):
with Session(engine) as session:
session.add(mvps)
session.commit()
return JSONResponse("success!")
@app.post("/chemistry/", status_code=status.HTTP_200_OK)
@api_router.post("/chemistry", dependencies=[Depends(get_current_active_user)])
def submit_chemistry(chemistry: Chemistry):
with Session(engine) as session:
session.add(chemistry)
session.commit()
return JSONResponse("success!")
class SPAStaticFiles(StaticFiles):
@@ -95,13 +104,16 @@ class SPAStaticFiles(StaticFiles):
return response
api_router.include_router(player_router)
api_router.include_router(team_router)
api_router.include_router(
analysis_router, dependencies=[Depends(get_current_active_user)]
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("/users/me/", endpoint=read_users_me, methods=["GET"])
api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"])
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,17 +10,18 @@
"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",

View File

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

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1,49 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- 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,19 +1,25 @@
from datetime import timedelta, timezone, datetime
from typing import Annotated
from fastapi import Depends, HTTPException, Response, status
from pydantic import BaseModel
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel, ValidationError
import jwt
from jwt.exceptions import InvalidTokenError
from jwt.exceptions import ExpiredSignatureError, InvalidTokenError
from sqlmodel import Session, select
from db import engine, User
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
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 = 30
access_token_expire_minutes: int = 15
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
@@ -24,17 +30,38 @@ config = Config()
class Token(BaseModel):
access_token: str
token_type: str
class TokenData(BaseModel):
username: str | None = None
scopes: list[str] = []
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token")
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):
@@ -47,10 +74,13 @@ def get_password_hash(password):
def get_user(username: str | None):
if username:
try:
with Session(engine) as session:
return session.exec(
select(User).where(User.username == username)
select(Player).where(Player.username == username)
).one_or_none()
except OperationalError:
return
def authenticate_user(username: str, password: str):
@@ -67,34 +97,61 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=15)
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)]):
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": "Bearer"},
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(token, config.secret_key, algorithms=["HS256"])
payload = jwt.decode(access_token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
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[User, Depends(get_current_user)],
current_user: Annotated[Player, Depends(get_current_user)],
):
if current_user.disabled:
raise HTTPException(status_code=400, detail="Inactive user")
@@ -111,23 +168,125 @@ async def login_for_access_token(
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=config.access_token_expire_minutes)
allowed_scopes = set(user.scopes.split())
requested_scopes = set(form_data.scopes)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
data={"sub": user.username, "scopes": list(allowed_scopes)}
)
response.set_cookie(
"Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none"
"access_token",
value=access_token,
httponly=True,
samesite="strict",
max_age=config.access_token_expire_minutes * 60,
)
return Token(access_token=access_token, token_type="bearer")
return Token(access_token=access_token)
async def read_users_me(
current_user: Annotated[User, Depends(get_current_active_user)],
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[User, Depends(get_current_active_user)],
current_user: Annotated[Player, Depends(get_current_active_user)],
):
return [{"item_id": "Foo", "owner": current_user.username}]

View File

@@ -17,13 +17,6 @@ import { apiAuth } from "./api";
// };
//};
//
interface Prop {
name: string;
min: string;
max: string;
step: string;
value: string;
}
interface Params {
nodeSize: number;
@@ -36,13 +29,7 @@ interface Params {
show: number;
}
interface DeferredProps {
timeout: number;
func: () => void;
}
let timeoutID: number | null = null;
let timeoutID: NodeJS.Timeout | null = null;
export default function Analysis() {
const [image, setImage] = useState("");
const [params, setParams] = useState<Params>({
@@ -65,9 +52,10 @@ export default function Analysis() {
.then((data) => {
setImage(data.image);
setLoading(false);
}).catch((e) => {
console.log("best to just reload... ", e);
})
.catch((e) => {
console.log("best to just reload... ", e);
});
}
useEffect(() => {
@@ -81,19 +69,35 @@ export default function Analysis() {
function showLabel() {
switch (params.show) {
case 0: return "dislike";
case 1: return "both";
case 2: return "like";
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 >
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>
@@ -109,7 +113,9 @@ export default function Analysis() {
max="2"
step="1"
width="16px"
onChange={(evt) => setParams({ ...params, show: Number(evt.target.value) })}
onChange={(evt) =>
setParams({ ...params, show: Number(evt.target.value) })
}
/>
<label>😍</label>
</div>
@@ -120,7 +126,9 @@ export default function Analysis() {
<input
type="checkbox"
checked={params.weighting}
onChange={(evt) => setParams({ ...params, weighting: evt.target.checked })}
onChange={(evt) =>
setParams({ ...params, weighting: evt.target.checked })
}
/>
<label>weighting</label>
</div>
@@ -129,7 +137,9 @@ export default function Analysis() {
<input
type="checkbox"
checked={params.popularity}
onChange={(evt) => setParams({ ...params, popularity: evt.target.checked })}
onChange={(evt) =>
setParams({ ...params, popularity: evt.target.checked })
}
/>
<label>popularity</label>
</div>
@@ -143,9 +153,12 @@ export default function Analysis() {
max="3.001"
step="0.05"
value={params.distance}
onChange={(evt) => setParams({ ...params, distance: Number(evt.target.value) })}
onChange={(evt) =>
setParams({ ...params, distance: Number(evt.target.value) })
}
/>
<span>{params.distance}</span></div>
<span>{params.distance}</span>
</div>
<div className="control">
<label>node size</label>
@@ -154,7 +167,9 @@ export default function Analysis() {
min="500"
max="3000"
value={params.nodeSize}
onChange={(evt) => setParams({ ...params, nodeSize: Number(evt.target.value) })}
onChange={(evt) =>
setParams({ ...params, nodeSize: Number(evt.target.value) })
}
/>
<span>{params.nodeSize}</span>
</div>
@@ -166,7 +181,9 @@ export default function Analysis() {
min="4"
max="24"
value={params.fontSize}
onChange={(evt) => setParams({ ...params, fontSize: Number(evt.target.value) })}
onChange={(evt) =>
setParams({ ...params, fontSize: Number(evt.target.value) })
}
/>
<span>{params.fontSize}</span>
</div>
@@ -179,7 +196,9 @@ export default function Analysis() {
max="5"
step="0.1"
value={params.edgeWidth}
onChange={(evt) => setParams({ ...params, edgeWidth: Number(evt.target.value) })}
onChange={(evt) =>
setParams({ ...params, edgeWidth: Number(evt.target.value) })
}
/>
<span>{params.edgeWidth}</span>
</div>
@@ -191,20 +210,19 @@ export default function Analysis() {
min="10"
max="50"
value={params.arrowSize}
onChange={(evt) => setParams({ ...params, arrowSize: Number(evt.target.value) })}
onChange={(evt) =>
setParams({ ...params, arrowSize: Number(evt.target.value) })
}
/>
<span>{params.arrowSize}</span>
</div>
</div>
<button onClick={() => loadImage()}>reload </button>
{
loading ? (
{loading ? (
<span className="loader"></span>
) : (
<img src={"data:image/png;base64," + image} width="86%" />
)
}
)}
</div>
);
}

View File

@@ -23,6 +23,114 @@ footer {
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 {
color: #444;
@@ -129,6 +237,7 @@ h3 {
button,
.button {
margin: 4px;
font-weight: bold;
font-size: large;
color: aliceblue;
@@ -168,13 +277,24 @@ button,
#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;
}
@@ -241,11 +361,17 @@ button,
font-size: 150%;
}
/*======LOGO=======*/
.logo {
position: relative;
text-align: center;
height: 140px;
margin-bottom: 20px;
span {
display: block;
margin: 2px;
}
img {
display: block;
@@ -268,6 +394,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;

View File

@@ -5,26 +5,41 @@ 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() {
//const [data, setData] = useState({ nodes: [], links: [] } as SociogramData);
//async function loadData() {
// await fetch(`${baseUrl}api/analysis/json`, { method: "GET" }).then(resp => resp.json() as unknown as SociogramData).then(json => { setData(json) })
//}
//useEffect(() => { loadData() }, [])
//
return (
<BrowserRouter>
<Routes>
<Route path="/password" element={<SetPassword />} />
<Route
path="/*"
element={
<SessionProvider>
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="/analysis" element={
<SessionProvider>
<Analysis />
</SessionProvider>
} />
<Route path="/network" element={<GraphComponent />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} />
</Routes>
<Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter>
);
}

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;

View File

@@ -1,12 +1,27 @@
import { useLocation } from "react-router";
import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() {
return <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>
<Link to="/">
<span>Form</span>
</Link>
<span>|</span>
<Link to="/analysis" ><span>Trainer Analysis</span></Link>
<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 />
@@ -18,4 +33,5 @@ export default function Footer() {
</a>
</p>
</footer>
);
}

View File

@@ -1,11 +1,18 @@
import { baseUrl } from "./api";
import { Link, useLocation } from "react-router";
import Avatar from "./Avatar";
export default function Header() {
return <div className="logo">
<a href={baseUrl}>
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>
</a>
</Link>
<span className="grey">cool ultimate team tool</span>
</div>
<Avatar />
</div>
);
}

72
src/Icons.tsx Normal file
View File

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

View File

@@ -1,6 +1,8 @@
import { FormEvent, useContext, useState } from "react";
import { useNavigate } from "react-router";
import { currentUser, login, LoginRequest, User } from "./api";
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;
@@ -9,78 +11,115 @@ export interface LoginProps {
export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<unknown>(null);
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(null);
const timeout = new Promise((r) => setTimeout(r, 1500));
setError("");
const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User;
try {
login({ username, password });
await login({ username, password });
user = await currentUser();
} catch (e) {
await timeout;
setError(e);
setError("failed");
setLoading(false);
return
return;
}
await timeout;
onLogin(user);
}
function handleClick() {
doLogin();
}
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 => setUsername(evt.target.value)} />
<input
type="text"
id="username"
name="username"
placeholder="username"
required
value={username}
onChange={(evt) => {
setError("");
setUsername(evt.target.value);
}}
/>
</div>
<div>
<input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} />
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</div>
<button type="submit" value="login" style={{ fontSize: "small" }} onClick={handleClick} >login</button>
</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>
)
}
/*
export default function Login(props: { onLogin: (user: User) => void }) {
const { onLogin } = props;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
async function handleLogin(e: FormEvent) {
e.preventDefault()
const timeout = new Promise((r) => setTimeout(r, 1500));
let user: User;
try {
login({ username, password })
user = await currentUser()
} catch (e) { await timeout; return }
await timeout;
onLogin(user);
}
return <div>
<form onSubmit={handleLogin}>
<div>
<input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} />
</div>
<div>
<input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} />
</div>
<input className="button" type="submit" value="login" onSubmit={handleLogin} />
</form>
</div>
} */
</>
);
};

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;

276
src/Network.tsx Normal file
View File

@@ -0,0 +1,276 @@
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() {
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 });
}
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: '#ddd',
stroke: 'transparent',
activeColor: '#1DE9AC'
}
},
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,7 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
import { useEffect, 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";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
@@ -17,116 +12,44 @@ 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) {
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>
)}
</>
);
}
interface PlayerInfoProps {
user: Player[];
players: Player[];
user: User;
players: User[];
}
export function Chemistry({ user, players }: PlayerInfoProps) {
const index = players.indexOf(user[0]);
const index = players.indexOf(user);
var otherPlayers = players.slice();
otherPlayers.splice(index, 1);
const [playersLeft, setPlayersLeft] = useState<Player[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<Player[]>([]);
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");
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");
}
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, "POST");
response ? setDialog(response) : setDialog("try sending again");
}
return (
@@ -188,24 +111,23 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
}
export function MVP({ user, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]);
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [dialog, setDialog] = useState("dialog");
useEffect(() => {
setAvailablePlayers(players);
}, [players]);
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");
}
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps };
const response = await apiAuth("mvps", data, "POST");
response ? setDialog(response) : setDialog("try sending again");
}
return (
@@ -267,24 +189,25 @@ export function MVP({ user, players }: PlayerInfoProps) {
}
export default function Rankings() {
const [user, setUser] = useState<Player[]>([]);
const [players, setPlayers] = useState<Player[]>([]);
const { user } = useSession();
const [players, setPlayers] = useState<User[]>([]);
const [openTab, setOpenTab] = useState("Chemistry");
async function loadPlayers() {
const response = await fetch(`${baseUrl}api/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);
}
}
useMemo(() => {
useEffect(() => {
loadPlayers();
}, []);
useEffect(() => {
user.length === 1 && openPage(openTab, "aliceblue");
openPage(openTab, "aliceblue");
}, [user]);
function openPage(pageName: string, color: string) {
@@ -313,9 +236,6 @@ export default function Rankings() {
}
return (
<>
<SelectUser {...{ user, setUser, players, setPlayers }} />
{user.length === 1 && (
<>
<div className="container navbar">
<button
@@ -334,17 +254,18 @@ export default function Rankings() {
</button>
</div>
<span className="grey">assign as many or as few players as you want<br />
and don't forget to <b>submit</b> (💾) when you're done :)</span>
<span className="grey">
assign as many or as few players as you want
<br />
and don't forget to <b>submit</b> (💾) when you're done :)
</span>
<div id="Chemistry" className="tabcontent">
<Chemistry {...{ user, players }} />
{user && <Chemistry {...{ user, players }} />}
</div>
<div id="MVP" className="tabcontent">
<MVP {...{ user, players }} />
{user && <MVP {...{ user, players }} />}
</div>
</>
)}
</>
);
}

View File

@@ -1,36 +1,86 @@
import { createContext, ReactNode, useContext, useLayoutEffect, useState } from "react";
import { currentUser, User } from "./api";
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;
}
const sessionContext = createContext<User | null>(null);
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); });
.then((user) => {
setUser(user);
setErr(null);
})
.catch((err) => {
setUser(null);
setErr(err);
})
.finally(() => setLoading(false));
}
useLayoutEffect(() => { loadUser(); }, [err]);
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 (!err && !user) content = <span className="loader" />;
else if (err) content = <Login onLogin={onLogin} />;
else content = <sessionContext.Provider value={user}>{children}</sessionContext.Provider>;
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;
}

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,31 +1,17 @@
export const baseUrl = import.meta.env.VITE_BASE_URL as string;
export const token = () => localStorage.getItem("access_token") 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),
});
let response: Response;
try {
response = await fetch(request);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
return response;
}
export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`, {
method: method, headers: {
"Authorization": `Bearer ${token()} `,
'Content-Type': 'application/json'
},
body: JSON.stringify(data),
credentials: "include",
...(data && { body: JSON.stringify(data) }),
});
let resp: Response;
try {
@@ -36,25 +22,31 @@ export async function apiAuth(path: string, data: any, method: string = "GET"):
if (!resp.ok) {
if (resp.status === 401) {
logout()
throw new Error('Unauthorized');
logout();
throw new Error("Unauthorized");
}
}
return resp.json()
return resp.json();
}
export type User = {
id: number;
username: string;
fullName: string;
}
display_name: string;
full_name: string;
email: string;
player_id: number;
number: string;
scopes: string;
};
export async function currentUser(): Promise<User> {
if (!token()) throw new Error("you have no access token")
const req = new Request(`${baseUrl}api/users/me/`, {
method: "GET", headers: {
"Authorization": `Bearer ${token()} `,
'Content-Type': 'application/json'
}
const req = new Request(`${baseUrl}api/player/me`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
});
let resp: Response;
try {
@@ -65,8 +57,8 @@ export async function currentUser(): Promise<User> {
if (!resp.ok) {
if (resp.status === 401) {
logout()
throw new Error('Unauthorized');
logout();
throw new Error("Unauthorized");
}
}
return resp.json() as Promise<User>;
@@ -76,18 +68,33 @@ export type LoginRequest = {
username: string;
password: string;
};
export type Token = {
access_token: string;
token_type: 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 login = (req: LoginRequest) => {
fetch(`${baseUrl}api/token`, {
method: "POST", headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}, body: new URLSearchParams(req).toString()
}).then(resp => resp.json() as Promise<Token>).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login"));
return Promise<void>
export const logout = async () => {
try {
await fetch(`${baseUrl}api/logout`, {
method: "POST",
credentials: "include",
});
} catch (e) {
console.error(e);
}
export const logout = () => localStorage.removeItem("access_token");
};

20
src/types.ts Normal file
View File

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

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