Compare commits
	
		
			132 Commits
		
	
	
		
			feat/inter
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 1626751083 | |||
| 710b0770cc | |||
| 56c1ba11fc | |||
| ad2b2993df | |||
| 638e8bf20c | |||
| a4ea0dfc41 | |||
| 62ba89c599 | |||
| 05bdc5c44c | |||
| 105b3778e1 | |||
| 003f401320 | |||
| 2195e7324d | |||
| ba26e7c9e6 | |||
| 64d6edd9f5 | |||
| b781408c18 | |||
| a0c8e0cd18 | |||
| de8dc6b9b9 | |||
| 241f6fa7eb | |||
| a42fff807c | |||
| 369cf0b727 | |||
| a6dfab47d5 | |||
| 4c78ede7c2 | |||
| 8c8a88e72c | |||
| b9efd4f7a3 | |||
| a6ebc28d47 | |||
| 48f282423f | |||
| 881e015c1f | |||
| 4e2e0dd2a5 | |||
| b739246129 | |||
| 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 | 
							
								
								
									
										195
									
								
								analysis.py
									
									
									
									
									
								
							
							
						
						
									
										195
									
								
								analysis.py
									
									
									
									
									
								
							| @@ -1,195 +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() |  | ||||||
|     edges = [] |  | ||||||
|     with Session(engine) as session: |  | ||||||
|         for p in session.exec(select(P)).fetchall(): |  | ||||||
|             nodes.append({"id": p.name, "label": p.name}) |  | ||||||
|         subquery = ( |  | ||||||
|             select(C.user, func.max(C.time).label("latest")) |  | ||||||
|             .where(C.time > datetime(2025, 2, 1, 10)) |  | ||||||
|             .group_by(C.user) |  | ||||||
|             .subquery() |  | ||||||
|         ) |  | ||||||
|         statement2 = select(C).join( |  | ||||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) |  | ||||||
|         ) |  | ||||||
|         for c in session.exec(statement2): |  | ||||||
|             # G.add_node(c.user) |  | ||||||
|             necessary_nodes.add(c.user) |  | ||||||
|             for p in c.love: |  | ||||||
|                 # G.add_edge(c.user, p) |  | ||||||
|                 # p_id = session.exec(select(P.id).where(P.name == p)).one() |  | ||||||
|                 necessary_nodes.add(p) |  | ||||||
|                 edges.append({"from": c.user, "to": p, "relation": "likes"}) |  | ||||||
|             for p in c.hate: |  | ||||||
|                 edges.append({"from": c.user, "to": p, "relation": "dislikes"}) |  | ||||||
|     # nodes = [n for n in nodes if n["name"] in necessary_nodes] |  | ||||||
|     return JSONResponse({"nodes": nodes, "edges": edges}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def graph_json(): |  | ||||||
|     nodes = [] |  | ||||||
|     edges = [] |  | ||||||
|     with Session(engine) as session: |  | ||||||
|         for p in session.exec(select(P)).fetchall(): |  | ||||||
|             nodes.append({"id": p.name, "label": p.name}) |  | ||||||
|         subquery = ( |  | ||||||
|             select(C.user, func.max(C.time).label("latest")) |  | ||||||
|             .where(C.time > datetime(2025, 2, 1, 10)) |  | ||||||
|             .group_by(C.user) |  | ||||||
|             .subquery() |  | ||||||
|         ) |  | ||||||
|         statement2 = select(C).join( |  | ||||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) |  | ||||||
|         ) |  | ||||||
|         for c in session.exec(statement2): |  | ||||||
|             for p in c.love: |  | ||||||
|                 edges.append( |  | ||||||
|                     { |  | ||||||
|                         "id": f"{c.user}->{p}", |  | ||||||
|                         "source": c.user, |  | ||||||
|                         "target": p, |  | ||||||
|                         "relation": "likes", |  | ||||||
|                     } |  | ||||||
|                 ) |  | ||||||
|             continue |  | ||||||
|             for p in c.hate: |  | ||||||
|                 edges.append( |  | ||||||
|                     { |  | ||||||
|                         id: f"{c.user}-x>{p}", |  | ||||||
|                         "source": c.user, |  | ||||||
|                         "target": p, |  | ||||||
|                         "relation": "dislikes", |  | ||||||
|                     } |  | ||||||
|                 ) |  | ||||||
|     return JSONResponse({"nodes": nodes, "edges": edges}) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def sociogram_data(show: int | None = 2): |  | ||||||
|     G = nx.DiGraph() |  | ||||||
|     with Session(engine) as session: |  | ||||||
|         for p in session.exec(select(P)).fetchall(): |  | ||||||
|             G.add_node(p.name) |  | ||||||
|         subquery = ( |  | ||||||
|             select(C.user, func.max(C.time).label("latest")) |  | ||||||
|             .where(C.time > datetime(2025, 2, 1, 10)) |  | ||||||
|             .group_by(C.user) |  | ||||||
|             .subquery() |  | ||||||
|         ) |  | ||||||
|         statement2 = ( |  | ||||||
|             select(C) |  | ||||||
|             # .where(C.user.in_(["Kruse", "Franz", "ck"])) |  | ||||||
|             .join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)) |  | ||||||
|         ) |  | ||||||
|         for c in session.exec(statement2): |  | ||||||
|             if show >= 1: |  | ||||||
|                 for i, p in enumerate(c.love): |  | ||||||
|                     G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i) |  | ||||||
|             if show <= 1: |  | ||||||
|                 for i, p in enumerate(c.hate): |  | ||||||
|                     G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16) |  | ||||||
|     return G |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Params(BaseModel): |  | ||||||
|     node_size: int | None = Field(default=2400, alias="nodeSize") |  | ||||||
|     font_size: int | None = Field(default=10, alias="fontSize") |  | ||||||
|     arrow_size: int | None = Field(default=20, alias="arrowSize") |  | ||||||
|     edge_width: float | None = Field(default=1, alias="edgeWidth") |  | ||||||
|     distance: float | None = 0.2 |  | ||||||
|     weighting: bool | None = True |  | ||||||
|     popularity: bool | None = True |  | ||||||
|     show: int | None = 2 |  | ||||||
|  |  | ||||||
|  |  | ||||||
| ARROWSTYLE = {"love": "-|>", "hate": "-|>"} |  | ||||||
| EDGESTYLE = {"love": "-", "hate": ":"} |  | ||||||
| EDGECOLOR = {"love": "#404040", "hate": "#cc0000"} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def render_sociogram(params: Params): |  | ||||||
|     plt.figure(figsize=(16, 10), facecolor="none") |  | ||||||
|     ax = plt.gca() |  | ||||||
|     ax.set_facecolor("none")  # Set the axis face color to none (transparent) |  | ||||||
|     ax.axis("off")  # Turn off axis ticks and frames |  | ||||||
|  |  | ||||||
|     G = sociogram_data(show=params.show) |  | ||||||
|     pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None) |  | ||||||
|     nodes = nx.draw_networkx_nodes( |  | ||||||
|         G, |  | ||||||
|         pos, |  | ||||||
|         node_color=[ |  | ||||||
|             v for k, v in G.in_degree(weight="popularity" if params.weighting else None) |  | ||||||
|         ] |  | ||||||
|         if params.popularity |  | ||||||
|         else "#99ccff", |  | ||||||
|         edgecolors="#404040", |  | ||||||
|         linewidths=0, |  | ||||||
|         # node_shape="8", |  | ||||||
|         node_size=params.node_size, |  | ||||||
|         cmap="coolwarm", |  | ||||||
|         alpha=0.86, |  | ||||||
|     ) |  | ||||||
|     if params.popularity: |  | ||||||
|         cbar = plt.colorbar(nodes) |  | ||||||
|         cbar.ax.set_xlabel("popularity") |  | ||||||
|     nx.draw_networkx_labels(G, pos, font_size=params.font_size) |  | ||||||
|     nx.draw_networkx_edges( |  | ||||||
|         G, |  | ||||||
|         pos, |  | ||||||
|         arrows=True, |  | ||||||
|         edge_color=[EDGECOLOR[G.edges()[*edge]["group"]] for edge in G.edges()], |  | ||||||
|         arrowsize=params.arrow_size, |  | ||||||
|         node_size=params.node_size, |  | ||||||
|         width=params.edge_width, |  | ||||||
|         style=[EDGESTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], |  | ||||||
|         arrowstyle=[ARROWSTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], |  | ||||||
|         connectionstyle="arc3,rad=0.12", |  | ||||||
|         alpha=[1 - 0.08 * G.edges()[*edge]["rank"] for edge in G.edges()] |  | ||||||
|         if params.weighting |  | ||||||
|         else 1, |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|     buf = io.BytesIO() |  | ||||||
|     plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True) |  | ||||||
|     buf.seek(0) |  | ||||||
|     encoded_image = base64.b64encode(buf.read()).decode("UTF-8") |  | ||||||
|     plt.close() |  | ||||||
|  |  | ||||||
|     return {"image": encoded_image} |  | ||||||
|  |  | ||||||
|  |  | ||||||
| analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) |  | ||||||
| analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"]) |  | ||||||
| analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) |  | ||||||
|  |  | ||||||
| if __name__ == "__main__": |  | ||||||
|     with Session(engine) as session: |  | ||||||
|         statement: SelectOfScalar[P] = select(func.count(P.id)) |  | ||||||
|         print("players in DB: ", session.exec(statement).first()) |  | ||||||
|     G = sociogram_data() |  | ||||||
|     pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42) |  | ||||||
							
								
								
									
										0
									
								
								cutt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								cutt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										516
									
								
								cutt/analysis.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										516
									
								
								cutt/analysis.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,516 @@ | |||||||
|  | import io | ||||||
|  | import itertools | ||||||
|  | import random | ||||||
|  | import base64 | ||||||
|  | from typing import Annotated | ||||||
|  | from fastapi import APIRouter, HTTPException, Security, status | ||||||
|  | from fastapi.responses import JSONResponse | ||||||
|  | from pydantic import BaseModel, Field | ||||||
|  | from sqlmodel import Session, func, select | ||||||
|  | from sqlmodel.sql.expression import SelectOfScalar | ||||||
|  | from cutt.db import ( | ||||||
|  |     Chemistry, | ||||||
|  |     MVPRanking, | ||||||
|  |     Player, | ||||||
|  |     PlayerTeamLink, | ||||||
|  |     PlayerType, | ||||||
|  |     Team, | ||||||
|  |     engine, | ||||||
|  | ) | ||||||
|  | import networkx as nx | ||||||
|  | import numpy as np | ||||||
|  | import matplotlib | ||||||
|  |  | ||||||
|  | from cutt.security import TeamScopedRequest, verify_team_scope | ||||||
|  | from cutt.demo import demo_players | ||||||
|  |  | ||||||
|  | matplotlib.use("agg") | ||||||
|  | import matplotlib.pyplot as plt | ||||||
|  |  | ||||||
|  |  | ||||||
|  | analysis_router = APIRouter(prefix="/analysis", tags=["analysis"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | C = Chemistry | ||||||
|  | R = MVPRanking | ||||||
|  | PT = PlayerType | ||||||
|  | P = Player | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sociogram_json(): | ||||||
|  |     nodes = [] | ||||||
|  |     necessary_nodes = set() | ||||||
|  |     edges = [] | ||||||
|  |     players = {} | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         for p in session.exec(select(P)).fetchall(): | ||||||
|  |             nodes.append({"id": p.display_name, "label": p.display_name}) | ||||||
|  |             players[p.id] = p.display_name | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(C).join( | ||||||
|  |             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         for c in session.exec(statement2): | ||||||
|  |             # G.add_node(c.user) | ||||||
|  |             necessary_nodes.add(c.user) | ||||||
|  |             for p in [players[p_id] for p_id in c.love]: | ||||||
|  |                 # G.add_edge(c.user, p) | ||||||
|  |                 # p_id = session.exec(select(P.id).where(P.name == p)).one() | ||||||
|  |                 necessary_nodes.add(p) | ||||||
|  |                 edges.append({"from": players[c.user], "to": p, "relation": "likes"}) | ||||||
|  |             for p in [players[p_id] for p_id in c.hate]: | ||||||
|  |                 edges.append({"from": players[c.user], "to": p, "relation": "dislikes"}) | ||||||
|  |     # nodes = [n for n in nodes if n["name"] in necessary_nodes] | ||||||
|  |     return JSONResponse({"nodes": nodes, "edges": edges}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def graph_json( | ||||||
|  |     request: Annotated[TeamScopedRequest, Security(verify_team_scope)], | ||||||
|  |     networkx_graph: bool = False, | ||||||
|  | ): | ||||||
|  |     nodes = [] | ||||||
|  |     edges = [] | ||||||
|  |     player_map = {} | ||||||
|  |     if request.team_id == 42: | ||||||
|  |         players = [request.user] + demo_players | ||||||
|  |         random.seed(42) | ||||||
|  |         for p in players: | ||||||
|  |             nodes.append({"id": p.display_name, "label": p.display_name}) | ||||||
|  |         for p, other in itertools.permutations(players, 2): | ||||||
|  |             value = random.random() | ||||||
|  |             if value > 0.5: | ||||||
|  |                 edges.append( | ||||||
|  |                     { | ||||||
|  |                         "id": f"{p.display_name}->{other.display_name}", | ||||||
|  |                         "source": p.display_name, | ||||||
|  |                         "target": other.display_name, | ||||||
|  |                         "size": max(value, 0.3), | ||||||
|  |                         "data": { | ||||||
|  |                             "relation": 2, | ||||||
|  |                             "origSize": max(value, 0.3), | ||||||
|  |                             "origFill": "#bed4ff", | ||||||
|  |                         }, | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             elif value < 0.1: | ||||||
|  |                 edges.append( | ||||||
|  |                     { | ||||||
|  |                         "id": f"{p.display_name}-x>{other.display_name}", | ||||||
|  |                         "source": p.display_name, | ||||||
|  |                         "target": other.display_name, | ||||||
|  |                         "size": 0.3, | ||||||
|  |                         "data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"}, | ||||||
|  |                         "fill": "#ff7c7c", | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |         G = nx.DiGraph() | ||||||
|  |         G.add_nodes_from([n["id"] for n in nodes]) | ||||||
|  |         G.add_weighted_edges_from( | ||||||
|  |             [ | ||||||
|  |                 ( | ||||||
|  |                     e["source"], | ||||||
|  |                     e["target"], | ||||||
|  |                     e["size"] if e["data"]["relation"] == 2 else -e["size"], | ||||||
|  |                 ) | ||||||
|  |                 for e in edges | ||||||
|  |             ] | ||||||
|  |         ) | ||||||
|  |         in_degrees = G.in_degree(weight="weight") | ||||||
|  |         nodes = [ | ||||||
|  |             dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) | ||||||
|  |             for node in nodes | ||||||
|  |         ] | ||||||
|  |         if networkx_graph: | ||||||
|  |             return G | ||||||
|  |         return JSONResponse({"nodes": nodes, "edges": edges}) | ||||||
|  |  | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         players = session.exec( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == request.team_id, P.disabled == False) | ||||||
|  |         ).all() | ||||||
|  |         if not players: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_404_NOT_FOUND, | ||||||
|  |                 detail="no players found in your team", | ||||||
|  |             ) | ||||||
|  |         for p in players: | ||||||
|  |             player_map[p.id] = p.display_name | ||||||
|  |             nodes.append({"id": p.display_name, "label": p.display_name}) | ||||||
|  |  | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")) | ||||||
|  |             .where(C.team == request.team_id) | ||||||
|  |             .group_by(C.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(C).join( | ||||||
|  |             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         for c in session.exec(statement2): | ||||||
|  |             if c.user not in player_map: | ||||||
|  |                 continue | ||||||
|  |             user = player_map[c.user] | ||||||
|  |             for i, p_id in enumerate(c.love): | ||||||
|  |                 if p_id not in player_map: | ||||||
|  |                     continue | ||||||
|  |                 p = player_map[p_id] | ||||||
|  |                 weight = 0.9**i | ||||||
|  |                 edges.append( | ||||||
|  |                     { | ||||||
|  |                         "id": f"{user}->{p}", | ||||||
|  |                         "source": user, | ||||||
|  |                         "target": p, | ||||||
|  |                         "size": weight, | ||||||
|  |                         "data": { | ||||||
|  |                             "relation": 2, | ||||||
|  |                             "origSize": weight, | ||||||
|  |                             "origFill": "#bed4ff", | ||||||
|  |                         }, | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |             for p_id in c.hate: | ||||||
|  |                 if p_id not in player_map: | ||||||
|  |                     continue | ||||||
|  |                 p = player_map[p_id] | ||||||
|  |                 edges.append( | ||||||
|  |                     { | ||||||
|  |                         "id": f"{user}-x>{p}", | ||||||
|  |                         "source": user, | ||||||
|  |                         "target": p, | ||||||
|  |                         "size": 0.5, | ||||||
|  |                         "data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"}, | ||||||
|  |                         "fill": "#ff7c7c", | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |  | ||||||
|  |     if not edges: | ||||||
|  |         raise HTTPException( | ||||||
|  |             status_code=status.HTTP_404_NOT_FOUND, detail="no entries found" | ||||||
|  |         ) | ||||||
|  |     G = nx.DiGraph() | ||||||
|  |     G.add_nodes_from([n["id"] for n in nodes]) | ||||||
|  |     G.add_weighted_edges_from( | ||||||
|  |         [ | ||||||
|  |             ( | ||||||
|  |                 e["source"], | ||||||
|  |                 e["target"], | ||||||
|  |                 e["size"] if e["data"]["relation"] == 2 else -e["size"], | ||||||
|  |             ) | ||||||
|  |             for e in edges | ||||||
|  |         ] | ||||||
|  |     ) | ||||||
|  |     in_degrees = G.in_degree(weight="weight") | ||||||
|  |     nodes = [ | ||||||
|  |         dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes | ||||||
|  |     ] | ||||||
|  |     if networkx_graph: | ||||||
|  |         return G | ||||||
|  |     return JSONResponse({"nodes": nodes, "edges": edges}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sociogram_data(show: int | None = 2): | ||||||
|  |     G = nx.DiGraph() | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         players = {} | ||||||
|  |         for p in session.exec(select(P)).fetchall(): | ||||||
|  |             G.add_node(p.display_name) | ||||||
|  |             players[p.id] = p.display_name | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = ( | ||||||
|  |             select(C) | ||||||
|  |             # .where(C.user.in_(["Kruse", "Franz", "ck"])) | ||||||
|  |             .join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)) | ||||||
|  |         ) | ||||||
|  |         for c in session.exec(statement2): | ||||||
|  |             if show >= 1: | ||||||
|  |                 for i, p_id in enumerate(c.love): | ||||||
|  |                     p = players[p_id] | ||||||
|  |                     G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i) | ||||||
|  |             if show <= 1: | ||||||
|  |                 for i, p_id in enumerate(c.hate): | ||||||
|  |                     p = players[p_id] | ||||||
|  |                     G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16) | ||||||
|  |     return G | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Params(BaseModel): | ||||||
|  |     node_size: int | None = Field(default=2400, alias="nodeSize") | ||||||
|  |     font_size: int | None = Field(default=10, alias="fontSize") | ||||||
|  |     arrow_size: int | None = Field(default=20, alias="arrowSize") | ||||||
|  |     edge_width: float | None = Field(default=1, alias="edgeWidth") | ||||||
|  |     distance: float | None = 0.2 | ||||||
|  |     weighting: bool | None = True | ||||||
|  |     popularity: bool | None = True | ||||||
|  |     show: int | None = 2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ARROWSTYLE = {"love": "-|>", "hate": "-|>"} | ||||||
|  | EDGESTYLE = {"love": "-", "hate": ":"} | ||||||
|  | EDGECOLOR = {"love": "#404040", "hate": "#cc0000"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def render_sociogram(params: Params): | ||||||
|  |     plt.figure(figsize=(16, 10), facecolor="none") | ||||||
|  |     ax = plt.gca() | ||||||
|  |     ax.set_facecolor("none")  # Set the axis face color to none (transparent) | ||||||
|  |     ax.axis("off")  # Turn off axis ticks and frames | ||||||
|  |  | ||||||
|  |     G = sociogram_data(show=params.show) | ||||||
|  |     pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None) | ||||||
|  |     nodes = nx.draw_networkx_nodes( | ||||||
|  |         G, | ||||||
|  |         pos, | ||||||
|  |         node_color=[ | ||||||
|  |             v for k, v in G.in_degree(weight="popularity" if params.weighting else None) | ||||||
|  |         ] | ||||||
|  |         if params.popularity | ||||||
|  |         else "#99ccff", | ||||||
|  |         edgecolors="#404040", | ||||||
|  |         linewidths=0, | ||||||
|  |         # node_shape="8", | ||||||
|  |         node_size=params.node_size, | ||||||
|  |         cmap="coolwarm", | ||||||
|  |         alpha=0.86, | ||||||
|  |     ) | ||||||
|  |     if params.popularity: | ||||||
|  |         cbar = plt.colorbar(nodes) | ||||||
|  |         cbar.ax.set_xlabel("popularity") | ||||||
|  |     nx.draw_networkx_labels(G, pos, font_size=params.font_size) | ||||||
|  |     nx.draw_networkx_edges( | ||||||
|  |         G, | ||||||
|  |         pos, | ||||||
|  |         arrows=True, | ||||||
|  |         edge_color=[EDGECOLOR[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||||
|  |         arrowsize=params.arrow_size, | ||||||
|  |         node_size=params.node_size, | ||||||
|  |         width=params.edge_width, | ||||||
|  |         style=[EDGESTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||||
|  |         arrowstyle=[ARROWSTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||||
|  |         connectionstyle="arc3,rad=0.12", | ||||||
|  |         alpha=[1 - 0.08 * G.edges()[*edge]["rank"] for edge in G.edges()] | ||||||
|  |         if params.weighting | ||||||
|  |         else 1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     buf = io.BytesIO() | ||||||
|  |     plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True) | ||||||
|  |     buf.seek(0) | ||||||
|  |     encoded_image = base64.b64encode(buf.read()).decode("UTF-8") | ||||||
|  |     plt.close() | ||||||
|  |  | ||||||
|  |     return {"image": encoded_image} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | translate_tablename = { | ||||||
|  |     R.__tablename__: "🏆", | ||||||
|  |     C.__tablename__: "🧪", | ||||||
|  |     PT.__tablename__: "🃏", | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def last_submissions( | ||||||
|  |     request: Annotated[TeamScopedRequest, Security(verify_team_scope)], | ||||||
|  | ): | ||||||
|  |     times = {} | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         player_ids = session.exec( | ||||||
|  |             select(P.id) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == request.team_id, P.disabled == False) | ||||||
|  |         ).all() | ||||||
|  |         for survey in [C, PT, R]: | ||||||
|  |             subquery = ( | ||||||
|  |                 select(survey.user, func.max(survey.time).label("latest")) | ||||||
|  |                 .where(survey.team == request.team_id) | ||||||
|  |                 .where(survey.user.in_(player_ids)) | ||||||
|  |                 .group_by(survey.user) | ||||||
|  |                 .subquery() | ||||||
|  |             ) | ||||||
|  |             statement2 = select(survey).join( | ||||||
|  |                 subquery, | ||||||
|  |                 (survey.user == subquery.c.user) & (survey.time == subquery.c.latest), | ||||||
|  |             ) | ||||||
|  |             for r in session.exec(statement2): | ||||||
|  |                 if r.time.date() not in times: | ||||||
|  |                     times[r.time.date()] = {} | ||||||
|  |                 times[r.time.date()][r.user] = ( | ||||||
|  |                     times[r.time.date()].get(r.user, "") | ||||||
|  |                     + translate_tablename[survey.__tablename__] | ||||||
|  |                 ) | ||||||
|  |         return times | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def mvp( | ||||||
|  |     request: Annotated[TeamScopedRequest, Security(verify_team_scope)], mixed=False | ||||||
|  | ): | ||||||
|  |     if request.team_id == 42: | ||||||
|  |         ranks = {} | ||||||
|  |         random.seed(42) | ||||||
|  |         players = [request.user] + demo_players | ||||||
|  |         for p in players: | ||||||
|  |             random.shuffle(players) | ||||||
|  |             for i, p in enumerate(players): | ||||||
|  |                 ranks[p.id] = ranks.get(p.id, []) + [i + 1] | ||||||
|  |         return [ | ||||||
|  |             [ | ||||||
|  |                 { | ||||||
|  |                     "p_id": p_id, | ||||||
|  |                     "rank": f"{np.mean(v):.02f}", | ||||||
|  |                     "std": f"{np.std(v):.02f}", | ||||||
|  |                     "n": len(v), | ||||||
|  |                 } | ||||||
|  |                 for i, (p_id, v) in enumerate(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 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) | ||||||
|  |         ) | ||||||
|  |         if mixed: | ||||||
|  |             all_ranks = [] | ||||||
|  |             for gender in ["fmp", "mmp"]: | ||||||
|  |                 ranks = {} | ||||||
|  |                 for r in session.exec(statement2): | ||||||
|  |                     mvps = [ | ||||||
|  |                         p_id | ||||||
|  |                         for p_id in r.mvps | ||||||
|  |                         if p_id in player_map and player_map[p_id].gender == gender | ||||||
|  |                     ] | ||||||
|  |                     for i, p_id in enumerate(mvps): | ||||||
|  |                         p = player_map[p_id] | ||||||
|  |                         ranks[p_id] = ranks.get(p_id, []) + [i + 1] | ||||||
|  |                 all_ranks.append(ranks) | ||||||
|  |         else: | ||||||
|  |             ranks = {} | ||||||
|  |             for r in session.exec(statement2): | ||||||
|  |                 for i, p_id in enumerate(r.mvps): | ||||||
|  |                     if p_id not in player_map: | ||||||
|  |                         continue | ||||||
|  |                     p = player_map[p_id] | ||||||
|  |                     ranks[p_id] = ranks.get(p_id, []) + [i + 1] | ||||||
|  |             all_ranks = [ranks] | ||||||
|  |  | ||||||
|  |     if not all_ranks: | ||||||
|  |         raise HTTPException( | ||||||
|  |             status_code=status.HTTP_404_NOT_FOUND, detail="no entries found" | ||||||
|  |         ) | ||||||
|  |     return [ | ||||||
|  |         [ | ||||||
|  |             { | ||||||
|  |                 "p_id": p_id, | ||||||
|  |                 "rank": f"{np.mean(v):.02f}", | ||||||
|  |                 "std": f"{np.std(v):.02f}", | ||||||
|  |                 "n": len(v), | ||||||
|  |             } | ||||||
|  |             for p_id, v in ranks.items() | ||||||
|  |         ] | ||||||
|  |         for ranks in all_ranks | ||||||
|  |     ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def turnout( | ||||||
|  |     request: Annotated[ | ||||||
|  |         TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"]) | ||||||
|  |     ], | ||||||
|  | ): | ||||||
|  |     player_map = {} | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         players = session.exec( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == request.team_id, P.disabled == False) | ||||||
|  |         ).all() | ||||||
|  |         if not players: | ||||||
|  |             raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) | ||||||
|  |         for p in players: | ||||||
|  |             player_map[p.id] = p.display_name | ||||||
|  |  | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")) | ||||||
|  |             .where(C.team == request.team_id) | ||||||
|  |             .group_by(C.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(C.user).join( | ||||||
|  |             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         chemistry_turnout = session.exec(statement2).all() | ||||||
|  |         chemistry_missing = set(player_map) - set(chemistry_turnout) | ||||||
|  |         chemistry_missing = [player_map[i] for i in chemistry_missing] | ||||||
|  |  | ||||||
|  |         subquery = ( | ||||||
|  |             select(R.user, func.max(R.time).label("latest")) | ||||||
|  |             .where(R.team == request.team_id) | ||||||
|  |             .group_by(R.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(R.user).join( | ||||||
|  |             subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         mvp_turnout = session.exec(statement2).all() | ||||||
|  |         mvp_missing = set(player_map) - set(mvp_turnout) | ||||||
|  |         mvp_missing = [player_map[i] for i in mvp_missing] | ||||||
|  |         return JSONResponse( | ||||||
|  |             { | ||||||
|  |                 "players": len(player_map), | ||||||
|  |                 "chemistry": { | ||||||
|  |                     "turnout": len(chemistry_turnout), | ||||||
|  |                     "missing": sorted(list(chemistry_missing)), | ||||||
|  |                 }, | ||||||
|  |                 "MVP": { | ||||||
|  |                     "turnout": len(mvp_turnout), | ||||||
|  |                     "missing": sorted(list(mvp_missing)), | ||||||
|  |                 }, | ||||||
|  |             } | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) | ||||||
|  | analysis_router.add_api_route( | ||||||
|  |     "/graph_json/{team_id}", endpoint=graph_json, methods=["GET"] | ||||||
|  | ) | ||||||
|  | # analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) | ||||||
|  | analysis_router.add_api_route( | ||||||
|  |     "/mvp/{team_id}", | ||||||
|  |     endpoint=mvp, | ||||||
|  |     methods=["GET"], | ||||||
|  |     name="MVPs", | ||||||
|  |     description="Request Most Valuable Players stats", | ||||||
|  | ) | ||||||
|  | analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"]) | ||||||
|  | analysis_router.add_api_route( | ||||||
|  |     "/times/{team_id}", endpoint=last_submissions, methods=["GET"] | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         statement: SelectOfScalar[P] = select(func.count(P.id)) | ||||||
|  |         print("players in DB: ", session.exec(statement).first()) | ||||||
|  |     G = sociogram_data() | ||||||
|  |     pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42) | ||||||
							
								
								
									
										99
									
								
								cutt/db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								cutt/db.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | |||||||
|  | from datetime import datetime, timezone | ||||||
|  | from sqlmodel import ( | ||||||
|  |     ARRAY, | ||||||
|  |     CHAR, | ||||||
|  |     Column, | ||||||
|  |     Integer, | ||||||
|  |     Relationship, | ||||||
|  |     SQLModel, | ||||||
|  |     Field, | ||||||
|  |     create_engine, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  | with open("db.secrets", "r") as f: | ||||||
|  |     db_secrets = f.readline().strip() | ||||||
|  |  | ||||||
|  | engine = create_engine( | ||||||
|  |     db_secrets, | ||||||
|  |     pool_timeout=20, | ||||||
|  |     pool_size=2, | ||||||
|  |     connect_args={"connect_timeout": 8}, | ||||||
|  | ) | ||||||
|  | del db_secrets | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def utctime(): | ||||||
|  |     return datetime.now(tz=timezone.utc) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PlayerTeamLink(SQLModel, table=True): | ||||||
|  |     team_id: int | None = Field(default=None, foreign_key="team.id", primary_key=True) | ||||||
|  |     player_id: int | None = Field( | ||||||
|  |         default=None, foreign_key="player.id", primary_key=True | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Team(SQLModel, table=True): | ||||||
|  |     id: int | None = Field(default=None, primary_key=True) | ||||||
|  |     name: str | ||||||
|  |     location: str | None | ||||||
|  |     country: str | None | ||||||
|  |     mixed: bool = False | ||||||
|  |     players: list["Player"] | None = Relationship( | ||||||
|  |         back_populates="teams", link_model=PlayerTeamLink | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Player(SQLModel, table=True): | ||||||
|  |     id: int | None = Field(default=None, primary_key=True) | ||||||
|  |     username: str = Field(default=None, unique=True) | ||||||
|  |     display_name: str | ||||||
|  |     email: str | None = None | ||||||
|  |     full_name: str | None = None | ||||||
|  |     gender: str | None = Field(default=None, sa_column=Column(CHAR(3))) | ||||||
|  |     disabled: bool | None = None | ||||||
|  |     hashed_password: str | None = None | ||||||
|  |     number: str | None = None | ||||||
|  |     teams: list[Team] = Relationship( | ||||||
|  |         back_populates="players", link_model=PlayerTeamLink | ||||||
|  |     ) | ||||||
|  |     scopes: str = "" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Chemistry(SQLModel, table=True): | ||||||
|  |     id: int | None = Field(default=None, primary_key=True) | ||||||
|  |     time: datetime | None = Field(default_factory=utctime) | ||||||
|  |     user: int = Field(default=None, foreign_key="player.id") | ||||||
|  |     hate: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     undecided: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     love: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     team: int = Field(default=None, foreign_key="team.id") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PlayerType(SQLModel, table=True): | ||||||
|  |     id: int | None = Field(default=None, primary_key=True) | ||||||
|  |     time: datetime | None = Field(default_factory=utctime) | ||||||
|  |     user: int = Field(default=None, foreign_key="player.id") | ||||||
|  |     handlers: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     combis: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     cutters: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     team: int = Field(default=None, foreign_key="team.id") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class MVPRanking(SQLModel, table=True): | ||||||
|  |     id: int | None = Field(default=None, primary_key=True) | ||||||
|  |     time: datetime | None = Field(default_factory=utctime) | ||||||
|  |     user: int = Field(default=None, foreign_key="player.id") | ||||||
|  |     mvps: list[int] = Field(sa_column=Column(ARRAY(Integer))) | ||||||
|  |     team: int = Field(default=None, foreign_key="team.id") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TokenDB(SQLModel, table=True): | ||||||
|  |     token: str = Field(index=True, primary_key=True) | ||||||
|  |     used: bool | None = False | ||||||
|  |     updated_at: datetime | None = Field( | ||||||
|  |         default_factory=utctime, sa_column_kwargs={"onupdate": utctime} | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | SQLModel.metadata.create_all(engine) | ||||||
							
								
								
									
										28
									
								
								cutt/demo.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								cutt/demo.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | import random | ||||||
|  | from cutt.db import Player | ||||||
|  |  | ||||||
|  | names = [ | ||||||
|  |     ("August", "mmp"), | ||||||
|  |     ("Beate", "fmp"), | ||||||
|  |     ("Ceasar", "mmp"), | ||||||
|  |     ("Daedalus", "mmp"), | ||||||
|  |     ("Elli", "fmp"), | ||||||
|  |     ("Ford P.", ""), | ||||||
|  |     ("Gabriel", "mmp"), | ||||||
|  |     ("Hugo", "mmp"), | ||||||
|  |     ("Ivar Johansson", "mmp"), | ||||||
|  |     ("Jürgen Gordon Malinauskas", "mmp"), | ||||||
|  | ] | ||||||
|  | demo_players = [ | ||||||
|  |     Player.model_validate( | ||||||
|  |         { | ||||||
|  |             "id": i + 4200, | ||||||
|  |             "display_name": name, | ||||||
|  |             "username": name.lower().replace(" ", "").replace(".", ""), | ||||||
|  |             "gender": gender, | ||||||
|  |             "number": str(random.randint(0, 100)), | ||||||
|  |             "email": name.lower().replace(" ", "").replace(".", "") + "@example.org", | ||||||
|  |         } | ||||||
|  |     ) | ||||||
|  |     for i, (name, gender) in enumerate(names) | ||||||
|  | ] | ||||||
							
								
								
									
										252
									
								
								cutt/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										252
									
								
								cutt/main.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,252 @@ | |||||||
|  | from typing import Annotated | ||||||
|  | from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status | ||||||
|  | from fastapi.responses import FileResponse, JSONResponse | ||||||
|  | from fastapi.staticfiles import StaticFiles | ||||||
|  | from cutt.db import Player, PlayerType, Team, Chemistry, MVPRanking, engine | ||||||
|  | from sqlmodel import ( | ||||||
|  |     Session, | ||||||
|  |     func, | ||||||
|  |     select, | ||||||
|  | ) | ||||||
|  | from fastapi.middleware.cors import CORSMiddleware | ||||||
|  | from cutt.analysis import analysis_router | ||||||
|  | from cutt.security import ( | ||||||
|  |     get_current_active_user, | ||||||
|  |     login_for_access_token, | ||||||
|  |     logout, | ||||||
|  |     register, | ||||||
|  |     set_first_password, | ||||||
|  | ) | ||||||
|  | from cutt.player import player_router | ||||||
|  |  | ||||||
|  | C = Chemistry | ||||||
|  | PT = PlayerType | ||||||
|  | R = MVPRanking | ||||||
|  | P = Player | ||||||
|  |  | ||||||
|  | app = FastAPI( | ||||||
|  |     title="cutt", swagger_ui_parameters={"syntaxHighlight": {"theme": "monokai"}} | ||||||
|  | ) | ||||||
|  | api_router = APIRouter(prefix="/api") | ||||||
|  | origins = [ | ||||||
|  |     "https://cutt.0124816.xyz", | ||||||
|  |     "http://localhost:5173", | ||||||
|  | ] | ||||||
|  |  | ||||||
|  | app.add_middleware( | ||||||
|  |     CORSMiddleware, | ||||||
|  |     allow_origins=origins, | ||||||
|  |     allow_credentials=True, | ||||||
|  |     allow_methods=["*"], | ||||||
|  |     allow_headers=["*"], | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def add_team(team: Team): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         session.add(team) | ||||||
|  |         session.commit() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def list_teams(): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         statement = select(Team) | ||||||
|  |         return session.exec(statement).fetchall() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | team_router = APIRouter( | ||||||
|  |     prefix="/teams", | ||||||
|  |     dependencies=[Security(get_current_active_user, scopes=["admin"])], | ||||||
|  |     tags=["team"], | ||||||
|  | ) | ||||||
|  | team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"]) | ||||||
|  | team_router.add_api_route("/add", endpoint=add_team, methods=["POST"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | wrong_user_id_exception = HTTPException( | ||||||
|  |     status_code=status.HTTP_401_UNAUTHORIZED, | ||||||
|  |     detail="you're not who you think you are...", | ||||||
|  | ) | ||||||
|  | somethings_fishy = HTTPException( | ||||||
|  |     status_code=status.HTTP_400_BAD_REQUEST, detail="something up..." | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_router.put("/mvps", tags=["analysis"]) | ||||||
|  | def submit_mvps( | ||||||
|  |     mvps: MVPRanking, | ||||||
|  |     user: Annotated[Player, Depends(get_current_active_user)], | ||||||
|  | ): | ||||||
|  |     if mvps.team == 42: | ||||||
|  |         return JSONResponse("DEMO team, nothing happens") | ||||||
|  |     if user.id == mvps.user: | ||||||
|  |         with Session(engine) as session: | ||||||
|  |             statement = select(Team).where(Team.id == mvps.team) | ||||||
|  |             players = [t.players for t in session.exec(statement)][0] | ||||||
|  |             if players: | ||||||
|  |                 player_ids = {p.id for p in players} | ||||||
|  |                 if player_ids >= set(mvps.mvps): | ||||||
|  |                     session.add(mvps) | ||||||
|  |                     session.commit() | ||||||
|  |                     return JSONResponse("success!") | ||||||
|  |         raise somethings_fishy | ||||||
|  |     else: | ||||||
|  |         raise wrong_user_id_exception | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_router.get("/mvps/{team_id}", tags=["analysis"]) | ||||||
|  | def get_mvps( | ||||||
|  |     team_id: int, | ||||||
|  |     user: Annotated[Player, Depends(get_current_active_user)], | ||||||
|  | ): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         subquery = ( | ||||||
|  |             select(R.user, func.max(R.time).label("latest")) | ||||||
|  |             .where(R.user == user.id) | ||||||
|  |             .where(R.team == team_id) | ||||||
|  |             .group_by(R.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(R).join( | ||||||
|  |             subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         mvps = session.exec(statement2).one_or_none() | ||||||
|  |         if mvps: | ||||||
|  |             return mvps | ||||||
|  |         else: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_400_BAD_REQUEST, | ||||||
|  |                 detail="no previous state was found", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_router.put("/chemistry", tags=["analysis"]) | ||||||
|  | def submit_chemistry( | ||||||
|  |     chemistry: Chemistry, user: Annotated[Player, Depends(get_current_active_user)] | ||||||
|  | ): | ||||||
|  |     if chemistry.team == 42: | ||||||
|  |         return JSONResponse("DEMO team, nothing happens") | ||||||
|  |     if user.id == chemistry.user: | ||||||
|  |         with Session(engine) as session: | ||||||
|  |             statement = select(Team).where(Team.id == chemistry.team) | ||||||
|  |             players = [t.players for t in session.exec(statement)][0] | ||||||
|  |             if players: | ||||||
|  |                 player_ids = {p.id for p in players} | ||||||
|  |                 if player_ids >= ( | ||||||
|  |                     set(chemistry.love) | set(chemistry.hate) | set(chemistry.undecided) | ||||||
|  |                 ): | ||||||
|  |                     session.add(chemistry) | ||||||
|  |                     session.commit() | ||||||
|  |                     return JSONResponse("success!") | ||||||
|  |         raise somethings_fishy | ||||||
|  |     else: | ||||||
|  |         raise wrong_user_id_exception | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_router.get("/chemistry/{team_id}", tags=["analysis"]) | ||||||
|  | def get_chemistry( | ||||||
|  |     team_id: int, user: Annotated[Player, Depends(get_current_active_user)] | ||||||
|  | ): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")) | ||||||
|  |             .where(C.user == user.id) | ||||||
|  |             .where(C.team == team_id) | ||||||
|  |             .group_by(C.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(C).join( | ||||||
|  |             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         chemistry = session.exec(statement2).one_or_none() | ||||||
|  |         if chemistry is not None: | ||||||
|  |             return chemistry | ||||||
|  |         else: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_400_BAD_REQUEST, | ||||||
|  |                 detail="no previous state was found", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_router.put("/playertype", tags=["analysis"]) | ||||||
|  | def submit_playertype( | ||||||
|  |     playertype: PlayerType, user: Annotated[Player, Depends(get_current_active_user)] | ||||||
|  | ): | ||||||
|  |     if playertype.team == 42: | ||||||
|  |         return JSONResponse("DEMO team, nothing happens") | ||||||
|  |     if user.id == playertype.user: | ||||||
|  |         with Session(engine) as session: | ||||||
|  |             statement = select(Team).where(Team.id == playertype.team) | ||||||
|  |             players = [t.players for t in session.exec(statement)][0] | ||||||
|  |             if players: | ||||||
|  |                 player_ids = {p.id for p in players} | ||||||
|  |                 if player_ids >= ( | ||||||
|  |                     set(playertype.handlers) | ||||||
|  |                     | set(playertype.combis) | ||||||
|  |                     | set(playertype.cutters) | ||||||
|  |                 ): | ||||||
|  |                     session.add(playertype) | ||||||
|  |                     session.commit() | ||||||
|  |                     return JSONResponse("success!") | ||||||
|  |         raise somethings_fishy | ||||||
|  |     else: | ||||||
|  |         raise wrong_user_id_exception | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @api_router.get("/playertype/{team_id}", tags=["analysis"]) | ||||||
|  | def get_playertype( | ||||||
|  |     team_id: int, user: Annotated[Player, Depends(get_current_active_user)] | ||||||
|  | ): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         subquery = ( | ||||||
|  |             select(PT.user, func.max(PT.time).label("latest")) | ||||||
|  |             .where(PT.user == user.id) | ||||||
|  |             .where(PT.team == team_id) | ||||||
|  |             .group_by(PT.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(PT).join( | ||||||
|  |             subquery, (PT.user == subquery.c.user) & (PT.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         playertype = session.exec(statement2).one_or_none() | ||||||
|  |         if playertype is not None: | ||||||
|  |             return playertype | ||||||
|  |         else: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_400_BAD_REQUEST, | ||||||
|  |                 detail="no previous state was found", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class SPAStaticFiles(StaticFiles): | ||||||
|  |     async def get_response(self, path: str, scope): | ||||||
|  |         response = await super().get_response(path, scope) | ||||||
|  |         if response.status_code == 404: | ||||||
|  |             response = await super().get_response(".", scope) | ||||||
|  |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | api_router.include_router( | ||||||
|  |     player_router, dependencies=[Depends(get_current_active_user)] | ||||||
|  | ) | ||||||
|  | api_router.include_router(team_router, dependencies=[Depends(get_current_active_user)]) | ||||||
|  | api_router.include_router(analysis_router) | ||||||
|  | api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) | ||||||
|  | api_router.add_api_route("/set_password", endpoint=set_first_password, methods=["POST"]) | ||||||
|  | api_router.add_api_route("/register", endpoint=register, methods=["POST"]) | ||||||
|  | api_router.add_api_route("/logout", endpoint=logout, methods=["POST"]) | ||||||
|  | app.include_router(api_router) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | # app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site") | ||||||
|  | @app.get("/") | ||||||
|  | async def root(): | ||||||
|  |     return FileResponse("dist/index.html") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | @app.exception_handler(404) | ||||||
|  | async def exception_404_handler(request, exc): | ||||||
|  |     return FileResponse("dist/index.html") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | app.mount("/", StaticFiles(directory="dist"), name="ui") | ||||||
							
								
								
									
										253
									
								
								cutt/player.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										253
									
								
								cutt/player.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,253 @@ | |||||||
|  | from typing import Annotated | ||||||
|  | from fastapi import APIRouter, Depends, HTTPException, Security, status | ||||||
|  | from fastapi.responses import PlainTextResponse | ||||||
|  | from pydantic import BaseModel | ||||||
|  | from sqlmodel import Session, select | ||||||
|  |  | ||||||
|  | from cutt.db import Player, PlayerTeamLink, Team, engine | ||||||
|  | from cutt.security import ( | ||||||
|  |     TeamScopedRequest, | ||||||
|  |     change_password, | ||||||
|  |     get_current_active_user, | ||||||
|  |     read_player_me, | ||||||
|  |     verify_team_scope, | ||||||
|  | ) | ||||||
|  | from cutt.demo import demo_players | ||||||
|  |  | ||||||
|  | P = Player | ||||||
|  |  | ||||||
|  | player_router = APIRouter(prefix="/player", tags=["player"]) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class PlayerRequest(BaseModel): | ||||||
|  |     display_name: str | ||||||
|  |     username: str | ||||||
|  |     gender: str | None | ||||||
|  |     number: str | ||||||
|  |     email: str | None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class AddPlayerRequest(PlayerRequest): ... | ||||||
|  |  | ||||||
|  |  | ||||||
|  | DEMO_TEAM_REQUEST = HTTPException( | ||||||
|  |     status_code=status.HTTP_400_BAD_REQUEST, | ||||||
|  |     detail="DEMO Team, nothing happens", | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def add_player( | ||||||
|  |     r: AddPlayerRequest, | ||||||
|  |     request: Annotated[TeamScopedRequest, Depends(verify_team_scope)], | ||||||
|  | ): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         if session.exec(select(P).where(P.username == r.username)).one_or_none(): | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_400_BAD_REQUEST, detail="username not available" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         stmt = ( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == request.team_id, P.display_name == r.display_name) | ||||||
|  |         ) | ||||||
|  |         if session.exec(stmt).one_or_none(): | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_400_BAD_REQUEST, | ||||||
|  |                 detail="the name is already taken on this team", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         team = session.exec(select(Team).where(Team.id == request.team_id)).one() | ||||||
|  |         new_player = Player( | ||||||
|  |             username=r.username, | ||||||
|  |             display_name=r.display_name, | ||||||
|  |             gender=r.gender if r.gender else None, | ||||||
|  |             email=r.email if r.email else None, | ||||||
|  |             number=r.number, | ||||||
|  |             disabled=False, | ||||||
|  |             teams=[team], | ||||||
|  |         ) | ||||||
|  |         session.add(new_player) | ||||||
|  |         session.commit() | ||||||
|  |         return PlainTextResponse(f"added {new_player.display_name}") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ModifyPlayerRequest(PlayerRequest): | ||||||
|  |     id: int | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def modify_player( | ||||||
|  |     r: ModifyPlayerRequest, | ||||||
|  |     request: Annotated[TeamScopedRequest, Depends(verify_team_scope)], | ||||||
|  | ): | ||||||
|  |     if request.team_id == 42: | ||||||
|  |         raise DEMO_TEAM_REQUEST | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         player = session.exec( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == request.team_id, P.id == r.id, P.username == r.username) | ||||||
|  |         ).one_or_none() | ||||||
|  |         if player: | ||||||
|  |             print(r) | ||||||
|  |             player.display_name = r.display_name.strip() | ||||||
|  |             player.number = r.number.strip() | ||||||
|  |             player.gender = r.gender.strip() if r.gender else None | ||||||
|  |             player.email = r.email.strip() if r.email else None | ||||||
|  |             session.add(player) | ||||||
|  |             session.commit() | ||||||
|  |             return PlainTextResponse("modification successful") | ||||||
|  |         else: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_404_NOT_FOUND, | ||||||
|  |                 detail="no such player found in your team", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class DisablePlayerRequest(BaseModel): | ||||||
|  |     player_id: int | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def disable_player( | ||||||
|  |     r: DisablePlayerRequest, | ||||||
|  |     request: Annotated[TeamScopedRequest, Depends(verify_team_scope)], | ||||||
|  | ): | ||||||
|  |     if request.team_id == 42: | ||||||
|  |         raise DEMO_TEAM_REQUEST | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         player = session.exec( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == request.team_id, P.id == r.player_id) | ||||||
|  |         ).one_or_none() | ||||||
|  |         if player: | ||||||
|  |             player.disabled = True | ||||||
|  |             session.add(player) | ||||||
|  |             session.commit() | ||||||
|  |             return PlainTextResponse(f"disabled {player.display_name}") | ||||||
|  |         else: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_404_NOT_FOUND, | ||||||
|  |                 detail="no such player found in your team", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def add_player_to_team(player_id: int, team_id: int): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         player = session.exec(select(P).where(P.id == player_id)).one() | ||||||
|  |         team = session.exec(select(Team).where(Team.id == team_id)).one() | ||||||
|  |         if player and team: | ||||||
|  |             team.players.append(player) | ||||||
|  |             session.add(team) | ||||||
|  |             session.commit() | ||||||
|  |             return PlainTextResponse( | ||||||
|  |                 f"added {player.display_name} ({player.username}) to {team.name}" | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def add_players(players: list[P]): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         for player in players: | ||||||
|  |             session.add(player) | ||||||
|  |         session.commit() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def list_all_players(): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         return session.exec(select(P)).all() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def list_players( | ||||||
|  |     team_id: int, user: Annotated[Player, Depends(get_current_active_user)] | ||||||
|  | ): | ||||||
|  |     if team_id == 42: | ||||||
|  |         return [ | ||||||
|  |             user.model_dump( | ||||||
|  |                 include={"id", "display_name", "gender", "username", "number", "email"} | ||||||
|  |             ) | ||||||
|  |         ] + demo_players | ||||||
|  |  | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         current_user = session.exec( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == team_id, P.disabled == False, P.id == user.id) | ||||||
|  |         ).one_or_none() | ||||||
|  |         if not current_user: | ||||||
|  |             raise HTTPException( | ||||||
|  |                 status_code=status.HTTP_400_BAD_REQUEST, | ||||||
|  |                 detail="you're not in this team", | ||||||
|  |             ) | ||||||
|  |  | ||||||
|  |         players = session.exec( | ||||||
|  |             select(P) | ||||||
|  |             .join(PlayerTeamLink) | ||||||
|  |             .join(Team) | ||||||
|  |             .where(Team.id == team_id, P.disabled == False) | ||||||
|  |             .order_by(P.display_name) | ||||||
|  |         ).all() | ||||||
|  |         if players: | ||||||
|  |             return [ | ||||||
|  |                 player.model_dump( | ||||||
|  |                     include={ | ||||||
|  |                         "id", | ||||||
|  |                         "display_name", | ||||||
|  |                         "username", | ||||||
|  |                         "gender", | ||||||
|  |                         "number", | ||||||
|  |                         "email", | ||||||
|  |                     } | ||||||
|  |                 ) | ||||||
|  |                 for player in players | ||||||
|  |                 if not player.disabled | ||||||
|  |             ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]): | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         return [p.teams for p in session.exec(select(P).where(P.id == user.id))][0] + [ | ||||||
|  |             {"country": "nowhere", "id": 42, "location": "everywhere", "name": "DEMO"} | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/{team_id}", | ||||||
|  |     endpoint=add_player, | ||||||
|  |     methods=["POST"], | ||||||
|  | ) | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/{team_id}", | ||||||
|  |     endpoint=modify_player, | ||||||
|  |     methods=["PUT"], | ||||||
|  | ) | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/{team_id}", | ||||||
|  |     endpoint=disable_player, | ||||||
|  |     methods=["DELETE"], | ||||||
|  | ) | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/{team_id}/list", | ||||||
|  |     endpoint=list_players, | ||||||
|  |     methods=["GET"], | ||||||
|  | ) | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/list", | ||||||
|  |     endpoint=list_all_players, | ||||||
|  |     methods=["GET"], | ||||||
|  |     dependencies=[Security(get_current_active_user, scopes=["admin"])], | ||||||
|  | ) | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/add/{team_id}/{player_id}", | ||||||
|  |     endpoint=add_player_to_team, | ||||||
|  |     methods=["GET"], | ||||||
|  |     dependencies=[Security(get_current_active_user, scopes=["admin"])], | ||||||
|  | ) | ||||||
|  | player_router.add_api_route("/me", endpoint=read_player_me, methods=["GET"]) | ||||||
|  | player_router.add_api_route("/me/teams", endpoint=read_teams_me, methods=["GET"]) | ||||||
|  | player_router.add_api_route( | ||||||
|  |     "/change_password", endpoint=change_password, methods=["POST"] | ||||||
|  | ) | ||||||
							
								
								
									
										401
									
								
								cutt/security.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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}] | ||||||
							
								
								
									
										74
									
								
								db.py
									
									
									
									
									
								
							
							
						
						
									
										74
									
								
								db.py
									
									
									
									
									
								
							| @@ -1,74 +0,0 @@ | |||||||
| from datetime import datetime, timezone |  | ||||||
| from sqlmodel import ( |  | ||||||
|     ARRAY, |  | ||||||
|     Column, |  | ||||||
|     Relationship, |  | ||||||
|     SQLModel, |  | ||||||
|     Field, |  | ||||||
|     create_engine, |  | ||||||
|     String, |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| with open("db.secrets", "r") as f: |  | ||||||
|     db_secrets = f.readline().strip() |  | ||||||
|  |  | ||||||
| engine = create_engine(db_secrets, connect_args={"connect_timeout": 8}) |  | ||||||
| del db_secrets |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def utctime(): |  | ||||||
|     return datetime.now(tz=timezone.utc) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class PlayerTeamLink(SQLModel, table=True): |  | ||||||
|     team_id: int | None = Field(default=None, foreign_key="team.id", primary_key=True) |  | ||||||
|     player_id: int | None = Field( |  | ||||||
|         default=None, foreign_key="player.id", primary_key=True |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Team(SQLModel, table=True): |  | ||||||
|     id: int | None = Field(default=None, primary_key=True) |  | ||||||
|     name: str |  | ||||||
|     location: str | None |  | ||||||
|     country: str | None |  | ||||||
|     players: list["Player"] | None = Relationship( |  | ||||||
|         back_populates="teams", link_model=PlayerTeamLink |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Player(SQLModel, table=True): |  | ||||||
|     id: int | None = Field(default=None, primary_key=True) |  | ||||||
|     name: str |  | ||||||
|     number: str | None = None |  | ||||||
|     teams: list[Team] | None = Relationship( |  | ||||||
|         back_populates="players", link_model=PlayerTeamLink |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Chemistry(SQLModel, table=True): |  | ||||||
|     id: int | None = Field(default=None, primary_key=True) |  | ||||||
|     time: datetime | None = Field(default_factory=utctime) |  | ||||||
|     user: str |  | ||||||
|     love: list[str] = Field(sa_column=Column(ARRAY(String))) |  | ||||||
|     hate: list[str] = Field(sa_column=Column(ARRAY(String))) |  | ||||||
|     undecided: list[str] = Field(sa_column=Column(ARRAY(String))) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class MVPRanking(SQLModel, table=True): |  | ||||||
|     id: int | None = Field(default=None, primary_key=True) |  | ||||||
|     time: datetime | None = Field(default_factory=utctime) |  | ||||||
|     user: str |  | ||||||
|     mvps: list[str] = Field(sa_column=Column(ARRAY(String))) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| 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") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| SQLModel.metadata.create_all(engine) |  | ||||||
							
								
								
									
										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") |  | ||||||
							
								
								
									
										10
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										10
									
								
								package.json
									
									
									
									
									
								
							| @@ -10,16 +10,18 @@ | |||||||
|     "preview": "vite preview" |     "preview": "vite preview" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "react": "^18.3.1", |     "jwt-decode": "^4.0.0", | ||||||
|     "react-dom": "^18.3.1", |     "react": "18.3.1", | ||||||
|  |     "react-dom": "18.3.1", | ||||||
|     "react-sortablejs": "^6.1.4", |     "react-sortablejs": "^6.1.4", | ||||||
|     "reagraph": "^4.21.2", |     "reagraph": "^4.21.2", | ||||||
|     "sortablejs": "^1.15.6" |     "sortablejs": "^1.15.6" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@eslint/js": "^9.17.0", |     "@eslint/js": "^9.17.0", | ||||||
|     "@types/react": "^18.3.18", |     "@types/node": "^22.13.10", | ||||||
|     "@types/react-dom": "^18.3.5", |     "@types/react": "18.3.18", | ||||||
|  |     "@types/react-dom": "18.3.5", | ||||||
|     "@types/sortablejs": "^1.15.8", |     "@types/sortablejs": "^1.15.8", | ||||||
|     "@vitejs/plugin-react": "^4.3.4", |     "@vitejs/plugin-react": "^4.3.4", | ||||||
|     "eslint": "^9.17.0", |     "eslint": "^9.17.0", | ||||||
|   | |||||||
| @@ -1,41 +1 @@ | |||||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | <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> | ||||||
| <svg |  | ||||||
|    xml:space="preserve" |  | ||||||
|    width="128" |  | ||||||
|    height="128" |  | ||||||
|    viewBox="0 0 2560 2560" |  | ||||||
|    version="1.1" |  | ||||||
|    id="svg3" |  | ||||||
|    sodipodi:docname="gitea.svg" |  | ||||||
|    inkscape:version="1.4 (e7c3feb100, 2024-10-09)" |  | ||||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" |  | ||||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" |  | ||||||
|    xmlns="http://www.w3.org/2000/svg" |  | ||||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs |  | ||||||
|      id="defs3" /><sodipodi:namedview |  | ||||||
|      id="namedview3" |  | ||||||
|      pagecolor="#ffffff" |  | ||||||
|      bordercolor="#000000" |  | ||||||
|      borderopacity="0.25" |  | ||||||
|      inkscape:showpageshadow="2" |  | ||||||
|      inkscape:pageopacity="0.0" |  | ||||||
|      inkscape:pagecheckerboard="0" |  | ||||||
|      inkscape:deskcolor="#d1d1d1" |  | ||||||
|      inkscape:zoom="2.4221483" |  | ||||||
|      inkscape:cx="89.58989" |  | ||||||
|      inkscape:cy="-60.483497" |  | ||||||
|      inkscape:window-width="1408" |  | ||||||
|      inkscape:window-height="1727" |  | ||||||
|      inkscape:window-x="0" |  | ||||||
|      inkscape:window-y="0" |  | ||||||
|      inkscape:window-maximized="0" |  | ||||||
|      inkscape:current-layer="svg3" /><path |  | ||||||
|      d="m 1569.914,2282.76 -484.616,-232.952 c -47.736,-22.913 -68.358,-80.96 -45.063,-129.078 l 232.952,-484.617 c 22.913,-47.736 80.96,-68.358 129.078,-45.062 65.685,31.696 103.492,49.645 103.492,49.645 l -0.382,-417.022 63.776,-0.382 0.381,447.191 c 0,0 219.204,92.417 317.35,153.138 14.13,8.783 38.952,25.968 49.263,54.992 8.02,23.295 7.638,50.027 -3.818,73.704 l -232.952,484.617 c -23.678,48.5 -81.725,69.121 -129.46,45.826 z" |  | ||||||
|      style="fill:#ffffff;stroke-width:3.81889" |  | ||||||
|      id="path1" /><path |  | ||||||
|      d="m 2436.037,1005.725 c -15.657,-15.657 -36.66,-15.276 -36.66,-15.276 0,0 -447.574,25.205 -679.38,30.552 -50.792,1.145 -101.201,2.29 -151.228,2.673 v 447.573 c -21.004,-9.929 -42.39,-20.24 -63.394,-30.17 0,-139.007 -0.382,-417.021 -0.382,-417.021 -110.747,1.527 -340.644,-8.402 -340.644,-8.402 0,0 -539.99,-27.114 -598.802,-32.46 -37.425,-2.292 -85.924,-8.02 -148.936,5.728 -33.224,6.874 -127.933,28.26 -205.456,102.728 -171.85,153.137 -127.933,396.782 -122.586,433.443 6.492,44.681 26.35,168.795 121.058,276.87 174.905,214.239 551.447,209.275 551.447,209.275 0,0 46.209,110.365 116.858,211.948 95.472,126.405 193.618,224.932 289.09,236.77 240.59,0 721.387,-0.381 721.387,-0.381 0,0 45.827,0.382 108.075,-39.335 53.464,-32.46 101.2,-89.362 101.2,-89.362 0,0 49.264,-52.7 118.004,-172.995 21.004,-37.043 38.57,-72.941 53.846,-106.93 0,0 210.803,-447.19 210.803,-882.543 -4.201,-131.752 -36.662,-155.047 -44.3,-162.685 z M 537.67,1785.159 c -98.91,-32.46 -140.917,-71.413 -140.917,-71.413 0,0 -72.94,-51.173 -109.602,-151.991 -63.012,-168.795 -5.347,-271.905 -5.347,-271.905 0,0 32.079,-85.925 147.027,-114.567 52.701,-14.13 118.386,-11.838 118.386,-11.838 0,0 27.114,226.842 59.956,359.739 27.496,111.511 94.709,296.727 94.709,296.727 0,0 -99.673,-11.838 -164.212,-34.752 z m 1146.81,410.912 c 0,0 -23.294,55.374 -74.85,58.811 -22.149,1.528 -39.334,-4.582 -39.334,-4.582 0,0 -1.145,-0.382 -20.24,-8.02 l -431.152,-210.039 c 0,0 -41.626,-21.767 -48.882,-59.574 -8.401,-30.933 10.311,-69.122 10.311,-69.122 l 207.366,-427.333 c 0,0 18.33,-37.044 46.59,-49.646 2.291,-1.146 8.784,-3.819 17.185,-5.728 30.933,-8.02 68.74,10.693 68.74,10.693 l 422.75,205.074 c 0,0 48.119,21.767 58.43,61.866 7.255,28.26 -1.91,53.464 -6.874,65.685 -24.06,58.81 -210.04,431.916 -210.04,431.916 z" |  | ||||||
|      style="fill:#609926;stroke-width:3.81889" |  | ||||||
|      id="path2" /><path |  | ||||||
|      d="m 1306.029,1885.214 c -31.314,0.382 -58.81,22.15 -66.066,52.7 -7.256,30.552 7.637,62.249 34.751,76.379 29.406,15.275 66.83,6.874 86.69,-20.622 19.476,-27.114 16.42,-64.54 -6.875,-88.217 l 91.653,-187.507 c 5.729,0.382 14.13,0.764 23.677,-1.91 15.658,-3.436 27.115,-13.747 27.115,-13.747 16.039,6.874 32.842,14.511 50.409,23.295 18.33,9.165 35.516,18.712 51.173,27.878 3.437,1.91 6.874,4.2 10.693,7.256 6.11,4.964 12.984,11.838 17.949,21.003 7.255,21.004 -7.256,56.902 -7.256,56.902 -8.784,29.023 -70.268,155.047 -70.268,155.047 -30.933,-0.764 -58.429,19.094 -67.594,47.736 -9.93,30.933 4.2,66.066 33.988,81.342 29.787,15.275 66.449,6.492 85.925,-20.24 19.094,-25.969 17.567,-62.248 -4.2,-86.307 7.255,-14.13 14.129,-28.26 21.385,-43.153 19.094,-39.717 51.555,-116.094 51.555,-116.094 3.437,-6.493 21.768,-39.335 10.31,-81.343 -9.546,-43.535 -48.117,-63.775 -48.117,-63.775 -46.59,-30.17 -111.512,-58.047 -111.512,-58.047 0,0 0,-15.658 -4.2,-27.114 -4.201,-11.839 -10.693,-19.477 -14.894,-24.06 17.949,-37.042 35.897,-73.704 53.846,-110.747 a 2647.928,2647.928 0 0 1 -46.59,-23.295 c -18.33,37.425 -37.043,75.232 -55.374,112.657 -25.587,-0.382 -49.264,13.366 -61.484,35.898 -12.984,24.058 -10.311,53.846 7.256,75.613 z" |  | ||||||
|      style="fill:#609926;stroke-width:3.81889" |  | ||||||
|      id="path3" /></svg> |  | ||||||
| Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 3.3 KiB | 
| @@ -1,49 +1 @@ | |||||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | <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> | ||||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> |  | ||||||
|  |  | ||||||
| <svg |  | ||||||
|    width="48" |  | ||||||
|    height="48" |  | ||||||
|    viewBox="0 0 12.7 12.7" |  | ||||||
|    version="1.1" |  | ||||||
|    id="svg1" |  | ||||||
|    inkscape:version="1.4 (e7c3feb100, 2024-10-09)" |  | ||||||
|    sodipodi:docname="logo.svg" |  | ||||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" |  | ||||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" |  | ||||||
|    xmlns="http://www.w3.org/2000/svg" |  | ||||||
|    xmlns:svg="http://www.w3.org/2000/svg"> |  | ||||||
|   <sodipodi:namedview |  | ||||||
|      id="namedview1" |  | ||||||
|      pagecolor="#ffffff" |  | ||||||
|      bordercolor="#000000" |  | ||||||
|      borderopacity="0.25" |  | ||||||
|      inkscape:showpageshadow="2" |  | ||||||
|      inkscape:pageopacity="0.0" |  | ||||||
|      inkscape:pagecheckerboard="0" |  | ||||||
|      inkscape:deskcolor="#d1d1d1" |  | ||||||
|      inkscape:document-units="mm" |  | ||||||
|      inkscape:zoom="14.329304" |  | ||||||
|      inkscape:cx="17.167617" |  | ||||||
|      inkscape:cy="25.088448" |  | ||||||
|      inkscape:window-width="1408" |  | ||||||
|      inkscape:window-height="1727" |  | ||||||
|      inkscape:window-x="0" |  | ||||||
|      inkscape:window-y="0" |  | ||||||
|      inkscape:window-maximized="0" |  | ||||||
|      inkscape:current-layer="layer1" /> |  | ||||||
|   <defs |  | ||||||
|      id="defs1" /> |  | ||||||
|   <g |  | ||||||
|      inkscape:label="Layer 1" |  | ||||||
|      inkscape:groupmode="layer" |  | ||||||
|      id="layer1"> |  | ||||||
|     <ellipse |  | ||||||
|        style="fill:#c7d6f1;stroke:#3366cc;stroke-width:1.94357;fill-opacity:1" |  | ||||||
|        id="path2" |  | ||||||
|        cx="6.3500028" |  | ||||||
|        cy="6.3500109" |  | ||||||
|        rx="4.5089426" |  | ||||||
|        ry="4.5918198" /> |  | ||||||
|   </g> |  | ||||||
| </svg> |  | ||||||
| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 214 B | 
							
								
								
									
										137
									
								
								security.py
									
									
									
									
									
								
							
							
						
						
									
										137
									
								
								security.py
									
									
									
									
									
								
							| @@ -1,137 +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 |  | ||||||
| from sqlalchemy.exc import OperationalError |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Config(BaseSettings): |  | ||||||
|     secret_key: str = "" |  | ||||||
|     access_token_expire_minutes: int = 30 |  | ||||||
|     model_config = SettingsConfigDict( |  | ||||||
|         env_file=".env", env_file_encoding="utf-8", extra="ignore" |  | ||||||
|     ) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| config = Config() |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class Token(BaseModel): |  | ||||||
|     access_token: str |  | ||||||
|     token_type: str |  | ||||||
|  |  | ||||||
|  |  | ||||||
| class TokenData(BaseModel): |  | ||||||
|     username: str | None = None |  | ||||||
|  |  | ||||||
|  |  | ||||||
| pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def verify_password(plain_password, hashed_password): |  | ||||||
|     return pwd_context.verify(plain_password, hashed_password) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_password_hash(password): |  | ||||||
|     return pwd_context.hash(password) |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def get_user(username: str | None): |  | ||||||
|     if username: |  | ||||||
|         try: |  | ||||||
|             with Session(engine) as session: |  | ||||||
|                 return session.exec( |  | ||||||
|                     select(User).where(User.username == username) |  | ||||||
|                 ).one_or_none() |  | ||||||
|         except OperationalError: |  | ||||||
|             return |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def authenticate_user(username: str, password: str): |  | ||||||
|     user = get_user(username) |  | ||||||
|     if not user: |  | ||||||
|         return False |  | ||||||
|     if not verify_password(password, user.hashed_password): |  | ||||||
|         return False |  | ||||||
|     return user |  | ||||||
|  |  | ||||||
|  |  | ||||||
| def create_access_token(data: dict, expires_delta: timedelta | None = None): |  | ||||||
|     to_encode = data.copy() |  | ||||||
|     if expires_delta: |  | ||||||
|         expire = datetime.now(timezone.utc) + expires_delta |  | ||||||
|     else: |  | ||||||
|         expire = datetime.now(timezone.utc) + timedelta(minutes=15) |  | ||||||
|     to_encode.update({"exp": expire}) |  | ||||||
|     encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256") |  | ||||||
|     return encoded_jwt |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): |  | ||||||
|     credentials_exception = HTTPException( |  | ||||||
|         status_code=status.HTTP_401_UNAUTHORIZED, |  | ||||||
|         detail="Could not validate credentials", |  | ||||||
|         headers={"WWW-Authenticate": "Bearer"}, |  | ||||||
|     ) |  | ||||||
|     try: |  | ||||||
|         payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) |  | ||||||
|         username: str = payload.get("sub") |  | ||||||
|         if username is None: |  | ||||||
|             raise credentials_exception |  | ||||||
|         token_data = TokenData(username=username) |  | ||||||
|     except InvalidTokenError: |  | ||||||
|         raise credentials_exception |  | ||||||
|     user = get_user(username=token_data.username) |  | ||||||
|     if user is None: |  | ||||||
|         raise credentials_exception |  | ||||||
|     return user |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def get_current_active_user( |  | ||||||
|     current_user: Annotated[User, Depends(get_current_user)], |  | ||||||
| ): |  | ||||||
|     if current_user.disabled: |  | ||||||
|         raise HTTPException(status_code=400, detail="Inactive user") |  | ||||||
|     return current_user |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def login_for_access_token( |  | ||||||
|     form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response |  | ||||||
| ) -> Token: |  | ||||||
|     user = authenticate_user(form_data.username, form_data.password) |  | ||||||
|     if not user: |  | ||||||
|         raise HTTPException( |  | ||||||
|             status_code=status.HTTP_401_UNAUTHORIZED, |  | ||||||
|             detail="Incorrect username or password", |  | ||||||
|             headers={"WWW-Authenticate": "Bearer"}, |  | ||||||
|         ) |  | ||||||
|     access_token_expires = timedelta(minutes=config.access_token_expire_minutes) |  | ||||||
|     access_token = create_access_token( |  | ||||||
|         data={"sub": user.username}, expires_delta=access_token_expires |  | ||||||
|     ) |  | ||||||
|     response.set_cookie( |  | ||||||
|         "Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none" |  | ||||||
|     ) |  | ||||||
|     return Token(access_token=access_token, token_type="bearer") |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def read_users_me( |  | ||||||
|     current_user: Annotated[User, Depends(get_current_active_user)], |  | ||||||
| ): |  | ||||||
|     return current_user |  | ||||||
|  |  | ||||||
|  |  | ||||||
| async def read_own_items( |  | ||||||
|     current_user: Annotated[User, Depends(get_current_active_user)], |  | ||||||
| ): |  | ||||||
|     return [{"item_id": "Foo", "owner": current_user.username}] |  | ||||||
							
								
								
									
										210
									
								
								src/Analysis.tsx
									
									
									
									
									
								
							
							
						
						
									
										210
									
								
								src/Analysis.tsx
									
									
									
									
									
								
							| @@ -1,210 +0,0 @@ | |||||||
| import { useEffect, useState } from "react"; |  | ||||||
| import { apiAuth } from "./api"; |  | ||||||
|  |  | ||||||
| //const debounce = <T extends (...args: any[]) => void>( |  | ||||||
| //  func: T, |  | ||||||
| //  delay: number |  | ||||||
| //): ((...args: Parameters<T>) => void) => { |  | ||||||
| //  let timeoutId: number | null = null; |  | ||||||
| //  return (...args: Parameters<T>) => { |  | ||||||
| //    if (timeoutId !== null) { |  | ||||||
| //      clearTimeout(timeoutId); |  | ||||||
| //    } |  | ||||||
| //    console.log(timeoutId); |  | ||||||
| //    timeoutId = setTimeout(() => { |  | ||||||
| //      func(...args); |  | ||||||
| //    }, delay); |  | ||||||
| //  }; |  | ||||||
| //}; |  | ||||||
| // |  | ||||||
| interface Prop { |  | ||||||
|   name: string; |  | ||||||
|   min: string; |  | ||||||
|   max: string; |  | ||||||
|   step: string; |  | ||||||
|   value: string; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| interface Params { |  | ||||||
|   nodeSize: number; |  | ||||||
|   edgeWidth: number; |  | ||||||
|   arrowSize: number; |  | ||||||
|   fontSize: number; |  | ||||||
|   distance: number; |  | ||||||
|   weighting: boolean; |  | ||||||
|   popularity: boolean; |  | ||||||
|   show: number; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| interface DeferredProps { |  | ||||||
|   timeout: number; |  | ||||||
|   func: () => void; |  | ||||||
| } |  | ||||||
|  |  | ||||||
|  |  | ||||||
| let timeoutID: number | null = null; |  | ||||||
| export default function Analysis() { |  | ||||||
|   const [image, setImage] = useState(""); |  | ||||||
|   const [params, setParams] = useState<Params>({ |  | ||||||
|     nodeSize: 2000, |  | ||||||
|     edgeWidth: 1, |  | ||||||
|     arrowSize: 16, |  | ||||||
|     fontSize: 10, |  | ||||||
|     distance: 2, |  | ||||||
|     weighting: true, |  | ||||||
|     popularity: true, |  | ||||||
|     show: 2, |  | ||||||
|   }); |  | ||||||
|   const [showControlPanel, setShowControlPanel] = useState(false); |  | ||||||
|   const [loading, setLoading] = useState(true); |  | ||||||
|  |  | ||||||
|   // Function to generate and fetch the graph image |  | ||||||
|   async function loadImage() { |  | ||||||
|     setLoading(true); |  | ||||||
|     await apiAuth("analysis/image", params, "POST") |  | ||||||
|       .then((data) => { |  | ||||||
|         setImage(data.image); |  | ||||||
|         setLoading(false); |  | ||||||
|       }).catch((e) => { |  | ||||||
|         console.log("best to just reload... ", e); |  | ||||||
|       }) |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     if (timeoutID) { |  | ||||||
|       clearTimeout(timeoutID); |  | ||||||
|     } |  | ||||||
|     timeoutID = setTimeout(() => { |  | ||||||
|       loadImage(); |  | ||||||
|     }, 1000); |  | ||||||
|   }, [params]); |  | ||||||
|  |  | ||||||
|   function showLabel() { |  | ||||||
|     switch (params.show) { |  | ||||||
|       case 0: return "dislike"; |  | ||||||
|       case 1: return "both"; |  | ||||||
|       case 2: return "like"; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return ( |  | ||||||
|     <div className="stack column dropdown"> |  | ||||||
|       <button onClick={() => setShowControlPanel(!showControlPanel)}> |  | ||||||
|         Parameters <svg viewBox="0 0 24 24" height="1.2em" style={{ fill: "#ffffff", display: "inline", top: "0.2em", position: "relative", transform: showControlPanel ? "rotate(180deg)" : "unset" }} > <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" > </path></svg > |  | ||||||
|       </button> |  | ||||||
|       <div id="control-panel" className={showControlPanel ? "opened" : ""}> |  | ||||||
|  |  | ||||||
|         <div className="control"> |  | ||||||
|           <datalist id="markers"> |  | ||||||
|             <option value="0"></option> |  | ||||||
|             <option value="1"></option> |  | ||||||
|             <option value="2"></option> |  | ||||||
|           </datalist> |  | ||||||
|           <div id="three-slider"> |  | ||||||
|             <label>😬</label> |  | ||||||
|             <input |  | ||||||
|               type="range" |  | ||||||
|               list="markers" |  | ||||||
|               min="0" |  | ||||||
|               max="2" |  | ||||||
|               step="1" |  | ||||||
|               width="16px" |  | ||||||
|               onChange={(evt) => setParams({ ...params, show: Number(evt.target.value) })} |  | ||||||
|             /> |  | ||||||
|             <label>😍</label> |  | ||||||
|           </div> |  | ||||||
|           {showLabel()} |  | ||||||
|         </div> |  | ||||||
|         <div className="control"> |  | ||||||
|           <div className="checkBox"> |  | ||||||
|             <input |  | ||||||
|               type="checkbox" |  | ||||||
|               checked={params.weighting} |  | ||||||
|               onChange={(evt) => setParams({ ...params, weighting: evt.target.checked })} |  | ||||||
|             /> |  | ||||||
|             <label>weighting</label> |  | ||||||
|           </div> |  | ||||||
|  |  | ||||||
|           <div className="checkBox"> |  | ||||||
|             <input |  | ||||||
|               type="checkbox" |  | ||||||
|               checked={params.popularity} |  | ||||||
|               onChange={(evt) => setParams({ ...params, popularity: evt.target.checked })} |  | ||||||
|             /> |  | ||||||
|             <label>popularity</label> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div className="control"> |  | ||||||
|           <label>distance between nodes</label> |  | ||||||
|           <input |  | ||||||
|             type="range" |  | ||||||
|             min="0.01" |  | ||||||
|             max="3.001" |  | ||||||
|             step="0.05" |  | ||||||
|             value={params.distance} |  | ||||||
|             onChange={(evt) => setParams({ ...params, distance: Number(evt.target.value) })} |  | ||||||
|           /> |  | ||||||
|           <span>{params.distance}</span></div> |  | ||||||
|  |  | ||||||
|         <div className="control"> |  | ||||||
|           <label>node size</label> |  | ||||||
|           <input |  | ||||||
|             type="range" |  | ||||||
|             min="500" |  | ||||||
|             max="3000" |  | ||||||
|             value={params.nodeSize} |  | ||||||
|             onChange={(evt) => setParams({ ...params, nodeSize: Number(evt.target.value) })} |  | ||||||
|           /> |  | ||||||
|           <span>{params.nodeSize}</span> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div className="control"> |  | ||||||
|           <label>font size</label> |  | ||||||
|           <input |  | ||||||
|             type="range" |  | ||||||
|             min="4" |  | ||||||
|             max="24" |  | ||||||
|             value={params.fontSize} |  | ||||||
|             onChange={(evt) => setParams({ ...params, fontSize: Number(evt.target.value) })} |  | ||||||
|           /> |  | ||||||
|           <span>{params.fontSize}</span> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div className="control"> |  | ||||||
|           <label>edge width</label> |  | ||||||
|           <input |  | ||||||
|             type="range" |  | ||||||
|             min="1" |  | ||||||
|             max="5" |  | ||||||
|             step="0.1" |  | ||||||
|             value={params.edgeWidth} |  | ||||||
|             onChange={(evt) => setParams({ ...params, edgeWidth: Number(evt.target.value) })} |  | ||||||
|           /> |  | ||||||
|           <span>{params.edgeWidth}</span> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|         <div className="control"> |  | ||||||
|           <label>arrow size</label> |  | ||||||
|           <input |  | ||||||
|             type="range" |  | ||||||
|             min="10" |  | ||||||
|             max="50" |  | ||||||
|             value={params.arrowSize} |  | ||||||
|             onChange={(evt) => setParams({ ...params, arrowSize: Number(evt.target.value) })} |  | ||||||
|           /> |  | ||||||
|           <span>{params.arrowSize}</span> |  | ||||||
|         </div> |  | ||||||
|  |  | ||||||
|       </div> |  | ||||||
|       <button onClick={() => loadImage()}>reload ↻</button> |  | ||||||
|       { |  | ||||||
|         loading ? ( |  | ||||||
|           <span className="loader"></span> |  | ||||||
|         ) : ( |  | ||||||
|           <img src={"data:image/png;base64," + image} width="86%" /> |  | ||||||
|         ) |  | ||||||
|       } |  | ||||||
|     </div > |  | ||||||
|   ); |  | ||||||
| } |  | ||||||
							
								
								
									
										512
									
								
								src/App.css
									
									
									
									
									
								
							
							
						
						
									
										512
									
								
								src/App.css
									
									
									
									
									
								
							| @@ -1,7 +1,3 @@ | |||||||
| * { |  | ||||||
|   border-radius: 16px; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| body { | body { | ||||||
|   background-color: aliceblue; |   background-color: aliceblue; | ||||||
|   position: relative; |   position: relative; | ||||||
| @@ -23,9 +19,122 @@ footer { | |||||||
|   font-size: x-small; |   font-size: x-small; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .fixed-footer { | ||||||
|  |   position: absolute; | ||||||
|  |   bottom: 4px; | ||||||
|  |   left: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | dialog { | ||||||
|  |   border-radius: 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /*=========Network Controls=========*/ | ||||||
|  |  | ||||||
|  | .infobutton { | ||||||
|  |   position: fixed; | ||||||
|  |   right: 8px; | ||||||
|  |   bottom: 8px; | ||||||
|  |   padding: 0.4em; | ||||||
|  |   border-radius: 1em; | ||||||
|  |   background-color: rgba(0, 0, 0, 0.3); | ||||||
|  |   font-size: medium; | ||||||
|  |   margin-bottom: 16px; | ||||||
|  |   margin-right: 16px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .controls { | ||||||
|  |   z-index: 9; | ||||||
|  |   position: absolute; | ||||||
|  |   color: black; | ||||||
|  |   top: 1vh; | ||||||
|  |   right: 0px; | ||||||
|  |   padding: 8px; | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: repeat(2, 1fr); | ||||||
|  |   gap: 8px; | ||||||
|  |  | ||||||
|  |   .control { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: row; | ||||||
|  |     flex-wrap: wrap; | ||||||
|  |     max-width: 240px; | ||||||
|  |     margin: 0px; | ||||||
|  |     background-color: #f0f8ffdd; | ||||||
|  |  | ||||||
|  |     .slider, | ||||||
|  |     span { | ||||||
|  |       padding-left: 4px; | ||||||
|  |       padding-right: 4px; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   #three-slider { | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: row; | ||||||
|  |     margin: auto; | ||||||
|  |     justify-content: center; | ||||||
|  |     align-items: center; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The switch - the box around the slider */ | ||||||
|  | .switch { | ||||||
|  |   position: relative; | ||||||
|  |   width: 48px; | ||||||
|  |   height: 24px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* Hide default HTML checkbox */ | ||||||
|  | .switch input { | ||||||
|  |   opacity: 0; | ||||||
|  |   width: 0; | ||||||
|  |   height: 0; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /* The slider */ | ||||||
|  | .slider { | ||||||
|  |   position: absolute; | ||||||
|  |   cursor: pointer; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   right: 0; | ||||||
|  |   bottom: 0; | ||||||
|  |   background-color: #ccc; | ||||||
|  |   border-radius: 34px; | ||||||
|  |   -webkit-transition: 0.4s; | ||||||
|  |   transition: 0.4s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .slider:before { | ||||||
|  |   position: absolute; | ||||||
|  |   content: ""; | ||||||
|  |   height: 18px; | ||||||
|  |   width: 18px; | ||||||
|  |   left: 3px; | ||||||
|  |   bottom: 3px; | ||||||
|  |   background-color: white; | ||||||
|  |   border-radius: 50%; | ||||||
|  |   -webkit-transition: 0.4s; | ||||||
|  |   transition: 0.4s; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | input:checked + .slider { | ||||||
|  |   background-color: #2196f3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | input:focus + .slider { | ||||||
|  |   box-shadow: 0 0 1px #2196f3; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | input:checked + .slider:before { | ||||||
|  |   -webkit-transform: translateX(24px); | ||||||
|  |   -ms-transform: translateX(24px); | ||||||
|  |   transform: translateX(24px); | ||||||
|  | } | ||||||
|  |  | ||||||
| .grey { | .grey { | ||||||
|   color: #444; |   opacity: 66%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .hint { | .hint { | ||||||
| @@ -39,9 +148,14 @@ footer { | |||||||
|   z-index: -1; |   z-index: -1; | ||||||
| } | } | ||||||
|  |  | ||||||
| input { | input, | ||||||
|  | select { | ||||||
|   padding: 0.2em 16px; |   padding: 0.2em 16px; | ||||||
|   margin-bottom: 0.5em; |   margin-top: 0.25em; | ||||||
|  |   margin-bottom: 0.25em; | ||||||
|  |   border-radius: 1em; | ||||||
|  |   color: black; | ||||||
|  |   background-color: white; | ||||||
| } | } | ||||||
|  |  | ||||||
| h1, | h1, | ||||||
| @@ -66,7 +180,6 @@ h3 { | |||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| .container { | .container { | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-wrap: nowrap; |   flex-wrap: nowrap; | ||||||
| @@ -83,7 +196,13 @@ h3 { | |||||||
| .box { | .box { | ||||||
|   position: relative; |   position: relative; | ||||||
|   flex: 1; |   flex: 1; | ||||||
|  |   border-width: 3px; | ||||||
|  |   border-style: solid; | ||||||
|  |   border-radius: 16px; | ||||||
|  |  | ||||||
|  |   h4 { | ||||||
|  |     margin: 4px; | ||||||
|  |   } | ||||||
|   &.one { |   &.one { | ||||||
|     max-width: min(96%, 768px); |     max-width: min(96%, 768px); | ||||||
|     margin: 4px auto; |     margin: 4px auto; | ||||||
| @@ -91,35 +210,23 @@ h3 { | |||||||
|  |  | ||||||
|   padding: 4px; |   padding: 4px; | ||||||
|   margin: 4px 0.5%; |   margin: 4px 0.5%; | ||||||
|   border-style: solid; |  | ||||||
|   border-color: black; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .reservoir { | .reservoir { | ||||||
|  |   display: flex; | ||||||
|   flex-direction: unset; |   flex-direction: unset; | ||||||
|   flex-wrap: wrap; |   flex-wrap: wrap; | ||||||
|   justify-content: space-around; |   justify-content: space-around; | ||||||
|   width: 100%; |   width: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| .user { |  | ||||||
|   max-width: 240px; |  | ||||||
|   min-width: 100px; |  | ||||||
|   margin: 4px auto; |  | ||||||
|  |  | ||||||
|   .item { |  | ||||||
|     font-weight: bold; |  | ||||||
|     border-style: solid; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| .item { | .item { | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   font-size: small; |   font-size: medium; | ||||||
|   border: 3px dashed black; |   border: 2px solid; | ||||||
|   border-radius: 1.2em; |   border-radius: 1em; | ||||||
|   margin: 8px auto; |   margin: 3px auto; | ||||||
|   padding: 4px 16px; |   padding: 5px 0.8em; | ||||||
| } | } | ||||||
|  |  | ||||||
| .extra-margin { | .extra-margin { | ||||||
| @@ -127,14 +234,17 @@ h3 { | |||||||
|   margin: auto; |   margin: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| button, | button { | ||||||
| .button { |   margin: 4px; | ||||||
|   font-weight: bold; |   font-weight: bold; | ||||||
|   font-size: large; |  | ||||||
|   color: aliceblue; |   color: aliceblue; | ||||||
|   background-color: black; |   background-color: black; | ||||||
|   border-radius: 1.2em; |   border-radius: 1.2em; | ||||||
|   z-index: 1; |   z-index: 1; | ||||||
|  |  | ||||||
|  |   &:hover { | ||||||
|  |     opacity: 80%; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| #control-panel { | #control-panel { | ||||||
| @@ -152,6 +262,7 @@ button, | |||||||
|  |  | ||||||
| .control { | .control { | ||||||
|   display: flex; |   display: flex; | ||||||
|  |   border-radius: 16px; | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
| @@ -168,6 +279,11 @@ button, | |||||||
|   #control-panel { |   #control-panel { | ||||||
|     grid-template-columns: repeat(2, 1fr); |     grid-template-columns: repeat(2, 1fr); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .control { | ||||||
|  |     font-size: 80%; | ||||||
|  |     margin: 0px; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media only screen and (max-width: 768px) { | @media only screen and (max-width: 768px) { | ||||||
| @@ -175,6 +291,10 @@ button, | |||||||
|     grid-template-columns: 1fr; |     grid-template-columns: 1fr; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .networkroute { | ||||||
|  |     display: none; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   .submit_text { |   .submit_text { | ||||||
|     display: none; |     display: none; | ||||||
|   } |   } | ||||||
| @@ -185,27 +305,43 @@ button, | |||||||
|     bottom: 16px; |     bottom: 16px; | ||||||
|     padding: 0.4em; |     padding: 0.4em; | ||||||
|     border-radius: 1em; |     border-radius: 1em; | ||||||
|     background-color: rgba(0, 0, 0, 0.3); |     background-color: #36c8; | ||||||
|     font-size: xx-large; |     font-size: xx-large; | ||||||
|     margin-bottom: 16px; |     margin-bottom: 16px; | ||||||
|     margin-right: 16px; |     margin-right: 16px; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   .wavering { | ||||||
|  |     animation: blink 40s infinite; | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| ::backdrop { | ::backdrop { | ||||||
|   background-image: linear-gradient(45deg, |   background-image: linear-gradient( | ||||||
|       magenta, |     45deg, | ||||||
|       rebeccapurple, |     magenta, | ||||||
|       dodgerblue, |     rebeccapurple, | ||||||
|       green); |     dodgerblue, | ||||||
|  |     green | ||||||
|  |   ); | ||||||
|   opacity: 0.75; |   opacity: 0.75; | ||||||
| } | } | ||||||
|  |  | ||||||
| .tablink { | .tab-button { | ||||||
|   color: white; |   color: black; | ||||||
|   cursor: pointer; |  | ||||||
|   flex: 1; |   flex: 1; | ||||||
|  |   background-color: #bfbfbf; | ||||||
|  |   border: none; | ||||||
|   margin: 4px auto; |   margin: 4px auto; | ||||||
|  |   cursor: pointer; | ||||||
|  |   opacity: 80%; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .tab-button.active { | ||||||
|  |   opacity: unset; | ||||||
|  |   font-weight: bold; | ||||||
|  |   background-color: black; | ||||||
|  |   color: white; | ||||||
| } | } | ||||||
|  |  | ||||||
| .navbar { | .navbar { | ||||||
| @@ -221,7 +357,7 @@ button, | |||||||
|     opacity: 50%; |     opacity: 50%; | ||||||
|  |  | ||||||
|     &:hover { |     &:hover { | ||||||
|       opacity: 75%; |       opacity: 80%; | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
| @@ -241,11 +377,17 @@ button, | |||||||
|   font-size: 150%; |   font-size: 150%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /*======LOGO=======*/ | ||||||
|  |  | ||||||
| .logo { | .logo { | ||||||
|   position: relative; |   position: relative; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   height: 140px; |   height: 140px; | ||||||
|   margin-bottom: 20px; |  | ||||||
|  |   span { | ||||||
|  |     display: block; | ||||||
|  |     margin: 2px; | ||||||
|  |   } | ||||||
|  |  | ||||||
|   img { |   img { | ||||||
|     display: block; |     display: block; | ||||||
| @@ -268,11 +410,216 @@ button, | |||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .avatars { | ||||||
|  |   margin: 16px auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .avatar { | ||||||
|  |   background-color: #f0f8ff88; | ||||||
|  |   font-weight: bold; | ||||||
|  |   font-size: 110%; | ||||||
|  |   padding: 3px 1em; | ||||||
|  |   width: fit-content; | ||||||
|  |   border: 3px solid; | ||||||
|  |   border-radius: 1em; | ||||||
|  |   margin: 4px auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .group-avatar { | ||||||
|  |   background-color: #f0f8ff88; | ||||||
|  |   color: inherit; | ||||||
|  |   font-weight: bold; | ||||||
|  |   font-size: 90%; | ||||||
|  |   padding: 3px 1em; | ||||||
|  |   width: fit-content; | ||||||
|  |   border: 3px solid; | ||||||
|  |   border-radius: 1em; | ||||||
|  |   margin: 4px auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .user-info { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: 8em 12em; | ||||||
|  |   gap: 2px 16px; | ||||||
|  |  | ||||||
|  |   div { | ||||||
|  |     text-align: left; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /*=======CONTEXT MENU=======*/ | ||||||
|  | .context-menu { | ||||||
|  |   z-index: 3; | ||||||
|  |   min-width: 8em; | ||||||
|  |   position: absolute; | ||||||
|  |   background: aliceblue; | ||||||
|  |   box-shadow: 4px 4px black; | ||||||
|  |   color: black; | ||||||
|  |   border: 3px solid black; | ||||||
|  |   border-radius: 16px; | ||||||
|  |   padding: 0; | ||||||
|  |   margin: 0; | ||||||
|  |   list-style: none; | ||||||
|  |  | ||||||
|  |   li { | ||||||
|  |     padding: 4px 0.5em; | ||||||
|  |     border-bottom: 2px solid #0008; | ||||||
|  |     border-radius: 0; | ||||||
|  |     cursor: pointer; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   li:last-child { | ||||||
|  |     border-bottom: none; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .networkroute { | ||||||
|  |   z-index: 3; | ||||||
|  |   position: absolute; | ||||||
|  |   top: 24px; | ||||||
|  |   left: 48px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /*========TEAM PANEL========*/ | ||||||
|  | .team-panel { | ||||||
|  |   max-width: 800px; | ||||||
|  |   padding: 1em; | ||||||
|  |   border: 3px solid black; | ||||||
|  |   box-shadow: 8px 8px black; | ||||||
|  |   margin: 1em; | ||||||
|  |  | ||||||
|  |   input { | ||||||
|  |     max-width: 300px; | ||||||
|  |   } | ||||||
|  |   select { | ||||||
|  |     max-width: 335px; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .team-player { | ||||||
|  |   color: black; | ||||||
|  |   background-color: #36c4; | ||||||
|  |   border: 1px solid black; | ||||||
|  |   border-radius: 1.5em; | ||||||
|  |   margin: 4px; | ||||||
|  |   padding: 0.2em 0.5em; | ||||||
|  |  | ||||||
|  |   &:hover { | ||||||
|  |     background-color: #36c8; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.new-player { | ||||||
|  |     background-color: #3838; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   &.disable-player { | ||||||
|  |     background-color: #e338; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .new-player-inputs { | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   margin: auto; | ||||||
|  |  | ||||||
|  |   div { | ||||||
|  |     display: grid; | ||||||
|  |     grid-template-columns: 20ch auto; | ||||||
|  |  | ||||||
|  |     @media only screen and (max-width: 768px) { | ||||||
|  |       grid-template-columns: auto; | ||||||
|  |       place-items: center; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     label { | ||||||
|  |       text-align: left; | ||||||
|  |       width: 20ch; | ||||||
|  |       margin: auto 1em; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     input, | ||||||
|  |     select { | ||||||
|  |       width: 90%; | ||||||
|  |       margin: 4px 0; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .mmp { | ||||||
|  |   background-color: lightskyblue; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .fmp { | ||||||
|  |   background-color: salmon; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @keyframes blink { | ||||||
|  |   0% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   13% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   15% { | ||||||
|  |     background-color: #f00a; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   17% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   38% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   40% { | ||||||
|  |     background-color: #ff0a; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   42% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   63% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   65% { | ||||||
|  |     background-color: #248f24aa; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   67% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   88% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   90% { | ||||||
|  |     background-color: #4700b3aa; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   92% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   100% { | ||||||
|  |     background-color: #8888; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /*======SPINNER=======*/ | ||||||
|  |  | ||||||
| .loader { | .loader { | ||||||
|   display: block; |   display: block; | ||||||
|  |   border-radius: 16px; | ||||||
|   position: relative; |   position: relative; | ||||||
|   height: 12px; |   height: 12px; | ||||||
|   width: 96%; |   width: 96%; | ||||||
|  |   margin: auto; | ||||||
|   border: 4px solid black; |   border: 4px solid black; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
| } | } | ||||||
| @@ -300,3 +647,88 @@ button, | |||||||
|     transform: translateX(0%); |     transform: translateX(0%); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .calendar-container { | ||||||
|  |   position: relative; | ||||||
|  |   margin: 20px auto; | ||||||
|  |   font-size: small; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .month-navigation { | ||||||
|  |   display: flex; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: space-between; | ||||||
|  |   padding: 8px; | ||||||
|  |   border-top: 2px solid grey; | ||||||
|  |   border-bottom: 2px solid grey; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .month-navigation button { | ||||||
|  |   cursor: pointer; | ||||||
|  |   padding: 4px 8px; | ||||||
|  |   border: none; | ||||||
|  |   color: black; | ||||||
|  |   background-color: transparent; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .month-navigation span { | ||||||
|  |   font-weight: bold; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .calendar { | ||||||
|  |   display: grid; | ||||||
|  |   grid-template-columns: repeat(7, 1fr); | ||||||
|  |   gap: 8px; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .day { | ||||||
|  |   padding: 2px; | ||||||
|  |   border: 1px solid grey; | ||||||
|  |   cursor: pointer; | ||||||
|  |   display: flex; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .selected-day { | ||||||
|  |   border: 4px solid grey; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .weekday { | ||||||
|  |   border-bottom: 3px solid black; | ||||||
|  |   margin: 0 1em; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .day-circle { | ||||||
|  |   text-align: center; | ||||||
|  |   border-radius: 1.5em; | ||||||
|  |   width: 1.5em; | ||||||
|  |   height: 1.5em; | ||||||
|  |   padding: 0; | ||||||
|  |   margin: auto; | ||||||
|  |   border: 2px solid transparent; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .today { | ||||||
|  |   border-radius: 1.6em; | ||||||
|  |   border: 4px solid red; | ||||||
|  |   text-align: center; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .has-event { | ||||||
|  |   border-radius: 1.5em; | ||||||
|  |   background-color: lightskyblue; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .active-player { | ||||||
|  |   border-radius: 1.5em; | ||||||
|  |   border: 4px solid rebeccapurple; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .events { | ||||||
|  |   font-size: large; | ||||||
|  |   padding: 20px; | ||||||
|  |   ul > li { | ||||||
|  |     padding: 0; | ||||||
|  |     margin: 0; | ||||||
|  |     list-style-type: none; | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										55
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										55
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -1,4 +1,3 @@ | |||||||
| import Analysis from "./Analysis"; |  | ||||||
| import "./App.css"; | import "./App.css"; | ||||||
| import Footer from "./Footer"; | import Footer from "./Footer"; | ||||||
| import Header from "./Header"; | import Header from "./Header"; | ||||||
| @@ -6,26 +5,46 @@ import Rankings from "./Rankings"; | |||||||
| import { BrowserRouter, Routes, Route } from "react-router"; | import { BrowserRouter, Routes, Route } from "react-router"; | ||||||
| import { SessionProvider } from "./Session"; | import { SessionProvider } from "./Session"; | ||||||
| import { GraphComponent } from "./Network"; | import { GraphComponent } from "./Network"; | ||||||
|  | import MVPChart from "./MVPChart"; | ||||||
|  | import { SetPassword } from "./SetPassword"; | ||||||
|  | import { ThemeProvider } from "./ThemeProvider"; | ||||||
|  | import TeamPanel from "./TeamPanel"; | ||||||
|  |  | ||||||
|  | const Maintenance = () => { | ||||||
|  |   return ( | ||||||
|  |     <div style={{ textAlign: "center", padding: "20px" }}> | ||||||
|  |       <h2>We are under maintenance.</h2> | ||||||
|  |       <p>Please check back later. Thank you for your patience.</p> | ||||||
|  |       <span style={{ fontSize: "xx-large" }}>🚧</span> | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
| function App() { | function App() { | ||||||
|   return ( |   return ( | ||||||
|     <BrowserRouter> |     <ThemeProvider> | ||||||
|       <Header /> |       <BrowserRouter> | ||||||
|       <Routes> |         <Routes> | ||||||
|         <Route index element={<Rankings />} /> |           <Route path="/password" element={<SetPassword />} /> | ||||||
|         <Route path="/network" element={ |           <Route | ||||||
|           <SessionProvider> |             path="/*" | ||||||
|             <GraphComponent /> |             element={ | ||||||
|           </SessionProvider> |               <SessionProvider> | ||||||
|         } /> |                 <Header /> | ||||||
|         <Route path="/analysis" element={ |                 <Routes> | ||||||
|           <SessionProvider> |                   <Route index element={<Rankings />} /> | ||||||
|             <Analysis /> |                   <Route path="network" element={<GraphComponent />} /> | ||||||
|           </SessionProvider> |                   <Route path="mvp" element={<MVPChart />} /> | ||||||
|         } /> |                   <Route path="changepassword" element={<SetPassword />} /> | ||||||
|       </Routes> |                   <Route path="team" element={<TeamPanel />} /> | ||||||
|       <Footer /> |                 </Routes> | ||||||
|     </BrowserRouter> |                 <Footer /> | ||||||
|  |               </SessionProvider> | ||||||
|  |             } | ||||||
|  |           /> | ||||||
|  |         </Routes> | ||||||
|  |       </BrowserRouter> | ||||||
|  |     </ThemeProvider> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| export default App; | export default App; | ||||||
|   | |||||||
							
								
								
									
										221
									
								
								src/Avatar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										221
									
								
								src/Avatar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,221 @@ | |||||||
|  | import { createRef, MouseEventHandler, useEffect, useState } from "react"; | ||||||
|  | import { TeamState, useSession } from "./Session"; | ||||||
|  | import { User } from "./api"; | ||||||
|  | import { useTheme } from "./ThemeProvider"; | ||||||
|  | import { colourTheme, darkTheme, normalTheme, rainbowTheme } from "./themes"; | ||||||
|  | import { useNavigate } from "react-router"; | ||||||
|  | import { Team } from "./types"; | ||||||
|  |  | ||||||
|  | interface ContextMenuItem { | ||||||
|  |   label: string; | ||||||
|  |   onClick: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const UserInfo = (user: User, teams: TeamState | undefined) => { | ||||||
|  |   return ( | ||||||
|  |     <div className="user-info"> | ||||||
|  |       <div> | ||||||
|  |         <b>username: </b> | ||||||
|  |       </div> | ||||||
|  |       <div>{user?.username}</div> | ||||||
|  |       <div> | ||||||
|  |         <b>display name: </b> | ||||||
|  |       </div> | ||||||
|  |       <div>{user?.display_name}</div> | ||||||
|  |       <div> | ||||||
|  |         <b>gender: </b> | ||||||
|  |       </div> | ||||||
|  |       <div>{user?.gender?.toUpperCase() || "-"}</div> | ||||||
|  |       <div> | ||||||
|  |         <b>number: </b> | ||||||
|  |       </div> | ||||||
|  |       <div>{user?.number || "-"}</div> | ||||||
|  |       <div> | ||||||
|  |         <b>email: </b> | ||||||
|  |       </div> | ||||||
|  |       <div>{user?.email || "-"}</div> | ||||||
|  |       {teams && ( | ||||||
|  |         <> | ||||||
|  |           <div> | ||||||
|  |             <b>teams: </b> | ||||||
|  |           </div> | ||||||
|  |           <ul | ||||||
|  |             style={{ | ||||||
|  |               margin: 0, | ||||||
|  |               padding: 0, | ||||||
|  |               textAlign: "left", | ||||||
|  |             }} | ||||||
|  |           > | ||||||
|  |             {teams.teams.map((team, index) => ( | ||||||
|  |               <li> | ||||||
|  |                 {<b>{team.name}</b>} ( | ||||||
|  |                 {team.location || team.country || "location unknown"}) | ||||||
|  |               </li> | ||||||
|  |             ))} | ||||||
|  |           </ul> | ||||||
|  |         </> | ||||||
|  |       )} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default function Avatar() { | ||||||
|  |   const { user, teams, setTeams, onLogout } = useSession(); | ||||||
|  |   const { theme, setTheme } = useTheme(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const [contextMenu, setContextMenu] = useState<{ | ||||||
|  |     open: boolean; | ||||||
|  |     allowOpen: boolean; | ||||||
|  |     mouseX: number; | ||||||
|  |     mouseY: number; | ||||||
|  |   }>({ open: false, allowOpen: true, mouseX: 0, mouseY: 0 }); | ||||||
|  |   const contextMenuRef = createRef<HTMLUListElement>(); | ||||||
|  |   const avatarRef = createRef<HTMLDivElement>(); | ||||||
|  |  | ||||||
|  |   const contextMenuItems: ContextMenuItem[] = [ | ||||||
|  |     { label: "view Profile", onClick: handleViewProfile }, | ||||||
|  |     { label: "change password", onClick: () => navigate("/changepassword") }, | ||||||
|  |     { | ||||||
|  |       label: "change theme", | ||||||
|  |       onClick: () => { | ||||||
|  |         switch (theme) { | ||||||
|  |           case darkTheme: | ||||||
|  |             setTheme(colourTheme); | ||||||
|  |             break; | ||||||
|  |           case colourTheme: | ||||||
|  |             setTheme(rainbowTheme); | ||||||
|  |             break; | ||||||
|  |           case rainbowTheme: | ||||||
|  |             setTheme(normalTheme); | ||||||
|  |             break; | ||||||
|  |           case normalTheme: | ||||||
|  |             setTheme(darkTheme); | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |     { label: "logout", onClick: onLogout }, | ||||||
|  |   ]; | ||||||
|  |  | ||||||
|  |   const handleMenuClick: MouseEventHandler<HTMLDivElement> = (event) => { | ||||||
|  |     if (!contextMenu.allowOpen) return; | ||||||
|  |     event.preventDefault(); | ||||||
|  |     setContextMenu({ | ||||||
|  |       open: !contextMenu.open, | ||||||
|  |       allowOpen: contextMenu.allowOpen, | ||||||
|  |       mouseX: event.clientX + 4, | ||||||
|  |       mouseY: event.clientY + 2, | ||||||
|  |     }); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (contextMenu.open) { | ||||||
|  |       document.addEventListener("click", handleCloseContextMenuOutside); | ||||||
|  |     } | ||||||
|  |     return () => { | ||||||
|  |       document.removeEventListener("click", handleCloseContextMenuOutside); | ||||||
|  |     }; | ||||||
|  |   }, [contextMenu.open]); | ||||||
|  |  | ||||||
|  |   const handleMenuClose = () => { | ||||||
|  |     setContextMenu({ ...contextMenu, open: false }); | ||||||
|  |     setContextMenu((prevContextMenu) => ({ | ||||||
|  |       ...prevContextMenu, | ||||||
|  |       allowOpen: false, | ||||||
|  |     })); | ||||||
|  |     setTimeout(() => { | ||||||
|  |       setContextMenu((prevContextMenu) => ({ | ||||||
|  |         ...prevContextMenu, | ||||||
|  |         allowOpen: true, | ||||||
|  |       })); | ||||||
|  |     }, 100); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const handleCloseContextMenuOutside: (event: MouseEvent) => void = (ev) => { | ||||||
|  |     if ( | ||||||
|  |       !( | ||||||
|  |         contextMenuRef.current?.contains(ev.target as Node) || | ||||||
|  |         avatarRef.current?.contains(ev.target as Node) | ||||||
|  |       ) | ||||||
|  |     ) | ||||||
|  |       handleMenuClose(); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   const [dialog, setDialog] = useState(<></>); | ||||||
|  |   const dialogRef = createRef<HTMLDialogElement>(); | ||||||
|  |  | ||||||
|  |   function handleViewProfile() { | ||||||
|  |     if (user && teams) { | ||||||
|  |       dialogRef.current?.showModal(); | ||||||
|  |       setDialog(UserInfo(user, teams)); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <div className="avatars" style={{ display: user ? "block" : "none" }}> | ||||||
|  |         <div | ||||||
|  |           className="avatar" | ||||||
|  |           onContextMenu={handleMenuClick} | ||||||
|  |           onClick={(event) => { | ||||||
|  |             if (contextMenu.open && event.target === avatarRef.current) { | ||||||
|  |               handleMenuClose(); | ||||||
|  |             } else { | ||||||
|  |               handleMenuClick(event); | ||||||
|  |             } | ||||||
|  |           }} | ||||||
|  |           ref={avatarRef} | ||||||
|  |         > | ||||||
|  |           👤 {user?.username} | ||||||
|  |         </div> | ||||||
|  |         {teams && teams?.teams.length > 1 && ( | ||||||
|  |           <select | ||||||
|  |             className="group-avatar" | ||||||
|  |             value={teams.activeTeam} | ||||||
|  |             onChange={(e) => | ||||||
|  |               setTeams({ ...teams, activeTeam: Number(e.target.value) }) | ||||||
|  |             } | ||||||
|  |           > | ||||||
|  |             {teams.teams.map((team) => ( | ||||||
|  |               <option key={team.id} value={team.id}> | ||||||
|  |                 👥 {team.name} | ||||||
|  |               </option> | ||||||
|  |             ))} | ||||||
|  |           </select> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       {contextMenu.open && ( | ||||||
|  |         <ul | ||||||
|  |           className="context-menu" | ||||||
|  |           ref={contextMenuRef} | ||||||
|  |           style={{ | ||||||
|  |             top: contextMenu.mouseY, | ||||||
|  |             left: contextMenu.mouseX, | ||||||
|  |           }} | ||||||
|  |         > | ||||||
|  |           {contextMenuItems.map((item, index) => ( | ||||||
|  |             <li | ||||||
|  |               key={index} | ||||||
|  |               onClick={() => { | ||||||
|  |                 item.onClick(); | ||||||
|  |                 handleMenuClose(); | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               {item.label} | ||||||
|  |             </li> | ||||||
|  |           ))} | ||||||
|  |         </ul> | ||||||
|  |       )} | ||||||
|  |       <dialog | ||||||
|  |         id="AvatarDialog" | ||||||
|  |         ref={dialogRef} | ||||||
|  |         onClick={(event) => { | ||||||
|  |           event.stopPropagation(); | ||||||
|  |           event.currentTarget.close(); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         {dialog} | ||||||
|  |       </dialog> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										174
									
								
								src/Calendar.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										174
									
								
								src/Calendar.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,174 @@ | |||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { apiAuth } from "./api"; | ||||||
|  | import { useSession } from "./Session"; | ||||||
|  |  | ||||||
|  | interface Datum { | ||||||
|  |   [id: number]: string; | ||||||
|  | } | ||||||
|  | interface Events { | ||||||
|  |   [key: string]: Datum; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const Calendar = ({ playerId }: { playerId: number }) => { | ||||||
|  |   const [selectedDate, setSelectedDate] = useState(new Date()); | ||||||
|  |   const [events, setEvents] = useState<Events>(); | ||||||
|  |   const { teams, players } = useSession(); | ||||||
|  |  | ||||||
|  |   async function loadSubmissionDates() { | ||||||
|  |     if (teams?.activeTeam) { | ||||||
|  |       const data = await apiAuth(`analysis/times/${teams?.activeTeam}`, null); | ||||||
|  |       if (data.detail) { | ||||||
|  |         console.log(data.detail); | ||||||
|  |       } else { | ||||||
|  |         setEvents(data as Events); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     loadSubmissionDates(); | ||||||
|  |   }, [players]); | ||||||
|  |  | ||||||
|  |   const getEventsForDay = (date: Date) => { | ||||||
|  |     return events && events[date.toISOString().split("T")[0]]; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Handle day click | ||||||
|  |   const handleDayClick = (date: Date) => { | ||||||
|  |     setSelectedDate(date); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Navigate to previous month | ||||||
|  |   const handlePrevMonth = () => { | ||||||
|  |     const date = new Date(selectedDate); | ||||||
|  |     date.setMonth(date.getMonth() - 1); | ||||||
|  |     setSelectedDate(date); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Navigate to next month | ||||||
|  |   const handleNextMonth = () => { | ||||||
|  |     const date = new Date(selectedDate); | ||||||
|  |     date.setMonth(date.getMonth() + 1); | ||||||
|  |     setSelectedDate(date); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Render month navigation | ||||||
|  |   const renderMonthNavigation = () => { | ||||||
|  |     return ( | ||||||
|  |       <div className="month-navigation"> | ||||||
|  |         <button onClick={handlePrevMonth}><</button> | ||||||
|  |         <span> | ||||||
|  |           <button onClick={() => setSelectedDate(new Date())}>📅</button> | ||||||
|  |           {selectedDate.toLocaleString("default", { | ||||||
|  |             month: "long", | ||||||
|  |             year: "numeric", | ||||||
|  |           })} | ||||||
|  |         </span> | ||||||
|  |         <button onClick={handleNextMonth}>></button> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Render the calendar | ||||||
|  |   const renderCalendar = () => { | ||||||
|  |     const firstDayOfMonth = new Date( | ||||||
|  |       selectedDate.getFullYear(), | ||||||
|  |       selectedDate.getMonth(), | ||||||
|  |       0 | ||||||
|  |     ).getDay(); | ||||||
|  |     const lastDateOfMonth = new Date( | ||||||
|  |       selectedDate.getFullYear(), | ||||||
|  |       selectedDate.getMonth() + 1, | ||||||
|  |       0 | ||||||
|  |     ).getDate(); | ||||||
|  |  | ||||||
|  |     let days: JSX.Element[] = []; | ||||||
|  |     let day = 1; | ||||||
|  |  | ||||||
|  |     for (let i = 0; i < 7; i++) { | ||||||
|  |       const date = new Date(0); | ||||||
|  |       date.setDate(i + 5); | ||||||
|  |       days.push( | ||||||
|  |         <div key={"weekday_" + i} className="weekday"> | ||||||
|  |           {date.toLocaleString("default", { | ||||||
|  |             weekday: "narrow", | ||||||
|  |           })} | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Add empty cells for the first week | ||||||
|  |     for (let i = 0; i < firstDayOfMonth; i++) { | ||||||
|  |       days.push(<div key={"prev" + i} className="empty"></div>); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // Render each day of the month | ||||||
|  |     while (day <= lastDateOfMonth) { | ||||||
|  |       const date = new Date(selectedDate); | ||||||
|  |       date.setDate(day); | ||||||
|  |       const todaysEvents = getEventsForDay(date); | ||||||
|  |  | ||||||
|  |       days.push( | ||||||
|  |         <div | ||||||
|  |           key={date.getDate()} | ||||||
|  |           className={ | ||||||
|  |             "day" + | ||||||
|  |             (date.toDateString() === selectedDate.toDateString() | ||||||
|  |               ? " selected-day" | ||||||
|  |               : "") | ||||||
|  |           } | ||||||
|  |           onClick={() => handleDayClick(date)} | ||||||
|  |         > | ||||||
|  |           <div | ||||||
|  |             className={ | ||||||
|  |               "day-circle" + | ||||||
|  |               (date.toDateString() === new Date().toDateString() | ||||||
|  |                 ? " today" | ||||||
|  |                 : "") + | ||||||
|  |               (todaysEvents ? " has-event" : "") + | ||||||
|  |               (todaysEvents && playerId in todaysEvents ? " active-player" : "") | ||||||
|  |             } | ||||||
|  |           > | ||||||
|  |             {day} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       ); | ||||||
|  |       day++; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return <div className="calendar">{days}</div>; | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   // Render events for the selected day | ||||||
|  |   const renderEvents = () => { | ||||||
|  |     const eventsForDay = getEventsForDay(selectedDate); | ||||||
|  |     return ( | ||||||
|  |       <div className="events"> | ||||||
|  |         {eventsForDay && ( | ||||||
|  |           <ul> | ||||||
|  |             {Object.entries(eventsForDay).map(([id, sub]) => { | ||||||
|  |               const name = players?.find((p) => p.id === Number(id)); | ||||||
|  |               return ( | ||||||
|  |                 <li key={id}> | ||||||
|  |                   {name !== undefined ? name.display_name : ""}:{" "} | ||||||
|  |                   <span style={{ letterSpacing: 8 }}>{sub}</span> | ||||||
|  |                 </li> | ||||||
|  |               ); | ||||||
|  |             })} | ||||||
|  |           </ul> | ||||||
|  |         )} | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <div className="calendar-container"> | ||||||
|  |       <h2>Latest Submissions</h2> | ||||||
|  |       {renderMonthNavigation()} | ||||||
|  |       {renderCalendar()} | ||||||
|  |       {renderEvents()} | ||||||
|  |     </div> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default Calendar; | ||||||
| @@ -1,21 +1,42 @@ | |||||||
|  | import { useLocation } from "react-router"; | ||||||
| import { Link } from "react-router"; | import { Link } from "react-router"; | ||||||
|  | import { useSession } from "./Session"; | ||||||
|  |  | ||||||
| export default function Footer() { | export default function Footer() { | ||||||
|         return <footer> |   const location = useLocation(); | ||||||
|                 <div className="navbar"> |   const { user, teams } = useSession(); | ||||||
|                         <Link to="/" ><span>Form</span></Link> |   return ( | ||||||
|                         <span>|</span> |     <footer className={location.pathname === "/network" ? "fixed-footer" : ""}> | ||||||
|                         <Link to="/network" ><span>Trainer Analysis</span></Link> |       {(user?.scopes.split(" ").includes("analysis") || | ||||||
|                 </div> |         teams?.activeTeam === 42) && ( | ||||||
|                 <p className="grey extra-margin"> |         <div className="navbar"> | ||||||
|                         something not working? |           <Link to="/"> | ||||||
|                         <br /> |             <span>Form</span> | ||||||
|                         message <a href="https://t.me/x0124816">me</a>. |           </Link> | ||||||
|                         <br /> |           <span>|</span> | ||||||
|                         or fix it here:{" "} |           <Link to="/network"> | ||||||
|                         <a href="https://git.0124816.xyz/julius/cutt" key="gitea"> |             <span>Sociogram</span> | ||||||
|                                 <img src="gitea.svg" alt="gitea" height="16" /> |           </Link> | ||||||
|                         </a> |           <span>|</span> | ||||||
|                 </p> |           <Link to="/mvp"> | ||||||
|         </footer> |             <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() { | export default function Header() { | ||||||
|   return <div className="logo"> |   const location = useLocation(); | ||||||
|     <a href={"/"}> |   return ( | ||||||
|       <img alt="logo" height="66%" src="logo.svg" /> |     <div className={location.pathname === "/network" ? "networkroute" : ""}> | ||||||
|       <h3 className="centered">cutt</h3> |       <div className="logo"> | ||||||
|     </a> |         <Link to="/"> | ||||||
|     <span className="grey">cool ultimate team tool</span> |           <img alt="logo" height="66%" src="logo.svg" /> | ||||||
|   </div> |           <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> | ||||||
|  | ); | ||||||
							
								
								
									
										153
									
								
								src/Login.tsx
									
									
									
									
									
								
							
							
						
						
									
										153
									
								
								src/Login.tsx
									
									
									
									
									
								
							| @@ -1,6 +1,8 @@ | |||||||
| import { FormEvent, useContext, useState } from "react"; | import { useEffect, useState } from "react"; | ||||||
| import { useNavigate } from "react-router"; | import { currentUser, login, User } from "./api"; | ||||||
| import { currentUser, login, LoginRequest, User } from "./api"; | import Header from "./Header"; | ||||||
|  | import { useLocation, useNavigate } from "react-router"; | ||||||
|  | import { Eye, EyeSlash } from "./Icons"; | ||||||
|  |  | ||||||
| export interface LoginProps { | export interface LoginProps { | ||||||
|   onLogin: (user: User) => void; |   onLogin: (user: User) => void; | ||||||
| @@ -9,78 +11,115 @@ export interface LoginProps { | |||||||
| export const Login = ({ onLogin }: LoginProps) => { | export const Login = ({ onLogin }: LoginProps) => { | ||||||
|   const [username, setUsername] = useState(""); |   const [username, setUsername] = useState(""); | ||||||
|   const [password, setPassword] = useState(""); |   const [password, setPassword] = useState(""); | ||||||
|   const [error, setError] = useState<unknown>(null); |   const [error, setError] = useState(""); | ||||||
|   const [loading, setLoading] = useState(false); |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [visible, setVisible] = useState(false); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   const location = useLocation(); | ||||||
|  |  | ||||||
|   async function doLogin() { |   async function doLogin() { | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     setError(null); |     setError(""); | ||||||
|     const timeout = new Promise((r) => setTimeout(r, 1500)); |     const timeout = new Promise((r) => setTimeout(r, 1000)); | ||||||
|     let user: User; |     let user: User; | ||||||
|     try { |     try { | ||||||
|       login({ username, password }); |       await login({ username, password }); | ||||||
|       user = await currentUser(); |       user = await currentUser(); | ||||||
|     } catch (e) { |     } catch (e) { | ||||||
|       await timeout; |       await timeout; | ||||||
|       setError(e); |       setError("failed"); | ||||||
|       setLoading(false); |       setLoading(false); | ||||||
|       return |       return; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     await timeout; |     await timeout; | ||||||
|     onLogin(user); |     onLogin(user); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   function handleClick() { |  | ||||||
|     doLogin(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   function handleSubmit(e: React.FormEvent) { |   function handleSubmit(e: React.FormEvent) { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     doLogin(); |     doLogin(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (location.state) { | ||||||
|  |       const queryUsername = location.state.username; | ||||||
|  |       const queryPassword = location.state.password; | ||||||
|  |       if (queryUsername) setUsername(queryUsername); | ||||||
|  |       if (queryPassword) setPassword(queryPassword); | ||||||
|  |       navigate(location.pathname, { replace: true }); | ||||||
|  |     } | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <form onSubmit={handleSubmit}> |     <> | ||||||
|       <div> |       <Header /> | ||||||
|         <input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} /> |       <form onSubmit={handleSubmit}> | ||||||
|       </div> |         <div | ||||||
|       <div> |           style={{ | ||||||
|         <input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} /> |             position: "relative", | ||||||
|       </div> |             display: "flex", | ||||||
|       <button type="submit" value="login" style={{ fontSize: "small" }} onClick={handleClick} >login</button> |             alignItems: "end", | ||||||
|       {loading && <span className="loader" />} |           }} | ||||||
|     </form> |         > | ||||||
|   ) |           <div | ||||||
| } |             style={{ | ||||||
|  |               width: "100%", | ||||||
| /* |               marginRight: "8px", | ||||||
| export default function Login(props: { onLogin: (user: User) => void }) { |               display: "flex", | ||||||
|   const { onLogin } = props; |               justifyContent: "center", | ||||||
|   const [username, setUsername] = useState(""); |               flexDirection: "column", | ||||||
|   const [password, setPassword] = useState(""); |             }} | ||||||
|  |           > | ||||||
|   async function handleLogin(e: FormEvent) { |             <div> | ||||||
|     e.preventDefault() |               <input | ||||||
|     const timeout = new Promise((r) => setTimeout(r, 1500)); |                 type="text" | ||||||
|     let user: User; |                 id="username" | ||||||
|     try { |                 name="username" | ||||||
|       login({ username, password }) |                 placeholder="username" | ||||||
|       user = await currentUser() |                 required | ||||||
|     } catch (e) { await timeout; return } |                 value={username} | ||||||
|     await timeout; |                 onChange={(evt) => { | ||||||
|     onLogin(user); |                   setError(""); | ||||||
|   } |                   setUsername(evt.target.value); | ||||||
|  |                 }} | ||||||
|   return <div> |               /> | ||||||
|     <form onSubmit={handleLogin}> |             </div> | ||||||
|       <div> |             <div> | ||||||
|         <input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} /> |               <input | ||||||
|       </div> |                 type={visible ? "text" : "password"} | ||||||
|       <div> |                 id="password" | ||||||
|         <input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} /> |                 name="password" | ||||||
|       </div> |                 placeholder="password" | ||||||
|       <input className="button" type="submit" value="login" onSubmit={handleLogin} /> |                 minLength={8} | ||||||
|     </form> |                 value={password} | ||||||
|   </div> |                 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> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|   | |||||||
							
								
								
									
										63
									
								
								src/MVPChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/MVPChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | 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 [mixed, setMixed] = useState(false); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   useEffect(() => { | ||||||
|  |     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||||
|  |       teams?.activeTeam === 42 || | ||||||
|  |       navigate("/", { replace: true }); | ||||||
|  |   }, [user]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (teams) { | ||||||
|  |       const activeTeam = teams.teams.find( | ||||||
|  |         (team) => team.id == teams.activeTeam | ||||||
|  |       ); | ||||||
|  |       activeTeam && setMixed(activeTeam.mixed); | ||||||
|  |     } | ||||||
|  |   }, [teams]); | ||||||
|  |  | ||||||
|  |   async function loadData() { | ||||||
|  |     setLoading(true); | ||||||
|  |     if (teams) { | ||||||
|  |       await apiAuth(`analysis/mvp/${teams?.activeTeam}?mixed=${mixed}`, null) | ||||||
|  |         .then((data) => { | ||||||
|  |           if (data.detail) { | ||||||
|  |             setError(data.detail); | ||||||
|  |             return initialData; | ||||||
|  |           } else { | ||||||
|  |             setError(""); | ||||||
|  |             return data as Promise<PlayerRanking[][]>; | ||||||
|  |           } | ||||||
|  |         }) | ||||||
|  |         .then((data) => { | ||||||
|  |           setData(data.map((_data) => _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 data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export default MVPChart; | ||||||
							
								
								
									
										323
									
								
								src/Network.tsx
									
									
									
									
									
								
							
							
						
						
									
										323
									
								
								src/Network.tsx
									
									
									
									
									
								
							| @@ -1,46 +1,319 @@ | |||||||
| import { useEffect, useRef, useState } from "react"; | import { ReactNode, useEffect, useRef, useState } from "react"; | ||||||
| import { apiAuth } from "./api"; | import { apiAuth } from "./api"; | ||||||
| import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, useSelection } from "reagraph"; | 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 { | interface NetworkData { | ||||||
|   nodes: GraphNode[], |   nodes: GraphNode[]; | ||||||
|   edges: GraphEdge[], |   edges: GraphEdge[]; | ||||||
|  | } | ||||||
|  | interface CustomSelectionProps extends SelectionProps { | ||||||
|  |   ignore: (GraphEdge | undefined)[]; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | const useCustomSelection = (props: CustomSelectionProps): SelectionResult => { | ||||||
|  |   var result = useSelection(props); | ||||||
|  |   result.actives = result.actives.filter( | ||||||
|  |     (s) => !props.ignore.map((edge) => edge?.id).includes(s) | ||||||
|  |   ); | ||||||
|  |   const ignored_nodes = props.ignore.map((edge) => | ||||||
|  |     edge && | ||||||
|  |     result.selections?.includes(edge.source) && | ||||||
|  |     !result.selections?.includes(edge.target) | ||||||
|  |       ? edge.target | ||||||
|  |       : "" | ||||||
|  |   ); | ||||||
|  |   result.actives = result.actives.filter((s) => !ignored_nodes.includes(s)); | ||||||
|  |   return result; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const GraphComponent = () => { | export const GraphComponent = () => { | ||||||
|   const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData); |   let initialData = { nodes: [], edges: [] } as NetworkData; | ||||||
|  |   const [data, setData] = useState(initialData); | ||||||
|   const [loading, setLoading] = useState(true); |   const [loading, setLoading] = useState(true); | ||||||
|  |   const [error, setError] = useState(""); | ||||||
|  |   const [threed, setThreed] = useState(false); | ||||||
|  |   const [likes, setLikes] = useState(2); | ||||||
|  |   const [popularity, setPopularity] = useState(false); | ||||||
|  |   const [mutuality, setMutuality] = useState(false); | ||||||
|  |   const { user, teams } = useSession(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   useEffect(() => { | ||||||
|  |     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||||
|  |       teams?.activeTeam === 42 || | ||||||
|  |       navigate("/", { replace: true }); | ||||||
|  |   }, [user]); | ||||||
|  |  | ||||||
|   async function loadData() { |   async function loadData() { | ||||||
|     setLoading(true); |     setLoading(true); | ||||||
|     await apiAuth("analysis/graph_json", null) |     if (teams) { | ||||||
|       .then(json => json as Promise<NetworkData>).then(json => { setData(json) }) |       await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null) | ||||||
|     setLoading(false); |         .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() }, []) |   useEffect(() => { | ||||||
|  |     loadData(); | ||||||
|  |   }, [teams]); | ||||||
|  |  | ||||||
|   const graphRef = useRef<GraphCanvasRef | null>(null); |   const graphRef = useRef<GraphCanvasRef | null>(null); | ||||||
|  |  | ||||||
|   const { selections, actives, onNodeClick, onCanvasClick } = useSelection({ |   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, |     ref: graphRef, | ||||||
|     nodes: data.nodes, |     nodes: data.nodes, | ||||||
|     edges: data.edges, |     edges: data.edges.filter((edge) => edge.data.relation === likes), | ||||||
|     pathSelectionType: 'out' |     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 ( |   return ( | ||||||
|     <GraphCanvas |     <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}> | ||||||
|       draggable |       <div className="controls"> | ||||||
|       ref={graphRef} |         <div className="control" onClick={handleMutuality}> | ||||||
|       nodes={data.nodes} |           <div className="switch"> | ||||||
|       edges={data.edges} |             <input type="checkbox" checked={mutuality} onChange={() => {}} /> | ||||||
|       selections={selections} |             <span className="slider round"></span> | ||||||
|       actives={actives} |           </div> | ||||||
|       onCanvasClick={onCanvasClick} |           <span>mutuality</span> | ||||||
|       onNodeClick={onNodeClick} |         </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", | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }; | ||||||
							
								
								
									
										103
									
								
								src/RaceChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										103
									
								
								src/RaceChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,103 @@ | |||||||
|  | import { FC, useEffect, useState } from "react"; | ||||||
|  | import { PlayerRanking } from "./types"; | ||||||
|  | import { useSession } from "./Session"; | ||||||
|  |  | ||||||
|  | interface RaceChartProps { | ||||||
|  |   playerRanks: 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> = ({ playerRanks, std }) => { | ||||||
|  |   const [width, setWidth] = useState(determineNiceWidth(window.innerWidth)); | ||||||
|  |   const height = (playerRanks.length + 1) * 40; | ||||||
|  |   const { players } = useSession(); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const handleResize = () => { | ||||||
|  |       setWidth(determineNiceWidth(window.innerWidth)); | ||||||
|  |     }; | ||||||
|  |     window.addEventListener("resize", handleResize); | ||||||
|  |     return () => { | ||||||
|  |       window.removeEventListener("resize", handleResize); | ||||||
|  |     }; | ||||||
|  |   }, []); | ||||||
|  |   const padding = 24; | ||||||
|  |   const gap = 8; | ||||||
|  |   const maxValue = Math.max(...playerRanks.map((player) => player.rank)) + 1; | ||||||
|  |   const barHeight = (height - 2 * padding) / playerRanks.length; | ||||||
|  |   const fontSize = Math.min(barHeight - 1.5 * gap, width / 22); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <svg width={width} height={height} id="RaceChartSVG"> | ||||||
|  |       {playerRanks.map((playerRank, index) => ( | ||||||
|  |         <rect | ||||||
|  |           key={String(index)} | ||||||
|  |           x={4} | ||||||
|  |           y={index * barHeight + padding} | ||||||
|  |           width={(1 - playerRank.rank / maxValue) * width} | ||||||
|  |           height={barHeight - gap} // subtract 2 for some spacing between bars | ||||||
|  |           fill="#36c" | ||||||
|  |           stroke="aliceblue" | ||||||
|  |           strokeWidth={4} | ||||||
|  |           paintOrder={"stroke fill"} | ||||||
|  |         /> | ||||||
|  |       ))} | ||||||
|  |  | ||||||
|  |       {playerRanks.map((playerRank, index) => { | ||||||
|  |         const player = players!.find((p) => p.id === playerRank.p_id); | ||||||
|  |         return ( | ||||||
|  |           <g key={"group" + index}> | ||||||
|  |             <text | ||||||
|  |               key={index + "_name"} | ||||||
|  |               x={8} | ||||||
|  |               y={index * barHeight + barHeight / 2 + padding + gap / 2} | ||||||
|  |               width={(1 - playerRank.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?.display_name}`} | ||||||
|  |             </text> | ||||||
|  |             <text | ||||||
|  |               key={index + "_value"} | ||||||
|  |               x={ | ||||||
|  |                 8 + | ||||||
|  |                 (4 + | ||||||
|  |                   Math.max(...players!.map((p, _) => p.display_name.length))) * | ||||||
|  |                   fontSize * | ||||||
|  |                   0.66 | ||||||
|  |               } | ||||||
|  |               y={index * barHeight + barHeight / 2 + padding + gap / 2} | ||||||
|  |               width={(1 - playerRank.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(playerRank.rank).padStart(5)} ± ${playerRank.std}   N = ${playerRank.n}`} | ||||||
|  |             </text> | ||||||
|  |           </g> | ||||||
|  |         ); | ||||||
|  |       })} | ||||||
|  |     </svg> | ||||||
|  |   ); | ||||||
|  | }; | ||||||
|  | export default RaceChart; | ||||||
							
								
								
									
										680
									
								
								src/Rankings.tsx
									
									
									
									
									
								
							
							
						
						
									
										680
									
								
								src/Rankings.tsx
									
									
									
									
									
								
							| @@ -1,181 +1,189 @@ | |||||||
| import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; | import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react"; | ||||||
| import { ReactSortable, ReactSortableProps } from "react-sortablejs"; | import { ReactSortable, ReactSortableProps } from "react-sortablejs"; | ||||||
| import api, { baseUrl } from "./api"; | import { apiAuth, User } from "./api"; | ||||||
|  | import { TeamState, useSession } from "./Session"; | ||||||
| interface Player { | import { Chemistry, MVPRanking, PlayerType } from "./types"; | ||||||
|   id: number; | import TabController from "./TabController"; | ||||||
|   name: string; |  | ||||||
|   number: string | null; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| type PlayerListProps = Partial<ReactSortableProps<any>> & { | type PlayerListProps = Partial<ReactSortableProps<any>> & { | ||||||
|   orderedList?: boolean; |   orderedList?: boolean; | ||||||
|  |   gender?: boolean; | ||||||
| }; | }; | ||||||
|  |  | ||||||
| function PlayerList(props: PlayerListProps) { | function PlayerList(props: PlayerListProps) { | ||||||
|  |   const fmps = props.list?.filter((item) => item.gender === "fmp").length; | ||||||
|   return ( |   return ( | ||||||
|     <ReactSortable {...props} animation={200}> |     <ReactSortable | ||||||
|       {props.list?.map((item, index) => ( |       {...props} | ||||||
|         <div key={item.id} className="item"> |       animation={200} | ||||||
|           {props.orderedList ? index + 1 + ". " + item.name : item.name} |       swapThreshold={0.2} | ||||||
|         </div> |       style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }} | ||||||
|       ))} |     > | ||||||
|  |       {props.list && | ||||||
|  |         props.list.map((item, index) => ( | ||||||
|  |           <div | ||||||
|  |             key={item.id} | ||||||
|  |             className={"item " + (props.gender ? item.gender : "")} | ||||||
|  |           > | ||||||
|  |             {props.orderedList | ||||||
|  |               ? props.gender | ||||||
|  |                 ? index + | ||||||
|  |                   1 - | ||||||
|  |                   (item.gender !== "fmp" ? fmps! : 0) + | ||||||
|  |                   ". " + | ||||||
|  |                   item.display_name | ||||||
|  |                 : index + 1 + ". " + item.display_name | ||||||
|  |               : item.display_name} | ||||||
|  |           </div> | ||||||
|  |         ))} | ||||||
|     </ReactSortable> |     </ReactSortable> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| interface SelectUserProps { | const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||||
|   user: Player[]; |  | ||||||
|   setUser: Dispatch<SetStateAction<Player[]>>; |  | ||||||
|   players: Player[]; |  | ||||||
|   setPlayers: Dispatch<SetStateAction<Player[]>>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export function SelectUser({ |  | ||||||
|   user, |  | ||||||
|   setUser, |  | ||||||
|   players, |  | ||||||
|   setPlayers, |  | ||||||
| }: SelectUserProps) { |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <button {...props} style={{ padding: "4px 16px" }}> | ||||||
|       <div className="box user"> |       🗃️ restore previous | ||||||
|         {user.length < 1 ? ( |     </button> | ||||||
|           <> |  | ||||||
|             <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> |  | ||||||
|       )} |  | ||||||
|     </> |  | ||||||
|   ); |   ); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | 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 { | interface PlayerInfoProps { | ||||||
|   user: Player[]; |   user: User; | ||||||
|   players: Player[]; |   teams: TeamState; | ||||||
|  |   players: User[]; | ||||||
| } | } | ||||||
|  |  | ||||||
| export function Chemistry({ user, players }: PlayerInfoProps) { | function ChemistryDnD({ user, teams, players }: PlayerInfoProps) { | ||||||
|   const index = players.indexOf(user[0]); |   var otherPlayers = players.filter((player) => player.id !== user.id); | ||||||
|   var otherPlayers = players.slice(); |   const [playersLeft, setPlayersLeft] = useState<User[]>([]); | ||||||
|   otherPlayers.splice(index, 1); |   const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers); | ||||||
|   const [playersLeft, setPlayersLeft] = useState<Player[]>([]); |   const [playersRight, setPlayersRight] = useState<User[]>([]); | ||||||
|   const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers); |   const [loading, setLoading] = useState(false); | ||||||
|   const [playersRight, setPlayersRight] = useState<Player[]>([]); |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     handleGet(); | ||||||
|  |   }, [players]); | ||||||
|  |  | ||||||
|   const [dialog, setDialog] = useState("dialog"); |   const [dialog, setDialog] = useState("dialog"); | ||||||
|  |   const dialogRef = useRef<HTMLDialogElement>(null); | ||||||
|  |  | ||||||
|   async function handleSubmit() { |   async function handleSubmit() { | ||||||
|     const dialog = document.querySelector("dialog[id='ChemistryDialog']"); |     if (dialogRef.current) dialogRef.current.showModal(); | ||||||
|     (dialog as HTMLDialogElement).showModal(); |     setDialog("sending..."); | ||||||
|     if (user.length < 1) { |     let left = playersLeft.map(({ id }) => id); | ||||||
|       setDialog("who are you?"); |     let middle = playersMiddle.map(({ id }) => id); | ||||||
|  |     let right = playersRight.map(({ id }) => id); | ||||||
|  |     const data = { | ||||||
|  |       user: user.id, | ||||||
|  |       hate: left, | ||||||
|  |       undecided: middle, | ||||||
|  |       love: right, | ||||||
|  |       team: teams.activeTeam, | ||||||
|  |     }; | ||||||
|  |     const response = await apiAuth("chemistry", data, "PUT"); | ||||||
|  |     setDialog(response || "try sending again"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function handleGet() { | ||||||
|  |     setLoading(true); | ||||||
|  |     const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET"); | ||||||
|  |     if (data.detail) { | ||||||
|  |       console.log(data.detail); | ||||||
|  |       setPlayersRight([]); | ||||||
|  |       setPlayersMiddle(otherPlayers); | ||||||
|  |       setPlayersLeft([]); | ||||||
|     } else { |     } else { | ||||||
|       setDialog("sending..."); |       const chemistry = data as Chemistry; | ||||||
|       let _user = user.map(({ name }) => name)[0]; |       setPlayersLeft(filterSort(otherPlayers, chemistry.hate)); | ||||||
|       let left = playersLeft.map(({ name }) => name); |       setPlayersMiddle( | ||||||
|       let middle = playersMiddle.map(({ name }) => name); |         otherPlayers.filter( | ||||||
|       let right = playersRight.map(({ name }) => name); |           (player) => | ||||||
|       const data = { user: _user, hate: left, undecided: middle, love: right }; |             !chemistry.hate.includes(player.id) && | ||||||
|       const response = await api("chemistry", data); |             !chemistry.love.includes(player.id) | ||||||
|       response.ok ? setDialog("success!") : setDialog("try sending again"); |         ) | ||||||
|  |       ); | ||||||
|  |       setPlayersRight(filterSort(otherPlayers, chemistry.love)); | ||||||
|     } |     } | ||||||
|  |     setLoading(false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <div className="container"> |       <HeaderControl | ||||||
|         <div className="box three"> |         onLoad={handleGet} | ||||||
|           <h2>😬</h2> |         onClear={() => { | ||||||
|           {playersLeft.length < 1 && ( |           setPlayersRight([]); | ||||||
|             <span className="grey hint"> |           setPlayersMiddle(otherPlayers); | ||||||
|               drag people here that you'd rather not play with |           setPlayersLeft([]); | ||||||
|             </span> |         }} | ||||||
|           )} |       /> | ||||||
|           <PlayerList |       {loading ? ( | ||||||
|             list={playersLeft} |         <span className="loader" style={{ width: 300 }} /> | ||||||
|             setList={setPlayersLeft} |       ) : ( | ||||||
|             group={"shared"} |         <div className="container"> | ||||||
|             className="dragbox" |           <div className="box three"> | ||||||
|           /> |             <h2>😬</h2> | ||||||
|  |             {playersLeft.length < 1 && ( | ||||||
|  |               <span className="grey hint"> | ||||||
|  |                 drag people here that you'd rather not play with | ||||||
|  |               </span> | ||||||
|  |             )} | ||||||
|  |             <PlayerList | ||||||
|  |               list={playersLeft} | ||||||
|  |               setList={setPlayersLeft} | ||||||
|  |               group={"shared"} | ||||||
|  |               className="dragbox" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |           <div className="box three"> | ||||||
|  |             <h2>🤷</h2> | ||||||
|  |             <PlayerList | ||||||
|  |               list={playersMiddle} | ||||||
|  |               setList={setPlayersMiddle} | ||||||
|  |               group={"shared"} | ||||||
|  |               className="middle dragbox" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |           <div className="box three"> | ||||||
|  |             <h2>😍</h2> | ||||||
|  |             {playersRight.length < 1 && ( | ||||||
|  |               <span className="grey hint"> | ||||||
|  |                 drag people here that you love playing with from best to ... ok | ||||||
|  |               </span> | ||||||
|  |             )} | ||||||
|  |             <PlayerList | ||||||
|  |               list={playersRight} | ||||||
|  |               setList={setPlayersRight} | ||||||
|  |               group={"shared"} | ||||||
|  |               className="dragbox" | ||||||
|  |               orderedList | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div className="box three"> |       )} | ||||||
|           <h2>🤷</h2> |  | ||||||
|           <PlayerList |  | ||||||
|             list={playersMiddle} |  | ||||||
|             setList={setPlayersMiddle} |  | ||||||
|             group={"shared"} |  | ||||||
|             className="middle dragbox" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|         <div className="box three"> |  | ||||||
|           <h2>😍</h2> |  | ||||||
|           {playersRight.length < 1 && ( |  | ||||||
|             <span className="grey hint"> |  | ||||||
|               drag people here that you love playing with from best to ... ok |  | ||||||
|             </span> |  | ||||||
|           )} |  | ||||||
|           <PlayerList |  | ||||||
|             list={playersRight} |  | ||||||
|             setList={setPlayersRight} |  | ||||||
|             group={"shared"} |  | ||||||
|             className="dragbox" |  | ||||||
|             orderedList |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
|  |  | ||||||
|       <button className="submit" onClick={() => handleSubmit()}> |       <button className="submit wavering" onClick={() => handleSubmit()}> | ||||||
|         💾 <span className="submit_text">submit</span> |         💾 <span className="submit_text">submit</span> | ||||||
|       </button> |       </button> | ||||||
|       <dialog |       <dialog | ||||||
|  |         ref={dialogRef} | ||||||
|         id="ChemistryDialog" |         id="ChemistryDialog" | ||||||
|         onClick={(event) => { |         onClick={(event) => { | ||||||
|           event.currentTarget.close(); |           event.currentTarget.close(); | ||||||
| @@ -187,74 +195,254 @@ export function Chemistry({ user, players }: PlayerInfoProps) { | |||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function MVP({ user, players }: PlayerInfoProps) { | function TypeDnD({ user, teams, players }: PlayerInfoProps) { | ||||||
|   const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players); |   const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); | ||||||
|   const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]); |   const [handlers, setHandlers] = useState<User[]>([]); | ||||||
|  |   const [combis, setCombis] = useState<User[]>([]); | ||||||
|  |   const [cutters, setCutters] = useState<User[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     handleGet(); | ||||||
|  |   }, [players]); | ||||||
|  |  | ||||||
|   const [dialog, setDialog] = useState("dialog"); |   const [dialog, setDialog] = useState("dialog"); | ||||||
|  |   const dialogRef = useRef<HTMLDialogElement>(null); | ||||||
|  |  | ||||||
|   async function handleSubmit() { |   async function handleSubmit() { | ||||||
|     const dialog = document.querySelector("dialog[id='MVPDialog']"); |     if (dialogRef.current) dialogRef.current.showModal(); | ||||||
|     (dialog as HTMLDialogElement).showModal(); |     setDialog("sending..."); | ||||||
|     if (user.length < 1) { |     let handlerlist = handlers.map(({ id }) => id); | ||||||
|       setDialog("who are you?"); |     let combilist = combis.map(({ id }) => id); | ||||||
|  |     let cutterlist = cutters.map(({ id }) => id); | ||||||
|  |     const data = { | ||||||
|  |       user: user.id, | ||||||
|  |       handlers: handlerlist, | ||||||
|  |       combis: combilist, | ||||||
|  |       cutters: cutterlist, | ||||||
|  |       team: teams.activeTeam, | ||||||
|  |     }; | ||||||
|  |     const response = await apiAuth("playertype", data, "PUT"); | ||||||
|  |     setDialog(response || "try sending again"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function handleGet() { | ||||||
|  |     setLoading(true); | ||||||
|  |     const data = await apiAuth(`playertype/${teams.activeTeam}`, null, "GET"); | ||||||
|  |     if (data.detail) { | ||||||
|  |       console.log(data.detail); | ||||||
|  |       setAvailablePlayers(players); | ||||||
|  |       setHandlers([]); | ||||||
|  |       setCombis([]); | ||||||
|  |       setCutters([]); | ||||||
|     } else { |     } else { | ||||||
|       setDialog("sending..."); |       const playertype = data as PlayerType; | ||||||
|       let _user = user.map(({ name }) => name)[0]; |       setAvailablePlayers( | ||||||
|       let mvps = rankedPlayers.map(({ name }) => name); |         players.filter( | ||||||
|       const data = { user: _user, mvps: mvps }; |           (player) => | ||||||
|       const response = await api("mvps", data); |             !playertype.handlers.includes(player.id) && | ||||||
|       response.ok ? setDialog("success!") : setDialog("try sending again"); |             !playertype.combis.includes(player.id) && | ||||||
|  |             !playertype.cutters.includes(player.id) | ||||||
|  |         ) | ||||||
|  |       ); | ||||||
|  |       setHandlers(filterSort(players, playertype.handlers)); | ||||||
|  |       setCombis(filterSort(players, playertype.combis)); | ||||||
|  |       setCutters(filterSort(players, playertype.cutters)); | ||||||
|     } |     } | ||||||
|  |     setLoading(false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|  |       <HeaderControl | ||||||
|  |         onLoad={handleGet} | ||||||
|  |         onClear={() => { | ||||||
|  |           setAvailablePlayers(players); | ||||||
|  |           setHandlers([]); | ||||||
|  |           setCombis([]); | ||||||
|  |           setCutters([]); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|       <div className="container"> |       <div className="container"> | ||||||
|         <div className="box two"> |         <div className="box one"> | ||||||
|           <h2>🥏🏃</h2> |  | ||||||
|           {availablePlayers.length < 1 && ( |  | ||||||
|             <span className="grey hint">all sorted 👍</span> |  | ||||||
|           )} |  | ||||||
|           <PlayerList |           <PlayerList | ||||||
|             list={availablePlayers} |             list={availablePlayers} | ||||||
|             setList={setAvailablePlayers} |             setList={setAvailablePlayers} | ||||||
|             group={{ |             group={"type-shared"} | ||||||
|               name: "mvp-shared", |             className="dragbox reservoir" | ||||||
|               pull: function (to) { |  | ||||||
|                 return to.el.classList.contains("putclone") ? "clone" : true; |  | ||||||
|               }, |  | ||||||
|             }} |  | ||||||
|             className="dragbox" |  | ||||||
|           /> |  | ||||||
|         </div> |  | ||||||
|         <div className="box two"> |  | ||||||
|           <h1>🏆</h1> |  | ||||||
|           {rankedPlayers.length < 1 && ( |  | ||||||
|             <span className="grey hint"> |  | ||||||
|               carefully place as many of the <i>Most Valuable Players</i>{" "} |  | ||||||
|               (according to your humble opinion) in this box |  | ||||||
|             </span> |  | ||||||
|           )} |  | ||||||
|           <PlayerList |  | ||||||
|             list={rankedPlayers} |  | ||||||
|             setList={setRankedPlayers} |  | ||||||
|             group={{ |  | ||||||
|               name: "mvp-shared", |  | ||||||
|               pull: function (to) { |  | ||||||
|                 return to.el.classList.contains("putclone") ? "clone" : true; |  | ||||||
|               }, |  | ||||||
|             }} |  | ||||||
|             className="dragbox" |  | ||||||
|             orderedList |  | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  |       <div className="container"> | ||||||
|       <button className="submit" onClick={() => handleSubmit()}> |         <div className="box three"> | ||||||
|  |           <h4>handler</h4> | ||||||
|  |           {handlers.length < 1 && ( | ||||||
|  |             <span className="grey hint"> | ||||||
|  |               drag people here that you like to see as handlers | ||||||
|  |             </span> | ||||||
|  |           )} | ||||||
|  |           <PlayerList | ||||||
|  |             list={handlers} | ||||||
|  |             setList={setHandlers} | ||||||
|  |             group={"type-shared"} | ||||||
|  |             className="dragbox" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <div className="box three"> | ||||||
|  |           <h4>combi</h4> | ||||||
|  |           {combis.length < 1 && ( | ||||||
|  |             <span className="grey hint"> | ||||||
|  |               drag people here that switch between handling and cutting | ||||||
|  |             </span> | ||||||
|  |           )} | ||||||
|  |           <PlayerList | ||||||
|  |             list={combis} | ||||||
|  |             setList={setCombis} | ||||||
|  |             group={"type-shared"} | ||||||
|  |             className="middle dragbox" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <div className="box three"> | ||||||
|  |           <h4>cutter</h4> | ||||||
|  |           {cutters.length < 1 && ( | ||||||
|  |             <span className="grey hint"> | ||||||
|  |               drag people here that you think are the best cutters | ||||||
|  |             </span> | ||||||
|  |           )} | ||||||
|  |           <PlayerList | ||||||
|  |             list={cutters} | ||||||
|  |             setList={setCutters} | ||||||
|  |             group={"type-shared"} | ||||||
|  |             className="dragbox" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |       <button className="submit wavering" onClick={() => handleSubmit()}> | ||||||
|         💾 <span className="submit_text">submit</span> |         💾 <span className="submit_text">submit</span> | ||||||
|       </button> |       </button> | ||||||
|       <dialog |       <dialog | ||||||
|  |         ref={dialogRef} | ||||||
|  |         id="PlayerTypeDialog" | ||||||
|  |         onClick={(event) => { | ||||||
|  |           event.currentTarget.close(); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         {dialog} | ||||||
|  |       </dialog> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function MVPDnD({ user, teams, players }: PlayerInfoProps) { | ||||||
|  |   const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); | ||||||
|  |   const [rankedPlayers, setRankedPlayers] = useState<User[]>([]); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |   const [mixed, setMixed] = useState(false); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam); | ||||||
|  |     activeTeam && setMixed(activeTeam.mixed); | ||||||
|  |     handleGet(); | ||||||
|  |   }, [players]); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     handleGet(); | ||||||
|  |     // setMixedList(rankedPlayers); | ||||||
|  |   }, [mixed]); | ||||||
|  |  | ||||||
|  |   const [dialog, setDialog] = useState("dialog"); | ||||||
|  |   const dialogRef = useRef<HTMLDialogElement>(null); | ||||||
|  |  | ||||||
|  |   async function handleSubmit() { | ||||||
|  |     if (dialogRef.current) dialogRef.current.showModal(); | ||||||
|  |     setDialog("sending..."); | ||||||
|  |     let mvps = rankedPlayers.map(({ id }) => id); | ||||||
|  |     const data = { user: user.id, mvps: mvps, team: teams.activeTeam }; | ||||||
|  |     const response = await apiAuth("mvps", data, "PUT"); | ||||||
|  |     response ? setDialog(response) : setDialog("try sending again"); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const setMixedList = (newList: User[]) => | ||||||
|  |     mixed | ||||||
|  |       ? setRankedPlayers( | ||||||
|  |           newList.sort((a, b) => | ||||||
|  |             a.gender && b.gender ? a.gender.localeCompare(b.gender) : -1 | ||||||
|  |           ) | ||||||
|  |         ) | ||||||
|  |       : setRankedPlayers(newList); | ||||||
|  |  | ||||||
|  |   async function handleGet() { | ||||||
|  |     setLoading(true); | ||||||
|  |     const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET"); | ||||||
|  |     if (data.detail) { | ||||||
|  |       console.log(data.detail); | ||||||
|  |       setAvailablePlayers(players); | ||||||
|  |       setRankedPlayers([]); | ||||||
|  |     } else { | ||||||
|  |       const mvps = data as MVPRanking; | ||||||
|  |       setMixedList(filterSort(players, mvps.mvps)); | ||||||
|  |       setAvailablePlayers( | ||||||
|  |         players.filter((user) => !mvps.mvps.includes(user.id)) | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     setLoading(false); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <HeaderControl | ||||||
|  |         onLoad={handleGet} | ||||||
|  |         onClear={() => { | ||||||
|  |           setAvailablePlayers(players); | ||||||
|  |           setRankedPlayers([]); | ||||||
|  |         }} | ||||||
|  |       /> | ||||||
|  |       {loading ? ( | ||||||
|  |         <span className="loader" style={{ width: 300 }} /> | ||||||
|  |       ) : ( | ||||||
|  |         <div className="container"> | ||||||
|  |           <div className="box two"> | ||||||
|  |             <h2>🥏🏃</h2> | ||||||
|  |             {availablePlayers.length < 1 && ( | ||||||
|  |               <span className="grey hint">all sorted 👍</span> | ||||||
|  |             )} | ||||||
|  |             <PlayerList | ||||||
|  |               list={availablePlayers} | ||||||
|  |               setList={setAvailablePlayers} | ||||||
|  |               group={{ | ||||||
|  |                 name: "mvp-shared", | ||||||
|  |               }} | ||||||
|  |               className="dragbox" | ||||||
|  |               gender={mixed} | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |           <div className="box two"> | ||||||
|  |             <h1>🏆</h1> | ||||||
|  |             {rankedPlayers.length < 1 && ( | ||||||
|  |               <span className="grey hint"> | ||||||
|  |                 carefully place as many of the <i>Most Valuable Players</i>{" "} | ||||||
|  |                 (according to your humble opinion) in this box | ||||||
|  |               </span> | ||||||
|  |             )} | ||||||
|  |             <PlayerList | ||||||
|  |               list={rankedPlayers} | ||||||
|  |               setList={setMixedList} | ||||||
|  |               group={{ | ||||||
|  |                 name: "mvp-shared", | ||||||
|  |               }} | ||||||
|  |               className="dragbox" | ||||||
|  |               orderedList | ||||||
|  |               gender={mixed} | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       )} | ||||||
|  |  | ||||||
|  |       <button className="submit wavering" onClick={() => handleSubmit()}> | ||||||
|  |         💾 <span className="submit_text">submit</span> | ||||||
|  |       </button> | ||||||
|  |       <dialog | ||||||
|  |         ref={dialogRef} | ||||||
|         id="MVPDialog" |         id="MVPDialog" | ||||||
|         onClick={(event) => { |         onClick={(event) => { | ||||||
|           event.currentTarget.close(); |           event.currentTarget.close(); | ||||||
| @@ -266,84 +454,46 @@ export function MVP({ user, players }: PlayerInfoProps) { | |||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | interface HeaderControlProps { | ||||||
|  |   onLoad: () => void; | ||||||
|  |   onClear: () => void; | ||||||
|  | } | ||||||
|  | function HeaderControl({ onLoad, onClear }: HeaderControlProps) { | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <div> | ||||||
|  |         <ClearButton onClick={onClear} /> | ||||||
|  |         <LoadButton onClick={onLoad} /> | ||||||
|  |       </div> | ||||||
|  |       <div> | ||||||
|  |         <span className="grey"> | ||||||
|  |           assign as many or as few players as you want and don't forget to{" "} | ||||||
|  |           <b>submit</b> 💾 when you're done :) | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
| export default function Rankings() { | export default function Rankings() { | ||||||
|   const [user, setUser] = useState<Player[]>([]); |   const { user, teams, players } = useSession(); | ||||||
|   const [players, setPlayers] = useState<Player[]>([]); |  | ||||||
|   const [openTab, setOpenTab] = useState("Chemistry"); |  | ||||||
|  |  | ||||||
|   async function loadPlayers() { |   const tabs = [ | ||||||
|     const response = await fetch(`${baseUrl}api/player/list`, { |     { id: "Chemistry", label: "🧪 Chemistry" }, | ||||||
|       method: "GET", |     { id: "Type", label: "🃏 Type" }, | ||||||
|     }); |     { id: "MVP", label: "🏆 MVP" }, | ||||||
|     const data = await response.json(); |   ]; | ||||||
|     setPlayers(data as Player[]); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   useMemo(() => { |  | ||||||
|     loadPlayers(); |  | ||||||
|   }, []); |  | ||||||
|  |  | ||||||
|   useEffect(() => { |  | ||||||
|     user.length === 1 && openPage(openTab, "aliceblue"); |  | ||||||
|   }, [user]); |  | ||||||
|  |  | ||||||
|   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); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <SelectUser {...{ user, setUser, players, setPlayers }} /> |       {user && teams && players ? ( | ||||||
|       {user.length === 1 && ( |         <TabController tabs={tabs}> | ||||||
|         <> |           <ChemistryDnD {...{ user, teams, players }} /> | ||||||
|           <div className="container navbar"> |           <TypeDnD {...{ user, teams, players }} /> | ||||||
|             <button |           <MVPDnD {...{ user, teams, players }} /> | ||||||
|               className="tablink" |         </TabController> | ||||||
|               id="ChemistryButton" |       ) : ( | ||||||
|               onClick={() => openPage("Chemistry", "aliceblue")} |         <span className="loader" /> | ||||||
|             > |  | ||||||
|               🧪 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> |  | ||||||
|         </> |  | ||||||
|       )} |       )} | ||||||
|     </> |     </> | ||||||
|   ); |   ); | ||||||
|   | |||||||
							
								
								
									
										101
									
								
								src/Session.tsx
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								src/Session.tsx
									
									
									
									
									
								
							| @@ -1,36 +1,119 @@ | |||||||
| import { createContext, ReactNode, useContext, useLayoutEffect, useState } from "react"; | import { | ||||||
| import { currentUser, User } from "./api"; |   createContext, | ||||||
|  |   ReactNode, | ||||||
|  |   useContext, | ||||||
|  |   useEffect, | ||||||
|  |   useState, | ||||||
|  | } from "react"; | ||||||
|  | import { apiAuth, currentUser, loadPlayers, logout, User } from "./api"; | ||||||
| import { Login } from "./Login"; | import { Login } from "./Login"; | ||||||
|  | import Header from "./Header"; | ||||||
|  | import { Team } from "./types"; | ||||||
|  |  | ||||||
| export interface SessionProviderProps { | export interface SessionProviderProps { | ||||||
|   children: ReactNode; |   children: ReactNode; | ||||||
| } | } | ||||||
|  |  | ||||||
| const sessionContext = createContext<User | null>(null); | export interface TeamState { | ||||||
|  |   teams: Team[]; | ||||||
|  |   activeTeam: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface Session { | ||||||
|  |   user: User | null; | ||||||
|  |   teams: TeamState | null; | ||||||
|  |   setTeams: (teams: TeamState) => void; | ||||||
|  |   players: User[] | null; | ||||||
|  |   reloadPlayers: () => void; | ||||||
|  |   onLogout: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const sessionContext = createContext<Session>({ | ||||||
|  |   user: null, | ||||||
|  |   teams: null, | ||||||
|  |   setTeams: () => {}, | ||||||
|  |   players: null, | ||||||
|  |   reloadPlayers: () => {}, | ||||||
|  |   onLogout: () => {}, | ||||||
|  | }); | ||||||
|  |  | ||||||
| export function SessionProvider(props: SessionProviderProps) { | export function SessionProvider(props: SessionProviderProps) { | ||||||
|   const { children } = props; |   const { children } = props; | ||||||
|  |  | ||||||
|   const [user, setUser] = useState<User | null>(null); |   const [user, setUser] = useState<User | null>(null); | ||||||
|  |   const [teams, setTeams] = useState<TeamState | null>(null); | ||||||
|  |   const [players, setPlayers] = useState<User[] | null>(null); | ||||||
|   const [err, setErr] = useState<unknown>(null); |   const [err, setErr] = useState<unknown>(null); | ||||||
|  |   const [loading, setLoading] = useState(false); | ||||||
|  |  | ||||||
|   function loadUser() { |   function loadUser() { | ||||||
|  |     setLoading(true); | ||||||
|     currentUser() |     currentUser() | ||||||
|       .then((user) => { setUser(user); setErr(null); }) |       .then((user) => { | ||||||
|       .catch((err) => { setUser(null); setErr(err); }); |         setUser(user); | ||||||
|  |         setErr(null); | ||||||
|  |       }) | ||||||
|  |       .catch((err) => { | ||||||
|  |         setUser(null); | ||||||
|  |         setErr(err); | ||||||
|  |       }) | ||||||
|  |       .finally(() => setLoading(false)); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   useLayoutEffect(() => { loadUser(); }, [err]); |   async function loadTeam() { | ||||||
|  |     const teams: Team[] = await apiAuth("player/me/teams", null, "GET"); | ||||||
|  |     if (teams) setTeams({ teams: teams, activeTeam: teams[0].id }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function reloadPlayers() { | ||||||
|  |     teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     loadUser(); | ||||||
|  |   }, []); | ||||||
|  |   useEffect(() => { | ||||||
|  |     loadTeam(); | ||||||
|  |   }, [user]); | ||||||
|  |   useEffect(() => { | ||||||
|  |     reloadPlayers(); | ||||||
|  |   }, [teams]); | ||||||
|  |  | ||||||
|   function onLogin(user: User) { |   function onLogin(user: User) { | ||||||
|     setUser(user); |     setUser(user); | ||||||
|     setErr(null); |     setErr(null); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   async function onLogout() { | ||||||
|  |     try { | ||||||
|  |       logout(); | ||||||
|  |       setUser(null); | ||||||
|  |       setErr({ message: "Logged out successfully" }); | ||||||
|  |       console.log("logged out."); | ||||||
|  |     } catch (e) { | ||||||
|  |       console.error(e); | ||||||
|  |       setErr(e); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   let content: ReactNode; |   let content: ReactNode; | ||||||
|   if (!err && !user) content = <span className="loader" />; |   if (loading || (!err && !user)) | ||||||
|   else if (err) content = <Login onLogin={onLogin} />; |     content = ( | ||||||
|   else content = <sessionContext.Provider value={user}>{children}</sessionContext.Provider>; |       <> | ||||||
|  |         <Header /> | ||||||
|  |         <span className="loader" /> | ||||||
|  |       </> | ||||||
|  |     ); | ||||||
|  |   else if (err) { | ||||||
|  |     content = <Login onLogin={onLogin} />; | ||||||
|  |   } else | ||||||
|  |     content = ( | ||||||
|  |       <sessionContext.Provider | ||||||
|  |         value={{ user, teams, setTeams, players, reloadPlayers, onLogout }} | ||||||
|  |       > | ||||||
|  |         {children} | ||||||
|  |       </sessionContext.Provider> | ||||||
|  |     ); | ||||||
|  |  | ||||||
|   return content; |   return content; | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										355
									
								
								src/SetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										355
									
								
								src/SetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,355 @@ | |||||||
|  | import { jwtDecode, JwtPayload } from "jwt-decode"; | ||||||
|  | import { ReactNode, useEffect, useState } from "react"; | ||||||
|  | import { apiAuth, baseUrl, Gender, 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>gender</label> | ||||||
|  |             <select | ||||||
|  |               name="gender" | ||||||
|  |               required | ||||||
|  |               value={player.gender} | ||||||
|  |               onChange={(e) => { | ||||||
|  |                 setPlayer({ ...player, gender: e.target.value as Gender }); | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               <option value={undefined}></option> | ||||||
|  |               <option value="fmp">FMP</option> | ||||||
|  |               <option value="mmp">MMP</option> | ||||||
|  |             </select> | ||||||
|  |           </div> | ||||||
|  |           <div> | ||||||
|  |             <label>number (optional)</label> | ||||||
|  |             <input | ||||||
|  |               type="text" | ||||||
|  |               value={player.number || ""} | ||||||
|  |               onChange={(e) => { | ||||||
|  |                 setPlayer({ ...player, number: e.target.value }); | ||||||
|  |               }} | ||||||
|  |             /> | ||||||
|  |           </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> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										224
									
								
								src/TeamPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										224
									
								
								src/TeamPanel.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,224 @@ | |||||||
|  | import { FormEvent, useEffect, useState } from "react"; | ||||||
|  | import { apiAuth, Gender, User } from "./api"; | ||||||
|  | import { useSession } from "./Session"; | ||||||
|  | import { ErrorState } from "./types"; | ||||||
|  | import { useNavigate } from "react-router"; | ||||||
|  | import Calendar from "./Calendar"; | ||||||
|  |  | ||||||
|  | const TeamPanel = () => { | ||||||
|  |   const { user, teams, players, reloadPlayers } = useSession(); | ||||||
|  |   const navigate = useNavigate(); | ||||||
|  |   useEffect(() => { | ||||||
|  |     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||||
|  |       teams?.activeTeam === 42 || | ||||||
|  |       navigate("/", { replace: true }); | ||||||
|  |   }, [user, teams]); | ||||||
|  |   const newPlayerTemplate = { | ||||||
|  |     id: 0, | ||||||
|  |     username: "", | ||||||
|  |     display_name: "", | ||||||
|  |     gender: undefined, | ||||||
|  |     number: "", | ||||||
|  |     email: "", | ||||||
|  |   } as User; | ||||||
|  |   const [error, setError] = useState<ErrorState>(); | ||||||
|  |   const [player, setPlayer] = useState(newPlayerTemplate); | ||||||
|  |  | ||||||
|  |   async function handleSubmit(e: FormEvent) { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     if (teams) { | ||||||
|  |       if (player.id === 0) { | ||||||
|  |         const r = await apiAuth(`player/${teams?.activeTeam}`, player, "POST"); | ||||||
|  |         if (r.detail) setError({ ok: false, message: r.detail }); | ||||||
|  |         else { | ||||||
|  |           setError({ ok: true, message: r }); | ||||||
|  |           reloadPlayers(); | ||||||
|  |         } | ||||||
|  |       } else { | ||||||
|  |         const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT"); | ||||||
|  |         if (r.detail) setError({ ok: false, message: r.detail }); | ||||||
|  |         else { | ||||||
|  |           setError({ ok: true, message: r }); | ||||||
|  |           reloadPlayers(); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async function handleDisable(e: FormEvent) { | ||||||
|  |     e.preventDefault(); | ||||||
|  |     if (teams && player.id !== 0) { | ||||||
|  |       var confirmation = confirm("are you sure?"); | ||||||
|  |       if (confirmation) { | ||||||
|  |         const r = await apiAuth( | ||||||
|  |           `player/${teams?.activeTeam}`, | ||||||
|  |           { player_id: player.id }, | ||||||
|  |           "DELETE" | ||||||
|  |         ); | ||||||
|  |         if (r.detail) setError({ ok: false, message: r.detail }); | ||||||
|  |         else { | ||||||
|  |           setError({ ok: true, message: r }); | ||||||
|  |           setPlayer(newPlayerTemplate); | ||||||
|  |           reloadPlayers(); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (teams && players) { | ||||||
|  |     const activeTeam = teams.teams.filter( | ||||||
|  |       (team) => team.id == teams?.activeTeam | ||||||
|  |     )[0]; | ||||||
|  |     return ( | ||||||
|  |       <div className="team-panel"> | ||||||
|  |         <h1>{activeTeam.name}</h1> | ||||||
|  |         <div> | ||||||
|  |           <input type="text" value={activeTeam.location || ""} disabled /> | ||||||
|  |           <br /> | ||||||
|  |           <input type="text" value={activeTeam.country || ""} disabled /> | ||||||
|  |           <hr style={{ width: "100%" }} /> | ||||||
|  |  | ||||||
|  |           <h2>players</h2> | ||||||
|  |           {players ? ( | ||||||
|  |             <div | ||||||
|  |               style={{ | ||||||
|  |                 display: "flex", | ||||||
|  |                 flexWrap: "wrap", | ||||||
|  |                 justifyContent: "center", | ||||||
|  |               }} | ||||||
|  |             > | ||||||
|  |               {players.map((p) => ( | ||||||
|  |                 <button | ||||||
|  |                   className={ | ||||||
|  |                     "team-player " + | ||||||
|  |                     p.gender + | ||||||
|  |                     (p.id === player.id ? " active-player" : "") | ||||||
|  |                   } | ||||||
|  |                   key={p.id} | ||||||
|  |                   onClick={() => { | ||||||
|  |                     setPlayer(p); | ||||||
|  |                     setError({ ok: true, message: "" }); | ||||||
|  |                   }} | ||||||
|  |                 > | ||||||
|  |                   {p.display_name} | ||||||
|  |                 </button> | ||||||
|  |               ))} | ||||||
|  |               <button | ||||||
|  |                 className="team-player new-player" | ||||||
|  |                 key="add-player" | ||||||
|  |                 onClick={() => { | ||||||
|  |                   setPlayer(newPlayerTemplate); | ||||||
|  |                   setError({ ok: true, message: "" }); | ||||||
|  |                 }} | ||||||
|  |               > | ||||||
|  |                 + | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |           ) : ( | ||||||
|  |             <span className="loader" /> | ||||||
|  |           )} | ||||||
|  |           <hr style={{ width: "100%" }} /> | ||||||
|  |  | ||||||
|  |           <form className="new-player-inputs" onSubmit={handleSubmit}> | ||||||
|  |             <div> | ||||||
|  |               <label>name</label> | ||||||
|  |               <input | ||||||
|  |                 type="text" | ||||||
|  |                 required | ||||||
|  |                 value={player.display_name} | ||||||
|  |                 onChange={(e) => { | ||||||
|  |                   setPlayer({ | ||||||
|  |                     ...player, | ||||||
|  |                     ...(player.id === 0 && { | ||||||
|  |                       username: e.target.value.toLowerCase().replace(/\W/g, ""), | ||||||
|  |                     }), | ||||||
|  |                     display_name: e.target.value, | ||||||
|  |                   }); | ||||||
|  |                   setError({ ok: true, message: "" }); | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <label>username</label> | ||||||
|  |               <input | ||||||
|  |                 type="text" | ||||||
|  |                 required | ||||||
|  |                 disabled={player.id !== 0} | ||||||
|  |                 value={player.username} | ||||||
|  |                 onChange={(e) => { | ||||||
|  |                   setPlayer({ ...player, username: e.target.value }); | ||||||
|  |                   setError({ ok: true, message: "" }); | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <label>gender</label> | ||||||
|  |               <select | ||||||
|  |                 name="gender" | ||||||
|  |                 value={player.gender} | ||||||
|  |                 onChange={(e) => { | ||||||
|  |                   setPlayer({ ...player, gender: e.target.value as Gender }); | ||||||
|  |                   setError({ ok: true, message: "" }); | ||||||
|  |                 }} | ||||||
|  |               > | ||||||
|  |                 <option value={undefined}></option> | ||||||
|  |                 <option value="fmp">FMP</option> | ||||||
|  |                 <option value="mmp">MMP</option> | ||||||
|  |               </select> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <label>number (optional)</label> | ||||||
|  |               <input | ||||||
|  |                 type="text" | ||||||
|  |                 value={player.number || ""} | ||||||
|  |                 onChange={(e) => { | ||||||
|  |                   setPlayer({ ...player, number: e.target.value }); | ||||||
|  |                   setError({ ok: true, message: "" }); | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |             <div> | ||||||
|  |               <label>email (optional)</label> | ||||||
|  |               <input | ||||||
|  |                 type="email" | ||||||
|  |                 value={player.email || ""} | ||||||
|  |                 onChange={(e) => { | ||||||
|  |                   setPlayer({ ...player, email: e.target.value }); | ||||||
|  |                   setError({ ok: true, message: "" }); | ||||||
|  |                 }} | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |             <div style={{ margin: "auto" }}> | ||||||
|  |               {error?.message && ( | ||||||
|  |                 <span | ||||||
|  |                   style={{ | ||||||
|  |                     color: error.ok ? "green" : "red", | ||||||
|  |                   }} | ||||||
|  |                 > | ||||||
|  |                   {error.message} | ||||||
|  |                 </span> | ||||||
|  |               )} | ||||||
|  |             </div> | ||||||
|  |             <div style={{ margin: "auto" }}> | ||||||
|  |               <button className="team-player new-player"> | ||||||
|  |                 {player.id === 0 ? "add player" : "modify player"} | ||||||
|  |               </button> | ||||||
|  |             </div> | ||||||
|  |             {player.id !== 0 && ( | ||||||
|  |               <div style={{ margin: "auto" }}> | ||||||
|  |                 <button | ||||||
|  |                   className="team-player disable-player" | ||||||
|  |                   onClick={handleDisable} | ||||||
|  |                 > | ||||||
|  |                   remove player | ||||||
|  |                 </button> | ||||||
|  |               </div> | ||||||
|  |             )} | ||||||
|  |           </form> | ||||||
|  |         </div> | ||||||
|  |         <Calendar playerId={player.id} /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } else <span className="loader" />; | ||||||
|  | }; | ||||||
|  | export default TeamPanel; | ||||||
							
								
								
									
										49
									
								
								src/ThemeProvider.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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 }; | ||||||
							
								
								
									
										135
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						
									
										135
									
								
								src/api.ts
									
									
									
									
									
								
							| @@ -1,35 +1,20 @@ | |||||||
| export const baseUrl = import.meta.env.VITE_BASE_URL as string; | import { useSession } from "./Session"; | ||||||
| export const token = () => localStorage.getItem("access_token") as string; |  | ||||||
|  |  | ||||||
| export default async function api(path: string, data: any): Promise<any> { | export const baseUrl = import.meta.env.VITE_BASE_URL as string; | ||||||
|   const request = new Request(`${baseUrl}${path}/`, { |  | ||||||
|     method: "POST", | export async function apiAuth( | ||||||
|  |   path: string, | ||||||
|  |   data: any, | ||||||
|  |   method: string = "GET" | ||||||
|  | ): Promise<any> { | ||||||
|  |   const req = new Request(`${baseUrl}api/${path}`, { | ||||||
|  |     method: method, | ||||||
|     headers: { |     headers: { | ||||||
|       "Content-Type": "application/json", |       "Content-Type": "application/json", | ||||||
|     }, |     }, | ||||||
|     body: JSON.stringify(data), |     credentials: "include", | ||||||
|  |     ...(data && { body: JSON.stringify(data) }), | ||||||
|   }); |   }); | ||||||
|   let response: Response; |  | ||||||
|   try { |  | ||||||
|     response = await fetch(request); |  | ||||||
|   } catch (e) { |  | ||||||
|     throw new Error(`request failed: ${e}`); |  | ||||||
|   } |  | ||||||
|   return response; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> { |  | ||||||
|  |  | ||||||
|   const req = new Request(`${baseUrl}api/${path}`, |  | ||||||
|     { |  | ||||||
|       method: method, |  | ||||||
|       headers: { |  | ||||||
|         "Authorization": `Bearer ${token()} `, |  | ||||||
|         'Content-Type': 'application/json' |  | ||||||
|       }, |  | ||||||
|       ...(data && { body: JSON.stringify(data) }) |  | ||||||
|     } |  | ||||||
|   ); |  | ||||||
|   let resp: Response; |   let resp: Response; | ||||||
|   try { |   try { | ||||||
|     resp = await fetch(req); |     resp = await fetch(req); | ||||||
| @@ -39,25 +24,44 @@ export async function apiAuth(path: string, data: any, method: string = "GET"): | |||||||
|  |  | ||||||
|   if (!resp.ok) { |   if (!resp.ok) { | ||||||
|     if (resp.status === 401) { |     if (resp.status === 401) { | ||||||
|       logout() |       const { onLogout } = useSession(); | ||||||
|       throw new Error('Unauthorized'); |       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 Gender = "fmp" | "mmp" | undefined; | ||||||
|  |  | ||||||
| export type User = { | export type User = { | ||||||
|  |   id: number; | ||||||
|   username: string; |   username: string; | ||||||
|   fullName: string; |   display_name: string; | ||||||
| } |   email: string; | ||||||
|  |   number: string; | ||||||
|  |   gender: Gender; | ||||||
|  |   scopes: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
| export async function currentUser(): Promise<User> { | export async function currentUser(): Promise<User> { | ||||||
|   if (!token()) throw new Error("you have no access token") |   const req = new Request(`${baseUrl}api/player/me`, { | ||||||
|   const req = new Request(`${baseUrl}api/users/me/`, { |     method: "GET", | ||||||
|     method: "GET", headers: { |     headers: { | ||||||
|       "Authorization": `Bearer ${token()} `, |       "Content-Type": "application/json", | ||||||
|       'Content-Type': 'application/json' |     }, | ||||||
|     } |     credentials: "include", | ||||||
|   }); |   }); | ||||||
|   let resp: Response; |   let resp: Response; | ||||||
|   try { |   try { | ||||||
| @@ -68,29 +72,54 @@ export async function currentUser(): Promise<User> { | |||||||
|  |  | ||||||
|   if (!resp.ok) { |   if (!resp.ok) { | ||||||
|     if (resp.status === 401) { |     if (resp.status === 401) { | ||||||
|       logout() |       logout(); | ||||||
|       throw new Error('Unauthorized'); |       throw new Error("Unauthorized"); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|   return resp.json() as Promise<User>; |   return resp.json() as Promise<User>; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export async function loadPlayers(teamId: number) { | ||||||
|  |   try { | ||||||
|  |     const data = await apiAuth(`player/${teamId}/list`, null, "GET"); | ||||||
|  |     return data as User[]; | ||||||
|  |   } catch (error) { | ||||||
|  |     console.error(error); | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| export type LoginRequest = { | export type LoginRequest = { | ||||||
|   username: string; |   username: string; | ||||||
|   password: string; |   password: string; | ||||||
| }; | }; | ||||||
| export type Token = { |  | ||||||
|   access_token: string; | export const login = async (req: LoginRequest): Promise<void> => { | ||||||
|   token_type: string; |   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) => { | export const logout = async () => { | ||||||
|   fetch(`${baseUrl}api/token`, { |   try { | ||||||
|     method: "POST", headers: { |     await fetch(`${baseUrl}api/logout`, { | ||||||
|       'Content-Type': 'application/x-www-form-urlencoded', |       method: "POST", | ||||||
|     }, body: new URLSearchParams(req).toString() |       credentials: "include", | ||||||
|   }).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> |   } catch (e) { | ||||||
| } |     console.error(e); | ||||||
|  |   } | ||||||
| export const logout = () => localStorage.removeItem("access_token"); | }; | ||||||
|   | |||||||
							
								
								
									
										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", | ||||||
|  | }; | ||||||
							
								
								
									
										55
									
								
								src/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										55
									
								
								src/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,55 @@ | |||||||
|  | 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 { | ||||||
|  |   p_id: number; | ||||||
|  |   rank: number; | ||||||
|  |   std: number; | ||||||
|  |   n: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface Chemistry { | ||||||
|  |   id: number; | ||||||
|  |   user: number; | ||||||
|  |   hate: number[]; | ||||||
|  |   undecided: number[]; | ||||||
|  |   love: number[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface PlayerType { | ||||||
|  |   id: number; | ||||||
|  |   user: number; | ||||||
|  |   handlers: number[]; | ||||||
|  |   combis: number[]; | ||||||
|  |   cutters: number[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface MVPRanking { | ||||||
|  |   id: number; | ||||||
|  |   user: number; | ||||||
|  |   mvps: number[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export interface Team { | ||||||
|  |   id: number; | ||||||
|  |   name: string; | ||||||
|  |   location: string; | ||||||
|  |   country: string; | ||||||
|  |   mixed: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type ErrorState = { | ||||||
|  |   ok: boolean; | ||||||
|  |   message: string; | ||||||
|  | }; | ||||||
| @@ -3,10 +3,13 @@ | |||||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||||
|     "target": "ES2020", |     "target": "ES2020", | ||||||
|     "useDefineForClassFields": true, |     "useDefineForClassFields": true, | ||||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], |     "lib": [ | ||||||
|  |       "ES2020", | ||||||
|  |       "DOM", | ||||||
|  |       "DOM.Iterable" | ||||||
|  |     ], | ||||||
|     "module": "ESNext", |     "module": "ESNext", | ||||||
|     "skipLibCheck": true, |     "skipLibCheck": true, | ||||||
|  |  | ||||||
|     /* Bundler mode */ |     /* Bundler mode */ | ||||||
|     "moduleResolution": "bundler", |     "moduleResolution": "bundler", | ||||||
|     "allowImportingTsExtensions": true, |     "allowImportingTsExtensions": true, | ||||||
| @@ -14,7 +17,6 @@ | |||||||
|     "moduleDetection": "force", |     "moduleDetection": "force", | ||||||
|     "noEmit": true, |     "noEmit": true, | ||||||
|     "jsx": "react-jsx", |     "jsx": "react-jsx", | ||||||
|  |  | ||||||
|     /* Linting */ |     /* Linting */ | ||||||
|     "strict": true, |     "strict": true, | ||||||
|     "noUnusedLocals": false, |     "noUnusedLocals": false, | ||||||
| @@ -22,5 +24,7 @@ | |||||||
|     "noFallthroughCasesInSwitch": true, |     "noFallthroughCasesInSwitch": true, | ||||||
|     "noUncheckedSideEffectImports": true |     "noUncheckedSideEffectImports": true | ||||||
|   }, |   }, | ||||||
|   "include": ["src"] |   "include": [ | ||||||
|  |     "src" | ||||||
|  |   ] | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user