Compare commits

..

No commits in common. "main" and "feat/interactive_network" have entirely different histories.

35 changed files with 999 additions and 3769 deletions

195
analysis.py Normal file
View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

@ -2,22 +2,17 @@ from datetime import datetime, timezone
from sqlmodel import ( from sqlmodel import (
ARRAY, ARRAY,
Column, Column,
Integer,
Relationship, Relationship,
SQLModel, SQLModel,
Field, Field,
create_engine, create_engine,
String,
) )
with open("db.secrets", "r") as f: with open("db.secrets", "r") as f:
db_secrets = f.readline().strip() db_secrets = f.readline().strip()
engine = create_engine( engine = create_engine(db_secrets, connect_args={"connect_timeout": 8})
db_secrets,
pool_timeout=20,
pool_size=2,
connect_args={"connect_timeout": 8},
)
del db_secrets del db_secrets
@ -44,43 +39,36 @@ class Team(SQLModel, table=True):
class Player(SQLModel, table=True): class Player(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
username: str = Field(default=None, unique=True) name: str
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 number: str | None = None
teams: list[Team] = Relationship( teams: list[Team] | None = Relationship(
back_populates="players", link_model=PlayerTeamLink back_populates="players", link_model=PlayerTeamLink
) )
scopes: str = ""
class Chemistry(SQLModel, table=True): class Chemistry(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id") user: str
hate: list[int] = Field(sa_column=Column(ARRAY(Integer))) love: list[str] = Field(sa_column=Column(ARRAY(String)))
undecided: list[int] = Field(sa_column=Column(ARRAY(Integer))) hate: list[str] = Field(sa_column=Column(ARRAY(String)))
love: list[int] = Field(sa_column=Column(ARRAY(Integer))) undecided: list[str] = Field(sa_column=Column(ARRAY(String)))
team: int = Field(default=None, foreign_key="team.id")
class MVPRanking(SQLModel, table=True): class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id") user: str
mvps: list[int] = Field(sa_column=Column(ARRAY(Integer))) mvps: list[str] = Field(sa_column=Column(ARRAY(String)))
team: int = Field(default=None, foreign_key="team.id")
class TokenDB(SQLModel, table=True): class User(SQLModel, table=True):
token: str = Field(index=True, primary_key=True) username: str = Field(default=None, primary_key=True)
used: bool | None = False email: str | None = None
updated_at: datetime | None = Field( full_name: str | None = None
default_factory=utctime, sa_column_kwargs={"onupdate": utctime} disabled: bool | None = None
) hashed_password: str | None = None
player_id: int | None = Field(default=None, foreign_key="player.id")
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)

107
main.py Normal file
View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

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

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 1.4 KiB

137
security.py Normal file
View File

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

View File

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

View File

@ -1,3 +1,7 @@
* {
border-radius: 16px;
}
body { body {
background-color: aliceblue; background-color: aliceblue;
position: relative; position: relative;
@ -19,127 +23,11 @@ footer {
font-size: x-small; font-size: x-small;
} }
.fixed-footer {
position: absolute;
bottom: 4px;
left: 8px;
}
dialog {
border-radius: 1em;
}
/*=========Network Controls=========*/
.infobutton {
position: fixed;
right: 8px;
bottom: 8px;
padding: 0.4em;
border-radius: 1em;
background-color: rgba(0, 0, 0, 0.3);
font-size: medium;
margin-bottom: 16px;
margin-right: 16px;
}
.controls {
z-index: 9;
position: absolute;
color: black;
top: 1vh;
right: 0px;
padding: 8px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.control {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 240px;
margin: 0px;
background-color: #F0F8FFdd;
.slider,
span {
padding-left: 4px;
padding-right: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
margin: auto;
justify-content: center;
align-items: center;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 48px;
height: 24px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 34px;
-webkit-transition: .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 { .grey {
opacity: 66%; color: #444;
} }
.hint { .hint {
position: absolute; position: absolute;
font-size: 80%; font-size: 80%;
@ -153,9 +41,7 @@ input:checked+.slider:before {
input { input {
padding: 0.2em 16px; padding: 0.2em 16px;
margin-top: 0.25em; margin-bottom: 0.5em;
margin-bottom: 0.25em;
border-radius: 1em;
} }
h1, h1,
@ -197,9 +83,6 @@ h3 {
.box { .box {
position: relative; position: relative;
flex: 1; flex: 1;
border-width: 3px;
border-style: solid;
border-radius: 16px;
&.one { &.one {
max-width: min(96%, 768px); max-width: min(96%, 768px);
@ -208,6 +91,8 @@ h3 {
padding: 4px; padding: 4px;
margin: 4px 0.5%; margin: 4px 0.5%;
border-style: solid;
border-color: black;
} }
.reservoir { .reservoir {
@ -217,13 +102,24 @@ h3 {
width: 100%; width: 100%;
} }
.user {
max-width: 240px;
min-width: 100px;
margin: 4px auto;
.item {
font-weight: bold;
border-style: solid;
}
}
.item { .item {
cursor: pointer; cursor: pointer;
font-size: medium; font-size: small;
border: 2px solid; border: 3px dashed black;
border-radius: 1em; border-radius: 1.2em;
margin: 3px auto; margin: 8px auto;
padding: 5px 0.8em; padding: 4px 16px;
} }
.extra-margin { .extra-margin {
@ -231,17 +127,14 @@ h3 {
margin: auto; margin: auto;
} }
button { button,
margin: 4px; .button {
font-weight: bold; font-weight: bold;
font-size: large;
color: aliceblue; color: aliceblue;
background-color: black; background-color: black;
border-radius: 1.2em; border-radius: 1.2em;
z-index: 1; z-index: 1;
&:hover {
opacity: 80%;
}
} }
#control-panel { #control-panel {
@ -259,7 +152,6 @@ button {
.control { .control {
display: flex; display: flex;
border-radius: 16px;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -276,24 +168,13 @@ button {
#control-panel { #control-panel {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.control {
font-size: 80%;
margin: 0px;
}
} }
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
#control-panel { #control-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.networkroute {
display: none;
}
.submit_text { .submit_text {
display: none; display: none;
} }
@ -304,15 +185,10 @@ button {
bottom: 16px; bottom: 16px;
padding: 0.4em; padding: 0.4em;
border-radius: 1em; border-radius: 1em;
background-color: #36c8; background-color: rgba(0, 0, 0, 0.3);
font-size: xx-large; font-size: xx-large;
margin-bottom: 16px; margin-bottom: 16px;
margin-right: 16px; margin-right: 16px;
}
.wavering {
animation: blink 40s infinite;
} }
} }
@ -325,21 +201,11 @@ button {
opacity: 0.75; opacity: 0.75;
} }
.tab-button { .tablink {
color: black;
flex: 1;
background-color: #bfbfbf;
border: none;
margin: 4px auto;
cursor: pointer;
opacity: 80%;
}
.tab-button.active {
opacity: unset;
font-weight: bold;
background-color: black;
color: white; color: white;
cursor: pointer;
flex: 1;
margin: 4px auto;
} }
.navbar { .navbar {
@ -355,7 +221,7 @@ button {
opacity: 50%; opacity: 50%;
&:hover { &:hover {
opacity: 80%; opacity: 75%;
} }
} }
} }
@ -375,17 +241,11 @@ button {
font-size: 150%; font-size: 150%;
} }
/*======LOGO=======*/
.logo { .logo {
position: relative; position: relative;
text-align: center; text-align: center;
height: 140px; height: 140px;
margin-bottom: 20px;
span {
display: block;
margin: 2px;
}
img { img {
display: block; display: block;
@ -408,209 +268,11 @@ button {
} }
} }
.avatars {
margin: 16px auto;
}
.avatar {
background-color: #f0f8ff88;
font-weight: bold;
font-size: 110%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
}
.group-avatar {
background-color: #f0f8ff88;
color: inherit;
font-weight: bold;
font-size: 90%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
}
.user-info {
display: grid;
grid-template-columns: 8em 12em;
gap: 2px 16px;
div {
text-align: left;
}
}
/*=======CONTEXT MENU=======*/
.context-menu {
z-index: 3;
min-width: 8em;
position: absolute;
background: aliceblue;
box-shadow: 4px 4px black;
color: black;
border: 3px solid black;
border-radius: 16px;
padding: 0;
margin: 0;
list-style: none;
li {
padding: 4px 0.5em;
border-bottom: 2px solid #0008;
border-radius: 0;
cursor: pointer;
}
li:last-child {
border-bottom: none;
}
}
.networkroute {
z-index: 3;
position: absolute;
top: 24px;
left: 48px;
}
/*========TEAM PANEL========*/
.team-panel {
max-width: 800px;
padding: 1em;
border: 3px solid black;
box-shadow: 8px 8px black;
margin: 1em;
input {
max-width: 300px;
margin: 0.2em auto;
}
}
.team-player {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.4em;
margin: 4px;
padding: 0.2em 0.5em;
&:hover {
background-color: #36c8;
}
&.new-player {
background-color: #3838;
}
&.disable-player {
background-color: #e338;
}
}
.new-player-inputs {
display: flex;
flex-direction: column;
margin: auto;
div {
display: grid;
grid-template-columns: 20ch auto;
@media only screen and (max-width: 768px) {
grid-template-columns: auto;
place-items: center;
}
label {
text-align: left;
width: 20ch;
margin: auto 1em;
}
input {
width: 90%;
margin: 4px 0;
}
}
}
@keyframes blink {
0% {
background-color: #8888;
}
13% {
background-color: #8888;
}
15% {
background-color: #f00a;
}
17% {
background-color: #8888;
}
38% {
background-color: #8888;
}
40% {
background-color: #ff0a;
}
42% {
background-color: #8888;
}
63% {
background-color: #8888;
}
65% {
background-color: #248f24aa;
}
67% {
background-color: #8888;
}
88% {
background-color: #8888;
}
90% {
background-color: #4700b3aa;
}
92% {
background-color: #8888;
}
100% {
background-color: #8888;
}
}
/*======SPINNER=======*/
.loader { .loader {
display: block; display: block;
border-radius: 16px;
position: relative; position: relative;
height: 12px; height: 12px;
width: 96%; width: 96%;
margin: auto;
border: 4px solid black; border: 4px solid black;
overflow: hidden; overflow: hidden;
} }

View File

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

View File

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

View File

@ -1,95 +0,0 @@
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,31 +1,12 @@
import { useLocation } from "react-router";
import { Link } from "react-router"; import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() { export default function Footer() {
const location = useLocation(); return <footer>
const { user } = useSession();
return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
{user?.scopes.split(" ").includes("analysis") && (
<div className="navbar"> <div className="navbar">
<Link to="/"> <Link to="/" ><span>Form</span></Link>
<span>Form</span>
</Link>
<span>|</span> <span>|</span>
<Link to="/network"> <Link to="/network" ><span>Trainer Analysis</span></Link>
<span>Trainer Analysis</span>
</Link>
<span>|</span>
<Link to="/mvp">
<span>MVP</span>
</Link>
<span>|</span>
<Link to="/team">
<span>Team</span>
</Link>
</div> </div>
)}
<p className="grey extra-margin"> <p className="grey extra-margin">
something not working? something not working?
<br /> <br />
@ -37,5 +18,4 @@ export default function Footer() {
</a> </a>
</p> </p>
</footer> </footer>
);
} }

View File

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

View File

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

View File

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

View File

@ -1,318 +1,46 @@
import { ReactNode, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, useSelection } from "reagraph";
GraphCanvas,
GraphCanvasRef,
GraphEdge,
GraphNode,
SelectionProps,
SelectionResult,
useSelection,
} from "reagraph";
import { customTheme } from "./NetworkTheme";
import { useSession } from "./Session";
import { useNavigate } from "react-router";
interface NetworkData { interface NetworkData {
nodes: GraphNode[]; nodes: GraphNode[],
edges: GraphEdge[]; edges: GraphEdge[],
} }
interface CustomSelectionProps extends SelectionProps {
ignore: (GraphEdge | undefined)[];
}
const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
var result = useSelection(props);
result.actives = result.actives.filter(
(s) => !props.ignore.map((edge) => edge?.id).includes(s)
);
const ignored_nodes = props.ignore.map((edge) =>
edge &&
result.selections?.includes(edge.source) &&
!result.selections?.includes(edge.target)
? edge.target
: ""
);
result.actives = result.actives.filter((s) => !ignored_nodes.includes(s));
return result;
};
export const GraphComponent = () => { export const GraphComponent = () => {
let initialData = { nodes: [], edges: [] } as NetworkData; const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [threed, setThreed] = useState(false);
const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false);
const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
navigate("/", { replace: true });
}, [user]);
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
if (teams) { await apiAuth("analysis/graph_json", null)
await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null) .then(json => json as Promise<NetworkData>).then(json => { setData(json) })
.then((data) => {
if (data.detail) {
setError(data.detail);
return initialData;
} else {
setError("");
return data as Promise<NetworkData>;
}
})
.then((data) => setData(data))
.catch(() => setError("no access"));
setLoading(false); setLoading(false);
} else setError("team unknown");
} }
useEffect(() => { useEffect(() => { loadData() }, [])
loadData();
}, [teams]);
const graphRef = useRef<GraphCanvasRef | null>(null); const graphRef = useRef<GraphCanvasRef | null>(null);
function handleThreed() { const { selections, actives, onNodeClick, onCanvasClick } = useSelection({
setThreed(!threed);
graphRef.current?.fitNodesInView();
graphRef.current?.centerGraph();
graphRef.current?.resetControls();
}
function handlePopularity() {
popularityLabel(!popularity);
setPopularity(!popularity);
}
function handleMutuality() {
colorMatches(!mutuality);
setMutuality(!mutuality);
}
function showLabel() {
switch (likes) {
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
function findMatches(edges: GraphEdge[]) {
const adjacencyList = edges.map(
(edge) => edge.source + edge.target + edge.data.relation
);
return edges.filter((edge) =>
adjacencyList.includes(edge.target + edge.source + edge.data.relation)
);
}
//const matches = useMemo(() => findMatches(data.edges), [])
function colorMatches(mutuality: boolean) {
const matches = findMatches(data.edges);
const newEdges = data.edges;
if (mutuality) {
newEdges.forEach((edge) => {
if (
(likes === 1 || edge.data.relation === likes) &&
matches.map((edge) => edge.id).includes(edge.id)
) {
edge.fill = "#9c3";
if (edge.size) edge.size = edge.size * 1.5;
}
});
} else {
newEdges.forEach((edge) => {
if (
(likes === 1 || edge.data.relation === likes) &&
matches.map((edge) => edge.id).includes(edge.id)
) {
edge.fill = edge.data.origFill;
edge.size = edge.data.origSize;
}
});
}
setData({ nodes: data.nodes, edges: newEdges });
}
function popularityLabel(popularity: boolean) {
const newNodes = data.nodes;
console.log(data.nodes);
if (popularity) {
newNodes.forEach(
(node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`)
);
} else {
newNodes.forEach((node) => (node.subLabel = undefined));
}
setData({ nodes: newNodes, edges: data.edges });
}
useEffect(() => {
if (mutuality) colorMatches(false);
colorMatches(mutuality);
}, [likes]);
const {
selections,
actives,
onNodeClick,
onCanvasClick,
onNodePointerOver,
onNodePointerOut,
} = useCustomSelection({
ref: graphRef, ref: graphRef,
nodes: data.nodes, nodes: data.nodes,
edges: data.edges.filter((edge) => edge.data.relation === likes), edges: data.edges,
ignore: data.edges.map((edge) => { pathSelectionType: 'out'
if (likes === 1 && edge.data.relation !== 2) return edge;
}),
pathSelectionType: "out",
pathHoverType: "in",
type: "multiModifier",
}); });
let content: ReactNode; return (
if (loading) {
content = <span className="loader" />;
} else if (error) {
content = <span>{error}</span>;
} else {
content = (
<>
<GraphCanvas <GraphCanvas
draggable draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef} ref={graphRef}
theme={customTheme}
nodes={data.nodes} nodes={data.nodes}
edges={data.edges.filter( edges={data.edges}
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections} selections={selections}
actives={actives} actives={actives}
onCanvasClick={onCanvasClick} onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/> />
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
</>
); );
}
return ( }
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls">
<div className="control" onClick={handleMutuality}>
<div className="switch">
<input type="checkbox" checked={mutuality} onChange={() => {}} />
<span className="slider round"></span>
</div>
<span>mutuality</span>
</div>
<div className="control" onClick={handleThreed}>
<span>2D</span>
<div className="switch">
<input type="checkbox" checked={threed} onChange={() => {}} />
<span className="slider round"></span>
</div>
<span>3D</span>
</div>
<div className="control">
<div className="stack column">
<datalist id="markers">
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
</datalist>
<div id="three-slider">
<label>😬</label>
<input
type="range"
list="markers"
min="0"
max="2"
step="1"
width="16px"
onChange={(evt) => setLikes(Number(evt.target.value))}
/>
<label>😍</label>
</div>
{showLabel()}
</div>
</div>
<div className="control" onClick={handlePopularity}>
<div className="switch">
<input type="checkbox" checked={popularity} onChange={() => {}} />
<span className="slider round"></span>
</div>
<span>
popularity<sup>*</sup>
</span>
</div>
</div>
{popularity && (
<div
style={{ position: "absolute", bottom: 0, right: "10px", zIndex: 10 }}
>
<span className="grey" style={{ fontSize: "70%" }}>
<sup>*</sup>popularity meassured by rank-weighted in-degree
</span>
</div>
)}
{content}
</div>
);
};

View File

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

View File

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

View File

@ -1,16 +1,12 @@
import { import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react";
ButtonHTMLAttributes,
Fragment,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs"; import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, loadPlayers, User } from "./api"; import api, { baseUrl } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types"; interface Player {
import TabController from "./TabController"; id: number;
name: string;
number: string | null;
}
type PlayerListProps = Partial<ReactSortableProps<any>> & { type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean; orderedList?: boolean;
@ -18,110 +14,123 @@ type PlayerListProps = Partial<ReactSortableProps<any>> & {
function PlayerList(props: PlayerListProps) { function PlayerList(props: PlayerListProps) {
return ( return (
<ReactSortable {...props} animation={200} swapThreshold={0.4}> <ReactSortable {...props} animation={200}>
{props.list?.map((item, index) => ( {props.list?.map((item, index) => (
<div key={item.id} className="item"> <div key={item.id} className="item">
{props.orderedList {props.orderedList ? index + 1 + ". " + item.name : item.name}
? index + 1 + ". " + item.display_name
: item.display_name}
</div> </div>
))} ))}
</ReactSortable> </ReactSortable>
); );
} }
const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { interface SelectUserProps {
return ( user: Player[];
<button {...props} style={{ padding: "4px 16px" }}> setUser: Dispatch<SetStateAction<Player[]>>;
🗃 restore previous players: Player[];
</button> setPlayers: Dispatch<SetStateAction<Player[]>>;
); }
};
const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { export function SelectUser({
user,
setUser,
players,
setPlayers,
}: SelectUserProps) {
return ( return (
<button {...props} style={{ padding: "4px 16px" }}> <>
🗑 start over <div className="box user">
</button> {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>
)}
</>
); );
};
function filterSort(list: User[], ids: number[]): User[] {
const objectMap = new Map(list.map((obj) => [obj.id, obj]));
const filteredAndSortedObjects = ids
.map((id) => objectMap.get(id))
.filter((obj) => obj !== undefined);
return filteredAndSortedObjects;
} }
interface PlayerInfoProps { interface PlayerInfoProps {
user: User; user: Player[];
teams: TeamState; players: Player[];
players: User[];
} }
function ChemistryDnD({ user, teams, players }: PlayerInfoProps) { export function Chemistry({ user, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id); const index = players.indexOf(user[0]);
const [playersLeft, setPlayersLeft] = useState<User[]>([]); var otherPlayers = players.slice();
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers); otherPlayers.splice(index, 1);
const [playersRight, setPlayersRight] = useState<User[]>([]); const [playersLeft, setPlayersLeft] = useState<Player[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers);
useEffect(() => { const [playersRight, setPlayersRight] = useState<Player[]>([]);
setPlayersMiddle(otherPlayers);
}, [players]);
useEffect(() => {
setPlayersLeft([]);
setPlayersMiddle(otherPlayers);
setPlayersRight([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() { async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal(); const dialog = document.querySelector("dialog[id='ChemistryDialog']");
(dialog as HTMLDialogElement).showModal();
if (user.length < 1) {
setDialog("who are you?");
} else {
setDialog("sending..."); setDialog("sending...");
let left = playersLeft.map(({ id }) => id); let _user = user.map(({ name }) => name)[0];
let middle = playersMiddle.map(({ id }) => id); let left = playersLeft.map(({ name }) => name);
let right = playersRight.map(({ id }) => id); let middle = playersMiddle.map(({ name }) => name);
const data = { let right = playersRight.map(({ name }) => name);
user: user.id, const data = { user: _user, hate: left, undecided: middle, love: right };
hate: left, const response = await api("chemistry", data);
undecided: middle, response.ok ? setDialog("success!") : setDialog("try sending again");
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(
otherPlayers.filter(
(player) =>
!chemistry.hate.includes(player.id) &&
!chemistry.love.includes(player.id)
)
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
} }
} }
return ( return (
<> <>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
}}
/>
<div className="container"> <div className="container">
<div className="box three"> <div className="box three">
<h2>😬</h2> <h2>😬</h2>
@ -163,11 +172,10 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
</div> </div>
</div> </div>
<button className="submit wavering" onClick={() => handleSubmit()}> <button className="submit" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span> 💾 <span className="submit_text">submit</span>
</button> </button>
<dialog <dialog
ref={dialogRef}
id="ChemistryDialog" id="ChemistryDialog"
onClick={(event) => { onClick={(event) => {
event.currentTarget.close(); event.currentTarget.close();
@ -179,52 +187,29 @@ function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
); );
} }
function MVPDnD({ user, teams, players }: PlayerInfoProps) { export function MVP({ user, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]); const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]);
useEffect(() => {
setAvailablePlayers(players);
}, [players]);
useEffect(() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog"); const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() { async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal(); const dialog = document.querySelector("dialog[id='MVPDialog']");
(dialog as HTMLDialogElement).showModal();
if (user.length < 1) {
setDialog("who are you?");
} else {
setDialog("sending..."); setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id); let _user = user.map(({ name }) => name)[0];
const data = { user: user.id, mvps: mvps, team: teams.activeTeam }; let mvps = rankedPlayers.map(({ name }) => name);
const response = await apiAuth("mvps", data, "PUT"); const data = { user: _user, mvps: mvps };
response ? setDialog(response) : setDialog("try sending again"); const response = await api("mvps", data);
} response.ok ? setDialog("success!") : setDialog("try sending again");
async function handleGet() {
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
const mvps = data as MVPRanking;
setRankedPlayers(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
} }
} }
return ( return (
<> <>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}}
/>
<div className="container"> <div className="container">
<div className="box two"> <div className="box two">
<h2>🥏🏃</h2> <h2>🥏🏃</h2>
@ -266,11 +251,10 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
</div> </div>
</div> </div>
<button className="submit wavering" onClick={() => handleSubmit()}> <button className="submit" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span> 💾 <span className="submit_text">submit</span>
</button> </button>
<dialog <dialog
ref={dialogRef}
id="MVPDialog" id="MVPDialog"
onClick={(event) => { onClick={(event) => {
event.currentTarget.close(); event.currentTarget.close();
@ -282,51 +266,84 @@ function MVPDnD({ user, teams, players }: PlayerInfoProps) {
); );
} }
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() { export default function Rankings() {
const { user, teams } = useSession(); const [user, setUser] = useState<Player[]>([]);
const [players, setPlayers] = useState<User[] | null>(null); const [players, setPlayers] = useState<Player[]>([]);
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[]);
}
useMemo(() => {
loadPlayers();
}, []);
useEffect(() => { useEffect(() => {
if (teams) { user.length === 1 && openPage(openTab, "aliceblue");
loadPlayers(teams.activeTeam).then((data) => setPlayers(data)); }, [user]);
}
}, [user, teams]);
const tabs = [ function openPage(pageName: string, color: string) {
{ id: "Chemistry", label: "🧪 Chemistry" }, // Hide all elements with class="tabcontent" by default */
{ id: "MVP", label: "🏆 MVP" }, var i, tabcontent, tablinks;
]; tabcontent = document.getElementsByClassName("tabcontent");
for (i = 0; i < tabcontent.length; i++) {
(tabcontent[i] as HTMLElement).style.display = "none";
}
// Remove the background color of all tablinks/buttons
tablinks = document.getElementsByClassName("tablink");
for (i = 0; i < tablinks.length; i++) {
let button = tablinks[i] as HTMLElement;
button.style.opacity = "50%";
}
// Show the specific tab content
(document.getElementById(pageName) as HTMLElement).style.display = "block";
// Add the specific color to the button used to open the tab content
let activeButton = document.getElementById(
pageName + "Button"
) as HTMLElement;
activeButton.style.fontWeight = "bold";
activeButton.style.opacity = "100%";
document.body.style.backgroundColor = color;
setOpenTab(pageName);
}
return ( return (
<> <>
{user && teams && players ? ( <SelectUser {...{ user, setUser, players, setPlayers }} />
<TabController tabs={tabs}> {user.length === 1 && (
<ChemistryDnD {...{ user, teams, players }} /> <>
<MVPDnD {...{ user, teams, players }} /> <div className="container navbar">
</TabController> <button
) : ( className="tablink"
<span className="loader" /> id="ChemistryButton"
onClick={() => openPage("Chemistry", "aliceblue")}
>
🧪 Chemistry
</button>
<button
className="tablink"
id="MVPButton"
onClick={() => openPage("MVP", "aliceblue")}
>
🏆 MVP
</button>
</div>
<span className="grey">assign as many or as few players as you want<br />
and don't forget to <b>submit</b> (💾) when you're done :)</span>
<div id="Chemistry" className="tabcontent">
<Chemistry {...{ user, players }} />
</div>
<div id="MVP" className="tabcontent">
<MVP {...{ user, players }} />
</div>
</>
)} )}
</> </>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,20 +1,35 @@
import { useSession } from "./Session";
export const baseUrl = import.meta.env.VITE_BASE_URL as string; export const baseUrl = import.meta.env.VITE_BASE_URL as string;
export const token = () => localStorage.getItem("access_token") as string;
export async function apiAuth( export default async function api(path: string, data: any): Promise<any> {
path: string, const request = new Request(`${baseUrl}${path}/`, {
data: any, method: "POST",
method: string = "GET"
): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`, {
method: method,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
credentials: "include", body: JSON.stringify(data),
...(data && { body: JSON.stringify(data) }),
}); });
let response: Response;
try {
response = await fetch(request);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
return response;
}
export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`,
{
method: method,
headers: {
"Authorization": `Bearer ${token()} `,
'Content-Type': 'application/json'
},
...(data && { body: JSON.stringify(data) })
}
);
let resp: Response; let resp: Response;
try { try {
resp = await fetch(req); resp = await fetch(req);
@ -24,41 +39,25 @@ export async function apiAuth(
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) { if (resp.status === 401) {
const { onLogout } = useSession(); logout()
onLogout(); throw new Error('Unauthorized');
throw new Error("Unauthorized");
} }
} }
const contentType = resp.headers.get("Content-Type"); return resp.json()
switch (contentType) {
case "application/json":
return resp.json();
case "text/plain":
case "text/plain; charset=utf-8":
return resp.text();
case null:
return null;
default:
throw new Error(`Unsupported content type: ${contentType}`);
}
} }
export type User = { export type User = {
id: number;
username: string; username: string;
display_name: string; fullName: string;
email: string; }
number: string;
scopes: string;
};
export async function currentUser(): Promise<User> { export async function currentUser(): Promise<User> {
const req = new Request(`${baseUrl}api/player/me`, { if (!token()) throw new Error("you have no access token")
method: "GET", const req = new Request(`${baseUrl}api/users/me/`, {
headers: { method: "GET", headers: {
"Content-Type": "application/json", "Authorization": `Bearer ${token()} `,
}, 'Content-Type': 'application/json'
credentials: "include", }
}); });
let resp: Response; let resp: Response;
try { try {
@ -69,54 +68,29 @@ export async function currentUser(): Promise<User> {
if (!resp.ok) { if (!resp.ok) {
if (resp.status === 401) { if (resp.status === 401) {
logout(); logout()
throw new Error("Unauthorized"); throw new Error('Unauthorized');
} }
} }
return resp.json() as Promise<User>; return resp.json() as Promise<User>;
} }
export async function loadPlayers(teamId: number) {
try {
const data = await apiAuth(`player/${teamId}/list`, null, "GET");
return data as User[];
} catch (error) {
console.error(error);
return null;
}
}
export type LoginRequest = { export type LoginRequest = {
username: string; username: string;
password: string; password: string;
}; };
export type Token = {
export const login = async (req: LoginRequest): Promise<void> => { access_token: string;
try { token_type: string;
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 () => { export const login = (req: LoginRequest) => {
try { fetch(`${baseUrl}api/token`, {
await fetch(`${baseUrl}api/logout`, { method: "POST", headers: {
method: "POST", 'Content-Type': 'application/x-www-form-urlencoded',
credentials: "include", }, 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"));
} catch (e) { return Promise<void>
console.error(e); }
}
}; export const logout = () => localStorage.removeItem("access_token");

View File

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

View File

@ -1,46 +0,0 @@
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[];
}
export interface Team {
id: number;
name: string;
location: string;
country: string;
}
export type ErrorState = {
ok: boolean;
message: string;
};

View File

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