Compare commits
	
		
			113 Commits
		
	
	
		
			df94b151a6
			...
			feat/demo
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cb2b7db7a6 | |||
| 1c71df781c | |||
| 6378488fd0 | |||
| 6902ffdca6 | |||
| a6d0f528d0 | |||
| 77d292974c | |||
| 43f9b0d47c | |||
| bef5119a0b | |||
| ee13d06ab1 | |||
| 03ed843679 | |||
| 81d6a02229 | |||
| 11f3f9f440 | |||
| 0507b9f7c4 | |||
| e701ebbb02 | |||
| d3daa83d68 | |||
| 90adb4fc9c | |||
| 19ae4a18ca | |||
| fc8592f8ab | |||
| 195d240a87 | |||
| df16497476 | |||
| 8b4ee3b289 | |||
| e88eb02ef1 | |||
| c04a1e03f2 | |||
| 691b99daa8 | |||
| 8c938a7ebc | |||
| 8bc38a10a4 | |||
| 0397725bda | |||
| a97eee842e | |||
| ab3ed9b497 | |||
| d9ad903798 | |||
| b28752830a | |||
| 7f4f6142c9 | |||
| ded2b79db7 | |||
| c246a0b264 | |||
| 054508cf6a | |||
| 3441e405a6 | |||
| 8f355c0cf3 | |||
| 4252e737d7 | |||
| 39630725a4 | |||
| 641ae50265 | |||
| 2500a8d293 | |||
| 719c57200d | |||
| a663b34500 | |||
| 8191587115 | |||
| 9ec457bb7a | |||
| 953a166ec5 | |||
| 453d7ca951 | |||
| 9afa4a88a8 | |||
| 630986d49c | |||
| 4f30888c5c | |||
| 5b8f476997 | |||
| e4c95c37ee | |||
| 2a396457aa | |||
| 34c030c1e9 | |||
| 6eb2563068 | |||
| 1067b12be8 | |||
| c42231907d | |||
| 95e66e5d73 | |||
| 6d2bf057a5 | |||
| b07c2fd8ab | |||
| 82ffa06a00 | |||
| 00442be4b5 | |||
| 26ee4b84a9 | |||
| aa3c3df5da | |||
| 401ac316c1 | |||
| 53fc8bb6e3 | |||
| 92a98064e5 | |||
| 1773a9885a | |||
| 9996752d94 | |||
| b386ee365f | |||
| 045c26d258 | |||
| a37971ed86 | |||
| f3e6382101 | |||
| 59e2fc4502 | |||
| 33c505fee4 | |||
| cfe2df01f7 | |||
| 7580a4f1e6 | |||
| 7bf35b65fb | |||
| d3f5c3cb82 | |||
| 8b092fed51 | |||
| 99e80c8077 | |||
| 854bd03c40 | |||
| bc6c2a4a98 | |||
| b7c8136b1e | |||
| b8c4190072 | |||
| d61bea3c86 | |||
| 70a4ece5bc | |||
| 406ea9ffdd | |||
| 104ec70695 | |||
| 9d65c1d1df | |||
| de79970987 | |||
| a52dae5605 | |||
| a46427c6b8 | |||
| fd323db6d0 | |||
| c2d94c0400 | |||
| f94c3402c2 | |||
| 5c21cf1fc3 | |||
| 5cd793b278 | |||
| de8688133f | |||
| d6e5d0334c | |||
| 5fef47f692 | |||
| 978aafc204 | |||
| 47fd9bd859 | |||
| 13bb965b28 | |||
| 5405c3e12f | |||
| 1eab163e10 | |||
| 7c054d6ba3 | |||
| 4a46cd505d | |||
| 1fa91a7228 | |||
| 8e91724462 | |||
| 1a1b44743a | |||
| 827eceed2b | |||
| 96f04e6d90 | 
							
								
								
									
										154
									
								
								analysis.py
									
									
									
									
									
								
							
							
						
						
									
										154
									
								
								analysis.py
									
									
									
									
									
								
							| @@ -1,154 +0,0 @@ | ||||
| from datetime import datetime | ||||
| import io | ||||
| import base64 | ||||
| from fastapi import APIRouter | ||||
| from fastapi.responses import JSONResponse | ||||
| from pydantic import BaseModel, Field | ||||
| from sqlmodel import Session, func, select | ||||
| from sqlmodel.sql.expression import SelectOfScalar | ||||
| from db import Chemistry, Player, engine | ||||
| import networkx as nx | ||||
| import matplotlib | ||||
|  | ||||
| matplotlib.use("agg") | ||||
| import matplotlib.pyplot as plt | ||||
|  | ||||
|  | ||||
| analysis_router = APIRouter(prefix="/analysis") | ||||
|  | ||||
|  | ||||
| C = Chemistry | ||||
| P = Player | ||||
|  | ||||
|  | ||||
| def sociogram_json(): | ||||
|     nodes = [] | ||||
|     necessary_nodes = set() | ||||
|     links = [] | ||||
|     with Session(engine) as session: | ||||
|         for p in session.exec(select(P)).fetchall(): | ||||
|             nodes.append({"id": p.name, "appearance": 1}) | ||||
|         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) | ||||
|                 links.append({"source": c.user, "target": p}) | ||||
|     # nodes = [n for n in nodes if n["name"] in necessary_nodes] | ||||
|     return JSONResponse({"nodes": nodes, "links": links}) | ||||
|  | ||||
|  | ||||
| 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("/image", endpoint=render_sociogram, methods=["POST"]) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     with Session(engine) as session: | ||||
|         statement: SelectOfScalar[P] = select(func.count(P.id)) | ||||
|         print("players in DB: ", session.exec(statement).first()) | ||||
|     G = sociogram_data() | ||||
|     pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42) | ||||
							
								
								
									
										0
									
								
								cutt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								cutt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										433
									
								
								cutt/analysis.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										433
									
								
								cutt/analysis.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,433 @@ | ||||
| import io | ||||
| import itertools | ||||
| import random | ||||
| import base64 | ||||
| from typing import Annotated | ||||
| from fastapi import APIRouter, HTTPException, Security, status | ||||
| from fastapi.responses import JSONResponse | ||||
| from pydantic import BaseModel, Field | ||||
| from sqlmodel import Session, func, select | ||||
| from sqlmodel.sql.expression import SelectOfScalar | ||||
| from cutt.db import Chemistry, MVPRanking, Player, PlayerTeamLink, Team, engine | ||||
| import networkx as nx | ||||
| import numpy as np | ||||
| import matplotlib | ||||
|  | ||||
| from cutt.security import TeamScopedRequest, verify_team_scope | ||||
| from cutt.demo import demo_players | ||||
|  | ||||
| matplotlib.use("agg") | ||||
| import matplotlib.pyplot as plt | ||||
|  | ||||
|  | ||||
| analysis_router = APIRouter(prefix="/analysis", tags=["analysis"]) | ||||
|  | ||||
|  | ||||
| C = Chemistry | ||||
| R = MVPRanking | ||||
| P = Player | ||||
|  | ||||
|  | ||||
| def sociogram_json(): | ||||
|     nodes = [] | ||||
|     necessary_nodes = set() | ||||
|     edges = [] | ||||
|     players = {} | ||||
|     with Session(engine) as session: | ||||
|         for p in session.exec(select(P)).fetchall(): | ||||
|             nodes.append({"id": p.display_name, "label": p.display_name}) | ||||
|             players[p.id] = p.display_name | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery() | ||||
|         ) | ||||
|         statement2 = select(C).join( | ||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||
|         ) | ||||
|         for c in session.exec(statement2): | ||||
|             # G.add_node(c.user) | ||||
|             necessary_nodes.add(c.user) | ||||
|             for p in [players[p_id] for p_id in c.love]: | ||||
|                 # G.add_edge(c.user, p) | ||||
|                 # p_id = session.exec(select(P.id).where(P.name == p)).one() | ||||
|                 necessary_nodes.add(p) | ||||
|                 edges.append({"from": players[c.user], "to": p, "relation": "likes"}) | ||||
|             for p in [players[p_id] for p_id in c.hate]: | ||||
|                 edges.append({"from": players[c.user], "to": p, "relation": "dislikes"}) | ||||
|     # nodes = [n for n in nodes if n["name"] in necessary_nodes] | ||||
|     return JSONResponse({"nodes": nodes, "edges": edges}) | ||||
|  | ||||
|  | ||||
| def graph_json( | ||||
|     request: Annotated[TeamScopedRequest, Security(verify_team_scope)], | ||||
|     networkx_graph: bool = False, | ||||
| ): | ||||
|     nodes = [] | ||||
|     edges = [] | ||||
|     player_map = {} | ||||
|     if request.team_id == 42: | ||||
|         players = [request.user] + demo_players | ||||
|         random.seed(42) | ||||
|         for p in players: | ||||
|             nodes.append({"id": p.display_name, "label": p.display_name}) | ||||
|         for p, other in itertools.permutations(players, 2): | ||||
|             value = random.random() | ||||
|             if value > 0.5: | ||||
|                 edges.append( | ||||
|                     { | ||||
|                         "id": f"{p.display_name}->{other.display_name}", | ||||
|                         "source": p.display_name, | ||||
|                         "target": other.display_name, | ||||
|                         "size": max(value, 0.3), | ||||
|                         "data": { | ||||
|                             "relation": 2, | ||||
|                             "origSize": max(value, 0.3), | ||||
|                             "origFill": "#bed4ff", | ||||
|                         }, | ||||
|                     } | ||||
|                 ) | ||||
|             elif value < 0.1: | ||||
|                 edges.append( | ||||
|                     { | ||||
|                         "id": f"{p.display_name}-x>{other.display_name}", | ||||
|                         "source": p.display_name, | ||||
|                         "target": other.display_name, | ||||
|                         "size": 0.3, | ||||
|                         "data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"}, | ||||
|                         "fill": "#ff7c7c", | ||||
|                     } | ||||
|                 ) | ||||
|         G = nx.DiGraph() | ||||
|         G.add_nodes_from([n["id"] for n in nodes]) | ||||
|         G.add_weighted_edges_from( | ||||
|             [ | ||||
|                 ( | ||||
|                     e["source"], | ||||
|                     e["target"], | ||||
|                     e["size"] if e["data"]["relation"] == 2 else -e["size"], | ||||
|                 ) | ||||
|                 for e in edges | ||||
|             ] | ||||
|         ) | ||||
|         in_degrees = G.in_degree(weight="weight") | ||||
|         nodes = [ | ||||
|             dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) | ||||
|             for node in nodes | ||||
|         ] | ||||
|         if networkx_graph: | ||||
|             return G | ||||
|         return JSONResponse({"nodes": nodes, "edges": edges}) | ||||
|  | ||||
|     with Session(engine) as session: | ||||
|         players = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.disabled == False) | ||||
|         ).all() | ||||
|         if not players: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_404_NOT_FOUND, | ||||
|                 detail="no players found in your team", | ||||
|             ) | ||||
|         for p in players: | ||||
|             player_map[p.id] = p.display_name | ||||
|             nodes.append({"id": p.display_name, "label": p.display_name}) | ||||
|  | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")) | ||||
|             .where(C.team == request.team_id) | ||||
|             .group_by(C.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(C).join( | ||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||
|         ) | ||||
|  | ||||
|         for c in session.exec(statement2): | ||||
|             user = player_map[c.user] | ||||
|             for i, p_id in enumerate(c.love): | ||||
|                 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)], | ||||
| ): | ||||
|     ranks = dict() | ||||
|     if request.team_id == 42: | ||||
|         random.seed(42) | ||||
|         players = [request.user] + demo_players | ||||
|         for p in players: | ||||
|             random.shuffle(players) | ||||
|             for i, p in enumerate(players): | ||||
|                 ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1] | ||||
|         return [ | ||||
|             { | ||||
|                 "name": p, | ||||
|                 "rank": f"{np.mean(v):.02f}", | ||||
|                 "std": f"{np.std(v):.02f}", | ||||
|                 "n": len(v), | ||||
|             } | ||||
|             for p, v in ranks.items() | ||||
|         ] | ||||
|  | ||||
|     with Session(engine) as session: | ||||
|         players = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.disabled == False) | ||||
|         ).all() | ||||
|         if not players: | ||||
|             raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) | ||||
|         player_map = {p.id: p.display_name for p in players} | ||||
|         subquery = ( | ||||
|             select(R.user, func.max(R.time).label("latest")) | ||||
|             .where(R.team == request.team_id) | ||||
|             .group_by(R.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(R).join( | ||||
|             subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) | ||||
|         ) | ||||
|         for r in session.exec(statement2): | ||||
|             for i, p_id in enumerate(r.mvps): | ||||
|                 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) | ||||
| @@ -2,17 +2,22 @@ from datetime import datetime, timezone | ||||
| from sqlmodel import ( | ||||
|     ARRAY, | ||||
|     Column, | ||||
|     Integer, | ||||
|     Relationship, | ||||
|     SQLModel, | ||||
|     Field, | ||||
|     create_engine, | ||||
|     String, | ||||
| ) | ||||
| 
 | ||||
| with open("db.secrets", "r") as f: | ||||
|     db_secrets = f.readline().strip() | ||||
| 
 | ||||
| engine = create_engine(db_secrets) | ||||
| engine = create_engine( | ||||
|     db_secrets, | ||||
|     pool_timeout=20, | ||||
|     pool_size=2, | ||||
|     connect_args={"connect_timeout": 8}, | ||||
| ) | ||||
| del db_secrets | ||||
| 
 | ||||
| 
 | ||||
| @@ -39,36 +44,43 @@ class Team(SQLModel, table=True): | ||||
| 
 | ||||
| class Player(SQLModel, table=True): | ||||
|     id: int | None = Field(default=None, primary_key=True) | ||||
|     name: str | ||||
|     username: str = Field(default=None, unique=True) | ||||
|     display_name: str | ||||
|     email: str | None = None | ||||
|     full_name: str | None = None | ||||
|     disabled: bool | None = None | ||||
|     hashed_password: str | None = None | ||||
|     number: str | None = None | ||||
|     teams: list[Team] | None = Relationship( | ||||
|     teams: list[Team] = Relationship( | ||||
|         back_populates="players", link_model=PlayerTeamLink | ||||
|     ) | ||||
|     scopes: str = "" | ||||
| 
 | ||||
| 
 | ||||
| class Chemistry(SQLModel, table=True): | ||||
|     id: int | None = Field(default=None, primary_key=True) | ||||
|     time: datetime | None = Field(default_factory=utctime) | ||||
|     user: str | ||||
|     love: list[str] = Field(sa_column=Column(ARRAY(String))) | ||||
|     hate: list[str] = Field(sa_column=Column(ARRAY(String))) | ||||
|     undecided: list[str] = Field(sa_column=Column(ARRAY(String))) | ||||
|     user: int = Field(default=None, foreign_key="player.id") | ||||
|     hate: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||
|     undecided: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||
|     love: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||
|     team: int = Field(default=None, foreign_key="team.id") | ||||
| 
 | ||||
| 
 | ||||
| class MVPRanking(SQLModel, table=True): | ||||
|     id: int | None = Field(default=None, primary_key=True) | ||||
|     time: datetime | None = Field(default_factory=utctime) | ||||
|     user: str | ||||
|     mvps: list[str] = Field(sa_column=Column(ARRAY(String))) | ||||
|     user: int = Field(default=None, foreign_key="player.id") | ||||
|     mvps: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||
|     team: int = Field(default=None, foreign_key="team.id") | ||||
| 
 | ||||
| 
 | ||||
| class User(SQLModel, table=True): | ||||
|     username: str = Field(default=None, primary_key=True) | ||||
|     email: str | None = None | ||||
|     full_name: str | None = None | ||||
|     disabled: bool | None = None | ||||
|     hashed_password: str | None = None | ||||
|     player_id: int | None = Field(default=None, foreign_key="player.id") | ||||
| class TokenDB(SQLModel, table=True): | ||||
|     token: str = Field(index=True, primary_key=True) | ||||
|     used: bool | None = False | ||||
|     updated_at: datetime | None = Field( | ||||
|         default_factory=utctime, sa_column_kwargs={"onupdate": utctime} | ||||
|     ) | ||||
| 
 | ||||
| 
 | ||||
| SQLModel.metadata.create_all(engine) | ||||
							
								
								
									
										27
									
								
								cutt/demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								cutt/demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | ||||
| import random | ||||
| from cutt.db import Player | ||||
|  | ||||
| names = [ | ||||
|     "August", | ||||
|     "Beate", | ||||
|     "Ceasar", | ||||
|     "Daedalus", | ||||
|     "Elli", | ||||
|     "Ford P.", | ||||
|     "Gabriel", | ||||
|     "Hugo", | ||||
|     "Ivar Johansson", | ||||
|     "Jürgen Gordon Malinauskas", | ||||
| ] | ||||
| demo_players = [ | ||||
|     Player.model_validate( | ||||
|         { | ||||
|             "id": i, | ||||
|             "display_name": name, | ||||
|             "username": name.lower().replace(" ", "").replace(".", ""), | ||||
|             "number": str(random.randint(0, 100)), | ||||
|             "email": name.lower().replace(" ", "").replace(".", "") + "@example.org", | ||||
|         } | ||||
|     ) | ||||
|     for i, name in enumerate(names) | ||||
| ] | ||||
							
								
								
									
										201
									
								
								cutt/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										201
									
								
								cutt/main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,201 @@ | ||||
| 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 mvps.team == 42: | ||||
|         return JSONResponse("DEMO team, nothing happens") | ||||
|     if user.id == mvps.user: | ||||
|         with Session(engine) as session: | ||||
|             statement = select(Team).where(Team.id == mvps.team) | ||||
|             players = [t.players for t in session.exec(statement)][0] | ||||
|             if players: | ||||
|                 player_ids = {p.id for p in players} | ||||
|                 if player_ids >= set(mvps.mvps): | ||||
|                     session.add(mvps) | ||||
|                     session.commit() | ||||
|                     return JSONResponse("success!") | ||||
|         raise somethings_fishy | ||||
|     else: | ||||
|         raise wrong_user_id_exception | ||||
|  | ||||
|  | ||||
| @api_router.get("/mvps/{team_id}", tags=["analysis"]) | ||||
| def get_mvps( | ||||
|     team_id: int, | ||||
|     user: Annotated[Player, Depends(get_current_active_user)], | ||||
| ): | ||||
|     with Session(engine) as session: | ||||
|         subquery = ( | ||||
|             select(R.user, func.max(R.time).label("latest")) | ||||
|             .where(R.user == user.id) | ||||
|             .where(R.team == team_id) | ||||
|             .group_by(R.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(R).join( | ||||
|             subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) | ||||
|         ) | ||||
|         mvps = session.exec(statement2).one_or_none() | ||||
|         if mvps: | ||||
|             return mvps | ||||
|         else: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_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 chemistry.team == 42: | ||||
|         return JSONResponse("DEMO team, nothing happens") | ||||
|     if user.id == chemistry.user: | ||||
|         with Session(engine) as session: | ||||
|             statement = select(Team).where(Team.id == chemistry.team) | ||||
|             players = [t.players for t in session.exec(statement)][0] | ||||
|             if players: | ||||
|                 player_ids = {p.id for p in players} | ||||
|                 if player_ids >= ( | ||||
|                     set(chemistry.love) | set(chemistry.hate) | set(chemistry.undecided) | ||||
|                 ): | ||||
|                     session.add(chemistry) | ||||
|                     session.commit() | ||||
|                     return JSONResponse("success!") | ||||
|         raise somethings_fishy | ||||
|     else: | ||||
|         raise wrong_user_id_exception | ||||
|  | ||||
|  | ||||
| @api_router.get("/chemistry/{team_id}", tags=["analysis"]) | ||||
| def get_chemistry( | ||||
|     team_id: int, user: Annotated[Player, Depends(get_current_active_user)] | ||||
| ): | ||||
|     with Session(engine) as session: | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")) | ||||
|             .where(C.user == user.id) | ||||
|             .where(C.team == team_id) | ||||
|             .group_by(C.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(C).join( | ||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||
|         ) | ||||
|         chemistry = session.exec(statement2).one_or_none() | ||||
|         if chemistry: | ||||
|             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") | ||||
							
								
								
									
										241
									
								
								cutt/player.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								cutt/player.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,241 @@ | ||||
| from typing import Annotated | ||||
| from fastapi import APIRouter, Depends, HTTPException, Security, status | ||||
| from fastapi.responses import PlainTextResponse | ||||
| from pydantic import BaseModel | ||||
| from sqlmodel import Session, select | ||||
|  | ||||
| from cutt.db import Player, PlayerTeamLink, Team, engine | ||||
| from cutt.security import ( | ||||
|     TeamScopedRequest, | ||||
|     change_password, | ||||
|     get_current_active_user, | ||||
|     read_player_me, | ||||
|     verify_team_scope, | ||||
| ) | ||||
| from cutt.demo import demo_players | ||||
|  | ||||
| P = Player | ||||
|  | ||||
| player_router = APIRouter(prefix="/player", tags=["player"]) | ||||
|  | ||||
|  | ||||
| class PlayerRequest(BaseModel): | ||||
|     display_name: str | ||||
|     username: str | ||||
|     number: str | ||||
|     email: str | ||||
|  | ||||
|  | ||||
| class AddPlayerRequest(PlayerRequest): ... | ||||
|  | ||||
|  | ||||
| DEMO_TEAM_REQUEST = HTTPException( | ||||
|     status_code=status.HTTP_400_BAD_REQUEST, | ||||
|     detail="DEMO Team, nothing happens", | ||||
| ) | ||||
|  | ||||
|  | ||||
| def add_player( | ||||
|     r: AddPlayerRequest, | ||||
|     request: Annotated[TeamScopedRequest, Depends(verify_team_scope)], | ||||
| ): | ||||
|     with Session(engine) as session: | ||||
|         if session.exec(select(P).where(P.username == r.username)).one_or_none(): | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_400_BAD_REQUEST, detail="username not available" | ||||
|             ) | ||||
|  | ||||
|         stmt = ( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.display_name == r.display_name) | ||||
|         ) | ||||
|         if session.exec(stmt).one_or_none(): | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_400_BAD_REQUEST, | ||||
|                 detail="the name is already taken on this team", | ||||
|             ) | ||||
|  | ||||
|         team = session.exec(select(Team).where(Team.id == request.team_id)).one() | ||||
|         new_player = Player( | ||||
|             username=r.username, | ||||
|             display_name=r.display_name, | ||||
|             email=r.email if r.email else None, | ||||
|             number=r.number, | ||||
|             disabled=False, | ||||
|             teams=[team], | ||||
|         ) | ||||
|         session.add(new_player) | ||||
|         session.commit() | ||||
|         return PlainTextResponse(f"added {new_player.display_name}") | ||||
|  | ||||
|  | ||||
| class ModifyPlayerRequest(PlayerRequest): | ||||
|     id: int | ||||
|  | ||||
|  | ||||
| def modify_player( | ||||
|     r: ModifyPlayerRequest, | ||||
|     request: Annotated[TeamScopedRequest, Depends(verify_team_scope)], | ||||
| ): | ||||
|     if request.team_id == 42: | ||||
|         raise DEMO_TEAM_REQUEST | ||||
|     with Session(engine) as session: | ||||
|         player = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.id == r.id, P.username == r.username) | ||||
|         ).one_or_none() | ||||
|         if player: | ||||
|             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)], | ||||
| ): | ||||
|     if request.team_id == 42: | ||||
|         raise DEMO_TEAM_REQUEST | ||||
|     with Session(engine) as session: | ||||
|         player = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.id == r.player_id) | ||||
|         ).one_or_none() | ||||
|         if player: | ||||
|             player.disabled = True | ||||
|             session.add(player) | ||||
|             session.commit() | ||||
|             return PlainTextResponse(f"disabled {player.display_name}") | ||||
|         else: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_404_NOT_FOUND, | ||||
|                 detail="no such player found in your team", | ||||
|             ) | ||||
|  | ||||
|  | ||||
| def add_player_to_team(player_id: int, team_id: int): | ||||
|     with Session(engine) as session: | ||||
|         player = session.exec(select(P).where(P.id == player_id)).one() | ||||
|         team = session.exec(select(Team).where(Team.id == team_id)).one() | ||||
|         if player and team: | ||||
|             team.players.append(player) | ||||
|             session.add(team) | ||||
|             session.commit() | ||||
|             return PlainTextResponse( | ||||
|                 f"added {player.display_name} ({player.username}) to {team.name}" | ||||
|             ) | ||||
|  | ||||
|  | ||||
| def add_players(players: list[P]): | ||||
|     with Session(engine) as session: | ||||
|         for player in players: | ||||
|             session.add(player) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| async def list_all_players(): | ||||
|     with Session(engine) as session: | ||||
|         return session.exec(select(P)).all() | ||||
|  | ||||
|  | ||||
| async def list_players( | ||||
|     team_id: int, user: Annotated[Player, Depends(get_current_active_user)] | ||||
| ): | ||||
|     if team_id == 42: | ||||
|         return [ | ||||
|             user.model_dump( | ||||
|                 include={"id", "display_name", "username", "number", "email"} | ||||
|             ) | ||||
|         ] + demo_players | ||||
|  | ||||
|     with Session(engine) as session: | ||||
|         current_user = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == team_id, P.disabled == False, P.id == user.id) | ||||
|         ).one_or_none() | ||||
|         if not current_user: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_400_BAD_REQUEST, | ||||
|                 detail="you're not in this team", | ||||
|             ) | ||||
|  | ||||
|         players = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == team_id, P.disabled == False) | ||||
|         ).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] + [ | ||||
|             {"country": "nowhere", "id": 42, "location": "everywhere", "name": "DEMO"} | ||||
|         ] | ||||
|  | ||||
|  | ||||
| player_router.add_api_route( | ||||
|     "/{team_id}", | ||||
|     endpoint=add_player, | ||||
|     methods=["POST"], | ||||
| ) | ||||
| player_router.add_api_route( | ||||
|     "/{team_id}", | ||||
|     endpoint=modify_player, | ||||
|     methods=["PUT"], | ||||
| ) | ||||
| player_router.add_api_route( | ||||
|     "/{team_id}", | ||||
|     endpoint=disable_player, | ||||
|     methods=["DELETE"], | ||||
| ) | ||||
| player_router.add_api_route( | ||||
|     "/{team_id}/list", | ||||
|     endpoint=list_players, | ||||
|     methods=["GET"], | ||||
| ) | ||||
| player_router.add_api_route( | ||||
|     "/list", | ||||
|     endpoint=list_all_players, | ||||
|     methods=["GET"], | ||||
|     dependencies=[Security(get_current_active_user, scopes=["admin"])], | ||||
| ) | ||||
| player_router.add_api_route( | ||||
|     "/add/{team_id}/{player_id}", | ||||
|     endpoint=add_player_to_team, | ||||
|     methods=["GET"], | ||||
|     dependencies=[Security(get_current_active_user, scopes=["admin"])], | ||||
| ) | ||||
| player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"]) | ||||
| player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"]) | ||||
| player_router.add_api_route( | ||||
|     "/change_password", endpoint=change_password, methods=["POST"] | ||||
| ) | ||||
							
								
								
									
										401
									
								
								cutt/security.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										401
									
								
								cutt/security.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,401 @@ | ||||
| from datetime import timedelta, timezone, datetime | ||||
| from typing import Annotated | ||||
| from fastapi import Depends, HTTPException, Request, Response, status | ||||
| from fastapi.responses import PlainTextResponse | ||||
| from pydantic import BaseModel, ValidationError | ||||
| import jwt | ||||
| from jwt.exceptions import ExpiredSignatureError, InvalidTokenError | ||||
| from sqlmodel import Session, select | ||||
| from cutt.db import PlayerTeamLink, Team, TokenDB, engine, Player | ||||
| from fastapi.security import ( | ||||
|     OAuth2PasswordBearer, | ||||
|     OAuth2PasswordRequestForm, | ||||
|     SecurityScopes, | ||||
| ) | ||||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||||
| from passlib.context import CryptContext | ||||
| from sqlalchemy.exc import OperationalError | ||||
|  | ||||
| P = Player | ||||
|  | ||||
|  | ||||
| class Config(BaseSettings): | ||||
|     secret_key: str = "" | ||||
|     access_token_expire_minutes: int = 15 | ||||
|     model_config = SettingsConfigDict( | ||||
|         env_file=".env", env_file_encoding="utf-8", extra="ignore" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| config = Config() | ||||
|  | ||||
|  | ||||
| class Token(BaseModel): | ||||
|     access_token: str | ||||
|  | ||||
|  | ||||
| class TokenData(BaseModel): | ||||
|     username: str | None = None | ||||
|     scopes: list[str] = [] | ||||
|  | ||||
|  | ||||
| pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") | ||||
|  | ||||
|  | ||||
| class CookieOAuth2(OAuth2PasswordBearer): | ||||
|     def __init__(self, *args, **kwargs): | ||||
|         super().__init__(*args, **kwargs) | ||||
|  | ||||
|     async def __call__(self, request: Request): | ||||
|         cookie_token = request.cookies.get("access_token") | ||||
|         if cookie_token: | ||||
|             return cookie_token | ||||
|         else: | ||||
|             header_token = await super().__call__(request) | ||||
|             if header_token: | ||||
|                 return header_token | ||||
|             else: | ||||
|                 raise HTTPException(status_code=401) | ||||
|  | ||||
|  | ||||
| oauth2_scheme = CookieOAuth2( | ||||
|     tokenUrl="api/token", | ||||
|     scopes={ | ||||
|         "analysis": "Access the results.", | ||||
|         "admin": "Maintain DB etc.", | ||||
|     }, | ||||
| ) | ||||
|  | ||||
|  | ||||
| def verify_password(plain_password, hashed_password): | ||||
|     return pwd_context.verify(plain_password, hashed_password) | ||||
|  | ||||
|  | ||||
| def get_password_hash(password): | ||||
|     return pwd_context.hash(password) | ||||
|  | ||||
|  | ||||
| def get_user(username: str | None): | ||||
|     if username: | ||||
|         try: | ||||
|             with Session(engine) as session: | ||||
|                 return session.exec( | ||||
|                     select(Player).where(Player.username == username) | ||||
|                 ).one_or_none() | ||||
|         except OperationalError: | ||||
|             return | ||||
|  | ||||
|  | ||||
| def authenticate_user(username: str, password: str): | ||||
|     user = get_user(username) | ||||
|     if not user: | ||||
|         return False | ||||
|     if not verify_password(password, user.hashed_password): | ||||
|         return False | ||||
|     return user | ||||
|  | ||||
|  | ||||
| def create_access_token(data: dict, expires_delta: timedelta | None = None): | ||||
|     to_encode = data.copy() | ||||
|     if expires_delta: | ||||
|         expire = datetime.now(timezone.utc) + expires_delta | ||||
|     else: | ||||
|         expire = datetime.now(timezone.utc) + timedelta( | ||||
|             minutes=config.access_token_expire_minutes | ||||
|         ) | ||||
|     to_encode.update({"exp": expire}) | ||||
|     encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256") | ||||
|     return encoded_jwt | ||||
|  | ||||
|  | ||||
| async def get_current_user( | ||||
|     token: Annotated[str, Depends(oauth2_scheme)], | ||||
|     security_scopes: SecurityScopes, | ||||
| ): | ||||
|     if security_scopes.scopes: | ||||
|         authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' | ||||
|     else: | ||||
|         authenticate_value = "Bearer" | ||||
|     credentials_exception = HTTPException( | ||||
|         status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|         detail="Could not validate credentials", | ||||
|         headers={"WWW-Authenticate": authenticate_value}, | ||||
|     ) | ||||
|     # access_token = request.cookies.get("access_token") | ||||
|     access_token = token | ||||
|     if not access_token: | ||||
|         raise credentials_exception | ||||
|     try: | ||||
|         payload = jwt.decode(access_token, config.secret_key, algorithms=["HS256"]) | ||||
|         username: str = payload.get("sub") | ||||
|         if username is None: | ||||
|             raise credentials_exception | ||||
|         token_scopes = payload.get("scopes", []) | ||||
|         token_data = TokenData(username=username, scopes=token_scopes) | ||||
|     except ExpiredSignatureError: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="Access token expired", | ||||
|             headers={"WWW-Authenticate": authenticate_value}, | ||||
|         ) | ||||
|     except (InvalidTokenError, ValidationError): | ||||
|         raise credentials_exception | ||||
|     user = get_user(username=token_data.username) | ||||
|     if user is None: | ||||
|         raise credentials_exception | ||||
|     allowed_scopes = set(user.scopes.split()) | ||||
|     for scope in security_scopes.scopes: | ||||
|         if scope not in allowed_scopes or scope not in token_data.scopes: | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|                 detail="Not enough permissions", | ||||
|                 headers={"WWW-Authenticate": authenticate_value}, | ||||
|             ) | ||||
|     return user | ||||
|  | ||||
|  | ||||
| async def get_current_active_user( | ||||
|     current_user: Annotated[Player, Depends(get_current_user)], | ||||
| ): | ||||
|     if current_user.disabled: | ||||
|         raise HTTPException(status_code=400, detail="Inactive user") | ||||
|     return current_user | ||||
|  | ||||
|  | ||||
| class TeamScopedRequest(BaseModel): | ||||
|     user: Player | ||||
|     team_id: int | ||||
|  | ||||
|  | ||||
| async def verify_team_scope( | ||||
|     team_id: int, user: Annotated[Player, Depends(get_current_active_user)] | ||||
| ): | ||||
|     if team_id == 42: | ||||
|         return TeamScopedRequest(user=user, team_id=team_id) | ||||
|     allowed_scopes = set(user.scopes.split()) | ||||
|     if f"team:{team_id}" not in allowed_scopes: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="Not enough permissions", | ||||
|         ) | ||||
|     else: | ||||
|         return TeamScopedRequest(user=user, team_id=team_id) | ||||
|  | ||||
|  | ||||
| async def login_for_access_token( | ||||
|     form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response | ||||
| ) -> Token: | ||||
|     user = authenticate_user(form_data.username, form_data.password) | ||||
|     if not user: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="Incorrect username or password", | ||||
|             headers={"WWW-Authenticate": "Bearer"}, | ||||
|         ) | ||||
|     allowed_scopes = set(user.scopes.split()) | ||||
|     requested_scopes = set(form_data.scopes) | ||||
|     access_token = create_access_token( | ||||
|         data={"sub": user.username, "scopes": list(allowed_scopes)} | ||||
|     ) | ||||
|     response.set_cookie( | ||||
|         "access_token", | ||||
|         value=access_token, | ||||
|         httponly=True, | ||||
|         samesite="strict", | ||||
|         max_age=config.access_token_expire_minutes * 60, | ||||
|     ) | ||||
|     return Token(access_token=access_token) | ||||
|  | ||||
|  | ||||
| async def logout(response: Response): | ||||
|     response.set_cookie("access_token", "", expires=0, httponly=True, samesite="strict") | ||||
|     return {"message": "Successfully logged out"} | ||||
|  | ||||
|  | ||||
| def set_password_token(username: str): | ||||
|     user = get_user(username) | ||||
|     if user: | ||||
|         expire = timedelta(days=30) | ||||
|         token = create_access_token( | ||||
|             data={ | ||||
|                 "sub": "set password", | ||||
|                 "username": username, | ||||
|                 "name": user.display_name, | ||||
|             }, | ||||
|             expires_delta=expire, | ||||
|         ) | ||||
|         return token | ||||
|  | ||||
|  | ||||
| def register_token(team_id: int): | ||||
|     with Session(engine) as session: | ||||
|         team = session.exec(select(Team).where(Team.id == team_id)).one() | ||||
|         if team: | ||||
|             expire = timedelta(days=30) | ||||
|             token = create_access_token( | ||||
|                 data={"sub": "register", "team_id": team_id, "name": team.name}, | ||||
|                 expires_delta=expire, | ||||
|             ) | ||||
|             return token | ||||
|  | ||||
|  | ||||
| def verify_one_time_token(token: str): | ||||
|     credentials_exception = HTTPException( | ||||
|         status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|         detail="could not validate token", | ||||
|     ) | ||||
|     with Session(engine) as session: | ||||
|         token_in_db = session.exec( | ||||
|             select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == False) | ||||
|         ).one_or_none() | ||||
|         if token_in_db: | ||||
|             try: | ||||
|                 payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) | ||||
|                 return payload | ||||
|             except ExpiredSignatureError: | ||||
|                 raise HTTPException( | ||||
|                     status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|                     detail="access token expired", | ||||
|                 ) | ||||
|             except (InvalidTokenError, ValidationError): | ||||
|                 raise credentials_exception | ||||
|         elif session.exec( | ||||
|             select(TokenDB).where(TokenDB.token == token).where(TokenDB.used == True) | ||||
|         ).one_or_none(): | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|                 detail="token already used", | ||||
|             ) | ||||
|         else: | ||||
|             raise credentials_exception | ||||
|  | ||||
|  | ||||
| def invalidate_one_time_token(token: str): | ||||
|     with Session(engine) as session: | ||||
|         token_in_db = session.exec(select(TokenDB).where(TokenDB.token == token)).one() | ||||
|         token_in_db.used = True | ||||
|         session.add(token_in_db) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| class FirstPassword(BaseModel): | ||||
|     token: str | ||||
|     password: str | ||||
|  | ||||
|  | ||||
| async def set_first_password(req: FirstPassword): | ||||
|     payload = verify_one_time_token(req.token) | ||||
|     action: str = payload.get("sub") | ||||
|     if action != "set password": | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="wrong type of token.", | ||||
|         ) | ||||
|     username: str = payload.get("username") | ||||
|     with Session(engine) as session: | ||||
|         user = get_user(username) | ||||
|         if user: | ||||
|             user.hashed_password = get_password_hash(req.password) | ||||
|             session.add(user) | ||||
|             session.commit() | ||||
|             invalidate_one_time_token(req.token) | ||||
|             return Response("password set successfully", status_code=status.HTTP_200_OK) | ||||
|  | ||||
|  | ||||
| class ChangedPassword(BaseModel): | ||||
|     current_password: str | ||||
|     new_password: str | ||||
|  | ||||
|  | ||||
| async def change_password( | ||||
|     request: ChangedPassword, | ||||
|     user: Annotated[Player, Depends(get_current_active_user)], | ||||
| ): | ||||
|     if ( | ||||
|         request.new_password | ||||
|         and user.hashed_password | ||||
|         and verify_password(request.current_password, user.hashed_password) | ||||
|     ): | ||||
|         with Session(engine) as session: | ||||
|             user.hashed_password = get_password_hash(request.new_password) | ||||
|             session.add(user) | ||||
|             session.commit() | ||||
|             return PlainTextResponse( | ||||
|                 "password changed successfully", | ||||
|                 status_code=status.HTTP_200_OK, | ||||
|                 media_type="text/plain", | ||||
|             ) | ||||
|     else: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_400_BAD_REQUEST, | ||||
|             detail="wrong password", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class RegisterRequest(BaseModel): | ||||
|     token: str | ||||
|     team_id: int | ||||
|     display_name: str | ||||
|     username: str | ||||
|     password: str | ||||
|     email: str | None | ||||
|     number: str | None | ||||
|  | ||||
|  | ||||
| async def register(req: RegisterRequest): | ||||
|     payload = verify_one_time_token(req.token) | ||||
|     action: str = payload.get("sub") | ||||
|     if action != "register": | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="wrong type of token.", | ||||
|         ) | ||||
|     team_id: int = payload.get("team_id") | ||||
|     if team_id != req.team_id: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="wrong team", | ||||
|         ) | ||||
|     with Session(engine) as session: | ||||
|         if session.exec(select(P).where(P.username == req.username)).one_or_none(): | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_400_BAD_REQUEST, | ||||
|                 detail="username exists", | ||||
|             ) | ||||
|         stmt = ( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == team_id, P.display_name == req.display_name) | ||||
|         ) | ||||
|         if session.exec(stmt).one_or_none(): | ||||
|             raise HTTPException( | ||||
|                 status_code=status.HTTP_400_BAD_REQUEST, | ||||
|                 detail="the name is already taken on this team", | ||||
|             ) | ||||
|         team = session.exec(select(Team).where(Team.id == team_id)).one() | ||||
|         new_player = Player( | ||||
|             username=req.username, | ||||
|             hashed_password=get_password_hash(req.password), | ||||
|             display_name=req.display_name, | ||||
|             email=req.email if req.email else None, | ||||
|             number=req.number, | ||||
|             disabled=False, | ||||
|             teams=[team], | ||||
|         ) | ||||
|         session.add(new_player) | ||||
|         session.commit() | ||||
|         # invalidate_one_time_token(req.token) | ||||
|         return PlainTextResponse(f"added {new_player.display_name}") | ||||
|  | ||||
|  | ||||
| async def read_player_me( | ||||
|     current_user: Annotated[Player, Depends(get_current_active_user)], | ||||
| ): | ||||
|     return current_user.model_dump(exclude={"hashed_password", "disabled"}) | ||||
|  | ||||
|  | ||||
| async def read_own_items( | ||||
|     current_user: Annotated[Player, Depends(get_current_active_user)], | ||||
| ): | ||||
|     return [{"item_id": "Foo", "owner": current_user.username}] | ||||
							
								
								
									
										107
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										107
									
								
								main.py
									
									
									
									
									
								
							| @@ -1,107 +0,0 @@ | ||||
| from fastapi import APIRouter, Depends, FastAPI, status | ||||
| from fastapi.staticfiles import StaticFiles | ||||
| from db import Player, Team, Chemistry, MVPRanking, engine | ||||
| from sqlmodel import ( | ||||
|     Session, | ||||
|     select, | ||||
| ) | ||||
| from fastapi.middleware.cors import CORSMiddleware | ||||
| from analysis import analysis_router | ||||
| from security import ( | ||||
|     get_current_active_user, | ||||
|     login_for_access_token, | ||||
|     read_users_me, | ||||
|     read_own_items, | ||||
| ) | ||||
|  | ||||
|  | ||||
| app = FastAPI(title="cutt") | ||||
| api_router = APIRouter(prefix="/api") | ||||
| origins = [ | ||||
|     "*", | ||||
|     "http://localhost", | ||||
|     "http://localhost:3000", | ||||
|     "http://localhost:8000", | ||||
| ] | ||||
|  | ||||
| app.add_middleware( | ||||
|     CORSMiddleware, | ||||
|     allow_origins=origins, | ||||
|     allow_credentials=True, | ||||
|     allow_methods=["*"], | ||||
|     allow_headers=["*"], | ||||
| ) | ||||
|  | ||||
|  | ||||
| def add_team(team: Team): | ||||
|     with Session(engine) as session: | ||||
|         session.add(team) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| def add_player(player: Player): | ||||
|     with Session(engine) as session: | ||||
|         session.add(player) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| def add_players(players: list[Player]): | ||||
|     with Session(engine) as session: | ||||
|         for player in players: | ||||
|             session.add(player) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| def list_players(): | ||||
|     with Session(engine) as session: | ||||
|         statement = select(Player).order_by(Player.name) | ||||
|         return session.exec(statement).fetchall() | ||||
|  | ||||
|  | ||||
| def list_teams(): | ||||
|     with Session(engine) as session: | ||||
|         statement = select(Team) | ||||
|         return session.exec(statement).fetchall() | ||||
|  | ||||
|  | ||||
| player_router = APIRouter(prefix="/player") | ||||
| player_router.add_api_route("/list", endpoint=list_players, methods=["GET"]) | ||||
| player_router.add_api_route("/add", endpoint=add_player, methods=["POST"]) | ||||
|  | ||||
| team_router = APIRouter(prefix="/team") | ||||
| team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"]) | ||||
| team_router.add_api_route("/add", endpoint=add_team, methods=["POST"]) | ||||
|  | ||||
|  | ||||
| @app.post("/mvps/", status_code=status.HTTP_200_OK) | ||||
| def submit_mvps(mvps: MVPRanking): | ||||
|     with Session(engine) as session: | ||||
|         session.add(mvps) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| @app.post("/chemistry/", status_code=status.HTTP_200_OK) | ||||
| def submit_chemistry(chemistry: Chemistry): | ||||
|     with Session(engine) as session: | ||||
|         session.add(chemistry) | ||||
|         session.commit() | ||||
|  | ||||
|  | ||||
| class SPAStaticFiles(StaticFiles): | ||||
|     async def get_response(self, path: str, scope): | ||||
|         response = await super().get_response(path, scope) | ||||
|         if response.status_code == 404: | ||||
|             response = await super().get_response(".", scope) | ||||
|         return response | ||||
|  | ||||
|  | ||||
| api_router.include_router(player_router) | ||||
| api_router.include_router(team_router) | ||||
| api_router.include_router( | ||||
|     analysis_router, dependencies=[Depends(get_current_active_user)] | ||||
| ) | ||||
| api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) | ||||
| api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"]) | ||||
| api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"]) | ||||
| app.include_router(api_router) | ||||
| app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site") | ||||
							
								
								
									
										13
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								package.json
									
									
									
									
									
								
							| @@ -10,17 +10,18 @@ | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "d3": "^7.9.0", | ||||
|     "react": "^18.3.1", | ||||
|     "react-dom": "^18.3.1", | ||||
|     "jwt-decode": "^4.0.0", | ||||
|     "react": "18.3.1", | ||||
|     "react-dom": "18.3.1", | ||||
|     "react-sortablejs": "^6.1.4", | ||||
|     "reagraph": "^4.21.2", | ||||
|     "sortablejs": "^1.15.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.17.0", | ||||
|     "@types/d3": "^7.4.3", | ||||
|     "@types/react": "^18.3.18", | ||||
|     "@types/react-dom": "^18.3.5", | ||||
|     "@types/node": "^22.13.10", | ||||
|     "@types/react": "18.3.18", | ||||
|     "@types/react-dom": "18.3.5", | ||||
|     "@types/sortablejs": "^1.15.8", | ||||
|     "@vitejs/plugin-react": "^4.3.4", | ||||
|     "eslint": "^9.17.0", | ||||
|   | ||||
| @@ -1,41 +1 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    xml:space="preserve" | ||||
|    width="128" | ||||
|    height="128" | ||||
|    viewBox="0 0 2560 2560" | ||||
|    version="1.1" | ||||
|    id="svg3" | ||||
|    sodipodi:docname="gitea.svg" | ||||
|    inkscape:version="1.4 (e7c3feb100, 2024-10-09)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||
|      id="defs3" /><sodipodi:namedview | ||||
|      id="namedview3" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#000000" | ||||
|      borderopacity="0.25" | ||||
|      inkscape:showpageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      inkscape:deskcolor="#d1d1d1" | ||||
|      inkscape:zoom="2.4221483" | ||||
|      inkscape:cx="89.58989" | ||||
|      inkscape:cy="-60.483497" | ||||
|      inkscape:window-width="1408" | ||||
|      inkscape:window-height="1727" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="0" | ||||
|      inkscape:current-layer="svg3" /><path | ||||
|      d="m 1569.914,2282.76 -484.616,-232.952 c -47.736,-22.913 -68.358,-80.96 -45.063,-129.078 l 232.952,-484.617 c 22.913,-47.736 80.96,-68.358 129.078,-45.062 65.685,31.696 103.492,49.645 103.492,49.645 l -0.382,-417.022 63.776,-0.382 0.381,447.191 c 0,0 219.204,92.417 317.35,153.138 14.13,8.783 38.952,25.968 49.263,54.992 8.02,23.295 7.638,50.027 -3.818,73.704 l -232.952,484.617 c -23.678,48.5 -81.725,69.121 -129.46,45.826 z" | ||||
|      style="fill:#ffffff;stroke-width:3.81889" | ||||
|      id="path1" /><path | ||||
|      d="m 2436.037,1005.725 c -15.657,-15.657 -36.66,-15.276 -36.66,-15.276 0,0 -447.574,25.205 -679.38,30.552 -50.792,1.145 -101.201,2.29 -151.228,2.673 v 447.573 c -21.004,-9.929 -42.39,-20.24 -63.394,-30.17 0,-139.007 -0.382,-417.021 -0.382,-417.021 -110.747,1.527 -340.644,-8.402 -340.644,-8.402 0,0 -539.99,-27.114 -598.802,-32.46 -37.425,-2.292 -85.924,-8.02 -148.936,5.728 -33.224,6.874 -127.933,28.26 -205.456,102.728 -171.85,153.137 -127.933,396.782 -122.586,433.443 6.492,44.681 26.35,168.795 121.058,276.87 174.905,214.239 551.447,209.275 551.447,209.275 0,0 46.209,110.365 116.858,211.948 95.472,126.405 193.618,224.932 289.09,236.77 240.59,0 721.387,-0.381 721.387,-0.381 0,0 45.827,0.382 108.075,-39.335 53.464,-32.46 101.2,-89.362 101.2,-89.362 0,0 49.264,-52.7 118.004,-172.995 21.004,-37.043 38.57,-72.941 53.846,-106.93 0,0 210.803,-447.19 210.803,-882.543 -4.201,-131.752 -36.662,-155.047 -44.3,-162.685 z M 537.67,1785.159 c -98.91,-32.46 -140.917,-71.413 -140.917,-71.413 0,0 -72.94,-51.173 -109.602,-151.991 -63.012,-168.795 -5.347,-271.905 -5.347,-271.905 0,0 32.079,-85.925 147.027,-114.567 52.701,-14.13 118.386,-11.838 118.386,-11.838 0,0 27.114,226.842 59.956,359.739 27.496,111.511 94.709,296.727 94.709,296.727 0,0 -99.673,-11.838 -164.212,-34.752 z m 1146.81,410.912 c 0,0 -23.294,55.374 -74.85,58.811 -22.149,1.528 -39.334,-4.582 -39.334,-4.582 0,0 -1.145,-0.382 -20.24,-8.02 l -431.152,-210.039 c 0,0 -41.626,-21.767 -48.882,-59.574 -8.401,-30.933 10.311,-69.122 10.311,-69.122 l 207.366,-427.333 c 0,0 18.33,-37.044 46.59,-49.646 2.291,-1.146 8.784,-3.819 17.185,-5.728 30.933,-8.02 68.74,10.693 68.74,10.693 l 422.75,205.074 c 0,0 48.119,21.767 58.43,61.866 7.255,28.26 -1.91,53.464 -6.874,65.685 -24.06,58.81 -210.04,431.916 -210.04,431.916 z" | ||||
|      style="fill:#609926;stroke-width:3.81889" | ||||
|      id="path2" /><path | ||||
|      d="m 1306.029,1885.214 c -31.314,0.382 -58.81,22.15 -66.066,52.7 -7.256,30.552 7.637,62.249 34.751,76.379 29.406,15.275 66.83,6.874 86.69,-20.622 19.476,-27.114 16.42,-64.54 -6.875,-88.217 l 91.653,-187.507 c 5.729,0.382 14.13,0.764 23.677,-1.91 15.658,-3.436 27.115,-13.747 27.115,-13.747 16.039,6.874 32.842,14.511 50.409,23.295 18.33,9.165 35.516,18.712 51.173,27.878 3.437,1.91 6.874,4.2 10.693,7.256 6.11,4.964 12.984,11.838 17.949,21.003 7.255,21.004 -7.256,56.902 -7.256,56.902 -8.784,29.023 -70.268,155.047 -70.268,155.047 -30.933,-0.764 -58.429,19.094 -67.594,47.736 -9.93,30.933 4.2,66.066 33.988,81.342 29.787,15.275 66.449,6.492 85.925,-20.24 19.094,-25.969 17.567,-62.248 -4.2,-86.307 7.255,-14.13 14.129,-28.26 21.385,-43.153 19.094,-39.717 51.555,-116.094 51.555,-116.094 3.437,-6.493 21.768,-39.335 10.31,-81.343 -9.546,-43.535 -48.117,-63.775 -48.117,-63.775 -46.59,-30.17 -111.512,-58.047 -111.512,-58.047 0,0 0,-15.658 -4.2,-27.114 -4.201,-11.839 -10.693,-19.477 -14.894,-24.06 17.949,-37.042 35.897,-73.704 53.846,-110.747 a 2647.928,2647.928 0 0 1 -46.59,-23.295 c -18.33,37.425 -37.043,75.232 -55.374,112.657 -25.587,-0.382 -49.264,13.366 -61.484,35.898 -12.984,24.058 -10.311,53.846 7.256,75.613 z" | ||||
|      style="fill:#609926;stroke-width:3.81889" | ||||
|      id="path3" /></svg> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="128" height="128" viewBox="0 0 2560 2560"><path d="m1569.914 2282.76-484.616-232.952c-47.736-22.913-68.358-80.96-45.063-129.078l232.952-484.617c22.913-47.736 80.96-68.358 129.078-45.062 65.685 31.696 103.492 49.645 103.492 49.645l-.382-417.022 63.776-.382.381 447.191s219.204 92.417 317.35 153.138c14.13 8.783 38.952 25.968 49.263 54.992 8.02 23.295 7.638 50.027-3.818 73.704l-232.952 484.617c-23.678 48.5-81.725 69.121-129.46 45.826z" style="fill:#fff;stroke-width:3.81889"/><path d="M2436.037 1005.725c-15.657-15.657-36.66-15.276-36.66-15.276s-447.574 25.205-679.38 30.552c-50.792 1.145-101.201 2.29-151.228 2.673v447.573c-21.004-9.929-42.39-20.24-63.394-30.17 0-139.007-.382-417.021-.382-417.021-110.747 1.527-340.644-8.402-340.644-8.402s-539.99-27.114-598.802-32.46c-37.425-2.292-85.924-8.02-148.936 5.728-33.224 6.874-127.933 28.26-205.456 102.728-171.85 153.137-127.933 396.782-122.586 433.443 6.492 44.681 26.35 168.795 121.058 276.87 174.905 214.239 551.447 209.275 551.447 209.275s46.209 110.365 116.858 211.948c95.472 126.405 193.618 224.932 289.09 236.77 240.59 0 721.387-.381 721.387-.381s45.827.382 108.075-39.335c53.464-32.46 101.2-89.362 101.2-89.362s49.264-52.7 118.004-172.995c21.004-37.043 38.57-72.941 53.846-106.93 0 0 210.803-447.19 210.803-882.543-4.201-131.752-36.662-155.047-44.3-162.685M537.67 1785.159c-98.91-32.46-140.917-71.413-140.917-71.413s-72.94-51.173-109.602-151.991c-63.012-168.795-5.347-271.905-5.347-271.905s32.079-85.925 147.027-114.567c52.701-14.13 118.386-11.838 118.386-11.838s27.114 226.842 59.956 359.739c27.496 111.511 94.709 296.727 94.709 296.727s-99.673-11.838-164.212-34.752m1146.81 410.912s-23.294 55.374-74.85 58.811c-22.149 1.528-39.334-4.582-39.334-4.582s-1.145-.382-20.24-8.02l-431.152-210.039s-41.626-21.767-48.882-59.574c-8.401-30.933 10.311-69.122 10.311-69.122l207.366-427.333s18.33-37.044 46.59-49.646c2.291-1.146 8.784-3.819 17.185-5.728 30.933-8.02 68.74 10.693 68.74 10.693l422.75 205.074s48.119 21.767 58.43 61.866c7.255 28.26-1.91 53.464-6.874 65.685-24.06 58.81-210.04 431.916-210.04 431.916z" style="fill:#609926;stroke-width:3.81889"/><path d="M1306.029 1885.214c-31.314.382-58.81 22.15-66.066 52.7-7.256 30.552 7.637 62.249 34.751 76.379 29.406 15.275 66.83 6.874 86.69-20.622 19.476-27.114 16.42-64.54-6.875-88.217l91.653-187.507c5.729.382 14.13.764 23.677-1.91 15.658-3.436 27.115-13.747 27.115-13.747 16.039 6.874 32.842 14.511 50.409 23.295 18.33 9.165 35.516 18.712 51.173 27.878 3.437 1.91 6.874 4.2 10.693 7.256 6.11 4.964 12.984 11.838 17.949 21.003 7.255 21.004-7.256 56.902-7.256 56.902-8.784 29.023-70.268 155.047-70.268 155.047-30.933-.764-58.429 19.094-67.594 47.736-9.93 30.933 4.2 66.066 33.988 81.342 29.787 15.275 66.449 6.492 85.925-20.24 19.094-25.969 17.567-62.248-4.2-86.307 7.255-14.13 14.129-28.26 21.385-43.153 19.094-39.717 51.555-116.094 51.555-116.094 3.437-6.493 21.768-39.335 10.31-81.343-9.546-43.535-48.117-63.775-48.117-63.775-46.59-30.17-111.512-58.047-111.512-58.047s0-15.658-4.2-27.114c-4.201-11.839-10.693-19.477-14.894-24.06 17.949-37.042 35.897-73.704 53.846-110.747a2648 2648 0 0 1-46.59-23.295c-18.33 37.425-37.043 75.232-55.374 112.657-25.587-.382-49.264 13.366-61.484 35.898-12.984 24.058-10.311 53.846 7.256 75.613z" style="fill:#609926;stroke-width:3.81889"/></svg> | ||||
| Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.3 KiB | 
| @@ -1,49 +1 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="48" | ||||
|    height="48" | ||||
|    viewBox="0 0 12.7 12.7" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    inkscape:version="1.4 (e7c3feb100, 2024-10-09)" | ||||
|    sodipodi:docname="logo.svg" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <sodipodi:namedview | ||||
|      id="namedview1" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#000000" | ||||
|      borderopacity="0.25" | ||||
|      inkscape:showpageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      inkscape:deskcolor="#d1d1d1" | ||||
|      inkscape:document-units="mm" | ||||
|      inkscape:zoom="14.329304" | ||||
|      inkscape:cx="17.167617" | ||||
|      inkscape:cy="25.088448" | ||||
|      inkscape:window-width="1408" | ||||
|      inkscape:window-height="1727" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="0" | ||||
|      inkscape:current-layer="layer1" /> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      inkscape:label="Layer 1" | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer1"> | ||||
|     <ellipse | ||||
|        style="fill:#c7d6f1;stroke:#3366cc;stroke-width:1.94357;fill-opacity:1" | ||||
|        id="path2" | ||||
|        cx="6.3500028" | ||||
|        cy="6.3500109" | ||||
|        rx="4.5089426" | ||||
|        ry="4.5918198" /> | ||||
|   </g> | ||||
| </svg> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 12.7 12.7"><ellipse cx="6.35" cy="6.35" rx="4.509" ry="4.592" style="fill:#c7d6f1;stroke:#36c;stroke-width:1.94357;fill-opacity:1"/></svg> | ||||
| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 214 B | 
							
								
								
									
										133
									
								
								security.py
									
									
									
									
									
								
							
							
						
						
									
										133
									
								
								security.py
									
									
									
									
									
								
							| @@ -1,133 +0,0 @@ | ||||
| from datetime import timedelta, timezone, datetime | ||||
| from typing import Annotated | ||||
| from fastapi import Depends, HTTPException, Response, status | ||||
| from pydantic import BaseModel | ||||
| import jwt | ||||
| from jwt.exceptions import InvalidTokenError | ||||
| from sqlmodel import Session, select | ||||
| from db import engine, User | ||||
| from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm | ||||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||||
| from passlib.context import CryptContext | ||||
|  | ||||
|  | ||||
| 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: | ||||
|         with Session(engine) as session: | ||||
|             return session.exec( | ||||
|                 select(User).where(User.username == username) | ||||
|             ).one_or_none() | ||||
|  | ||||
|  | ||||
| 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}] | ||||
| @@ -17,13 +17,6 @@ import { apiAuth } from "./api"; | ||||
| //  }; | ||||
| //}; | ||||
| // | ||||
| interface Prop { | ||||
|   name: string; | ||||
|   min: string; | ||||
|   max: string; | ||||
|   step: string; | ||||
|   value: string; | ||||
| } | ||||
|  | ||||
| interface Params { | ||||
|   nodeSize: number; | ||||
| @@ -36,13 +29,7 @@ interface Params { | ||||
|   show: number; | ||||
| } | ||||
|  | ||||
| interface DeferredProps { | ||||
|   timeout: number; | ||||
|   func: () => void; | ||||
| } | ||||
|  | ||||
|  | ||||
| let timeoutID: number | null = null; | ||||
| let timeoutID: NodeJS.Timeout | null = null; | ||||
| export default function Analysis() { | ||||
|   const [image, setImage] = useState(""); | ||||
|   const [params, setParams] = useState<Params>({ | ||||
| @@ -65,9 +52,10 @@ export default function Analysis() { | ||||
|       .then((data) => { | ||||
|         setImage(data.image); | ||||
|         setLoading(false); | ||||
|       }).catch((e) => { | ||||
|         console.log("best to just reload... ", e); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.log("best to just reload... ", e); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
| @@ -81,19 +69,35 @@ export default function Analysis() { | ||||
|  | ||||
|   function showLabel() { | ||||
|     switch (params.show) { | ||||
|       case 0: return "dislike"; | ||||
|       case 1: return "both"; | ||||
|       case 2: return "like"; | ||||
|       case 0: | ||||
|         return "dislike"; | ||||
|       case 1: | ||||
|         return "both"; | ||||
|       case 2: | ||||
|         return "like"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="stack column dropdown"> | ||||
|       <button onClick={() => setShowControlPanel(!showControlPanel)}> | ||||
|         Parameters <svg viewBox="0 0 24 24" height="1.2em" style={{ fill: "#ffffff", display: "inline", top: "0.2em", position: "relative", transform: showControlPanel ? "rotate(180deg)" : "unset" }} > <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" > </path></svg > | ||||
|         Parameters{" "} | ||||
|         <svg | ||||
|           viewBox="0 0 24 24" | ||||
|           height="1.2em" | ||||
|           style={{ | ||||
|             fill: "#ffffff", | ||||
|             display: "inline", | ||||
|             top: "0.2em", | ||||
|             position: "relative", | ||||
|             transform: showControlPanel ? "rotate(180deg)" : "unset", | ||||
|           }} | ||||
|         > | ||||
|           {" "} | ||||
|           <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"> </path> | ||||
|         </svg> | ||||
|       </button> | ||||
|       <div id="control-panel" className={showControlPanel ? "opened" : ""}> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <datalist id="markers"> | ||||
|             <option value="0"></option> | ||||
| @@ -109,7 +113,9 @@ export default function Analysis() { | ||||
|               max="2" | ||||
|               step="1" | ||||
|               width="16px" | ||||
|               onChange={(evt) => setParams({ ...params, show: Number(evt.target.value) })} | ||||
|               onChange={(evt) => | ||||
|                 setParams({ ...params, show: Number(evt.target.value) }) | ||||
|               } | ||||
|             /> | ||||
|             <label>😍</label> | ||||
|           </div> | ||||
| @@ -120,7 +126,9 @@ export default function Analysis() { | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={params.weighting} | ||||
|               onChange={(evt) => setParams({ ...params, weighting: evt.target.checked })} | ||||
|               onChange={(evt) => | ||||
|                 setParams({ ...params, weighting: evt.target.checked }) | ||||
|               } | ||||
|             /> | ||||
|             <label>weighting</label> | ||||
|           </div> | ||||
| @@ -129,7 +137,9 @@ export default function Analysis() { | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={params.popularity} | ||||
|               onChange={(evt) => setParams({ ...params, popularity: evt.target.checked })} | ||||
|               onChange={(evt) => | ||||
|                 setParams({ ...params, popularity: evt.target.checked }) | ||||
|               } | ||||
|             /> | ||||
|             <label>popularity</label> | ||||
|           </div> | ||||
| @@ -143,9 +153,12 @@ export default function Analysis() { | ||||
|             max="3.001" | ||||
|             step="0.05" | ||||
|             value={params.distance} | ||||
|             onChange={(evt) => setParams({ ...params, distance: Number(evt.target.value) })} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, distance: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.distance}</span></div> | ||||
|           <span>{params.distance}</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <label>node size</label> | ||||
| @@ -154,7 +167,9 @@ export default function Analysis() { | ||||
|             min="500" | ||||
|             max="3000" | ||||
|             value={params.nodeSize} | ||||
|             onChange={(evt) => setParams({ ...params, nodeSize: Number(evt.target.value) })} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, nodeSize: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.nodeSize}</span> | ||||
|         </div> | ||||
| @@ -166,7 +181,9 @@ export default function Analysis() { | ||||
|             min="4" | ||||
|             max="24" | ||||
|             value={params.fontSize} | ||||
|             onChange={(evt) => setParams({ ...params, fontSize: Number(evt.target.value) })} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, fontSize: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.fontSize}</span> | ||||
|         </div> | ||||
| @@ -179,7 +196,9 @@ export default function Analysis() { | ||||
|             max="5" | ||||
|             step="0.1" | ||||
|             value={params.edgeWidth} | ||||
|             onChange={(evt) => setParams({ ...params, edgeWidth: Number(evt.target.value) })} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, edgeWidth: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.edgeWidth}</span> | ||||
|         </div> | ||||
| @@ -191,20 +210,19 @@ export default function Analysis() { | ||||
|             min="10" | ||||
|             max="50" | ||||
|             value={params.arrowSize} | ||||
|             onChange={(evt) => setParams({ ...params, arrowSize: Number(evt.target.value) })} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, arrowSize: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.arrowSize}</span> | ||||
|         </div> | ||||
|  | ||||
|       </div> | ||||
|       <button onClick={() => loadImage()}>reload ↻</button> | ||||
|       { | ||||
|         loading ? ( | ||||
|           <span className="loader"></span> | ||||
|         ) : ( | ||||
|           <img src={"data:image/png;base64," + image} width="86%" /> | ||||
|         ) | ||||
|       } | ||||
|     </div > | ||||
|       {loading ? ( | ||||
|         <span className="loader"></span> | ||||
|       ) : ( | ||||
|         <img src={"data:image/png;base64," + image} width="86%" /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										404
									
								
								src/App.css
									
									
									
									
									
								
							
							
						
						
									
										404
									
								
								src/App.css
									
									
									
									
									
								
							| @@ -1,7 +1,3 @@ | ||||
| * { | ||||
|   border-radius: 16px; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   background-color: aliceblue; | ||||
|   position: relative; | ||||
| @@ -23,11 +19,127 @@ footer { | ||||
|   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 { | ||||
|   color: #444; | ||||
|   opacity: 66%; | ||||
| } | ||||
|  | ||||
|  | ||||
|  | ||||
| .hint { | ||||
|   position: absolute; | ||||
|   font-size: 80%; | ||||
| @@ -41,7 +153,9 @@ footer { | ||||
|  | ||||
| input { | ||||
|   padding: 0.2em 16px; | ||||
|   margin-bottom: 0.5em; | ||||
|   margin-top: 0.25em; | ||||
|   margin-bottom: 0.25em; | ||||
|   border-radius: 1em; | ||||
| } | ||||
|  | ||||
| h1, | ||||
| @@ -83,6 +197,9 @@ h3 { | ||||
| .box { | ||||
|   position: relative; | ||||
|   flex: 1; | ||||
|   border-width: 3px; | ||||
|   border-style: solid; | ||||
|   border-radius: 16px; | ||||
|  | ||||
|   &.one { | ||||
|     max-width: min(96%, 768px); | ||||
| @@ -91,8 +208,6 @@ h3 { | ||||
|  | ||||
|   padding: 4px; | ||||
|   margin: 4px 0.5%; | ||||
|   border-style: solid; | ||||
|   border-color: black; | ||||
| } | ||||
|  | ||||
| .reservoir { | ||||
| @@ -102,24 +217,13 @@ h3 { | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .user { | ||||
|   max-width: 240px; | ||||
|   min-width: 100px; | ||||
|   margin: 4px auto; | ||||
|  | ||||
|   .item { | ||||
|     font-weight: bold; | ||||
|     border-style: solid; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .item { | ||||
|   cursor: pointer; | ||||
|   font-size: small; | ||||
|   border: 3px dashed black; | ||||
|   border-radius: 1.2em; | ||||
|   margin: 8px auto; | ||||
|   padding: 4px 16px; | ||||
|   font-size: medium; | ||||
|   border: 2px solid; | ||||
|   border-radius: 1em; | ||||
|   margin: 3px auto; | ||||
|   padding: 5px 0.8em; | ||||
| } | ||||
|  | ||||
| .extra-margin { | ||||
| @@ -127,14 +231,17 @@ h3 { | ||||
|   margin: auto; | ||||
| } | ||||
|  | ||||
| button, | ||||
| .button { | ||||
| button { | ||||
|   margin: 4px; | ||||
|   font-weight: bold; | ||||
|   font-size: large; | ||||
|   color: aliceblue; | ||||
|   background-color: black; | ||||
|   border-radius: 1.2em; | ||||
|   z-index: 1; | ||||
|  | ||||
|   &:hover { | ||||
|     opacity: 80%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| #control-panel { | ||||
| @@ -152,6 +259,7 @@ button, | ||||
|  | ||||
| .control { | ||||
|   display: flex; | ||||
|   border-radius: 16px; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
| @@ -168,13 +276,24 @@ button, | ||||
|   #control-panel { | ||||
|     grid-template-columns: repeat(2, 1fr); | ||||
|   } | ||||
|  | ||||
|   .control { | ||||
|     font-size: 80%; | ||||
|     margin: 0px; | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
|  | ||||
| @media only screen and (max-width: 768px) { | ||||
|   #control-panel { | ||||
|     grid-template-columns: 1fr; | ||||
|   } | ||||
|  | ||||
|   .networkroute { | ||||
|     display: none; | ||||
|   } | ||||
|  | ||||
|   .submit_text { | ||||
|     display: none; | ||||
|   } | ||||
| @@ -185,10 +304,15 @@ button, | ||||
|     bottom: 16px; | ||||
|     padding: 0.4em; | ||||
|     border-radius: 1em; | ||||
|     background-color: rgba(0, 0, 0, 0.3); | ||||
|     background-color: #36c8; | ||||
|     font-size: xx-large; | ||||
|     margin-bottom: 16px; | ||||
|     margin-right: 16px; | ||||
|  | ||||
|   } | ||||
|  | ||||
|   .wavering { | ||||
|     animation: blink 40s infinite; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -201,11 +325,21 @@ button, | ||||
|   opacity: 0.75; | ||||
| } | ||||
|  | ||||
| .tablink { | ||||
|   color: white; | ||||
|   cursor: pointer; | ||||
| .tab-button { | ||||
|   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; | ||||
| } | ||||
|  | ||||
| .navbar { | ||||
| @@ -221,7 +355,7 @@ button, | ||||
|     opacity: 50%; | ||||
|  | ||||
|     &:hover { | ||||
|       opacity: 75%; | ||||
|       opacity: 80%; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -241,11 +375,17 @@ button, | ||||
|   font-size: 150%; | ||||
| } | ||||
|  | ||||
| /*======LOGO=======*/ | ||||
|  | ||||
| .logo { | ||||
|   position: relative; | ||||
|   text-align: center; | ||||
|   height: 140px; | ||||
|   margin-bottom: 20px; | ||||
|  | ||||
|   span { | ||||
|     display: block; | ||||
|     margin: 2px; | ||||
|   } | ||||
|  | ||||
|   img { | ||||
|     display: block; | ||||
| @@ -268,11 +408,209 @@ 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 { | ||||
|   display: block; | ||||
|   border-radius: 16px; | ||||
|   position: relative; | ||||
|   height: 12px; | ||||
|   width: 96%; | ||||
|   margin: auto; | ||||
|   border: 4px solid black; | ||||
|   overflow: hidden; | ||||
| } | ||||
|   | ||||
							
								
								
									
										57
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										57
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -5,27 +5,48 @@ import Header from "./Header"; | ||||
| import Rankings from "./Rankings"; | ||||
| import { BrowserRouter, Routes, Route } from "react-router"; | ||||
| import { SessionProvider } from "./Session"; | ||||
| import { GraphComponent } from "./Network"; | ||||
| import MVPChart from "./MVPChart"; | ||||
| import { SetPassword } from "./SetPassword"; | ||||
| import { ThemeProvider } from "./ThemeProvider"; | ||||
| import TeamPanel from "./TeamPanel"; | ||||
|  | ||||
| const Maintenance = () => { | ||||
|   return ( | ||||
|     <div style={{ textAlign: "center", padding: "20px" }}> | ||||
|       <h2>We are under maintenance.</h2> | ||||
|       <p>Please check back later. Thank you for your patience.</p> | ||||
|       <span style={{ fontSize: "xx-large" }}>🚧</span> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| function App() { | ||||
|   //const [data, setData] = useState({ nodes: [], links: [] } as SociogramData); | ||||
|   //async function loadData() { | ||||
|   //  await fetch(`${baseUrl}api/analysis/json`, { method: "GET" }).then(resp => resp.json() as unknown as SociogramData).then(json => { setData(json) }) | ||||
|   //} | ||||
|   //useEffect(() => { loadData() }, []) | ||||
|   // | ||||
|   return ( | ||||
|     <BrowserRouter> | ||||
|       <Header /> | ||||
|       <Routes> | ||||
|         <Route index element={<Rankings />} /> | ||||
|         <Route path="/analysis" element={ | ||||
|           <SessionProvider> | ||||
|             <Analysis /> | ||||
|           </SessionProvider> | ||||
|         } /> | ||||
|       </Routes> | ||||
|       <Footer /> | ||||
|     </BrowserRouter> | ||||
|     <ThemeProvider> | ||||
|       <BrowserRouter> | ||||
|         <Routes> | ||||
|           <Route path="/password" element={<SetPassword />} /> | ||||
|           <Route | ||||
|             path="/*" | ||||
|             element={ | ||||
|               <SessionProvider> | ||||
|                 <Header /> | ||||
|                 <Routes> | ||||
|                   <Route index element={<Rankings />} /> | ||||
|                   <Route path="network" element={<GraphComponent />} /> | ||||
|                   <Route path="analysis" element={<Analysis />} /> | ||||
|                   <Route path="mvp" element={<MVPChart />} /> | ||||
|                   <Route path="changepassword" element={<SetPassword />} /> | ||||
|                   <Route path="team" element={<TeamPanel />} /> | ||||
|                 </Routes> | ||||
|                 <Footer /> | ||||
|               </SessionProvider> | ||||
|             } | ||||
|           /> | ||||
|         </Routes> | ||||
|       </BrowserRouter> | ||||
|     </ThemeProvider> | ||||
|   ); | ||||
| } | ||||
| export default App; | ||||
|   | ||||
							
								
								
									
										217
									
								
								src/Avatar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										217
									
								
								src/Avatar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,217 @@ | ||||
| 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> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										95
									
								
								src/BarChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/BarChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| import { FC } from 'react'; | ||||
| import { PlayerRanking } from './types'; | ||||
|  | ||||
| interface BarChartProps { | ||||
|   players: PlayerRanking[]; | ||||
|   width: number; | ||||
|   height: number; | ||||
|   std: boolean; | ||||
| } | ||||
|  | ||||
| const BarChart: FC<BarChartProps> = ({ players, width, height, std }) => { | ||||
|   const padding = 24; | ||||
|   const maxValue = Math.max(...players.map((player) => player.rank)) + 1; | ||||
|   const barWidth = (width - 2 * padding) / players.length; | ||||
|  | ||||
|   return ( | ||||
|     <svg width={width} height={height}> | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <rect | ||||
|           key={index} | ||||
|           x={index * barWidth + padding} | ||||
|           y={height - (1 - player.rank / maxValue) * height} | ||||
|           width={barWidth - 8} // subtract 2 for some spacing between bars | ||||
|           height={(1 - player.rank / maxValue) * height} | ||||
|           fill="#69f" | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <text | ||||
|           key={index} | ||||
|           x={index * barWidth + barWidth / 2 - 4 + padding} | ||||
|           y={height - (1 - player.rank / maxValue) * height - 5} | ||||
|           textAnchor="middle" | ||||
|           //transform='rotate(-27)' | ||||
|           //style={{ transformOrigin: "center", transformBox: "fill-box" }} | ||||
|           fontSize="16px" | ||||
|           fill="#404040" | ||||
|         > | ||||
|           {player.name} | ||||
|         </text> | ||||
|       ))} | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <text | ||||
|           key={index} | ||||
|           x={index * barWidth + barWidth / 2 + padding - 4} | ||||
|           y={height - 8} | ||||
|           textAnchor="middle" | ||||
|           fontSize="12px" | ||||
|           fill="#404040" | ||||
|         > | ||||
|           {player.rank} | ||||
|         </text> | ||||
|       ))} | ||||
|  | ||||
|       {std && players.map((player, index) => ( | ||||
|         <line | ||||
|           key={`error-${index}`} | ||||
|           x1={index * barWidth + barWidth / 2 + padding} | ||||
|           y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height} | ||||
|           x2={index * barWidth + barWidth / 2 + padding} | ||||
|           y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height} | ||||
|           stroke="#ff0000" | ||||
|           strokeWidth="1" | ||||
|         /> | ||||
|       ))} | ||||
|       {std && players.map((player, index) => ( | ||||
|         <line | ||||
|           key={`cap-${index}-top`} | ||||
|           x1={index * barWidth + barWidth / 2 - 2 + padding} | ||||
|           y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height} | ||||
|           x2={index * barWidth + barWidth / 2 + 2 + padding} | ||||
|           y2={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height} | ||||
|           stroke="#ff0000" | ||||
|           strokeWidth="1" | ||||
|         /> | ||||
|       ))} | ||||
|       {std && players.map((player, index) => ( | ||||
|         <line | ||||
|           key={`cap-${index}-bottom`} | ||||
|           x1={index * barWidth + barWidth / 2 - 2 + padding} | ||||
|           y1={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height} | ||||
|           x2={index * barWidth + barWidth / 2 + 2 + padding} | ||||
|           y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height} | ||||
|           stroke="#ff0000" | ||||
|           strokeWidth="1" | ||||
|         /> | ||||
|       ))} | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default BarChart; | ||||
| @@ -1,21 +1,42 @@ | ||||
| import { useLocation } from "react-router"; | ||||
| import { Link } from "react-router"; | ||||
| import { useSession } from "./Session"; | ||||
|  | ||||
| export default function Footer() { | ||||
|         return <footer> | ||||
|                 <div className="navbar"> | ||||
|                         <Link to="/" ><span>Form</span></Link> | ||||
|                         <span>|</span> | ||||
|                         <Link to="/analysis" ><span>Trainer Analysis</span></Link> | ||||
|                 </div> | ||||
|                 <p className="grey extra-margin"> | ||||
|                         something not working? | ||||
|                         <br /> | ||||
|                         message <a href="https://t.me/x0124816">me</a>. | ||||
|                         <br /> | ||||
|                         or fix it here:{" "} | ||||
|                         <a href="https://git.0124816.xyz/julius/cutt" key="gitea"> | ||||
|                                 <img src="gitea.svg" alt="gitea" height="16" /> | ||||
|                         </a> | ||||
|                 </p> | ||||
|         </footer> | ||||
|   const location = useLocation(); | ||||
|   const { user, teams } = useSession(); | ||||
|   return ( | ||||
|     <footer className={location.pathname === "/network" ? "fixed-footer" : ""}> | ||||
|       {(user?.scopes.split(" ").includes("analysis") || | ||||
|         teams?.activeTeam === 42) && ( | ||||
|         <div className="navbar"> | ||||
|           <Link to="/"> | ||||
|             <span>Form</span> | ||||
|           </Link> | ||||
|           <span>|</span> | ||||
|           <Link to="/network"> | ||||
|             <span>Trainer Analysis</span> | ||||
|           </Link> | ||||
|           <span>|</span> | ||||
|           <Link to="/mvp"> | ||||
|             <span>MVP</span> | ||||
|           </Link> | ||||
|           <span>|</span> | ||||
|           <Link to="/team"> | ||||
|             <span>Team</span> | ||||
|           </Link> | ||||
|         </div> | ||||
|       )} | ||||
|       <p className="grey extra-margin"> | ||||
|         something not working? | ||||
|         <br /> | ||||
|         message <a href="https://t.me/x0124816">me</a>. | ||||
|         <br /> | ||||
|         or fix it here:{" "} | ||||
|         <a href="https://git.0124816.xyz/julius/cutt" key="gitea"> | ||||
|           <img src="gitea.svg" alt="gitea" height="16" /> | ||||
|         </a> | ||||
|       </p> | ||||
|     </footer> | ||||
|   ); | ||||
| } | ||||
|   | ||||
| @@ -1,11 +1,18 @@ | ||||
| import { baseUrl } from "./api"; | ||||
| import { Link, useLocation } from "react-router"; | ||||
| import Avatar from "./Avatar"; | ||||
|  | ||||
| export default function Header() { | ||||
|   return <div className="logo"> | ||||
|     <a href={baseUrl}> | ||||
|       <img alt="logo" height="66%" src="logo.svg" /> | ||||
|       <h3 className="centered">cutt</h3> | ||||
|     </a> | ||||
|     <span className="grey">cool ultimate team tool</span> | ||||
|   </div> | ||||
|   const location = useLocation(); | ||||
|   return ( | ||||
|     <div className={location.pathname === "/network" ? "networkroute" : ""}> | ||||
|       <div className="logo"> | ||||
|         <Link to="/"> | ||||
|           <img alt="logo" height="66%" src="logo.svg" /> | ||||
|           <h3 className="centered">cutt</h3> | ||||
|         </Link> | ||||
|         <span className="grey">cool ultimate team tool</span> | ||||
|       </div> | ||||
|       <Avatar /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										72
									
								
								src/Icons.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/Icons.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| export const Eye = () => ( | ||||
|   <svg | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="1em" | ||||
|     height="1em" | ||||
|     viewBox="0 0 36 36" | ||||
|   > | ||||
|     <path | ||||
|       fill="#E1E8ED" | ||||
|       d="M35.059 18c0 3.304-7.642 11-17.067 11S.925 22.249.925 18c0-3.314 34.134-3.314 34.134 0" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#292F33" | ||||
|       d="M35.059 18H.925c0-3.313 7.642-11 17.067-11s17.067 7.686 17.067 11" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#F5F8FA" | ||||
|       d="M33.817 18c0 2.904-7.087 9.667-15.826 9.667S2.166 21.732 2.166 18c0-2.912 7.085-9.666 15.825-9.666C26.73 8.333 33.817 15.088 33.817 18" | ||||
|     /> | ||||
|     <circle | ||||
|       cx="18" | ||||
|       cy="18" | ||||
|       r="8.458" | ||||
|       fill="#8B5E3C" | ||||
|       style={{ fill: "#919191", fillOpacity: 1 }} | ||||
|     /> | ||||
|     <circle cx="18" cy="18" r="4.708" fill="#292F33" /> | ||||
|     <circle cx="14.983" cy="15" r="2" fill="#F5F8FA" /> | ||||
|   </svg> | ||||
| ); | ||||
|  | ||||
| export const EyeSlash = () => ( | ||||
|   <svg | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="1em" | ||||
|     height="1em" | ||||
|     viewBox="0 0 36 36" | ||||
|   > | ||||
|     <path | ||||
|       fill="#e1e8ed" | ||||
|       d="M35.059 18c0 3.304-7.642 11-17.067 11S.925 22.249.925 18c0-3.314 34.134-3.314 34.134 0" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#292f33" | ||||
|       d="M35.059 18H.925c0-3.313 7.642-11 17.067-11s17.067 7.686 17.067 11" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#f5f8fa" | ||||
|       d="M33.817 18c0 2.904-7.087 9.667-15.826 9.667S2.166 21.732 2.166 18c0-2.912 7.085-9.666 15.825-9.666C26.73 8.333 33.817 15.088 33.817 18" | ||||
|     /> | ||||
|     <circle | ||||
|       cx="18" | ||||
|       cy="18" | ||||
|       r="8.458" | ||||
|       fill="#8B5E3C" | ||||
|       style={{ fill: "#919191", fillOpacity: 1 }} | ||||
|     /> | ||||
|     <circle cx="18" cy="18" r="4.708" fill="#292f33" /> | ||||
|     <circle cx="14.983" cy="15" r="2" fill="#f5f8fa" /> | ||||
|     <path | ||||
|       d="m-2.97 30.25 41.94-24.5" | ||||
|       style={{ | ||||
|         fill: "#404040", | ||||
|         fillOpacity: 1, | ||||
|         stroke: "#404040", | ||||
|         strokeWidth: 3, | ||||
|         strokeDasharray: "none", | ||||
|         strokeOpacity: 1, | ||||
|       }} | ||||
|     /> | ||||
|   </svg> | ||||
| ); | ||||
							
								
								
									
										125
									
								
								src/Login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/Login.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { currentUser, login, User } from "./api"; | ||||
| import Header from "./Header"; | ||||
| import { useLocation, useNavigate } from "react-router"; | ||||
| import { Eye, EyeSlash } from "./Icons"; | ||||
|  | ||||
| export interface LoginProps { | ||||
|   onLogin: (user: User) => void; | ||||
| } | ||||
|  | ||||
| export const Login = ({ onLogin }: LoginProps) => { | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [visible, setVisible] = useState(false); | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|  | ||||
|   async function doLogin() { | ||||
|     setLoading(true); | ||||
|     setError(""); | ||||
|     const timeout = new Promise((r) => setTimeout(r, 1000)); | ||||
|     let user: User; | ||||
|     try { | ||||
|       await login({ username, password }); | ||||
|       user = await currentUser(); | ||||
|     } catch (e) { | ||||
|       await timeout; | ||||
|       setError("failed"); | ||||
|       setLoading(false); | ||||
|       return; | ||||
|     } | ||||
|     await timeout; | ||||
|     onLogin(user); | ||||
|   } | ||||
|  | ||||
|   function handleSubmit(e: React.FormEvent) { | ||||
|     e.preventDefault(); | ||||
|     doLogin(); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (location.state) { | ||||
|       const queryUsername = location.state.username; | ||||
|       const queryPassword = location.state.password; | ||||
|       if (queryUsername) setUsername(queryUsername); | ||||
|       if (queryPassword) setPassword(queryPassword); | ||||
|       navigate(location.pathname, { replace: true }); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Header /> | ||||
|       <form onSubmit={handleSubmit}> | ||||
|         <div | ||||
|           style={{ | ||||
|             position: "relative", | ||||
|             display: "flex", | ||||
|             alignItems: "end", | ||||
|           }} | ||||
|         > | ||||
|           <div | ||||
|             style={{ | ||||
|               width: "100%", | ||||
|               marginRight: "8px", | ||||
|               display: "flex", | ||||
|               justifyContent: "center", | ||||
|               flexDirection: "column", | ||||
|             }} | ||||
|           > | ||||
|             <div> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 id="username" | ||||
|                 name="username" | ||||
|                 placeholder="username" | ||||
|                 required | ||||
|                 value={username} | ||||
|                 onChange={(evt) => { | ||||
|                   setError(""); | ||||
|                   setUsername(evt.target.value); | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|             <div> | ||||
|               <input | ||||
|                 type={visible ? "text" : "password"} | ||||
|                 id="password" | ||||
|                 name="password" | ||||
|                 placeholder="password" | ||||
|                 minLength={8} | ||||
|                 value={password} | ||||
|                 required | ||||
|                 onChange={(evt) => { | ||||
|                   setError(""); | ||||
|                   setPassword(evt.target.value); | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div | ||||
|             style={{ | ||||
|               position: "absolute", | ||||
|               right: "-1em", | ||||
|               margin: "auto 4px", | ||||
|               background: "unset", | ||||
|               fontSize: "x-large", | ||||
|               cursor: "pointer", | ||||
|             }} | ||||
|             onClick={() => setVisible(!visible)} | ||||
|           > | ||||
|             {visible ? <Eye /> : <EyeSlash />} | ||||
|           </div> | ||||
|         </div> | ||||
|         {error && <span style={{ color: "red" }}>{error}</span>} | ||||
|         <button type="submit" value="login" style={{ fontSize: "small" }}> | ||||
|           login | ||||
|         </button> | ||||
|         {loading && <span className="loader" />} | ||||
|       </form> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										52
									
								
								src/MVPChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/MVPChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { apiAuth } from "./api"; | ||||
| import { PlayerRanking } from "./types"; | ||||
| import RaceChart from "./RaceChart"; | ||||
| import { useSession } from "./Session"; | ||||
| import { useNavigate } from "react-router"; | ||||
|  | ||||
| const MVPChart = () => { | ||||
|   let initialData = {} as PlayerRanking[]; | ||||
|   const [data, setData] = useState(initialData); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [showStd, setShowStd] = useState(false); | ||||
|   const { user, teams } = useSession(); | ||||
|   const navigate = useNavigate(); | ||||
|   useEffect(() => { | ||||
|     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||
|       teams?.activeTeam === 42 || | ||||
|       navigate("/", { replace: true }); | ||||
|   }, [user]); | ||||
|  | ||||
|   async function loadData() { | ||||
|     setLoading(true); | ||||
|     if (teams) { | ||||
|       await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null) | ||||
|         .then((data) => { | ||||
|           if (data.detail) { | ||||
|             setError(data.detail); | ||||
|             return initialData; | ||||
|           } else { | ||||
|             setError(""); | ||||
|             return data as Promise<PlayerRanking[]>; | ||||
|           } | ||||
|         }) | ||||
|         .then((data) => { | ||||
|           setData(data.sort((a, b) => a.rank - b.rank)); | ||||
|         }) | ||||
|         .catch(() => setError("no access")); | ||||
|       setLoading(false); | ||||
|     } else setError("team unknown"); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadData(); | ||||
|   }, [teams]); | ||||
|  | ||||
|   if (loading) return <span className="loader" />; | ||||
|   else if (error) return <span>{error}</span>; | ||||
|   else return <RaceChart std={showStd} players={data} />; | ||||
| }; | ||||
|  | ||||
| export default MVPChart; | ||||
							
								
								
									
										319
									
								
								src/Network.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								src/Network.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | ||||
| import { ReactNode, useEffect, useRef, useState } from "react"; | ||||
| import { apiAuth } from "./api"; | ||||
| import { | ||||
|   GraphCanvas, | ||||
|   GraphCanvasRef, | ||||
|   GraphEdge, | ||||
|   GraphNode, | ||||
|   SelectionProps, | ||||
|   SelectionResult, | ||||
|   useSelection, | ||||
| } from "reagraph"; | ||||
| import { customTheme } from "./NetworkTheme"; | ||||
| import { useSession } from "./Session"; | ||||
| import { useNavigate } from "react-router"; | ||||
|  | ||||
| interface NetworkData { | ||||
|   nodes: GraphNode[]; | ||||
|   edges: GraphEdge[]; | ||||
| } | ||||
| interface CustomSelectionProps extends SelectionProps { | ||||
|   ignore: (GraphEdge | undefined)[]; | ||||
| } | ||||
|  | ||||
| const useCustomSelection = (props: CustomSelectionProps): SelectionResult => { | ||||
|   var result = useSelection(props); | ||||
|   result.actives = result.actives.filter( | ||||
|     (s) => !props.ignore.map((edge) => edge?.id).includes(s) | ||||
|   ); | ||||
|   const ignored_nodes = props.ignore.map((edge) => | ||||
|     edge && | ||||
|     result.selections?.includes(edge.source) && | ||||
|     !result.selections?.includes(edge.target) | ||||
|       ? edge.target | ||||
|       : "" | ||||
|   ); | ||||
|   result.actives = result.actives.filter((s) => !ignored_nodes.includes(s)); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| export const GraphComponent = () => { | ||||
|   let initialData = { nodes: [], edges: [] } as NetworkData; | ||||
|   const [data, setData] = useState(initialData); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [threed, setThreed] = useState(false); | ||||
|   const [likes, setLikes] = useState(2); | ||||
|   const [popularity, setPopularity] = useState(false); | ||||
|   const [mutuality, setMutuality] = useState(false); | ||||
|   const { user, teams } = useSession(); | ||||
|   const navigate = useNavigate(); | ||||
|   useEffect(() => { | ||||
|     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||
|       teams?.activeTeam === 42 || | ||||
|       navigate("/", { replace: true }); | ||||
|   }, [user]); | ||||
|  | ||||
|   async function loadData() { | ||||
|     setLoading(true); | ||||
|     if (teams) { | ||||
|       await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null) | ||||
|         .then((data) => { | ||||
|           if (data.detail) { | ||||
|             setError(data.detail); | ||||
|             return initialData; | ||||
|           } else { | ||||
|             setError(""); | ||||
|             return data as Promise<NetworkData>; | ||||
|           } | ||||
|         }) | ||||
|         .then((data) => setData(data)) | ||||
|         .catch(() => setError("no access")); | ||||
|       setLoading(false); | ||||
|     } else setError("team unknown"); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadData(); | ||||
|   }, [teams]); | ||||
|  | ||||
|   const graphRef = useRef<GraphCanvasRef | null>(null); | ||||
|  | ||||
|   function handleThreed() { | ||||
|     setThreed(!threed); | ||||
|     graphRef.current?.fitNodesInView(); | ||||
|     graphRef.current?.centerGraph(); | ||||
|     graphRef.current?.resetControls(); | ||||
|   } | ||||
|  | ||||
|   function handlePopularity() { | ||||
|     popularityLabel(!popularity); | ||||
|     setPopularity(!popularity); | ||||
|   } | ||||
|  | ||||
|   function handleMutuality() { | ||||
|     colorMatches(!mutuality); | ||||
|     setMutuality(!mutuality); | ||||
|   } | ||||
|  | ||||
|   function showLabel() { | ||||
|     switch (likes) { | ||||
|       case 0: | ||||
|         return "dislike"; | ||||
|       case 1: | ||||
|         return "both"; | ||||
|       case 2: | ||||
|         return "like"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function findMatches(edges: GraphEdge[]) { | ||||
|     const adjacencyList = edges.map( | ||||
|       (edge) => edge.source + edge.target + edge.data.relation | ||||
|     ); | ||||
|     return edges.filter((edge) => | ||||
|       adjacencyList.includes(edge.target + edge.source + edge.data.relation) | ||||
|     ); | ||||
|   } | ||||
|   //const matches = useMemo(() => findMatches(data.edges), []) | ||||
|  | ||||
|   function colorMatches(mutuality: boolean) { | ||||
|     const matches = findMatches(data.edges); | ||||
|     const newEdges = data.edges; | ||||
|     if (mutuality) { | ||||
|       newEdges.forEach((edge) => { | ||||
|         if ( | ||||
|           (likes === 1 || edge.data.relation === likes) && | ||||
|           matches.map((edge) => edge.id).includes(edge.id) | ||||
|         ) { | ||||
|           edge.fill = "#9c3"; | ||||
|           if (edge.size) edge.size = edge.size * 1.5; | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       newEdges.forEach((edge) => { | ||||
|         if ( | ||||
|           (likes === 1 || edge.data.relation === likes) && | ||||
|           matches.map((edge) => edge.id).includes(edge.id) | ||||
|         ) { | ||||
|           edge.fill = edge.data.origFill; | ||||
|           edge.size = edge.data.origSize; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     setData({ nodes: data.nodes, edges: newEdges }); | ||||
|   } | ||||
|  | ||||
|   function popularityLabel(popularity: boolean) { | ||||
|     const newNodes = data.nodes; | ||||
|     console.log(data.nodes); | ||||
|     if (popularity) { | ||||
|       newNodes.forEach( | ||||
|         (node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`) | ||||
|       ); | ||||
|     } else { | ||||
|       newNodes.forEach((node) => (node.subLabel = undefined)); | ||||
|     } | ||||
|     setData({ nodes: newNodes, edges: data.edges }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (mutuality) colorMatches(false); | ||||
|     colorMatches(mutuality); | ||||
|   }, [likes]); | ||||
|  | ||||
|   const { | ||||
|     selections, | ||||
|     actives, | ||||
|     onNodeClick, | ||||
|     onCanvasClick, | ||||
|     onNodePointerOver, | ||||
|     onNodePointerOut, | ||||
|   } = useCustomSelection({ | ||||
|     ref: graphRef, | ||||
|     nodes: data.nodes, | ||||
|     edges: data.edges.filter((edge) => edge.data.relation === likes), | ||||
|     ignore: data.edges.map((edge) => { | ||||
|       if (likes === 1 && edge.data.relation !== 2) return edge; | ||||
|     }), | ||||
|     pathSelectionType: "out", | ||||
|     pathHoverType: "in", | ||||
|     type: "multiModifier", | ||||
|   }); | ||||
|  | ||||
|   let content: ReactNode; | ||||
|   if (loading) { | ||||
|     content = <span className="loader" />; | ||||
|   } else if (error) { | ||||
|     content = <span>{error}</span>; | ||||
|   } else { | ||||
|     content = ( | ||||
|       <> | ||||
|         <GraphCanvas | ||||
|           draggable | ||||
|           cameraMode={threed ? "rotate" : "pan"} | ||||
|           layoutType={threed ? "forceDirected3d" : "forceDirected2d"} | ||||
|           layoutOverrides={{ | ||||
|             nodeStrength: -200, | ||||
|             linkDistance: 100, | ||||
|           }} | ||||
|           labelType="nodes" | ||||
|           sizingType="attribute" | ||||
|           sizingAttribute={popularity ? "inDegree" : undefined} | ||||
|           ref={graphRef} | ||||
|           theme={customTheme} | ||||
|           nodes={data.nodes} | ||||
|           edges={data.edges.filter( | ||||
|             (edge) => edge.data.relation === likes || likes === 1 | ||||
|           )} | ||||
|           selections={selections} | ||||
|           actives={actives} | ||||
|           onCanvasClick={onCanvasClick} | ||||
|           onNodeClick={onNodeClick} | ||||
|           onNodePointerOut={onNodePointerOut} | ||||
|           onNodePointerOver={onNodePointerOver} | ||||
|         /> | ||||
|         <button | ||||
|           className="infobutton" | ||||
|           onClick={() => { | ||||
|             const dialog = document.querySelector("dialog[id='InfoDialog']"); | ||||
|             (dialog as HTMLDialogElement).showModal(); | ||||
|           }} | ||||
|         > | ||||
|           info | ||||
|         </button> | ||||
|  | ||||
|         <dialog | ||||
|           id="InfoDialog" | ||||
|           style={{ textAlign: "left" }} | ||||
|           onClick={(event) => { | ||||
|             event.currentTarget.close(); | ||||
|           }} | ||||
|         > | ||||
|           scroll to zoom | ||||
|           <br /> | ||||
|           <br /> | ||||
|           <b>hover</b>: show inbound links | ||||
|           <br /> | ||||
|           <b>click</b>: show outward links | ||||
|           <br /> | ||||
|           <br /> | ||||
|           multi-selection possible | ||||
|           <br /> | ||||
|           with <i>Ctrl</i> or <i>Shift</i> | ||||
|           <br /> | ||||
|           <br /> | ||||
|           drag to pan/rotate | ||||
|         </dialog> | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}> | ||||
|       <div className="controls"> | ||||
|         <div className="control" onClick={handleMutuality}> | ||||
|           <div className="switch"> | ||||
|             <input type="checkbox" checked={mutuality} onChange={() => {}} /> | ||||
|             <span className="slider round"></span> | ||||
|           </div> | ||||
|           <span>mutuality</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control" onClick={handleThreed}> | ||||
|           <span>2D</span> | ||||
|           <div className="switch"> | ||||
|             <input type="checkbox" checked={threed} onChange={() => {}} /> | ||||
|             <span className="slider round"></span> | ||||
|           </div> | ||||
|           <span>3D</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <div className="stack column"> | ||||
|             <datalist id="markers"> | ||||
|               <option value="0"></option> | ||||
|               <option value="1"></option> | ||||
|               <option value="2"></option> | ||||
|             </datalist> | ||||
|             <div id="three-slider"> | ||||
|               <label>😬</label> | ||||
|               <input | ||||
|                 type="range" | ||||
|                 list="markers" | ||||
|                 min="0" | ||||
|                 max="2" | ||||
|                 step="1" | ||||
|                 width="16px" | ||||
|                 onChange={(evt) => setLikes(Number(evt.target.value))} | ||||
|               /> | ||||
|               <label>😍</label> | ||||
|             </div> | ||||
|             {showLabel()} | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control" onClick={handlePopularity}> | ||||
|           <div className="switch"> | ||||
|             <input type="checkbox" checked={popularity} onChange={() => {}} /> | ||||
|             <span className="slider round"></span> | ||||
|           </div> | ||||
|           <span> | ||||
|             popularity<sup>*</sup> | ||||
|           </span> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {popularity && ( | ||||
|         <div | ||||
|           style={{ position: "absolute", bottom: 0, right: "10px", zIndex: 10 }} | ||||
|         > | ||||
|           <span className="grey" style={{ fontSize: "70%" }}> | ||||
|             <sup>*</sup>popularity meassured by rank-weighted in-degree | ||||
|           </span> | ||||
|         </div> | ||||
|       )} | ||||
|       {content} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										59
									
								
								src/NetworkTheme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/NetworkTheme.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { Theme } from "reagraph"; | ||||
|  | ||||
| export var customTheme: Theme = { | ||||
|   canvas: { | ||||
|     background: "aliceblue", | ||||
|   }, | ||||
|   node: { | ||||
|     fill: "#69F", | ||||
|     activeFill: "#36C", | ||||
|     opacity: 1, | ||||
|     selectedOpacity: 1, | ||||
|     inactiveOpacity: 0.333, | ||||
|     label: { | ||||
|       color: "#404040", | ||||
|       stroke: "white", | ||||
|       activeColor: "black", | ||||
|     }, | ||||
|     subLabel: { | ||||
|       color: "#404040", | ||||
|       stroke: "white", | ||||
|       activeColor: "black", | ||||
|     }, | ||||
|   }, | ||||
|   lasso: { | ||||
|     border: "1px solid #55aaff", | ||||
|     background: "rgba(75, 160, 255, 0.1)", | ||||
|   }, | ||||
|   ring: { | ||||
|     fill: "#69F", | ||||
|     activeFill: "#36C", | ||||
|   }, | ||||
|   edge: { | ||||
|     fill: "#bed4ff", | ||||
|     activeFill: "#36C", | ||||
|     opacity: 1, | ||||
|     selectedOpacity: 1, | ||||
|     inactiveOpacity: 0.333, | ||||
|     label: { | ||||
|       stroke: "#fff", | ||||
|       color: "#2A6475", | ||||
|       activeColor: "#1DE9AC", | ||||
|       fontSize: 6, | ||||
|     }, | ||||
|   }, | ||||
|   arrow: { | ||||
|     fill: "#bed4ff", | ||||
|     activeFill: "#36C", | ||||
|   }, | ||||
|   cluster: { | ||||
|     stroke: "#D8E6EA", | ||||
|     opacity: 1, | ||||
|     selectedOpacity: 1, | ||||
|     inactiveOpacity: 0.1, | ||||
|     label: { | ||||
|       stroke: "#fff", | ||||
|       color: "#2A6475", | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										99
									
								
								src/RaceChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/RaceChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| import { FC, useEffect, useState } from "react"; | ||||
| import { PlayerRanking } from "./types"; | ||||
|  | ||||
| interface RaceChartProps { | ||||
|   players: PlayerRanking[]; | ||||
|   std: boolean; | ||||
| } | ||||
|  | ||||
| const determineNiceWidth = (width: number) => { | ||||
|   const max = 1080; | ||||
|   if (width >= max) return max; | ||||
|   else if (width > 768) return width * 0.8; | ||||
|   else return width * 0.96; | ||||
| }; | ||||
|  | ||||
| const RaceChart: FC<RaceChartProps> = ({ players, std }) => { | ||||
|   const [width, setWidth] = useState(determineNiceWidth(window.innerWidth)); | ||||
|   //const [height, setHeight] = useState(window.innerHeight); | ||||
|   const height = (players.length + 1) * 40; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleResize = () => { | ||||
|       setWidth(determineNiceWidth(window.innerWidth)); | ||||
|       //setHeight(window.innerHeight); | ||||
|     }; | ||||
|     window.addEventListener("resize", handleResize); | ||||
|     return () => { | ||||
|       window.removeEventListener("resize", handleResize); | ||||
|     }; | ||||
|   }, []); | ||||
|   const padding = 24; | ||||
|   const gap = 8; | ||||
|   const maxValue = Math.max(...players.map((player) => player.rank)) + 1; | ||||
|   const barHeight = (height - 2 * padding) / players.length; | ||||
|   const fontSize = Math.min(barHeight - 1.5 * gap, width / 22); | ||||
|  | ||||
|   return ( | ||||
|     <svg width={width} height={height} id="RaceChartSVG"> | ||||
|       {players.map((player, index) => ( | ||||
|         <rect | ||||
|           key={String(index)} | ||||
|           x={4} | ||||
|           y={index * barHeight + padding} | ||||
|           width={(1 - player.rank / maxValue) * width} | ||||
|           height={barHeight - gap} // subtract 2 for some spacing between bars | ||||
|           fill="#36c" | ||||
|           stroke="aliceblue" | ||||
|           strokeWidth={4} | ||||
|           paintOrder={"stroke fill"} | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <g key={"group" + index}> | ||||
|           <text | ||||
|             key={index + "_name"} | ||||
|             x={8} | ||||
|             y={index * barHeight + barHeight / 2 + padding + gap / 2} | ||||
|             width={(1 - player.rank / maxValue) * width} | ||||
|             height={barHeight - 8} // subtract 2 for some spacing between bars | ||||
|             fontSize={fontSize} | ||||
|             fill="aliceblue" | ||||
|             stroke="#36c" | ||||
|             strokeWidth={4} | ||||
|             fontWeight={"bold"} | ||||
|             paintOrder={"stroke fill"} | ||||
|             fontFamily="monospace" | ||||
|             style={{ whiteSpace: "pre" }} | ||||
|           > | ||||
|             {`${String(index + 1).padStart(2)}. ${player.name}`} | ||||
|           </text> | ||||
|           <text | ||||
|             key={index + "_value"} | ||||
|             x={ | ||||
|               8 + | ||||
|               (4 + Math.max(...players.map((p, _) => p.name.length))) * | ||||
|                 fontSize * | ||||
|                 0.66 | ||||
|             } | ||||
|             y={index * barHeight + barHeight / 2 + padding + gap / 2} | ||||
|             width={(1 - player.rank / maxValue) * width} | ||||
|             height={barHeight - 8} // subtract 2 for some spacing between bars | ||||
|             fontSize={0.8 * fontSize} | ||||
|             fill="aliceblue" | ||||
|             stroke="#36c" | ||||
|             fontWeight={"bold"} | ||||
|             fontFamily="monospace" | ||||
|             strokeWidth={4} | ||||
|             paintOrder={"stroke fill"} | ||||
|             style={{ whiteSpace: "pre" }} | ||||
|           > | ||||
|             {`${String(player.rank).padStart(5)} ± ${player.std}   N = ${player.n}`} | ||||
|           </text> | ||||
|         </g> | ||||
|       ))} | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| export default RaceChart; | ||||
							
								
								
									
										359
									
								
								src/Rankings.tsx
									
									
									
									
									
								
							
							
						
						
									
										359
									
								
								src/Rankings.tsx
									
									
									
									
									
								
							| @@ -1,12 +1,16 @@ | ||||
| import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; | ||||
| import { | ||||
|   ButtonHTMLAttributes, | ||||
|   Fragment, | ||||
|   ReactNode, | ||||
|   useEffect, | ||||
|   useRef, | ||||
|   useState, | ||||
| } from "react"; | ||||
| import { ReactSortable, ReactSortableProps } from "react-sortablejs"; | ||||
| import api, { baseUrl } from "./api"; | ||||
|  | ||||
| interface Player { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   number: string | null; | ||||
| } | ||||
| import { apiAuth, loadPlayers, User } from "./api"; | ||||
| import { TeamState, useSession } from "./Session"; | ||||
| import { Chemistry, MVPRanking } from "./types"; | ||||
| import TabController from "./TabController"; | ||||
|  | ||||
| type PlayerListProps = Partial<ReactSortableProps<any>> & { | ||||
|   orderedList?: boolean; | ||||
| @@ -14,123 +18,110 @@ type PlayerListProps = Partial<ReactSortableProps<any>> & { | ||||
|  | ||||
| function PlayerList(props: PlayerListProps) { | ||||
|   return ( | ||||
|     <ReactSortable {...props} animation={200}> | ||||
|     <ReactSortable {...props} animation={200} swapThreshold={0.4}> | ||||
|       {props.list?.map((item, index) => ( | ||||
|         <div key={item.id} className="item"> | ||||
|           {props.orderedList ? index + 1 + ". " + item.name : item.name} | ||||
|           {props.orderedList | ||||
|             ? index + 1 + ". " + item.display_name | ||||
|             : item.display_name} | ||||
|         </div> | ||||
|       ))} | ||||
|     </ReactSortable> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| interface SelectUserProps { | ||||
|   user: Player[]; | ||||
|   setUser: Dispatch<SetStateAction<Player[]>>; | ||||
|   players: Player[]; | ||||
|   setPlayers: Dispatch<SetStateAction<Player[]>>; | ||||
| } | ||||
|  | ||||
| export function SelectUser({ | ||||
|   user, | ||||
|   setUser, | ||||
|   players, | ||||
|   setPlayers, | ||||
| }: SelectUserProps) { | ||||
| const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="box user"> | ||||
|         {user.length < 1 ? ( | ||||
|           <> | ||||
|             <span>your name?</span> | ||||
|             <br /> <span className="grey hint">drag your name here</span> | ||||
|           </> | ||||
|         ) : ( | ||||
|           <> | ||||
|             <span | ||||
|               className="renew" | ||||
|               onClick={() => { | ||||
|                 setUser([]); | ||||
|               }} | ||||
|             > | ||||
|               {" ✕"} | ||||
|             </span> | ||||
|           </> | ||||
|         )} | ||||
|         <PlayerList | ||||
|           list={user} | ||||
|           setList={setUser} | ||||
|           group={{ | ||||
|             name: "user-shared", | ||||
|             put: function (to) { | ||||
|               return to.el.children.length < 1; | ||||
|             }, | ||||
|           }} | ||||
|           className="dragbox" | ||||
|         /> | ||||
|       </div> | ||||
|       {user.length < 1 && ( | ||||
|         <div className="box one"> | ||||
|           <h2>🥏🏃</h2> | ||||
|           <ReactSortable | ||||
|             list={players} | ||||
|             setList={setPlayers} | ||||
|             group={{ name: "user-shared", pull: "clone" }} | ||||
|             className="dragbox reservoir" | ||||
|             animation={200} | ||||
|           > | ||||
|             {players.length < 1 ? ( | ||||
|               <span className="loader"></span> | ||||
|             ) : ( | ||||
|               players.map((item, _) => ( | ||||
|                 <div key={"extra" + item.id} className="extra-margin"> | ||||
|                   <div key={item.id} className="item"> | ||||
|                     {item.name} | ||||
|                   </div> | ||||
|                 </div> | ||||
|               )) | ||||
|             )} | ||||
|           </ReactSortable> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|     <button {...props} style={{ padding: "4px 16px" }}> | ||||
|       🗃️ restore previous | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||
|   return ( | ||||
|     <button {...props} style={{ padding: "4px 16px" }}> | ||||
|       🗑️ start over | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| function filterSort(list: User[], ids: number[]): User[] { | ||||
|   const objectMap = new Map(list.map((obj) => [obj.id, obj])); | ||||
|   const filteredAndSortedObjects = ids | ||||
|     .map((id) => objectMap.get(id)) | ||||
|     .filter((obj) => obj !== undefined); | ||||
|   return filteredAndSortedObjects; | ||||
| } | ||||
|  | ||||
| interface PlayerInfoProps { | ||||
|   user: Player[]; | ||||
|   players: Player[]; | ||||
|   user: User; | ||||
|   teams: TeamState; | ||||
|   players: User[]; | ||||
| } | ||||
|  | ||||
| export function Chemistry({ user, players }: PlayerInfoProps) { | ||||
|   const index = players.indexOf(user[0]); | ||||
|   var otherPlayers = players.slice(); | ||||
|   otherPlayers.splice(index, 1); | ||||
|   const [playersLeft, setPlayersLeft] = useState<Player[]>([]); | ||||
|   const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers); | ||||
|   const [playersRight, setPlayersRight] = useState<Player[]>([]); | ||||
| function ChemistryDnD({ user, teams, players }: PlayerInfoProps) { | ||||
|   var otherPlayers = players.filter((player) => player.id !== user.id); | ||||
|   const [playersLeft, setPlayersLeft] = useState<User[]>([]); | ||||
|   const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers); | ||||
|   const [playersRight, setPlayersRight] = useState<User[]>([]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setPlayersMiddle(otherPlayers); | ||||
|   }, [players]); | ||||
|   useEffect(() => { | ||||
|     setPlayersLeft([]); | ||||
|     setPlayersMiddle(otherPlayers); | ||||
|     setPlayersRight([]); | ||||
|   }, [teams]); | ||||
|  | ||||
|   const [dialog, setDialog] = useState("dialog"); | ||||
|   const dialogRef = useRef<HTMLDialogElement>(null); | ||||
|  | ||||
|   async function handleSubmit() { | ||||
|     const dialog = document.querySelector("dialog[id='ChemistryDialog']"); | ||||
|     (dialog as HTMLDialogElement).showModal(); | ||||
|     if (user.length < 1) { | ||||
|       setDialog("who are you?"); | ||||
|     } else { | ||||
|       setDialog("sending..."); | ||||
|       let _user = user.map(({ name }) => name)[0]; | ||||
|       let left = playersLeft.map(({ name }) => name); | ||||
|       let middle = playersMiddle.map(({ name }) => name); | ||||
|       let right = playersRight.map(({ name }) => name); | ||||
|       const data = { user: _user, hate: left, undecided: middle, love: right }; | ||||
|       const response = await api("chemistry", data); | ||||
|       response.ok ? setDialog("success!") : setDialog("try sending again"); | ||||
|     if (dialogRef.current) dialogRef.current.showModal(); | ||||
|     setDialog("sending..."); | ||||
|     let left = playersLeft.map(({ id }) => id); | ||||
|     let middle = playersMiddle.map(({ id }) => id); | ||||
|     let right = playersRight.map(({ id }) => id); | ||||
|     const data = { | ||||
|       user: user.id, | ||||
|       hate: left, | ||||
|       undecided: middle, | ||||
|       love: right, | ||||
|       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 ( | ||||
|     <> | ||||
|       <HeaderControl | ||||
|         onLoad={handleGet} | ||||
|         onClear={() => { | ||||
|           setPlayersRight([]); | ||||
|           setPlayersMiddle(otherPlayers); | ||||
|           setPlayersLeft([]); | ||||
|         }} | ||||
|       /> | ||||
|       <div className="container"> | ||||
|         <div className="box three"> | ||||
|           <h2>😬</h2> | ||||
| @@ -172,10 +163,11 @@ export function Chemistry({ user, players }: PlayerInfoProps) { | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <button className="submit" onClick={() => handleSubmit()}> | ||||
|       <button className="submit wavering" onClick={() => handleSubmit()}> | ||||
|         💾 <span className="submit_text">submit</span> | ||||
|       </button> | ||||
|       <dialog | ||||
|         ref={dialogRef} | ||||
|         id="ChemistryDialog" | ||||
|         onClick={(event) => { | ||||
|           event.currentTarget.close(); | ||||
| @@ -187,29 +179,52 @@ export function Chemistry({ user, players }: PlayerInfoProps) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function MVP({ user, players }: PlayerInfoProps) { | ||||
|   const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players); | ||||
|   const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]); | ||||
| function MVPDnD({ user, teams, players }: PlayerInfoProps) { | ||||
|   const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); | ||||
|   const [rankedPlayers, setRankedPlayers] = useState<User[]>([]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setAvailablePlayers(players); | ||||
|   }, [players]); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     setAvailablePlayers(players); | ||||
|     setRankedPlayers([]); | ||||
|   }, [teams]); | ||||
|  | ||||
|   const [dialog, setDialog] = useState("dialog"); | ||||
|   const dialogRef = useRef<HTMLDialogElement>(null); | ||||
|  | ||||
|   async function handleSubmit() { | ||||
|     const dialog = document.querySelector("dialog[id='MVPDialog']"); | ||||
|     (dialog as HTMLDialogElement).showModal(); | ||||
|     if (user.length < 1) { | ||||
|       setDialog("who are you?"); | ||||
|     } else { | ||||
|       setDialog("sending..."); | ||||
|       let _user = user.map(({ name }) => name)[0]; | ||||
|       let mvps = rankedPlayers.map(({ name }) => name); | ||||
|       const data = { user: _user, mvps: mvps }; | ||||
|       const response = await api("mvps", data); | ||||
|       response.ok ? setDialog("success!") : setDialog("try sending again"); | ||||
|     if (dialogRef.current) dialogRef.current.showModal(); | ||||
|     setDialog("sending..."); | ||||
|     let mvps = rankedPlayers.map(({ id }) => id); | ||||
|     const data = { user: user.id, mvps: mvps, team: teams.activeTeam }; | ||||
|     const response = await apiAuth("mvps", data, "PUT"); | ||||
|     response ? setDialog(response) : 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 ( | ||||
|     <> | ||||
|       <HeaderControl | ||||
|         onLoad={handleGet} | ||||
|         onClear={() => { | ||||
|           setAvailablePlayers(players); | ||||
|           setRankedPlayers([]); | ||||
|         }} | ||||
|       /> | ||||
|       <div className="container"> | ||||
|         <div className="box two"> | ||||
|           <h2>🥏🏃</h2> | ||||
| @@ -251,10 +266,11 @@ export function MVP({ user, players }: PlayerInfoProps) { | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <button className="submit" onClick={() => handleSubmit()}> | ||||
|       <button className="submit wavering" onClick={() => handleSubmit()}> | ||||
|         💾 <span className="submit_text">submit</span> | ||||
|       </button> | ||||
|       <dialog | ||||
|         ref={dialogRef} | ||||
|         id="MVPDialog" | ||||
|         onClick={(event) => { | ||||
|           event.currentTarget.close(); | ||||
| @@ -266,84 +282,51 @@ export function MVP({ user, 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() { | ||||
|   const [user, setUser] = useState<Player[]>([]); | ||||
|   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(); | ||||
|   }, []); | ||||
|   const { user, teams } = useSession(); | ||||
|   const [players, setPlayers] = useState<User[] | null>(null); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     user.length === 1 && openPage(openTab, "aliceblue"); | ||||
|   }, [user]); | ||||
|     if (teams) { | ||||
|       loadPlayers(teams.activeTeam).then((data) => setPlayers(data)); | ||||
|     } | ||||
|   }, [user, teams]); | ||||
|  | ||||
|   function openPage(pageName: string, color: string) { | ||||
|     // Hide all elements with class="tabcontent" by default */ | ||||
|     var i, tabcontent, tablinks; | ||||
|     tabcontent = document.getElementsByClassName("tabcontent"); | ||||
|     for (i = 0; i < tabcontent.length; i++) { | ||||
|       (tabcontent[i] as HTMLElement).style.display = "none"; | ||||
|     } | ||||
|     // Remove the background color of all tablinks/buttons | ||||
|     tablinks = document.getElementsByClassName("tablink"); | ||||
|     for (i = 0; i < tablinks.length; i++) { | ||||
|       let button = tablinks[i] as HTMLElement; | ||||
|       button.style.opacity = "50%"; | ||||
|     } | ||||
|     // Show the specific tab content | ||||
|     (document.getElementById(pageName) as HTMLElement).style.display = "block"; | ||||
|     // Add the specific color to the button used to open the tab content | ||||
|     let activeButton = document.getElementById( | ||||
|       pageName + "Button" | ||||
|     ) as HTMLElement; | ||||
|     activeButton.style.fontWeight = "bold"; | ||||
|     activeButton.style.opacity = "100%"; | ||||
|     document.body.style.backgroundColor = color; | ||||
|     setOpenTab(pageName); | ||||
|   } | ||||
|   const tabs = [ | ||||
|     { id: "Chemistry", label: "🧪 Chemistry" }, | ||||
|     { id: "MVP", label: "🏆 MVP" }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <SelectUser {...{ user, setUser, players, setPlayers }} /> | ||||
|       {user.length === 1 && ( | ||||
|         <> | ||||
|           <div className="container navbar"> | ||||
|             <button | ||||
|               className="tablink" | ||||
|               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> | ||||
|         </> | ||||
|       {user && teams && players ? ( | ||||
|         <TabController tabs={tabs}> | ||||
|           <ChemistryDnD {...{ user, teams, players }} /> | ||||
|           <MVPDnD {...{ user, teams, players }} /> | ||||
|         </TabController> | ||||
|       ) : ( | ||||
|         <span className="loader" /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
							
								
								
									
										109
									
								
								src/Session.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/Session.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,109 @@ | ||||
| import { | ||||
|   createContext, | ||||
|   ReactNode, | ||||
|   useContext, | ||||
|   useEffect, | ||||
|   useState, | ||||
| } from "react"; | ||||
| import { apiAuth, currentUser, logout, User } from "./api"; | ||||
| import { Login } from "./Login"; | ||||
| import Header from "./Header"; | ||||
| import { Team } from "./types"; | ||||
|  | ||||
| export interface SessionProviderProps { | ||||
|   children: ReactNode; | ||||
| } | ||||
|  | ||||
| export interface TeamState { | ||||
|   teams: Team[]; | ||||
|   activeTeam: number; | ||||
| } | ||||
|  | ||||
| export interface Session { | ||||
|   user: User | null; | ||||
|   teams: TeamState | null; | ||||
|   setTeams: (teams: TeamState) => void; | ||||
|   onLogout: () => void; | ||||
| } | ||||
|  | ||||
| const sessionContext = createContext<Session>({ | ||||
|   user: null, | ||||
|   teams: null, | ||||
|   setTeams: () => {}, | ||||
|   onLogout: () => {}, | ||||
| }); | ||||
|  | ||||
| export function SessionProvider(props: SessionProviderProps) { | ||||
|   const { children } = props; | ||||
|  | ||||
|   const [user, setUser] = useState<User | null>(null); | ||||
|   const [teams, setTeams] = useState<TeamState | null>(null); | ||||
|   const [err, setErr] = useState<unknown>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   function loadUser() { | ||||
|     setLoading(true); | ||||
|     currentUser() | ||||
|       .then((user) => { | ||||
|         setUser(user); | ||||
|         setErr(null); | ||||
|       }) | ||||
|       .catch((err) => { | ||||
|         setUser(null); | ||||
|         setErr(err); | ||||
|       }) | ||||
|       .finally(() => setLoading(false)); | ||||
|   } | ||||
|  | ||||
|   async function loadTeam() { | ||||
|     const teams: Team[] = await apiAuth("player/me/teams", null, "GET"); | ||||
|     if (teams) setTeams({ teams: teams, activeTeam: teams[0].id }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadUser(); | ||||
|   }, []); | ||||
|   useEffect(() => { | ||||
|     loadTeam(); | ||||
|   }, [user]); | ||||
|  | ||||
|   function onLogin(user: User) { | ||||
|     setUser(user); | ||||
|     setErr(null); | ||||
|   } | ||||
|  | ||||
|   async function onLogout() { | ||||
|     try { | ||||
|       logout(); | ||||
|       setUser(null); | ||||
|       setErr({ message: "Logged out successfully" }); | ||||
|       console.log("logged out."); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       setErr(e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   let content: ReactNode; | ||||
|   if (loading || (!err && !user)) | ||||
|     content = ( | ||||
|       <> | ||||
|         <Header /> | ||||
|         <span className="loader" /> | ||||
|       </> | ||||
|     ); | ||||
|   else if (err) { | ||||
|     content = <Login onLogin={onLogin} />; | ||||
|   } else | ||||
|     content = ( | ||||
|       <sessionContext.Provider value={{ user, teams, setTeams, onLogout }}> | ||||
|         {children} | ||||
|       </sessionContext.Provider> | ||||
|     ); | ||||
|  | ||||
|   return content; | ||||
| } | ||||
|  | ||||
| export function useSession() { | ||||
|   return useContext(sessionContext); | ||||
| } | ||||
							
								
								
									
										340
									
								
								src/SetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										340
									
								
								src/SetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,340 @@ | ||||
| import { jwtDecode, JwtPayload } from "jwt-decode"; | ||||
| import { ReactNode, useEffect, useState } from "react"; | ||||
| import { apiAuth, baseUrl, User } from "./api"; | ||||
| import { useNavigate } from "react-router"; | ||||
| import { Eye, EyeSlash } from "./Icons"; | ||||
| import { useSession } from "./Session"; | ||||
| import { relative } from "path"; | ||||
| import Header from "./Header"; | ||||
|  | ||||
| interface PassToken extends JwtPayload { | ||||
|   username: string; | ||||
|   name: string; | ||||
|   team_id: number; | ||||
| } | ||||
|  | ||||
| enum Mode { | ||||
|   register = "register", | ||||
|   set = "set password", | ||||
|   change = "change password", | ||||
| } | ||||
|  | ||||
| export const SetPassword = () => { | ||||
|   const [mode, setMode] = useState<Mode>(); | ||||
|   const [name, setName] = useState("after getting your token."); | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [teamID, setTeamID] = useState<number>(); | ||||
|   const [currentPassword, setCurrentPassword] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [passwordr, setPasswordr] = useState(""); | ||||
|   const [token, setToken] = useState(""); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [visible, setVisible] = useState(false); | ||||
|   const newPlayerTemplate = { | ||||
|     username: "", | ||||
|     display_name: "", | ||||
|     number: "", | ||||
|     email: "", | ||||
|   } as User; | ||||
|   const [player, setPlayer] = useState(newPlayerTemplate); | ||||
|   const navigate = useNavigate(); | ||||
|   const { user } = useSession(); | ||||
|  | ||||
|   async function handleSubmit(e: React.FormEvent) { | ||||
|     e.preventDefault(); | ||||
|     if (password === passwordr) { | ||||
|       setLoading(true); | ||||
|       if (mode === Mode.change) { | ||||
|         //====CHANGING PASSWORD==== | ||||
|         const resp = await apiAuth( | ||||
|           "player/change_password", | ||||
|           { current_password: currentPassword, new_password: password }, | ||||
|           "POST" | ||||
|         ); | ||||
|         setLoading(false); | ||||
|         if (resp.detail) setError(resp.detail); | ||||
|         else { | ||||
|           setError(resp); | ||||
|           setTimeout(() => navigate("/"), 2000); | ||||
|         } | ||||
|       } else if (mode === Mode.set) { | ||||
|         //====SETTING PASSWORD==== | ||||
|         const req = new Request(`${baseUrl}api/set_password`, { | ||||
|           method: "POST", | ||||
|           headers: { | ||||
|             "Content-Type": "application/json", | ||||
|           }, | ||||
|           body: JSON.stringify({ token: token, password: password }), | ||||
|         }); | ||||
|         let resp: Response; | ||||
|         try { | ||||
|           resp = await fetch(req); | ||||
|         } catch (e) { | ||||
|           throw new Error(`request failed: ${e}`); | ||||
|         } | ||||
|         setLoading(false); | ||||
|  | ||||
|         if (resp.ok) { | ||||
|           console.log(resp); | ||||
|           navigate("/", { | ||||
|             replace: true, | ||||
|             state: { username: username, password: password }, | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         if (!resp.ok) { | ||||
|           if (resp.status === 401) { | ||||
|             const { detail } = await resp.json(); | ||||
|             if (detail) setError(detail); | ||||
|             else setError("unauthorized"); | ||||
|             throw new Error("Unauthorized"); | ||||
|           } | ||||
|         } | ||||
|       } else if (mode === Mode.register) { | ||||
|         //====REGISTER NEW USER==== | ||||
|         const req = new Request(`${baseUrl}api/register`, { | ||||
|           method: "POST", | ||||
|           headers: { | ||||
|             "Content-Type": "application/json", | ||||
|           }, | ||||
|           body: JSON.stringify({ | ||||
|             ...player, | ||||
|             team_id: teamID, | ||||
|             token: token, | ||||
|             password: password, | ||||
|           }), | ||||
|         }); | ||||
|         let resp: Response; | ||||
|         try { | ||||
|           resp = await fetch(req); | ||||
|         } catch (e) { | ||||
|           throw new Error(`request failed: ${e}`); | ||||
|         } | ||||
|         setLoading(false); | ||||
|  | ||||
|         if (resp.ok) { | ||||
|           console.log(resp); | ||||
|           navigate("/", { | ||||
|             replace: true, | ||||
|             state: { username: player.username, password: password }, | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         if (!resp.ok) { | ||||
|           const { detail } = await resp.json(); | ||||
|           if (detail) setError(detail); | ||||
|           else setError("unauthorized"); | ||||
|           throw new Error("Unauthorized"); | ||||
|         } | ||||
|       } | ||||
|     } else setError("passwords are not the same"); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (user) { | ||||
|       setUsername(user.username); | ||||
|       setName(user.display_name); | ||||
|       setMode(Mode.change); | ||||
|     } else { | ||||
|       const params = new URLSearchParams(window.location.search); | ||||
|       const token = params.get("token"); | ||||
|       if (token) { | ||||
|         setToken(token); | ||||
|         try { | ||||
|           const payload = jwtDecode<PassToken>(token); | ||||
|           console.log(payload); | ||||
|           switch (payload.sub) { | ||||
|             case "register": | ||||
|               setMode(Mode.register); | ||||
|               if (payload.team_id) setTeamID(payload.team_id); | ||||
|               break; | ||||
|             case "set password": | ||||
|               setMode(Mode.set); | ||||
|               if (payload.username) setUsername(payload.username); | ||||
|               break; | ||||
|           } | ||||
|           if (payload.name) setName(payload.name); | ||||
|         } catch (InvalidTokenError) { | ||||
|           setName("Mr. I-have-no-valid Token"); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   let header: ReactNode; | ||||
|   switch (mode) { | ||||
|     case Mode.change: | ||||
|       header = <h2>change your password, {name}</h2>; | ||||
|       break; | ||||
|     case Mode.set: | ||||
|       header = ( | ||||
|         <> | ||||
|           <Header /> | ||||
|           <h2>set your password, {name}</h2> | ||||
|         </> | ||||
|       ); | ||||
|       break; | ||||
|     case Mode.register: | ||||
|       header = ( | ||||
|         <> | ||||
|           <Header /> | ||||
|           <h2> | ||||
|             register as a member of <i>{name}</i> | ||||
|           </h2> | ||||
|         </> | ||||
|       ); | ||||
|   } | ||||
|  | ||||
|   let textInputs: ReactNode; | ||||
|   switch (mode) { | ||||
|     case Mode.change: | ||||
|       textInputs = ( | ||||
|         <div> | ||||
|           <input | ||||
|             type={visible ? "text" : "password"} | ||||
|             id="password" | ||||
|             name="password" | ||||
|             placeholder="current password" | ||||
|             minLength={8} | ||||
|             value={currentPassword} | ||||
|             required | ||||
|             onChange={(evt) => { | ||||
|               setError(""); | ||||
|               setCurrentPassword(evt.target.value); | ||||
|             }} | ||||
|           /> | ||||
|           <hr style={{ margin: "8px" }} /> | ||||
|         </div> | ||||
|       ); | ||||
|       break; | ||||
|     case Mode.register: | ||||
|       textInputs = ( | ||||
|         <div className="new-player-inputs"> | ||||
|           <div> | ||||
|             <label>name</label> | ||||
|             <input | ||||
|               type="text" | ||||
|               required | ||||
|               value={player.display_name} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ | ||||
|                   ...player, | ||||
|                   display_name: e.target.value, | ||||
|                   username: e.target.value.toLowerCase().replace(/\W/g, ""), | ||||
|                 }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <label>username</label> | ||||
|             <input | ||||
|               type="text" | ||||
|               required | ||||
|               value={player.username} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ ...player, username: e.target.value }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <label>number (optional)</label> | ||||
|             <input | ||||
|               type="text" | ||||
|               value={player.number || ""} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ ...player, number: e.target.value }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <label>email (optional)</label> | ||||
|             <input | ||||
|               type="email" | ||||
|               value={player.email || ""} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ ...player, email: e.target.value }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <hr style={{ margin: "8px" }} /> | ||||
|         </div> | ||||
|       ); | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   let passwordInputs = ( | ||||
|     <> | ||||
|       <div> | ||||
|         <input | ||||
|           type={visible ? "text" : "password"} | ||||
|           id="password" | ||||
|           name="password" | ||||
|           placeholder="password" | ||||
|           minLength={8} | ||||
|           value={password} | ||||
|           required | ||||
|           onChange={(evt) => { | ||||
|             setError(""); | ||||
|             setPassword(evt.target.value); | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|       <div> | ||||
|         <input | ||||
|           type={visible ? "text" : "password"} | ||||
|           id="password-repeat" | ||||
|           name="password-repeat" | ||||
|           placeholder="repeat password" | ||||
|           minLength={8} | ||||
|           value={passwordr} | ||||
|           required | ||||
|           onChange={(evt) => { | ||||
|             setError(""); | ||||
|             setPasswordr(evt.target.value); | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
|  | ||||
|   return mode ? ( | ||||
|     <> | ||||
|       {header} | ||||
|       <hr style={{ width: "100%" }} /> | ||||
|       <form onSubmit={handleSubmit}> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             alignItems: "center", | ||||
|             justifyContent: "center", | ||||
|             flexDirection: "column", | ||||
|           }} | ||||
|         > | ||||
|           {textInputs} | ||||
|           {passwordInputs} | ||||
|           <div | ||||
|             style={{ | ||||
|               background: "unset", | ||||
|               fontSize: "medium", | ||||
|               cursor: "pointer", | ||||
|               display: "flex", | ||||
|               alignItems: "center", | ||||
|               gap: "8px", | ||||
|             }} | ||||
|             onClick={() => setVisible(!visible)} | ||||
|           > | ||||
|             {visible ? <Eye /> : <EyeSlash />} show passwords | ||||
|           </div> | ||||
|         </div> | ||||
|         <div>{error && <span style={{ color: "red" }}>{error}</span>}</div> | ||||
|         <button type="submit" value="login" style={{ fontSize: "small" }}> | ||||
|           submit | ||||
|         </button> | ||||
|         {loading && <span className="loader" />} | ||||
|       </form> | ||||
|     </> | ||||
|   ) : ( | ||||
|     <span className="loader" /> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										45
									
								
								src/TabController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/TabController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| import { Fragment, ReactNode, useState } from "react"; | ||||
|  | ||||
| interface TabProps { | ||||
|   id: string; | ||||
|   label: string; | ||||
| } | ||||
|  | ||||
| interface TabControllerProps { | ||||
|   tabs: TabProps[]; | ||||
|   children: ReactNode[]; | ||||
| } | ||||
|  | ||||
| export default function TabController({ tabs, children }: TabControllerProps) { | ||||
|   const [currentIndex, setCurrentIndex] = useState(0); | ||||
|   const handleTabClick = (index: number) => { | ||||
|     setCurrentIndex(index); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <div> | ||||
|         <div className="container navbar"> | ||||
|           {tabs.map((tab, index) => ( | ||||
|             <button | ||||
|               key={tab.id} | ||||
|               className={ | ||||
|                 currentIndex === index ? "tab-button active" : "tab-button" | ||||
|               } | ||||
|               onClick={() => handleTabClick(index)} | ||||
|             > | ||||
|               {tab.label} | ||||
|             </button> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|       {children.map((child, index) => ( | ||||
|         <Fragment key={index}> | ||||
|           <div style={{ display: currentIndex === index ? "block" : "none" }}> | ||||
|             {child} | ||||
|           </div> | ||||
|         </Fragment> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										209
									
								
								src/TeamPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								src/TeamPanel.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,209 @@ | ||||
| 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}`) || | ||||
|       teams?.activeTeam === 42 || | ||||
|       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; | ||||
							
								
								
									
										49
									
								
								src/ThemeProvider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/ThemeProvider.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | ||||
| import { normalTheme, Theme } from "./themes"; | ||||
| import { | ||||
|   createContext, | ||||
|   ReactNode, | ||||
|   useContext, | ||||
|   useEffect, | ||||
|   useState, | ||||
| } from "react"; | ||||
|  | ||||
| interface ThemeContextProps { | ||||
|   children: ReactNode; | ||||
| } | ||||
|  | ||||
| interface ThemeContextValue { | ||||
|   theme: Theme; | ||||
|   setTheme: (theme: Theme) => void; | ||||
| } | ||||
|  | ||||
| const themeContext = createContext<ThemeContextValue>({ | ||||
|   theme: normalTheme, | ||||
|   setTheme: () => {}, | ||||
| }); | ||||
|  | ||||
| const ThemeProvider = ({ children }: ThemeContextProps) => { | ||||
|   const [theme, setTheme] = useState(normalTheme); | ||||
|   useEffect(() => { | ||||
|     if (theme.backgroundColor) { | ||||
|       document.body.style.backgroundColor = theme.backgroundColor; | ||||
|       document.body.style.backgroundImage = "unset"; | ||||
|     } else if (theme.backgroundImage) { | ||||
|       document.body.style.backgroundColor = "unset"; | ||||
|       document.body.style.backgroundImage = theme.backgroundImage; | ||||
|     } | ||||
|     document.body.style.color = theme.textColor; | ||||
|     document.body.style.borderColor = theme.textColor; | ||||
|   }, [theme]); | ||||
|  | ||||
|   return ( | ||||
|     <themeContext.Provider value={{ theme, setTheme }}> | ||||
|       {children} | ||||
|     </themeContext.Provider> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| function useTheme() { | ||||
|   return useContext(themeContext); | ||||
| } | ||||
|  | ||||
| export { ThemeProvider, useTheme }; | ||||
							
								
								
									
										129
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						
									
										129
									
								
								src/api.ts
									
									
									
									
									
								
							| @@ -1,31 +1,19 @@ | ||||
| export const baseUrl = import.meta.env.VITE_BASE_URL as string; | ||||
| export const token = () => localStorage.getItem("access_token") as string; | ||||
| import { useSession } from "./Session"; | ||||
|  | ||||
| export default async function api(path: string, data: any): Promise<any> { | ||||
|   const request = new Request(`${baseUrl}${path}/`, { | ||||
|     method: "POST", | ||||
| export const baseUrl = import.meta.env.VITE_BASE_URL as string; | ||||
|  | ||||
| export async function apiAuth( | ||||
|   path: string, | ||||
|   data: any, | ||||
|   method: string = "GET" | ||||
| ): Promise<any> { | ||||
|   const req = new Request(`${baseUrl}api/${path}`, { | ||||
|     method: method, | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     body: JSON.stringify(data), | ||||
|   }); | ||||
|   let response: Response; | ||||
|   try { | ||||
|     response = await fetch(request); | ||||
|   } catch (e) { | ||||
|     throw new Error(`request failed: ${e}`); | ||||
|   } | ||||
|   return response; | ||||
| } | ||||
|  | ||||
| export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> { | ||||
|  | ||||
|   const req = new Request(`${baseUrl}api/${path}`, { | ||||
|     method: method, headers: { | ||||
|       "Authorization": `Bearer ${token()} `, | ||||
|       'Content-Type': 'application/json' | ||||
|     }, | ||||
|     body: JSON.stringify(data), | ||||
|     credentials: "include", | ||||
|     ...(data && { body: JSON.stringify(data) }), | ||||
|   }); | ||||
|   let resp: Response; | ||||
|   try { | ||||
| @@ -36,25 +24,41 @@ export async function apiAuth(path: string, data: any, method: string = "GET"): | ||||
|  | ||||
|   if (!resp.ok) { | ||||
|     if (resp.status === 401) { | ||||
|       logout() | ||||
|       throw new Error('Unauthorized'); | ||||
|       const { onLogout } = useSession(); | ||||
|       onLogout(); | ||||
|       throw new Error("Unauthorized"); | ||||
|     } | ||||
|   } | ||||
|   return resp.json() | ||||
|   const contentType = resp.headers.get("Content-Type"); | ||||
|   switch (contentType) { | ||||
|     case "application/json": | ||||
|       return resp.json(); | ||||
|     case "text/plain": | ||||
|     case "text/plain; charset=utf-8": | ||||
|       return resp.text(); | ||||
|     case null: | ||||
|       return null; | ||||
|     default: | ||||
|       throw new Error(`Unsupported content type: ${contentType}`); | ||||
|   } | ||||
| } | ||||
|  | ||||
| export type User = { | ||||
|   id: number; | ||||
|   username: string; | ||||
|   fullName: string; | ||||
| } | ||||
|   display_name: string; | ||||
|   email: string; | ||||
|   number: string; | ||||
|   scopes: string; | ||||
| }; | ||||
|  | ||||
| export async function currentUser(): Promise<User> { | ||||
|   if (!token()) throw new Error("you have no access token") | ||||
|   const req = new Request(`${baseUrl}api/users/me/`, { | ||||
|     method: "GET", headers: { | ||||
|       "Authorization": `Bearer ${token()} `, | ||||
|       'Content-Type': 'application/json' | ||||
|     } | ||||
|   const req = new Request(`${baseUrl}api/player/me`, { | ||||
|     method: "GET", | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     credentials: "include", | ||||
|   }); | ||||
|   let resp: Response; | ||||
|   try { | ||||
| @@ -65,29 +69,54 @@ export async function currentUser(): Promise<User> { | ||||
|  | ||||
|   if (!resp.ok) { | ||||
|     if (resp.status === 401) { | ||||
|       logout() | ||||
|       throw new Error('Unauthorized'); | ||||
|       logout(); | ||||
|       throw new Error("Unauthorized"); | ||||
|     } | ||||
|   } | ||||
|   return resp.json() as Promise<User>; | ||||
| } | ||||
|  | ||||
| export async function loadPlayers(teamId: number) { | ||||
|   try { | ||||
|     const data = await apiAuth(`player/${teamId}/list`, null, "GET"); | ||||
|     return data as User[]; | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export type LoginRequest = { | ||||
|   username: string; | ||||
|   password: string; | ||||
| }; | ||||
| export type Token = { | ||||
|   access_token: string; | ||||
|   token_type: string; | ||||
|  | ||||
| export const login = async (req: LoginRequest): Promise<void> => { | ||||
|   try { | ||||
|     const response = await fetch(`${baseUrl}api/token`, { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/x-www-form-urlencoded", | ||||
|       }, | ||||
|       body: new URLSearchParams(req).toString(), | ||||
|       credentials: "include", | ||||
|     }); | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`HTTP error! status: ${response.status}`); | ||||
|     } | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     throw e; // rethrow the error so it can be caught by the caller | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const login = (req: LoginRequest) => { | ||||
|   fetch(`${baseUrl}api/token`, { | ||||
|     method: "POST", headers: { | ||||
|       'Content-Type': 'application/x-www-form-urlencoded', | ||||
|     }, body: new URLSearchParams(req).toString() | ||||
|   }).then(resp => resp.json() as Promise<Token>).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login")); | ||||
|   return Promise<void> | ||||
| } | ||||
|  | ||||
| export const logout = () => localStorage.removeItem("access_token"); | ||||
| export const logout = async () => { | ||||
|   try { | ||||
|     await fetch(`${baseUrl}api/logout`, { | ||||
|       method: "POST", | ||||
|       credentials: "include", | ||||
|     }); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|   } | ||||
| }; | ||||
|   | ||||
							
								
								
									
										24
									
								
								src/themes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/themes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| export interface Theme { | ||||
|   backgroundColor?: string; | ||||
|   backgroundImage?: string; | ||||
|   textColor: string; | ||||
| } | ||||
|  | ||||
| export const normalTheme: Theme = { | ||||
|   backgroundColor: "aliceblue", | ||||
|   textColor: "black", | ||||
| }; | ||||
| export const darkTheme: Theme = { | ||||
|   backgroundColor: "#444", | ||||
|   textColor: "white", | ||||
| }; | ||||
| export const colourTheme: Theme = { | ||||
|   backgroundImage: | ||||
|     "linear-gradient(45deg, magenta, rebeccapurple, dodgerblue, green)", | ||||
|   textColor: "white", | ||||
| }; | ||||
| export const rainbowTheme: Theme = { | ||||
|   backgroundImage: | ||||
|     "linear-gradient(135deg, #FF0000, #FFA500, #888800, #008000, #0000FF, #4B0082, #800080 ", | ||||
|   textColor: "white", | ||||
| }; | ||||
							
								
								
									
										46
									
								
								src/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| 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; | ||||
| }; | ||||
| @@ -3,10 +3,13 @@ | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "lib": [ | ||||
|       "ES2020", | ||||
|       "DOM", | ||||
|       "DOM.Iterable" | ||||
|     ], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
| @@ -14,7 +17,6 @@ | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": false, | ||||
| @@ -22,5 +24,7 @@ | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|     "noUncheckedSideEffectImports": true | ||||
|   }, | ||||
|   "include": ["src"] | ||||
|   "include": [ | ||||
|     "src" | ||||
|   ] | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user