Compare commits
	
		
			163 Commits
		
	
	
		
			floating_b
			...
			ba26e7c9e6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5405c3e12f | |||
| 1eab163e10 | |||
| 7c054d6ba3 | |||
| 4a46cd505d | |||
| 1fa91a7228 | |||
| 8e91724462 | |||
| 1a1b44743a | |||
| 827eceed2b | |||
| 96f04e6d90 | |||
| df94b151a6 | |||
| 9647e890f6 | |||
| 15c9a64de2 | |||
| fbe17479f7 | |||
| 18e693bd2d | |||
| c1ff2120ad | |||
| 8a9af450d4 | |||
| 9c54eaf59b | |||
| b1e5de086c | |||
| eb4fa02327 | |||
| d37c6f7158 | |||
| 06fd18ef4c | |||
| 44bc27b567 | |||
| dee40ebdb6 | |||
| 3ec065aaf9 | |||
| a34c88c18c | |||
| 0c830c1f8f | |||
| 686fb3a5a4 | |||
| 94bee44cb6 | |||
| e89a2eea20 | |||
| 55b7b6f206 | |||
| c64f93e912 | |||
| 501811a0b5 | |||
| 25bda2bc4d | |||
| 8def52fbf2 | |||
| 16a6814d69 | |||
| bb7f795175 | |||
| af28539a02 | |||
| 11bd3c4849 | |||
| e8c788832c | |||
| 2d760cda16 | |||
| 2256fbfdf9 | |||
| d5e684eb98 | 
							
								
								
									
										3
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								.env.example
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| VITE_BASE_URL= | ||||
| SECRET_KEY="tg07Cp0daCpcbeeFw+lI4RG2lEuT8oq5xOwB5ETs9YaJTylG1euC4ogjTK4zym/d" | ||||
| ACCESS_TOKEN_EXPIRE_MINUTES = 30 | ||||
| @@ -1,3 +1,3 @@ | ||||
| # cutt | ||||
| # cutt - cool ultimate team tool | ||||
|  | ||||
| cool ultimate team tool | ||||
| app to survey the chemistry between the players in your team and determine the most valued players in your team | ||||
|   | ||||
							
								
								
									
										0
									
								
								cutt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								cutt/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										484
									
								
								cutt/analysis.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										484
									
								
								cutt/analysis.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,484 @@ | ||||
| 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): | ||||
|             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] | ||||
|                 edges.append( | ||||
|                     { | ||||
|                         "id": f"{user}->{p}", | ||||
|                         "source": user, | ||||
|                         "target": p, | ||||
|                         "size": max(1.0 - 0.1 * i, 0.3), | ||||
|                         "data": { | ||||
|                             "relation": 2, | ||||
|                             "origSize": max(1.0 - 0.1 * i, 0.3), | ||||
|                             "origFill": "#bed4ff", | ||||
|                         }, | ||||
|                     } | ||||
|                 ) | ||||
|             for p_id in c.hate: | ||||
|                 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.3, | ||||
|                         "data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"}, | ||||
|                         "fill": "#ff7c7c", | ||||
|                     } | ||||
|                 ) | ||||
|  | ||||
|     if not edges: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_404_NOT_FOUND, detail="no entries found" | ||||
|         ) | ||||
|     G = nx.DiGraph() | ||||
|     G.add_nodes_from([n["id"] for n in nodes]) | ||||
|     G.add_weighted_edges_from( | ||||
|         [ | ||||
|             ( | ||||
|                 e["source"], | ||||
|                 e["target"], | ||||
|                 e["size"] if e["data"]["relation"] == 2 else -e["size"], | ||||
|             ) | ||||
|             for e in edges | ||||
|         ] | ||||
|     ) | ||||
|     in_degrees = G.in_degree(weight="weight") | ||||
|     nodes = [ | ||||
|         dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes | ||||
|     ] | ||||
|     if networkx_graph: | ||||
|         return G | ||||
|     return JSONResponse({"nodes": nodes, "edges": edges}) | ||||
|  | ||||
|  | ||||
| def sociogram_data(show: int | None = 2): | ||||
|     G = nx.DiGraph() | ||||
|     with Session(engine) as session: | ||||
|         players = {} | ||||
|         for p in session.exec(select(P)).fetchall(): | ||||
|             G.add_node(p.display_name) | ||||
|             players[p.id] = p.display_name | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")).group_by(C.user).subquery() | ||||
|         ) | ||||
|         statement2 = ( | ||||
|             select(C) | ||||
|             # .where(C.user.in_(["Kruse", "Franz", "ck"])) | ||||
|             .join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)) | ||||
|         ) | ||||
|         for c in session.exec(statement2): | ||||
|             if show >= 1: | ||||
|                 for i, p_id in enumerate(c.love): | ||||
|                     p = players[p_id] | ||||
|                     G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i) | ||||
|             if show <= 1: | ||||
|                 for i, p_id in enumerate(c.hate): | ||||
|                     p = players[p_id] | ||||
|                     G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16) | ||||
|     return G | ||||
|  | ||||
|  | ||||
| class Params(BaseModel): | ||||
|     node_size: int | None = Field(default=2400, alias="nodeSize") | ||||
|     font_size: int | None = Field(default=10, alias="fontSize") | ||||
|     arrow_size: int | None = Field(default=20, alias="arrowSize") | ||||
|     edge_width: float | None = Field(default=1, alias="edgeWidth") | ||||
|     distance: float | None = 0.2 | ||||
|     weighting: bool | None = True | ||||
|     popularity: bool | None = True | ||||
|     show: int | None = 2 | ||||
|  | ||||
|  | ||||
| ARROWSTYLE = {"love": "-|>", "hate": "-|>"} | ||||
| EDGESTYLE = {"love": "-", "hate": ":"} | ||||
| EDGECOLOR = {"love": "#404040", "hate": "#cc0000"} | ||||
|  | ||||
|  | ||||
| async def render_sociogram(params: Params): | ||||
|     plt.figure(figsize=(16, 10), facecolor="none") | ||||
|     ax = plt.gca() | ||||
|     ax.set_facecolor("none")  # Set the axis face color to none (transparent) | ||||
|     ax.axis("off")  # Turn off axis ticks and frames | ||||
|  | ||||
|     G = sociogram_data(show=params.show) | ||||
|     pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None) | ||||
|     nodes = nx.draw_networkx_nodes( | ||||
|         G, | ||||
|         pos, | ||||
|         node_color=[ | ||||
|             v for k, v in G.in_degree(weight="popularity" if params.weighting else None) | ||||
|         ] | ||||
|         if params.popularity | ||||
|         else "#99ccff", | ||||
|         edgecolors="#404040", | ||||
|         linewidths=0, | ||||
|         # node_shape="8", | ||||
|         node_size=params.node_size, | ||||
|         cmap="coolwarm", | ||||
|         alpha=0.86, | ||||
|     ) | ||||
|     if params.popularity: | ||||
|         cbar = plt.colorbar(nodes) | ||||
|         cbar.ax.set_xlabel("popularity") | ||||
|     nx.draw_networkx_labels(G, pos, font_size=params.font_size) | ||||
|     nx.draw_networkx_edges( | ||||
|         G, | ||||
|         pos, | ||||
|         arrows=True, | ||||
|         edge_color=[EDGECOLOR[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||
|         arrowsize=params.arrow_size, | ||||
|         node_size=params.node_size, | ||||
|         width=params.edge_width, | ||||
|         style=[EDGESTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||
|         arrowstyle=[ARROWSTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||
|         connectionstyle="arc3,rad=0.12", | ||||
|         alpha=[1 - 0.08 * G.edges()[*edge]["rank"] for edge in G.edges()] | ||||
|         if params.weighting | ||||
|         else 1, | ||||
|     ) | ||||
|  | ||||
|     buf = io.BytesIO() | ||||
|     plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True) | ||||
|     buf.seek(0) | ||||
|     encoded_image = base64.b64encode(buf.read()).decode("UTF-8") | ||||
|     plt.close() | ||||
|  | ||||
|     return {"image": encoded_image} | ||||
|  | ||||
|  | ||||
| translate_tablename = { | ||||
|     R.__tablename__: "🏆", | ||||
|     C.__tablename__: "🧪", | ||||
|     PT.__tablename__: "🃏", | ||||
| } | ||||
|  | ||||
|  | ||||
| def last_submissions( | ||||
|     request: Annotated[TeamScopedRequest, Security(verify_team_scope)], | ||||
| ): | ||||
|     times = {} | ||||
|     with Session(engine) as session: | ||||
|         for survey in [C, PT, R]: | ||||
|             subquery = ( | ||||
|                 select(survey.user, func.max(survey.time).label("latest")) | ||||
|                 .where(survey.team == request.team_id) | ||||
|                 .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)], | ||||
| ): | ||||
|     ranks = dict() | ||||
|     if request.team_id == 42: | ||||
|         random.seed(42) | ||||
|         players = [request.user] + demo_players | ||||
|         for p in players: | ||||
|             random.shuffle(players) | ||||
|             for i, p in enumerate(players): | ||||
|                 ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1] | ||||
|         return [ | ||||
|             { | ||||
|                 "name": p, | ||||
|                 "rank": f"{np.mean(v):.02f}", | ||||
|                 "std": f"{np.std(v):.02f}", | ||||
|                 "n": len(v), | ||||
|             } | ||||
|             for p, v in ranks.items() | ||||
|         ] | ||||
|  | ||||
|     with Session(engine) as session: | ||||
|         players = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.disabled == False) | ||||
|         ).all() | ||||
|         if not players: | ||||
|             raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) | ||||
|         player_map = {p.id: p.display_name for p in players} | ||||
|         subquery = ( | ||||
|             select(R.user, func.max(R.time).label("latest")) | ||||
|             .where(R.team == request.team_id) | ||||
|             .group_by(R.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(R).join( | ||||
|             subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) | ||||
|         ) | ||||
|         for r in session.exec(statement2): | ||||
|             for i, p_id in enumerate(r.mvps): | ||||
|                 if p_id not in player_map: | ||||
|                     continue | ||||
|                 p = player_map[p_id] | ||||
|                 ranks[p] = ranks.get(p, []) + [i + 1] | ||||
|  | ||||
|     if not ranks: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_404_NOT_FOUND, detail="no entries found" | ||||
|         ) | ||||
|     return [ | ||||
|         { | ||||
|             "name": p, | ||||
|             "rank": f"{np.mean(v):.02f}", | ||||
|             "std": f"{np.std(v):.02f}", | ||||
|             "n": len(v), | ||||
|         } | ||||
|         for p, v in ranks.items() | ||||
|     ] | ||||
|  | ||||
|  | ||||
| async def turnout( | ||||
|     request: Annotated[ | ||||
|         TeamScopedRequest, Security(verify_team_scope, scopes=["analysis"]) | ||||
|     ], | ||||
| ): | ||||
|     player_map = {} | ||||
|     with Session(engine) as session: | ||||
|         players = session.exec( | ||||
|             select(P) | ||||
|             .join(PlayerTeamLink) | ||||
|             .join(Team) | ||||
|             .where(Team.id == request.team_id, P.disabled == False) | ||||
|         ).all() | ||||
|         if not players: | ||||
|             raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) | ||||
|         for p in players: | ||||
|             player_map[p.id] = p.display_name | ||||
|  | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")) | ||||
|             .where(C.team == request.team_id) | ||||
|             .group_by(C.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(C.user).join( | ||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||
|         ) | ||||
|         chemistry_turnout = session.exec(statement2).all() | ||||
|         chemistry_missing = set(player_map) - set(chemistry_turnout) | ||||
|         chemistry_missing = [player_map[i] for i in chemistry_missing] | ||||
|  | ||||
|         subquery = ( | ||||
|             select(R.user, func.max(R.time).label("latest")) | ||||
|             .where(R.team == request.team_id) | ||||
|             .group_by(R.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(R.user).join( | ||||
|             subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) | ||||
|         ) | ||||
|         mvp_turnout = session.exec(statement2).all() | ||||
|         mvp_missing = set(player_map) - set(mvp_turnout) | ||||
|         mvp_missing = [player_map[i] for i in mvp_missing] | ||||
|         return JSONResponse( | ||||
|             { | ||||
|                 "players": len(player_map), | ||||
|                 "chemistry": { | ||||
|                     "turnout": len(chemistry_turnout), | ||||
|                     "missing": sorted(list(chemistry_missing)), | ||||
|                 }, | ||||
|                 "MVP": { | ||||
|                     "turnout": len(mvp_turnout), | ||||
|                     "missing": sorted(list(mvp_missing)), | ||||
|                 }, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|  | ||||
| # analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) | ||||
| analysis_router.add_api_route( | ||||
|     "/graph_json/{team_id}", endpoint=graph_json, methods=["GET"] | ||||
| ) | ||||
| # analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) | ||||
| analysis_router.add_api_route( | ||||
|     "/mvp/{team_id}", | ||||
|     endpoint=mvp, | ||||
|     methods=["GET"], | ||||
|     name="MVPs", | ||||
|     description="Request Most Valuable Players stats", | ||||
| ) | ||||
| analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"]) | ||||
| 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) | ||||
							
								
								
									
										98
									
								
								cutt/db.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										98
									
								
								cutt/db.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,98 @@ | ||||
| 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 | ||||
|     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, | ||||
|             "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}] | ||||
							
								
								
									
										57
									
								
								db.py
									
									
									
									
									
								
							
							
						
						
									
										57
									
								
								db.py
									
									
									
									
									
								
							| @@ -1,57 +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) | ||||
| 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))) | ||||
|  | ||||
|  | ||||
| SQLModel.metadata.create_all(engine) | ||||
							
								
								
									
										84
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										84
									
								
								main.py
									
									
									
									
									
								
							| @@ -1,84 +0,0 @@ | ||||
| from fastapi import APIRouter, 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 | ||||
|  | ||||
|  | ||||
| app = FastAPI(title="cutt") | ||||
| 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) | ||||
|         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() | ||||
|  | ||||
|  | ||||
| app.include_router(player_router) | ||||
| app.include_router(team_router) | ||||
| app.mount("/", StaticFiles(directory="dist", html=True), name="site") | ||||
							
								
								
									
										19
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								package.json
									
									
									
									
									
								
							| @@ -10,25 +10,32 @@ | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "d3": "^7.9.0", | ||||
|     "react": "^18.3.1", | ||||
|     "react-dom": "^18.3.1", | ||||
|     "jwt-decode": "^4.0.0", | ||||
|     "react": "18.3.1", | ||||
|     "react-dom": "18.3.1", | ||||
|     "react-sortablejs": "^6.1.4", | ||||
|     "reagraph": "^4.21.2", | ||||
|     "sortablejs": "^1.15.6" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@eslint/js": "^9.17.0", | ||||
|     "@types/d3": "^7.4.3", | ||||
|     "@types/react": "^18.3.18", | ||||
|     "@types/react-dom": "^18.3.5", | ||||
|     "@types/node": "^22.13.10", | ||||
|     "@types/react": "18.3.18", | ||||
|     "@types/react-dom": "18.3.5", | ||||
|     "@types/sortablejs": "^1.15.8", | ||||
|     "@vitejs/plugin-react": "^4.3.4", | ||||
|     "eslint": "^9.17.0", | ||||
|     "eslint-plugin-react-hooks": "^5.0.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.16", | ||||
|     "globals": "^15.14.0", | ||||
|     "react-router": "^7.1.5", | ||||
|     "typescript": "~5.6.2", | ||||
|     "typescript-eslint": "^8.18.2", | ||||
|     "vite": "^6.0.5" | ||||
|   }, | ||||
|   "prettier": { | ||||
|     "trailingComma": "es5", | ||||
|     "tabWidth": 2, | ||||
|     "semi": true | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										1
									
								
								public/gitea.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								public/gitea.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="128" height="128" viewBox="0 0 2560 2560"><path d="m1569.914 2282.76-484.616-232.952c-47.736-22.913-68.358-80.96-45.063-129.078l232.952-484.617c22.913-47.736 80.96-68.358 129.078-45.062 65.685 31.696 103.492 49.645 103.492 49.645l-.382-417.022 63.776-.382.381 447.191s219.204 92.417 317.35 153.138c14.13 8.783 38.952 25.968 49.263 54.992 8.02 23.295 7.638 50.027-3.818 73.704l-232.952 484.617c-23.678 48.5-81.725 69.121-129.46 45.826z" style="fill:#fff;stroke-width:3.81889"/><path d="M2436.037 1005.725c-15.657-15.657-36.66-15.276-36.66-15.276s-447.574 25.205-679.38 30.552c-50.792 1.145-101.201 2.29-151.228 2.673v447.573c-21.004-9.929-42.39-20.24-63.394-30.17 0-139.007-.382-417.021-.382-417.021-110.747 1.527-340.644-8.402-340.644-8.402s-539.99-27.114-598.802-32.46c-37.425-2.292-85.924-8.02-148.936 5.728-33.224 6.874-127.933 28.26-205.456 102.728-171.85 153.137-127.933 396.782-122.586 433.443 6.492 44.681 26.35 168.795 121.058 276.87 174.905 214.239 551.447 209.275 551.447 209.275s46.209 110.365 116.858 211.948c95.472 126.405 193.618 224.932 289.09 236.77 240.59 0 721.387-.381 721.387-.381s45.827.382 108.075-39.335c53.464-32.46 101.2-89.362 101.2-89.362s49.264-52.7 118.004-172.995c21.004-37.043 38.57-72.941 53.846-106.93 0 0 210.803-447.19 210.803-882.543-4.201-131.752-36.662-155.047-44.3-162.685M537.67 1785.159c-98.91-32.46-140.917-71.413-140.917-71.413s-72.94-51.173-109.602-151.991c-63.012-168.795-5.347-271.905-5.347-271.905s32.079-85.925 147.027-114.567c52.701-14.13 118.386-11.838 118.386-11.838s27.114 226.842 59.956 359.739c27.496 111.511 94.709 296.727 94.709 296.727s-99.673-11.838-164.212-34.752m1146.81 410.912s-23.294 55.374-74.85 58.811c-22.149 1.528-39.334-4.582-39.334-4.582s-1.145-.382-20.24-8.02l-431.152-210.039s-41.626-21.767-48.882-59.574c-8.401-30.933 10.311-69.122 10.311-69.122l207.366-427.333s18.33-37.044 46.59-49.646c2.291-1.146 8.784-3.819 17.185-5.728 30.933-8.02 68.74 10.693 68.74 10.693l422.75 205.074s48.119 21.767 58.43 61.866c7.255 28.26-1.91 53.464-6.874 65.685-24.06 58.81-210.04 431.916-210.04 431.916z" style="fill:#609926;stroke-width:3.81889"/><path d="M1306.029 1885.214c-31.314.382-58.81 22.15-66.066 52.7-7.256 30.552 7.637 62.249 34.751 76.379 29.406 15.275 66.83 6.874 86.69-20.622 19.476-27.114 16.42-64.54-6.875-88.217l91.653-187.507c5.729.382 14.13.764 23.677-1.91 15.658-3.436 27.115-13.747 27.115-13.747 16.039 6.874 32.842 14.511 50.409 23.295 18.33 9.165 35.516 18.712 51.173 27.878 3.437 1.91 6.874 4.2 10.693 7.256 6.11 4.964 12.984 11.838 17.949 21.003 7.255 21.004-7.256 56.902-7.256 56.902-8.784 29.023-70.268 155.047-70.268 155.047-30.933-.764-58.429 19.094-67.594 47.736-9.93 30.933 4.2 66.066 33.988 81.342 29.787 15.275 66.449 6.492 85.925-20.24 19.094-25.969 17.567-62.248-4.2-86.307 7.255-14.13 14.129-28.26 21.385-43.153 19.094-39.717 51.555-116.094 51.555-116.094 3.437-6.493 21.768-39.335 10.31-81.343-9.546-43.535-48.117-63.775-48.117-63.775-46.59-30.17-111.512-58.047-111.512-58.047s0-15.658-4.2-27.114c-4.201-11.839-10.693-19.477-14.894-24.06 17.949-37.042 35.897-73.704 53.846-110.747a2648 2648 0 0 1-46.59-23.295c-18.33 37.425-37.043 75.232-55.374 112.657-25.587-.382-49.264 13.366-61.484 35.898-12.984 24.058-10.311 53.846 7.256 75.613z" style="fill:#609926;stroke-width:3.81889"/></svg> | ||||
| After Width: | Height: | Size: 3.3 KiB | 
| @@ -1,49 +1 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <!-- Created with Inkscape (http://www.inkscape.org/) --> | ||||
|  | ||||
| <svg | ||||
|    width="48" | ||||
|    height="48" | ||||
|    viewBox="0 0 12.7 12.7" | ||||
|    version="1.1" | ||||
|    id="svg1" | ||||
|    inkscape:version="1.4 (e7c3feb100, 2024-10-09)" | ||||
|    sodipodi:docname="logo.svg" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"> | ||||
|   <sodipodi:namedview | ||||
|      id="namedview1" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#000000" | ||||
|      borderopacity="0.25" | ||||
|      inkscape:showpageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      inkscape:deskcolor="#d1d1d1" | ||||
|      inkscape:document-units="mm" | ||||
|      inkscape:zoom="14.329304" | ||||
|      inkscape:cx="17.167617" | ||||
|      inkscape:cy="25.088448" | ||||
|      inkscape:window-width="1408" | ||||
|      inkscape:window-height="1727" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="0" | ||||
|      inkscape:current-layer="layer1" /> | ||||
|   <defs | ||||
|      id="defs1" /> | ||||
|   <g | ||||
|      inkscape:label="Layer 1" | ||||
|      inkscape:groupmode="layer" | ||||
|      id="layer1"> | ||||
|     <ellipse | ||||
|        style="fill:#c7d6f1;stroke:#3366cc;stroke-width:1.94357;fill-opacity:1" | ||||
|        id="path2" | ||||
|        cx="6.3500028" | ||||
|        cy="6.3500109" | ||||
|        rx="4.5089426" | ||||
|        ry="4.5918198" /> | ||||
|   </g> | ||||
| </svg> | ||||
| <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 12.7 12.7"><ellipse cx="6.35" cy="6.35" rx="4.509" ry="4.592" style="fill:#c7d6f1;stroke:#36c;stroke-width:1.94357;fill-opacity:1"/></svg> | ||||
| Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 214 B | 
| @@ -1,15 +1,19 @@ | ||||
| [project] | ||||
| name = "cutt" | ||||
| version = "0.1.0" | ||||
| description = "Add your description here" | ||||
| version = "0.1.1" | ||||
| description = "cool ultimate team tool" | ||||
| author = "julius" | ||||
| readme = "README.md" | ||||
| requires-python = ">=3.13" | ||||
| dependencies = [ | ||||
|     "argon2-cffi>=23.1.0", | ||||
|     "fastapi[standard]>=0.115.7", | ||||
|     "matplotlib>=3.10.0", | ||||
|     "networkx>=3.4.2", | ||||
|     "passlib>=1.7.4", | ||||
|     "psycopg>=3.2.4", | ||||
|     "pydantic-settings>=2.7.1", | ||||
|     "pyjwt>=2.10.1", | ||||
|     "pyqt6>=6.8.0", | ||||
|     "sqlmodel>=0.0.22", | ||||
|     "uvicorn>=0.34.0", | ||||
|   | ||||
							
								
								
									
										228
									
								
								src/Analysis.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								src/Analysis.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,228 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { apiAuth } from "./api"; | ||||
|  | ||||
| //const debounce = <T extends (...args: any[]) => void>( | ||||
| //  func: T, | ||||
| //  delay: number | ||||
| //): ((...args: Parameters<T>) => void) => { | ||||
| //  let timeoutId: number | null = null; | ||||
| //  return (...args: Parameters<T>) => { | ||||
| //    if (timeoutId !== null) { | ||||
| //      clearTimeout(timeoutId); | ||||
| //    } | ||||
| //    console.log(timeoutId); | ||||
| //    timeoutId = setTimeout(() => { | ||||
| //      func(...args); | ||||
| //    }, delay); | ||||
| //  }; | ||||
| //}; | ||||
| // | ||||
|  | ||||
| interface Params { | ||||
|   nodeSize: number; | ||||
|   edgeWidth: number; | ||||
|   arrowSize: number; | ||||
|   fontSize: number; | ||||
|   distance: number; | ||||
|   weighting: boolean; | ||||
|   popularity: boolean; | ||||
|   show: number; | ||||
| } | ||||
|  | ||||
| let timeoutID: NodeJS.Timeout | null = null; | ||||
| export default function Analysis() { | ||||
|   const [image, setImage] = useState(""); | ||||
|   const [params, setParams] = useState<Params>({ | ||||
|     nodeSize: 2000, | ||||
|     edgeWidth: 1, | ||||
|     arrowSize: 16, | ||||
|     fontSize: 10, | ||||
|     distance: 2, | ||||
|     weighting: true, | ||||
|     popularity: true, | ||||
|     show: 2, | ||||
|   }); | ||||
|   const [showControlPanel, setShowControlPanel] = useState(false); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   // Function to generate and fetch the graph image | ||||
|   async function loadImage() { | ||||
|     setLoading(true); | ||||
|     await apiAuth("analysis/image", params, "POST") | ||||
|       .then((data) => { | ||||
|         setImage(data.image); | ||||
|         setLoading(false); | ||||
|       }) | ||||
|       .catch((e) => { | ||||
|         console.log("best to just reload... ", e); | ||||
|       }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (timeoutID) { | ||||
|       clearTimeout(timeoutID); | ||||
|     } | ||||
|     timeoutID = setTimeout(() => { | ||||
|       loadImage(); | ||||
|     }, 1000); | ||||
|   }, [params]); | ||||
|  | ||||
|   function showLabel() { | ||||
|     switch (params.show) { | ||||
|       case 0: | ||||
|         return "dislike"; | ||||
|       case 1: | ||||
|         return "both"; | ||||
|       case 2: | ||||
|         return "like"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div className="stack column dropdown"> | ||||
|       <button onClick={() => setShowControlPanel(!showControlPanel)}> | ||||
|         Parameters{" "} | ||||
|         <svg | ||||
|           viewBox="0 0 24 24" | ||||
|           height="1.2em" | ||||
|           style={{ | ||||
|             fill: "#ffffff", | ||||
|             display: "inline", | ||||
|             top: "0.2em", | ||||
|             position: "relative", | ||||
|             transform: showControlPanel ? "rotate(180deg)" : "unset", | ||||
|           }} | ||||
|         > | ||||
|           {" "} | ||||
|           <path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"> </path> | ||||
|         </svg> | ||||
|       </button> | ||||
|       <div id="control-panel" className={showControlPanel ? "opened" : ""}> | ||||
|         <div className="control"> | ||||
|           <datalist id="markers"> | ||||
|             <option value="0"></option> | ||||
|             <option value="1"></option> | ||||
|             <option value="2"></option> | ||||
|           </datalist> | ||||
|           <div id="three-slider"> | ||||
|             <label>😬</label> | ||||
|             <input | ||||
|               type="range" | ||||
|               list="markers" | ||||
|               min="0" | ||||
|               max="2" | ||||
|               step="1" | ||||
|               width="16px" | ||||
|               onChange={(evt) => | ||||
|                 setParams({ ...params, show: Number(evt.target.value) }) | ||||
|               } | ||||
|             /> | ||||
|             <label>😍</label> | ||||
|           </div> | ||||
|           {showLabel()} | ||||
|         </div> | ||||
|         <div className="control"> | ||||
|           <div className="checkBox"> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={params.weighting} | ||||
|               onChange={(evt) => | ||||
|                 setParams({ ...params, weighting: evt.target.checked }) | ||||
|               } | ||||
|             /> | ||||
|             <label>weighting</label> | ||||
|           </div> | ||||
|  | ||||
|           <div className="checkBox"> | ||||
|             <input | ||||
|               type="checkbox" | ||||
|               checked={params.popularity} | ||||
|               onChange={(evt) => | ||||
|                 setParams({ ...params, popularity: evt.target.checked }) | ||||
|               } | ||||
|             /> | ||||
|             <label>popularity</label> | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <label>distance between nodes</label> | ||||
|           <input | ||||
|             type="range" | ||||
|             min="0.01" | ||||
|             max="3.001" | ||||
|             step="0.05" | ||||
|             value={params.distance} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, distance: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.distance}</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <label>node size</label> | ||||
|           <input | ||||
|             type="range" | ||||
|             min="500" | ||||
|             max="3000" | ||||
|             value={params.nodeSize} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, nodeSize: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.nodeSize}</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <label>font size</label> | ||||
|           <input | ||||
|             type="range" | ||||
|             min="4" | ||||
|             max="24" | ||||
|             value={params.fontSize} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, fontSize: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.fontSize}</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <label>edge width</label> | ||||
|           <input | ||||
|             type="range" | ||||
|             min="1" | ||||
|             max="5" | ||||
|             step="0.1" | ||||
|             value={params.edgeWidth} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, edgeWidth: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.edgeWidth}</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <label>arrow size</label> | ||||
|           <input | ||||
|             type="range" | ||||
|             min="10" | ||||
|             max="50" | ||||
|             value={params.arrowSize} | ||||
|             onChange={(evt) => | ||||
|               setParams({ ...params, arrowSize: Number(evt.target.value) }) | ||||
|             } | ||||
|           /> | ||||
|           <span>{params.arrowSize}</span> | ||||
|         </div> | ||||
|       </div> | ||||
|       <button onClick={() => loadImage()}>reload ↻</button> | ||||
|       {loading ? ( | ||||
|         <span className="loader"></span> | ||||
|       ) : ( | ||||
|         <img src={"data:image/png;base64," + image} width="86%" /> | ||||
|       )} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										639
									
								
								src/App.css
									
									
									
									
									
								
							
							
						
						
									
										639
									
								
								src/App.css
									
									
									
									
									
								
							| @@ -1,35 +1,146 @@ | ||||
| * { | ||||
|   border-radius: 8px; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   background-color: aliceblue; | ||||
|   position: relative; | ||||
|   z-index: 0; | ||||
|   color: black; | ||||
|   text-align: center; | ||||
|   overflow-wrap: anywhere; | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| footer { | ||||
|   font-size: x-small; | ||||
| } | ||||
|  | ||||
| #root { | ||||
|   max-width: 1280px; | ||||
|   margin: 0 auto; | ||||
|   padding: 8px; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| footer { | ||||
|   margin-top: 24px; | ||||
|   font-size: x-small; | ||||
| } | ||||
|  | ||||
| .fixed-footer { | ||||
|   position: absolute; | ||||
|   bottom: 4px; | ||||
|   left: 8px; | ||||
| } | ||||
|  | ||||
| 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 { | ||||
|   color: #444; | ||||
|   opacity: 66%; | ||||
| } | ||||
|  | ||||
| .hint { | ||||
|   position: absolute; | ||||
|   font-size: 80%; | ||||
|   padding: 4px; | ||||
|   padding: 8px; | ||||
|   top: auto; | ||||
|   left: 4px; | ||||
|   bottom: auto; | ||||
| @@ -37,19 +148,42 @@ footer { | ||||
|   z-index: -1; | ||||
| } | ||||
|  | ||||
| input, | ||||
| select { | ||||
|   padding: 0.2em 16px; | ||||
|   margin-top: 0.25em; | ||||
|   margin-bottom: 0.25em; | ||||
|   border-radius: 1em; | ||||
|   color: black; | ||||
|   background-color: white; | ||||
| } | ||||
|  | ||||
| h1, | ||||
| h2, | ||||
| h3 { | ||||
|   margin-top: 0px; | ||||
|   margin-bottom: 0px; | ||||
|   padding: 4px 16px; | ||||
|   padding: 8px 16px; | ||||
| } | ||||
|  | ||||
| .stack { | ||||
|   display: flex; | ||||
|  | ||||
|   button, | ||||
|   img { | ||||
|     padding: 0px 1em 4px 1em; | ||||
|     margin: 3px auto; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .column { | ||||
|   flex-direction: column; | ||||
| } | ||||
|  | ||||
| .container { | ||||
|   display: flex; | ||||
|   flex-wrap: nowrap; | ||||
|   justify-content: space-evenly; | ||||
|   min-width: 737px; | ||||
|   width: min(96vw, 900px); | ||||
| } | ||||
|  | ||||
| .dragbox { | ||||
| @@ -59,85 +193,126 @@ h3 { | ||||
|   height: 92%; | ||||
| } | ||||
|  | ||||
| .box { | ||||
|   position: relative; | ||||
|   flex: 1; | ||||
|   border-width: 3px; | ||||
|   border-style: solid; | ||||
|   border-radius: 16px; | ||||
|  | ||||
|   h4 { | ||||
|     margin: 4px; | ||||
|   } | ||||
|   &.one { | ||||
|     max-width: min(96%, 768px); | ||||
|     margin: 4px auto; | ||||
|   } | ||||
|  | ||||
|   padding: 4px; | ||||
|   margin: 4px 0.5%; | ||||
| } | ||||
|  | ||||
| .reservoir { | ||||
|   display: flex; | ||||
|   flex-direction: unset; | ||||
|   flex-wrap: wrap; | ||||
|   justify-content: space-around; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| .box { | ||||
|   position: relative; | ||||
|   &.one { | ||||
|     max-width: min(80vw, 500px); | ||||
|   } | ||||
|   &.two { | ||||
|     min-width: 43%; | ||||
|     max-width: 20vw; | ||||
|   } | ||||
|   &.three { | ||||
|     min-width: 27%; | ||||
|     max-width: 10vw; | ||||
|   } | ||||
|   padding: 4px; | ||||
|   margin: 4px auto; | ||||
|   border-style: solid; | ||||
|   border-color: black; | ||||
| } | ||||
|  | ||||
| .user { | ||||
|   max-width: 400px; | ||||
|   min-width: 200px; | ||||
|   margin: 4px auto; | ||||
|   .item { | ||||
|     font-weight: bold; | ||||
|     border-style: solid; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .item { | ||||
|   cursor: pointer; | ||||
|   font-size: small; | ||||
|   border: 3px dashed black; | ||||
|   border-radius: 4px; | ||||
|   margin: 8px auto; | ||||
|   padding: 4px 8px; | ||||
|   font-size: medium; | ||||
|   border: 2px solid; | ||||
|   border-radius: 1em; | ||||
|   margin: 3px auto; | ||||
|   padding: 5px 0.8em; | ||||
| } | ||||
|  | ||||
| .extra-margin { | ||||
|   padding: 0px 8px; | ||||
|   margin: auto; | ||||
| } | ||||
|  | ||||
| button { | ||||
|   margin: 4px; | ||||
|   font-weight: bold; | ||||
|   font-size: large; | ||||
|   color: ghostwhite; | ||||
|   color: aliceblue; | ||||
|   background-color: black; | ||||
|   border-radius: 1.2em; | ||||
|   z-index: 1; | ||||
|   &:focus { | ||||
|     outline: black; | ||||
|   } | ||||
|  | ||||
|   &:hover { | ||||
|     border-color: black; | ||||
|     opacity: 80%; | ||||
|   } | ||||
| } | ||||
|  | ||||
| #control-panel { | ||||
|   display: none; | ||||
|   overflow: hidden; | ||||
|   margin: auto; | ||||
|   gap: 16px; | ||||
|   grid-template-columns: repeat(3, 1fr); | ||||
|   transition: display 1s ease-out 0s; | ||||
| } | ||||
|  | ||||
| #control-panel.opened { | ||||
|   display: grid; | ||||
| } | ||||
|  | ||||
| .control { | ||||
|   display: flex; | ||||
|   border-radius: 16px; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   justify-content: center; | ||||
|   border: 2px solid #404040; | ||||
|   padding: 8px 16px; | ||||
| } | ||||
|  | ||||
| #three-slider input { | ||||
|   margin: 4px; | ||||
|   width: 50%; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 1000px) { | ||||
|   #control-panel { | ||||
|     grid-template-columns: repeat(2, 1fr); | ||||
|   } | ||||
|  | ||||
|   .control { | ||||
|     font-size: 80%; | ||||
|     margin: 0px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 768px) { | ||||
|   .container { | ||||
|     min-width: 96vw; | ||||
|   #control-panel { | ||||
|     grid-template-columns: 1fr; | ||||
|   } | ||||
|  | ||||
|   .networkroute { | ||||
|     display: none; | ||||
|   } | ||||
|  | ||||
|   .submit_text { | ||||
|     display: none; | ||||
|   } | ||||
|  | ||||
|   .submit { | ||||
|     position: fixed; | ||||
|     right: 16px; | ||||
|     bottom: 16px; | ||||
|     padding: 0px; | ||||
|     background-color: unset; | ||||
|     padding: 0.4em; | ||||
|     border-radius: 1em; | ||||
|     background-color: #36c8; | ||||
|     font-size: xx-large; | ||||
|     margin-bottom: 20px; | ||||
|     margin-right: 20px; | ||||
|     margin-bottom: 16px; | ||||
|     margin-right: 16px; | ||||
|   } | ||||
|  | ||||
|   .wavering { | ||||
|     animation: blink 40s infinite; | ||||
|   } | ||||
| } | ||||
|  | ||||
| @@ -152,16 +327,39 @@ button { | ||||
|   opacity: 0.75; | ||||
| } | ||||
|  | ||||
| .tablink { | ||||
|   background-color: unset; | ||||
|   font-weight: unset; | ||||
| .tab-button { | ||||
|   color: black; | ||||
|   border: 2px solid black; | ||||
|   border-radius: unset; | ||||
|   outline: black; | ||||
|   flex: 1; | ||||
|   background-color: #bfbfbf; | ||||
|   border: none; | ||||
|   margin: 4px auto; | ||||
|   cursor: pointer; | ||||
|   padding: 8px 16px; | ||||
|   width: 50%; | ||||
|   opacity: 80%; | ||||
| } | ||||
|  | ||||
| .tab-button.active { | ||||
|   opacity: unset; | ||||
|   font-weight: bold; | ||||
|   background-color: black; | ||||
|   color: white; | ||||
| } | ||||
|  | ||||
| .navbar { | ||||
|   span { | ||||
|     padding: 4px; | ||||
|   } | ||||
|  | ||||
|   button { | ||||
|     font-size: medium; | ||||
|     margin: 4px 0.5%; | ||||
|     padding-top: 4px; | ||||
|     padding-bottom: 4px; | ||||
|     opacity: 50%; | ||||
|  | ||||
|     &:hover { | ||||
|       opacity: 80%; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| /* Style the tab content (and add height:100% for full page content) */ | ||||
| @@ -179,18 +377,27 @@ button { | ||||
|   font-size: 150%; | ||||
| } | ||||
|  | ||||
| /*======LOGO=======*/ | ||||
|  | ||||
| .logo { | ||||
|   position: relative; | ||||
|   text-align: center; | ||||
|   height: 196px; | ||||
|   margin: auto; | ||||
|   height: 140px; | ||||
|  | ||||
|   span { | ||||
|     display: block; | ||||
|     margin: 2px; | ||||
|   } | ||||
|  | ||||
|   img { | ||||
|     display: block; | ||||
|     margin: auto; | ||||
|   } | ||||
|  | ||||
|   h3 { | ||||
|     position: absolute; | ||||
|     width: 200px; | ||||
|     font-size: medium; | ||||
|     width: 140px; | ||||
|     top: 33%; | ||||
|     left: 50%; | ||||
|     transform: translate(-50%, -50%); | ||||
| @@ -203,14 +410,218 @@ 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; | ||||
|   } | ||||
|   &.mmp { | ||||
|     background-color: lightskyblue; | ||||
|   } | ||||
|   &.fmp { | ||||
|     background-color: salmon; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .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; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @keyframes blink { | ||||
|   0% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   13% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   15% { | ||||
|     background-color: #f00a; | ||||
|   } | ||||
|  | ||||
|   17% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   38% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   40% { | ||||
|     background-color: #ff0a; | ||||
|   } | ||||
|  | ||||
|   42% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   63% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   65% { | ||||
|     background-color: #248f24aa; | ||||
|   } | ||||
|  | ||||
|   67% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   88% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   90% { | ||||
|     background-color: #4700b3aa; | ||||
|   } | ||||
|  | ||||
|   92% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
|  | ||||
|   100% { | ||||
|     background-color: #8888; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /*======SPINNER=======*/ | ||||
|  | ||||
| .loader { | ||||
|   display: block; | ||||
|   border-radius: 16px; | ||||
|   position: relative; | ||||
|   height: 12px; | ||||
|   width: 96%; | ||||
|   margin: auto; | ||||
|   border: 4px solid black; | ||||
|   overflow: hidden; | ||||
| } | ||||
|  | ||||
| .loader::after { | ||||
|   content: ""; | ||||
|   width: 32%; | ||||
| @@ -228,8 +639,94 @@ button { | ||||
|     left: 0; | ||||
|     transform: translateX(-100%); | ||||
|   } | ||||
|  | ||||
|   100% { | ||||
|     left: 100%; | ||||
|     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; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										67
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -1,31 +1,52 @@ | ||||
| import { baseUrl } from "./api"; | ||||
| import Analysis from "./Analysis"; | ||||
| import "./App.css"; | ||||
| import Footer from "./Footer"; | ||||
| import Header from "./Header"; | ||||
| import Rankings from "./Rankings"; | ||||
| import { BrowserRouter, Routes, Route } from "react-router"; | ||||
| import { SessionProvider } from "./Session"; | ||||
| import { GraphComponent } from "./Network"; | ||||
| import MVPChart from "./MVPChart"; | ||||
| import { SetPassword } from "./SetPassword"; | ||||
| 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() { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="logo"> | ||||
|         <a href={baseUrl}> | ||||
|           <img alt="logo" height="66%" src="logo.svg" /> | ||||
|         </a> | ||||
|         <h3 className="centered">cutt</h3> | ||||
|         <span className="grey">cool ultimate team tool</span> | ||||
|       </div> | ||||
|       <Rankings /> | ||||
|       <footer> | ||||
|         <p className="grey"> | ||||
|           something not working? | ||||
|           <br /> | ||||
|           message <a href="https://t.me/x0124816">me</a>. | ||||
|           <br /> | ||||
|           or fix it here:{" "} | ||||
|           <a href="https://git.0124816.xyz/julius/cutt" key="gitea"> | ||||
|             <img src="gitea.svg" alt="gitea" height="16" /> | ||||
|           </a> | ||||
|         </p> | ||||
|       </footer> | ||||
|     </> | ||||
|     <ThemeProvider> | ||||
|       <BrowserRouter> | ||||
|         <Routes> | ||||
|           <Route path="/password" element={<SetPassword />} /> | ||||
|           <Route | ||||
|             path="/*" | ||||
|             element={ | ||||
|               <SessionProvider> | ||||
|                 <Header /> | ||||
|                 <Routes> | ||||
|                   <Route index element={<Rankings />} /> | ||||
|                   <Route path="network" element={<GraphComponent />} /> | ||||
|                   <Route path="analysis" element={<Analysis />} /> | ||||
|                   <Route path="mvp" element={<MVPChart />} /> | ||||
|                   <Route path="changepassword" element={<SetPassword />} /> | ||||
|                   <Route path="team" element={<TeamPanel />} /> | ||||
|                 </Routes> | ||||
|                 <Footer /> | ||||
|               </SessionProvider> | ||||
|             } | ||||
|           /> | ||||
|         </Routes> | ||||
|       </BrowserRouter> | ||||
|     </ThemeProvider> | ||||
|   ); | ||||
| } | ||||
| export default App; | ||||
|   | ||||
							
								
								
									
										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> | ||||
|     </> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										95
									
								
								src/BarChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										95
									
								
								src/BarChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,95 @@ | ||||
| import { FC } from 'react'; | ||||
| import { PlayerRanking } from './types'; | ||||
|  | ||||
| interface BarChartProps { | ||||
|   players: PlayerRanking[]; | ||||
|   width: number; | ||||
|   height: number; | ||||
|   std: boolean; | ||||
| } | ||||
|  | ||||
| const BarChart: FC<BarChartProps> = ({ players, width, height, std }) => { | ||||
|   const padding = 24; | ||||
|   const maxValue = Math.max(...players.map((player) => player.rank)) + 1; | ||||
|   const barWidth = (width - 2 * padding) / players.length; | ||||
|  | ||||
|   return ( | ||||
|     <svg width={width} height={height}> | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <rect | ||||
|           key={index} | ||||
|           x={index * barWidth + padding} | ||||
|           y={height - (1 - player.rank / maxValue) * height} | ||||
|           width={barWidth - 8} // subtract 2 for some spacing between bars | ||||
|           height={(1 - player.rank / maxValue) * height} | ||||
|           fill="#69f" | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <text | ||||
|           key={index} | ||||
|           x={index * barWidth + barWidth / 2 - 4 + padding} | ||||
|           y={height - (1 - player.rank / maxValue) * height - 5} | ||||
|           textAnchor="middle" | ||||
|           //transform='rotate(-27)' | ||||
|           //style={{ transformOrigin: "center", transformBox: "fill-box" }} | ||||
|           fontSize="16px" | ||||
|           fill="#404040" | ||||
|         > | ||||
|           {player.name} | ||||
|         </text> | ||||
|       ))} | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <text | ||||
|           key={index} | ||||
|           x={index * barWidth + barWidth / 2 + padding - 4} | ||||
|           y={height - 8} | ||||
|           textAnchor="middle" | ||||
|           fontSize="12px" | ||||
|           fill="#404040" | ||||
|         > | ||||
|           {player.rank} | ||||
|         </text> | ||||
|       ))} | ||||
|  | ||||
|       {std && players.map((player, index) => ( | ||||
|         <line | ||||
|           key={`error-${index}`} | ||||
|           x1={index * barWidth + barWidth / 2 + padding} | ||||
|           y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height} | ||||
|           x2={index * barWidth + barWidth / 2 + padding} | ||||
|           y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height} | ||||
|           stroke="#ff0000" | ||||
|           strokeWidth="1" | ||||
|         /> | ||||
|       ))} | ||||
|       {std && players.map((player, index) => ( | ||||
|         <line | ||||
|           key={`cap-${index}-top`} | ||||
|           x1={index * barWidth + barWidth / 2 - 2 + padding} | ||||
|           y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height} | ||||
|           x2={index * barWidth + barWidth / 2 + 2 + padding} | ||||
|           y2={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height} | ||||
|           stroke="#ff0000" | ||||
|           strokeWidth="1" | ||||
|         /> | ||||
|       ))} | ||||
|       {std && players.map((player, index) => ( | ||||
|         <line | ||||
|           key={`cap-${index}-bottom`} | ||||
|           x1={index * barWidth + barWidth / 2 - 2 + padding} | ||||
|           y1={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height} | ||||
|           x2={index * barWidth + barWidth / 2 + 2 + padding} | ||||
|           y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height} | ||||
|           stroke="#ff0000" | ||||
|           strokeWidth="1" | ||||
|         /> | ||||
|       ))} | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| export default BarChart; | ||||
							
								
								
									
										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?.filter((p) => p.id === Number(id)); | ||||
|               return ( | ||||
|                 <li key={id}> | ||||
|                   {name ? name[0].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; | ||||
							
								
								
									
										42
									
								
								src/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								src/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,42 @@ | ||||
| import { useLocation } from "react-router"; | ||||
| import { Link } from "react-router"; | ||||
| import { useSession } from "./Session"; | ||||
|  | ||||
| export default function Footer() { | ||||
|   const location = useLocation(); | ||||
|   const { user, teams } = useSession(); | ||||
|   return ( | ||||
|     <footer className={location.pathname === "/network" ? "fixed-footer" : ""}> | ||||
|       {(user?.scopes.split(" ").includes("analysis") || | ||||
|         teams?.activeTeam === 42) && ( | ||||
|         <div className="navbar"> | ||||
|           <Link to="/"> | ||||
|             <span>Form</span> | ||||
|           </Link> | ||||
|           <span>|</span> | ||||
|           <Link to="/network"> | ||||
|             <span>Sociogram</span> | ||||
|           </Link> | ||||
|           <span>|</span> | ||||
|           <Link to="/mvp"> | ||||
|             <span>MVP</span> | ||||
|           </Link> | ||||
|           <span>|</span> | ||||
|           <Link to="/team"> | ||||
|             <span>Team</span> | ||||
|           </Link> | ||||
|         </div> | ||||
|       )} | ||||
|       <p className="grey extra-margin"> | ||||
|         something not working? | ||||
|         <br /> | ||||
|         message <a href="https://t.me/x0124816">me</a>. | ||||
|         <br /> | ||||
|         or fix it here:{" "} | ||||
|         <a href="https://git.0124816.xyz/julius/cutt" key="gitea"> | ||||
|           <img src="gitea.svg" alt="gitea" height="16" /> | ||||
|         </a> | ||||
|       </p> | ||||
|     </footer> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										18
									
								
								src/Header.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/Header.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { Link, useLocation } from "react-router"; | ||||
| import Avatar from "./Avatar"; | ||||
|  | ||||
| export default function Header() { | ||||
|   const location = useLocation(); | ||||
|   return ( | ||||
|     <div className={location.pathname === "/network" ? "networkroute" : ""}> | ||||
|       <div className="logo"> | ||||
|         <Link to="/"> | ||||
|           <img alt="logo" height="66%" src="logo.svg" /> | ||||
|           <h3 className="centered">cutt</h3> | ||||
|         </Link> | ||||
|         <span className="grey">cool ultimate team tool</span> | ||||
|       </div> | ||||
|       <Avatar /> | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										72
									
								
								src/Icons.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/Icons.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| export const Eye = () => ( | ||||
|   <svg | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="1em" | ||||
|     height="1em" | ||||
|     viewBox="0 0 36 36" | ||||
|   > | ||||
|     <path | ||||
|       fill="#E1E8ED" | ||||
|       d="M35.059 18c0 3.304-7.642 11-17.067 11S.925 22.249.925 18c0-3.314 34.134-3.314 34.134 0" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#292F33" | ||||
|       d="M35.059 18H.925c0-3.313 7.642-11 17.067-11s17.067 7.686 17.067 11" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#F5F8FA" | ||||
|       d="M33.817 18c0 2.904-7.087 9.667-15.826 9.667S2.166 21.732 2.166 18c0-2.912 7.085-9.666 15.825-9.666C26.73 8.333 33.817 15.088 33.817 18" | ||||
|     /> | ||||
|     <circle | ||||
|       cx="18" | ||||
|       cy="18" | ||||
|       r="8.458" | ||||
|       fill="#8B5E3C" | ||||
|       style={{ fill: "#919191", fillOpacity: 1 }} | ||||
|     /> | ||||
|     <circle cx="18" cy="18" r="4.708" fill="#292F33" /> | ||||
|     <circle cx="14.983" cy="15" r="2" fill="#F5F8FA" /> | ||||
|   </svg> | ||||
| ); | ||||
|  | ||||
| export const EyeSlash = () => ( | ||||
|   <svg | ||||
|     xmlns="http://www.w3.org/2000/svg" | ||||
|     width="1em" | ||||
|     height="1em" | ||||
|     viewBox="0 0 36 36" | ||||
|   > | ||||
|     <path | ||||
|       fill="#e1e8ed" | ||||
|       d="M35.059 18c0 3.304-7.642 11-17.067 11S.925 22.249.925 18c0-3.314 34.134-3.314 34.134 0" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#292f33" | ||||
|       d="M35.059 18H.925c0-3.313 7.642-11 17.067-11s17.067 7.686 17.067 11" | ||||
|     /> | ||||
|     <path | ||||
|       fill="#f5f8fa" | ||||
|       d="M33.817 18c0 2.904-7.087 9.667-15.826 9.667S2.166 21.732 2.166 18c0-2.912 7.085-9.666 15.825-9.666C26.73 8.333 33.817 15.088 33.817 18" | ||||
|     /> | ||||
|     <circle | ||||
|       cx="18" | ||||
|       cy="18" | ||||
|       r="8.458" | ||||
|       fill="#8B5E3C" | ||||
|       style={{ fill: "#919191", fillOpacity: 1 }} | ||||
|     /> | ||||
|     <circle cx="18" cy="18" r="4.708" fill="#292f33" /> | ||||
|     <circle cx="14.983" cy="15" r="2" fill="#f5f8fa" /> | ||||
|     <path | ||||
|       d="m-2.97 30.25 41.94-24.5" | ||||
|       style={{ | ||||
|         fill: "#404040", | ||||
|         fillOpacity: 1, | ||||
|         stroke: "#404040", | ||||
|         strokeWidth: 3, | ||||
|         strokeDasharray: "none", | ||||
|         strokeOpacity: 1, | ||||
|       }} | ||||
|     /> | ||||
|   </svg> | ||||
| ); | ||||
							
								
								
									
										125
									
								
								src/Login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										125
									
								
								src/Login.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,125 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { currentUser, login, User } from "./api"; | ||||
| import Header from "./Header"; | ||||
| import { useLocation, useNavigate } from "react-router"; | ||||
| import { Eye, EyeSlash } from "./Icons"; | ||||
|  | ||||
| export interface LoginProps { | ||||
|   onLogin: (user: User) => void; | ||||
| } | ||||
|  | ||||
| export const Login = ({ onLogin }: LoginProps) => { | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [visible, setVisible] = useState(false); | ||||
|   const navigate = useNavigate(); | ||||
|   const location = useLocation(); | ||||
|  | ||||
|   async function doLogin() { | ||||
|     setLoading(true); | ||||
|     setError(""); | ||||
|     const timeout = new Promise((r) => setTimeout(r, 1000)); | ||||
|     let user: User; | ||||
|     try { | ||||
|       await login({ username, password }); | ||||
|       user = await currentUser(); | ||||
|     } catch (e) { | ||||
|       await timeout; | ||||
|       setError("failed"); | ||||
|       setLoading(false); | ||||
|       return; | ||||
|     } | ||||
|     await timeout; | ||||
|     onLogin(user); | ||||
|   } | ||||
|  | ||||
|   function handleSubmit(e: React.FormEvent) { | ||||
|     e.preventDefault(); | ||||
|     doLogin(); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (location.state) { | ||||
|       const queryUsername = location.state.username; | ||||
|       const queryPassword = location.state.password; | ||||
|       if (queryUsername) setUsername(queryUsername); | ||||
|       if (queryPassword) setPassword(queryPassword); | ||||
|       navigate(location.pathname, { replace: true }); | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <Header /> | ||||
|       <form onSubmit={handleSubmit}> | ||||
|         <div | ||||
|           style={{ | ||||
|             position: "relative", | ||||
|             display: "flex", | ||||
|             alignItems: "end", | ||||
|           }} | ||||
|         > | ||||
|           <div | ||||
|             style={{ | ||||
|               width: "100%", | ||||
|               marginRight: "8px", | ||||
|               display: "flex", | ||||
|               justifyContent: "center", | ||||
|               flexDirection: "column", | ||||
|             }} | ||||
|           > | ||||
|             <div> | ||||
|               <input | ||||
|                 type="text" | ||||
|                 id="username" | ||||
|                 name="username" | ||||
|                 placeholder="username" | ||||
|                 required | ||||
|                 value={username} | ||||
|                 onChange={(evt) => { | ||||
|                   setError(""); | ||||
|                   setUsername(evt.target.value); | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|             <div> | ||||
|               <input | ||||
|                 type={visible ? "text" : "password"} | ||||
|                 id="password" | ||||
|                 name="password" | ||||
|                 placeholder="password" | ||||
|                 minLength={8} | ||||
|                 value={password} | ||||
|                 required | ||||
|                 onChange={(evt) => { | ||||
|                   setError(""); | ||||
|                   setPassword(evt.target.value); | ||||
|                 }} | ||||
|               /> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div | ||||
|             style={{ | ||||
|               position: "absolute", | ||||
|               right: "-1em", | ||||
|               margin: "auto 4px", | ||||
|               background: "unset", | ||||
|               fontSize: "x-large", | ||||
|               cursor: "pointer", | ||||
|             }} | ||||
|             onClick={() => setVisible(!visible)} | ||||
|           > | ||||
|             {visible ? <Eye /> : <EyeSlash />} | ||||
|           </div> | ||||
|         </div> | ||||
|         {error && <span style={{ color: "red" }}>{error}</span>} | ||||
|         <button type="submit" value="login" style={{ fontSize: "small" }}> | ||||
|           login | ||||
|         </button> | ||||
|         {loading && <span className="loader" />} | ||||
|       </form> | ||||
|     </> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										52
									
								
								src/MVPChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								src/MVPChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,52 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { apiAuth } from "./api"; | ||||
| import { PlayerRanking } from "./types"; | ||||
| import RaceChart from "./RaceChart"; | ||||
| import { useSession } from "./Session"; | ||||
| import { useNavigate } from "react-router"; | ||||
|  | ||||
| const MVPChart = () => { | ||||
|   let initialData = {} as PlayerRanking[]; | ||||
|   const [data, setData] = useState(initialData); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [showStd, setShowStd] = useState(false); | ||||
|   const { user, teams } = useSession(); | ||||
|   const navigate = useNavigate(); | ||||
|   useEffect(() => { | ||||
|     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||
|       teams?.activeTeam === 42 || | ||||
|       navigate("/", { replace: true }); | ||||
|   }, [user]); | ||||
|  | ||||
|   async function loadData() { | ||||
|     setLoading(true); | ||||
|     if (teams) { | ||||
|       await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null) | ||||
|         .then((data) => { | ||||
|           if (data.detail) { | ||||
|             setError(data.detail); | ||||
|             return initialData; | ||||
|           } else { | ||||
|             setError(""); | ||||
|             return data as Promise<PlayerRanking[]>; | ||||
|           } | ||||
|         }) | ||||
|         .then((data) => { | ||||
|           setData(data.sort((a, b) => a.rank - b.rank)); | ||||
|         }) | ||||
|         .catch(() => setError("no access")); | ||||
|       setLoading(false); | ||||
|     } else setError("team unknown"); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadData(); | ||||
|   }, [teams]); | ||||
|  | ||||
|   if (loading) return <span className="loader" />; | ||||
|   else if (error) return <span>{error}</span>; | ||||
|   else return <RaceChart std={showStd} players={data} />; | ||||
| }; | ||||
|  | ||||
| export default MVPChart; | ||||
							
								
								
									
										319
									
								
								src/Network.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								src/Network.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,319 @@ | ||||
| import { ReactNode, useEffect, useRef, useState } from "react"; | ||||
| import { apiAuth } from "./api"; | ||||
| import { | ||||
|   GraphCanvas, | ||||
|   GraphCanvasRef, | ||||
|   GraphEdge, | ||||
|   GraphNode, | ||||
|   SelectionProps, | ||||
|   SelectionResult, | ||||
|   useSelection, | ||||
| } from "reagraph"; | ||||
| import { customTheme } from "./NetworkTheme"; | ||||
| import { useSession } from "./Session"; | ||||
| import { useNavigate } from "react-router"; | ||||
|  | ||||
| interface NetworkData { | ||||
|   nodes: GraphNode[]; | ||||
|   edges: GraphEdge[]; | ||||
| } | ||||
| interface CustomSelectionProps extends SelectionProps { | ||||
|   ignore: (GraphEdge | undefined)[]; | ||||
| } | ||||
|  | ||||
| const useCustomSelection = (props: CustomSelectionProps): SelectionResult => { | ||||
|   var result = useSelection(props); | ||||
|   result.actives = result.actives.filter( | ||||
|     (s) => !props.ignore.map((edge) => edge?.id).includes(s) | ||||
|   ); | ||||
|   const ignored_nodes = props.ignore.map((edge) => | ||||
|     edge && | ||||
|     result.selections?.includes(edge.source) && | ||||
|     !result.selections?.includes(edge.target) | ||||
|       ? edge.target | ||||
|       : "" | ||||
|   ); | ||||
|   result.actives = result.actives.filter((s) => !ignored_nodes.includes(s)); | ||||
|   return result; | ||||
| }; | ||||
|  | ||||
| export const GraphComponent = () => { | ||||
|   let initialData = { nodes: [], edges: [] } as NetworkData; | ||||
|   const [data, setData] = useState(initialData); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [threed, setThreed] = useState(false); | ||||
|   const [likes, setLikes] = useState(2); | ||||
|   const [popularity, setPopularity] = useState(false); | ||||
|   const [mutuality, setMutuality] = useState(false); | ||||
|   const { user, teams } = useSession(); | ||||
|   const navigate = useNavigate(); | ||||
|   useEffect(() => { | ||||
|     user?.scopes.includes(`team:${teams?.activeTeam}`) || | ||||
|       teams?.activeTeam === 42 || | ||||
|       navigate("/", { replace: true }); | ||||
|   }, [user]); | ||||
|  | ||||
|   async function loadData() { | ||||
|     setLoading(true); | ||||
|     if (teams) { | ||||
|       await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null) | ||||
|         .then((data) => { | ||||
|           if (data.detail) { | ||||
|             setError(data.detail); | ||||
|             return initialData; | ||||
|           } else { | ||||
|             setError(""); | ||||
|             return data as Promise<NetworkData>; | ||||
|           } | ||||
|         }) | ||||
|         .then((data) => setData(data)) | ||||
|         .catch(() => setError("no access")); | ||||
|       setLoading(false); | ||||
|     } else setError("team unknown"); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadData(); | ||||
|   }, [teams]); | ||||
|  | ||||
|   const graphRef = useRef<GraphCanvasRef | null>(null); | ||||
|  | ||||
|   function handleThreed() { | ||||
|     setThreed(!threed); | ||||
|     graphRef.current?.fitNodesInView(); | ||||
|     graphRef.current?.centerGraph(); | ||||
|     graphRef.current?.resetControls(); | ||||
|   } | ||||
|  | ||||
|   function handlePopularity() { | ||||
|     popularityLabel(!popularity); | ||||
|     setPopularity(!popularity); | ||||
|   } | ||||
|  | ||||
|   function handleMutuality() { | ||||
|     colorMatches(!mutuality); | ||||
|     setMutuality(!mutuality); | ||||
|   } | ||||
|  | ||||
|   function showLabel() { | ||||
|     switch (likes) { | ||||
|       case 0: | ||||
|         return "dislike"; | ||||
|       case 1: | ||||
|         return "both"; | ||||
|       case 2: | ||||
|         return "like"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   function findMatches(edges: GraphEdge[]) { | ||||
|     const adjacencyList = edges.map( | ||||
|       (edge) => edge.source + edge.target + edge.data.relation | ||||
|     ); | ||||
|     return edges.filter((edge) => | ||||
|       adjacencyList.includes(edge.target + edge.source + edge.data.relation) | ||||
|     ); | ||||
|   } | ||||
|   //const matches = useMemo(() => findMatches(data.edges), []) | ||||
|  | ||||
|   function colorMatches(mutuality: boolean) { | ||||
|     const matches = findMatches(data.edges); | ||||
|     const newEdges = data.edges; | ||||
|     if (mutuality) { | ||||
|       newEdges.forEach((edge) => { | ||||
|         if ( | ||||
|           (likes === 1 || edge.data.relation === likes) && | ||||
|           matches.map((edge) => edge.id).includes(edge.id) | ||||
|         ) { | ||||
|           edge.fill = "#9c3"; | ||||
|           if (edge.size) edge.size = edge.size * 1.5; | ||||
|         } | ||||
|       }); | ||||
|     } else { | ||||
|       newEdges.forEach((edge) => { | ||||
|         if ( | ||||
|           (likes === 1 || edge.data.relation === likes) && | ||||
|           matches.map((edge) => edge.id).includes(edge.id) | ||||
|         ) { | ||||
|           edge.fill = edge.data.origFill; | ||||
|           edge.size = edge.data.origSize; | ||||
|         } | ||||
|       }); | ||||
|     } | ||||
|     setData({ nodes: data.nodes, edges: newEdges }); | ||||
|   } | ||||
|  | ||||
|   function popularityLabel(popularity: boolean) { | ||||
|     const newNodes = data.nodes; | ||||
|     console.log(data.nodes); | ||||
|     if (popularity) { | ||||
|       newNodes.forEach( | ||||
|         (node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`) | ||||
|       ); | ||||
|     } else { | ||||
|       newNodes.forEach((node) => (node.subLabel = undefined)); | ||||
|     } | ||||
|     setData({ nodes: newNodes, edges: data.edges }); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (mutuality) colorMatches(false); | ||||
|     colorMatches(mutuality); | ||||
|   }, [likes]); | ||||
|  | ||||
|   const { | ||||
|     selections, | ||||
|     actives, | ||||
|     onNodeClick, | ||||
|     onCanvasClick, | ||||
|     onNodePointerOver, | ||||
|     onNodePointerOut, | ||||
|   } = useCustomSelection({ | ||||
|     ref: graphRef, | ||||
|     nodes: data.nodes, | ||||
|     edges: data.edges.filter((edge) => edge.data.relation === likes), | ||||
|     ignore: data.edges.map((edge) => { | ||||
|       if (likes === 1 && edge.data.relation !== 2) return edge; | ||||
|     }), | ||||
|     pathSelectionType: "out", | ||||
|     pathHoverType: "in", | ||||
|     type: "multiModifier", | ||||
|   }); | ||||
|  | ||||
|   let content: ReactNode; | ||||
|   if (loading) { | ||||
|     content = <span className="loader" />; | ||||
|   } else if (error) { | ||||
|     content = <span>{error}</span>; | ||||
|   } else { | ||||
|     content = ( | ||||
|       <> | ||||
|         <GraphCanvas | ||||
|           draggable | ||||
|           cameraMode={threed ? "rotate" : "pan"} | ||||
|           layoutType={threed ? "forceDirected3d" : "forceDirected2d"} | ||||
|           layoutOverrides={{ | ||||
|             nodeStrength: -200, | ||||
|             linkDistance: 100, | ||||
|           }} | ||||
|           labelType="nodes" | ||||
|           sizingType="attribute" | ||||
|           sizingAttribute={popularity ? "inDegree" : undefined} | ||||
|           ref={graphRef} | ||||
|           theme={customTheme} | ||||
|           nodes={data.nodes} | ||||
|           edges={data.edges.filter( | ||||
|             (edge) => edge.data.relation === likes || likes === 1 | ||||
|           )} | ||||
|           selections={selections} | ||||
|           actives={actives} | ||||
|           onCanvasClick={onCanvasClick} | ||||
|           onNodeClick={onNodeClick} | ||||
|           onNodePointerOut={onNodePointerOut} | ||||
|           onNodePointerOver={onNodePointerOver} | ||||
|         /> | ||||
|         <button | ||||
|           className="infobutton" | ||||
|           onClick={() => { | ||||
|             const dialog = document.querySelector("dialog[id='InfoDialog']"); | ||||
|             (dialog as HTMLDialogElement).showModal(); | ||||
|           }} | ||||
|         > | ||||
|           info | ||||
|         </button> | ||||
|  | ||||
|         <dialog | ||||
|           id="InfoDialog" | ||||
|           style={{ textAlign: "left" }} | ||||
|           onClick={(event) => { | ||||
|             event.currentTarget.close(); | ||||
|           }} | ||||
|         > | ||||
|           scroll to zoom | ||||
|           <br /> | ||||
|           <br /> | ||||
|           <b>hover</b>: show inbound links | ||||
|           <br /> | ||||
|           <b>click</b>: show outward links | ||||
|           <br /> | ||||
|           <br /> | ||||
|           multi-selection possible | ||||
|           <br /> | ||||
|           with <i>Ctrl</i> or <i>Shift</i> | ||||
|           <br /> | ||||
|           <br /> | ||||
|           drag to pan/rotate | ||||
|         </dialog> | ||||
|       </> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}> | ||||
|       <div className="controls"> | ||||
|         <div className="control" onClick={handleMutuality}> | ||||
|           <div className="switch"> | ||||
|             <input type="checkbox" checked={mutuality} onChange={() => {}} /> | ||||
|             <span className="slider round"></span> | ||||
|           </div> | ||||
|           <span>mutuality</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control" onClick={handleThreed}> | ||||
|           <span>2D</span> | ||||
|           <div className="switch"> | ||||
|             <input type="checkbox" checked={threed} onChange={() => {}} /> | ||||
|             <span className="slider round"></span> | ||||
|           </div> | ||||
|           <span>3D</span> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control"> | ||||
|           <div className="stack column"> | ||||
|             <datalist id="markers"> | ||||
|               <option value="0"></option> | ||||
|               <option value="1"></option> | ||||
|               <option value="2"></option> | ||||
|             </datalist> | ||||
|             <div id="three-slider"> | ||||
|               <label>😬</label> | ||||
|               <input | ||||
|                 type="range" | ||||
|                 list="markers" | ||||
|                 min="0" | ||||
|                 max="2" | ||||
|                 step="1" | ||||
|                 width="16px" | ||||
|                 onChange={(evt) => setLikes(Number(evt.target.value))} | ||||
|               /> | ||||
|               <label>😍</label> | ||||
|             </div> | ||||
|             {showLabel()} | ||||
|           </div> | ||||
|         </div> | ||||
|  | ||||
|         <div className="control" onClick={handlePopularity}> | ||||
|           <div className="switch"> | ||||
|             <input type="checkbox" checked={popularity} onChange={() => {}} /> | ||||
|             <span className="slider round"></span> | ||||
|           </div> | ||||
|           <span> | ||||
|             popularity<sup>*</sup> | ||||
|           </span> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       {popularity && ( | ||||
|         <div | ||||
|           style={{ position: "absolute", bottom: 0, right: "10px", zIndex: 10 }} | ||||
|         > | ||||
|           <span className="grey" style={{ fontSize: "70%" }}> | ||||
|             <sup>*</sup>popularity meassured by rank-weighted in-degree | ||||
|           </span> | ||||
|         </div> | ||||
|       )} | ||||
|       {content} | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										59
									
								
								src/NetworkTheme.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								src/NetworkTheme.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| import { Theme } from "reagraph"; | ||||
|  | ||||
| export var customTheme: Theme = { | ||||
|   canvas: { | ||||
|     background: "aliceblue", | ||||
|   }, | ||||
|   node: { | ||||
|     fill: "#69F", | ||||
|     activeFill: "#36C", | ||||
|     opacity: 1, | ||||
|     selectedOpacity: 1, | ||||
|     inactiveOpacity: 0.333, | ||||
|     label: { | ||||
|       color: "#404040", | ||||
|       stroke: "white", | ||||
|       activeColor: "black", | ||||
|     }, | ||||
|     subLabel: { | ||||
|       color: "#404040", | ||||
|       stroke: "white", | ||||
|       activeColor: "black", | ||||
|     }, | ||||
|   }, | ||||
|   lasso: { | ||||
|     border: "1px solid #55aaff", | ||||
|     background: "rgba(75, 160, 255, 0.1)", | ||||
|   }, | ||||
|   ring: { | ||||
|     fill: "#69F", | ||||
|     activeFill: "#36C", | ||||
|   }, | ||||
|   edge: { | ||||
|     fill: "#bed4ff", | ||||
|     activeFill: "#36C", | ||||
|     opacity: 1, | ||||
|     selectedOpacity: 1, | ||||
|     inactiveOpacity: 0.333, | ||||
|     label: { | ||||
|       stroke: "#fff", | ||||
|       color: "#2A6475", | ||||
|       activeColor: "#1DE9AC", | ||||
|       fontSize: 6, | ||||
|     }, | ||||
|   }, | ||||
|   arrow: { | ||||
|     fill: "#bed4ff", | ||||
|     activeFill: "#36C", | ||||
|   }, | ||||
|   cluster: { | ||||
|     stroke: "#D8E6EA", | ||||
|     opacity: 1, | ||||
|     selectedOpacity: 1, | ||||
|     inactiveOpacity: 0.1, | ||||
|     label: { | ||||
|       stroke: "#fff", | ||||
|       color: "#2A6475", | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
							
								
								
									
										99
									
								
								src/RaceChart.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								src/RaceChart.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
| import { FC, useEffect, useState } from "react"; | ||||
| import { PlayerRanking } from "./types"; | ||||
|  | ||||
| interface RaceChartProps { | ||||
|   players: PlayerRanking[]; | ||||
|   std: boolean; | ||||
| } | ||||
|  | ||||
| const determineNiceWidth = (width: number) => { | ||||
|   const max = 1080; | ||||
|   if (width >= max) return max; | ||||
|   else if (width > 768) return width * 0.8; | ||||
|   else return width * 0.96; | ||||
| }; | ||||
|  | ||||
| const RaceChart: FC<RaceChartProps> = ({ players, std }) => { | ||||
|   const [width, setWidth] = useState(determineNiceWidth(window.innerWidth)); | ||||
|   //const [height, setHeight] = useState(window.innerHeight); | ||||
|   const height = (players.length + 1) * 40; | ||||
|  | ||||
|   useEffect(() => { | ||||
|     const handleResize = () => { | ||||
|       setWidth(determineNiceWidth(window.innerWidth)); | ||||
|       //setHeight(window.innerHeight); | ||||
|     }; | ||||
|     window.addEventListener("resize", handleResize); | ||||
|     return () => { | ||||
|       window.removeEventListener("resize", handleResize); | ||||
|     }; | ||||
|   }, []); | ||||
|   const padding = 24; | ||||
|   const gap = 8; | ||||
|   const maxValue = Math.max(...players.map((player) => player.rank)) + 1; | ||||
|   const barHeight = (height - 2 * padding) / players.length; | ||||
|   const fontSize = Math.min(barHeight - 1.5 * gap, width / 22); | ||||
|  | ||||
|   return ( | ||||
|     <svg width={width} height={height} id="RaceChartSVG"> | ||||
|       {players.map((player, index) => ( | ||||
|         <rect | ||||
|           key={String(index)} | ||||
|           x={4} | ||||
|           y={index * barHeight + padding} | ||||
|           width={(1 - player.rank / maxValue) * width} | ||||
|           height={barHeight - gap} // subtract 2 for some spacing between bars | ||||
|           fill="#36c" | ||||
|           stroke="aliceblue" | ||||
|           strokeWidth={4} | ||||
|           paintOrder={"stroke fill"} | ||||
|         /> | ||||
|       ))} | ||||
|  | ||||
|       {players.map((player, index) => ( | ||||
|         <g key={"group" + index}> | ||||
|           <text | ||||
|             key={index + "_name"} | ||||
|             x={8} | ||||
|             y={index * barHeight + barHeight / 2 + padding + gap / 2} | ||||
|             width={(1 - player.rank / maxValue) * width} | ||||
|             height={barHeight - 8} // subtract 2 for some spacing between bars | ||||
|             fontSize={fontSize} | ||||
|             fill="aliceblue" | ||||
|             stroke="#36c" | ||||
|             strokeWidth={4} | ||||
|             fontWeight={"bold"} | ||||
|             paintOrder={"stroke fill"} | ||||
|             fontFamily="monospace" | ||||
|             style={{ whiteSpace: "pre" }} | ||||
|           > | ||||
|             {`${String(index + 1).padStart(2)}. ${player.name}`} | ||||
|           </text> | ||||
|           <text | ||||
|             key={index + "_value"} | ||||
|             x={ | ||||
|               8 + | ||||
|               (4 + Math.max(...players.map((p, _) => p.name.length))) * | ||||
|                 fontSize * | ||||
|                 0.66 | ||||
|             } | ||||
|             y={index * barHeight + barHeight / 2 + padding + gap / 2} | ||||
|             width={(1 - player.rank / maxValue) * width} | ||||
|             height={barHeight - 8} // subtract 2 for some spacing between bars | ||||
|             fontSize={0.8 * fontSize} | ||||
|             fill="aliceblue" | ||||
|             stroke="#36c" | ||||
|             fontWeight={"bold"} | ||||
|             fontFamily="monospace" | ||||
|             strokeWidth={4} | ||||
|             paintOrder={"stroke fill"} | ||||
|             style={{ whiteSpace: "pre" }} | ||||
|           > | ||||
|             {`${String(player.rank).padStart(5)} ± ${player.std}   N = ${player.n}`} | ||||
|           </text> | ||||
|         </g> | ||||
|       ))} | ||||
|     </svg> | ||||
|   ); | ||||
| }; | ||||
| export default RaceChart; | ||||
							
								
								
									
										641
									
								
								src/Rankings.tsx
									
									
									
									
									
								
							
							
						
						
									
										641
									
								
								src/Rankings.tsx
									
									
									
									
									
								
							| @@ -1,12 +1,9 @@ | ||||
| import { Dispatch, SetStateAction, useEffect, useState } from "react"; | ||||
| import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react"; | ||||
| import { ReactSortable, ReactSortableProps } from "react-sortablejs"; | ||||
| import api, { baseUrl } from "./api"; | ||||
|  | ||||
| interface Player { | ||||
|   id: number; | ||||
|   name: string; | ||||
|   number: string | null; | ||||
| } | ||||
| import { apiAuth, User } from "./api"; | ||||
| import { TeamState, useSession } from "./Session"; | ||||
| import { Chemistry, MVPRanking, PlayerType } from "./types"; | ||||
| import TabController from "./TabController"; | ||||
|  | ||||
| type PlayerListProps = Partial<ReactSortableProps<any>> & { | ||||
|   orderedList?: boolean; | ||||
| @@ -14,170 +11,167 @@ type PlayerListProps = Partial<ReactSortableProps<any>> & { | ||||
|  | ||||
| function PlayerList(props: PlayerListProps) { | ||||
|   return ( | ||||
|     <ReactSortable {...props} animation={200}> | ||||
|     <ReactSortable | ||||
|       {...props} | ||||
|       animation={200} | ||||
|       swapThreshold={0.2} | ||||
|       style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }} | ||||
|     > | ||||
|       {props.list?.map((item, index) => ( | ||||
|         <div key={item.id} className="item"> | ||||
|           {props.orderedList ? index + 1 + ". " + item.name : item.name} | ||||
|           {props.orderedList | ||||
|             ? index + 1 + ". " + item.display_name | ||||
|             : item.display_name} | ||||
|         </div> | ||||
|       ))} | ||||
|     </ReactSortable> | ||||
|   ); | ||||
| } | ||||
|  | ||||
| interface SelectUserProps { | ||||
|   user: Player[]; | ||||
|   setUser: Dispatch<SetStateAction<Player[]>>; | ||||
|   players: Player[]; | ||||
|   setPlayers: Dispatch<SetStateAction<Player[]>>; | ||||
| } | ||||
|  | ||||
| export function SelectUser({ | ||||
|   user, | ||||
|   setUser, | ||||
|   players, | ||||
|   setPlayers, | ||||
| }: SelectUserProps) { | ||||
| const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="box user"> | ||||
|         {user.length < 1 ? ( | ||||
|           <> | ||||
|             <span>your name?</span> | ||||
|             <br /> <span className="grey hint">drag your name here</span> | ||||
|           </> | ||||
|         ) : ( | ||||
|           <> | ||||
|             <span | ||||
|               className="renew" | ||||
|               onClick={() => { | ||||
|                 setUser([]); | ||||
|               }} | ||||
|             > | ||||
|               {" ✕"} | ||||
|             </span> | ||||
|           </> | ||||
|         )} | ||||
|         <PlayerList | ||||
|           list={user} | ||||
|           setList={setUser} | ||||
|           group={{ | ||||
|             name: "user-shared", | ||||
|             put: function (to) { | ||||
|               return to.el.children.length < 1; | ||||
|             }, | ||||
|           }} | ||||
|           className="dragbox" | ||||
|         /> | ||||
|       </div> | ||||
|       {user.length < 1 && ( | ||||
|         <div className="box one"> | ||||
|           <h2>🥏🏃</h2> | ||||
|           <ReactSortable | ||||
|             list={players} | ||||
|             setList={setPlayers} | ||||
|             group={{ name: "user-shared", pull: "clone" }} | ||||
|             className="dragbox reservoir" | ||||
|             animation={200} | ||||
|           > | ||||
|             {players.length < 1 ? ( | ||||
|               <span className="loader"></span> | ||||
|             ) : ( | ||||
|               players.map((item, _) => ( | ||||
|                 <div key={"extra" + item.id} className="extra-margin"> | ||||
|                   <div key={item.id} className="item"> | ||||
|                     {item.name} | ||||
|                   </div> | ||||
|                 </div> | ||||
|               )) | ||||
|             )} | ||||
|           </ReactSortable> | ||||
|         </div> | ||||
|       )} | ||||
|     </> | ||||
|     <button {...props} style={{ padding: "4px 16px" }}> | ||||
|       🗃️ restore previous | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => { | ||||
|   return ( | ||||
|     <button {...props} style={{ padding: "4px 16px" }}> | ||||
|       🗑️ start over | ||||
|     </button> | ||||
|   ); | ||||
| }; | ||||
|  | ||||
| function filterSort(list: User[], ids: number[]): User[] { | ||||
|   const objectMap = new Map(list.map((obj) => [obj.id, obj])); | ||||
|   const filteredAndSortedObjects = ids | ||||
|     .map((id) => objectMap.get(id)) | ||||
|     .filter((obj) => obj !== undefined); | ||||
|   return filteredAndSortedObjects; | ||||
| } | ||||
|  | ||||
| interface PlayerInfoProps { | ||||
|   user: Player[]; | ||||
|   players: Player[]; | ||||
|   user: User; | ||||
|   teams: TeamState; | ||||
|   players: User[]; | ||||
| } | ||||
|  | ||||
| export function Chemistry({ user, players }: PlayerInfoProps) { | ||||
|   const index = players.indexOf(user[0]); | ||||
|   var otherPlayers = players.slice(); | ||||
|   otherPlayers.splice(index, 1); | ||||
|   const [playersLeft, setPlayersLeft] = useState<Player[]>([]); | ||||
|   const [playersMiddle, setPlayersMiddle] = useState<Player[]>(otherPlayers); | ||||
|   const [playersRight, setPlayersRight] = useState<Player[]>([]); | ||||
| function ChemistryDnD({ user, teams, players }: PlayerInfoProps) { | ||||
|   var otherPlayers = players.filter((player) => player.id !== user.id); | ||||
|   const [playersLeft, setPlayersLeft] = useState<User[]>([]); | ||||
|   const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers); | ||||
|   const [playersRight, setPlayersRight] = useState<User[]>([]); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     handleGet(); | ||||
|   }, [players]); | ||||
|  | ||||
|   const [dialog, setDialog] = useState("dialog"); | ||||
|   const dialogRef = useRef<HTMLDialogElement>(null); | ||||
|  | ||||
|   async function handleSubmit() { | ||||
|     const dialog = document.querySelector("dialog[id='ChemistryDialog']"); | ||||
|     (dialog as HTMLDialogElement).showModal(); | ||||
|     if (user.length < 1) { | ||||
|       setDialog("who are you?"); | ||||
|     if (dialogRef.current) dialogRef.current.showModal(); | ||||
|     setDialog("sending..."); | ||||
|     let left = playersLeft.map(({ id }) => id); | ||||
|     let middle = playersMiddle.map(({ id }) => id); | ||||
|     let right = playersRight.map(({ id }) => id); | ||||
|     const data = { | ||||
|       user: user.id, | ||||
|       hate: left, | ||||
|       undecided: middle, | ||||
|       love: right, | ||||
|       team: teams.activeTeam, | ||||
|     }; | ||||
|     const response = await apiAuth("chemistry", data, "PUT"); | ||||
|     setDialog(response || "try sending again"); | ||||
|   } | ||||
|  | ||||
|   async function handleGet() { | ||||
|     setLoading(true); | ||||
|     const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET"); | ||||
|     if (data.detail) { | ||||
|       console.log(data.detail); | ||||
|       setPlayersRight([]); | ||||
|       setPlayersMiddle(otherPlayers); | ||||
|       setPlayersLeft([]); | ||||
|     } else { | ||||
|       setDialog("sending..."); | ||||
|       let _user = user.map(({ name }) => name)[0]; | ||||
|       let left = playersLeft.map(({ name }) => name); | ||||
|       let middle = playersMiddle.map(({ name }) => name); | ||||
|       let right = playersRight.map(({ name }) => name); | ||||
|       const data = { user: _user, hate: left, undecided: middle, love: right }; | ||||
|       const response = await api("chemistry", data); | ||||
|       response.ok ? setDialog("success!") : setDialog("try sending again"); | ||||
|       const chemistry = data as Chemistry; | ||||
|       setPlayersLeft(filterSort(otherPlayers, chemistry.hate)); | ||||
|       setPlayersMiddle( | ||||
|         otherPlayers.filter( | ||||
|           (player) => | ||||
|             !chemistry.hate.includes(player.id) && | ||||
|             !chemistry.love.includes(player.id) | ||||
|         ) | ||||
|       ); | ||||
|       setPlayersRight(filterSort(otherPlayers, chemistry.love)); | ||||
|     } | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <div className="container"> | ||||
|         <div className="box three"> | ||||
|           <h2>😬</h2> | ||||
|           {playersLeft.length < 1 && ( | ||||
|             <span className="grey hint"> | ||||
|               drag people here that you'd rather not play with from worst to ... | ||||
|               ok | ||||
|             </span> | ||||
|           )} | ||||
|           <PlayerList | ||||
|             list={playersLeft} | ||||
|             setList={setPlayersLeft} | ||||
|             group={"shared"} | ||||
|             className="dragbox" | ||||
|             orderedList | ||||
|           /> | ||||
|       <HeaderControl | ||||
|         onLoad={handleGet} | ||||
|         onClear={() => { | ||||
|           setPlayersRight([]); | ||||
|           setPlayersMiddle(otherPlayers); | ||||
|           setPlayersLeft([]); | ||||
|         }} | ||||
|       /> | ||||
|       {loading ? ( | ||||
|         <span className="loader" style={{ width: 300 }} /> | ||||
|       ) : ( | ||||
|         <div className="container"> | ||||
|           <div className="box three"> | ||||
|             <h2>😬</h2> | ||||
|             {playersLeft.length < 1 && ( | ||||
|               <span className="grey hint"> | ||||
|                 drag people here that you'd rather not play with | ||||
|               </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 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> | ||||
|       </button> | ||||
|       <dialog | ||||
|         ref={dialogRef} | ||||
|         id="ChemistryDialog" | ||||
|         onClick={(event) => { | ||||
|           event.currentTarget.close(); | ||||
| @@ -189,74 +183,241 @@ export function Chemistry({ user, players }: PlayerInfoProps) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| export function MVP({ user, players }: PlayerInfoProps) { | ||||
|   const [availablePlayers, setAvailablePlayers] = useState<Player[]>(players); | ||||
|   const [rankedPlayers, setRankedPlayers] = useState<Player[]>([]); | ||||
| function TypeDnD({ user, teams, players }: PlayerInfoProps) { | ||||
|   const [availablePlayers, setAvailablePlayers] = useState<User[]>(players); | ||||
|   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 dialogRef = useRef<HTMLDialogElement>(null); | ||||
|  | ||||
|   async function handleSubmit() { | ||||
|     const dialog = document.querySelector("dialog[id='MVPDialog']"); | ||||
|     (dialog as HTMLDialogElement).showModal(); | ||||
|     if (user.length < 1) { | ||||
|       setDialog("who are you?"); | ||||
|     if (dialogRef.current) dialogRef.current.showModal(); | ||||
|     setDialog("sending..."); | ||||
|     let handlerlist = handlers.map(({ id }) => id); | ||||
|     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 { | ||||
|       setDialog("sending..."); | ||||
|       let _user = user.map(({ name }) => name)[0]; | ||||
|       let mvps = rankedPlayers.map(({ name }) => name); | ||||
|       const data = { user: _user, mvps: mvps }; | ||||
|       const response = await api("mvps", data); | ||||
|       response.ok ? setDialog("success!") : setDialog("try sending again"); | ||||
|       const playertype = data as PlayerType; | ||||
|       setAvailablePlayers( | ||||
|         players.filter( | ||||
|           (player) => | ||||
|             !playertype.handlers.includes(player.id) && | ||||
|             !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 ( | ||||
|     <> | ||||
|       <HeaderControl | ||||
|         onLoad={handleGet} | ||||
|         onClear={() => { | ||||
|           setAvailablePlayers(players); | ||||
|           setHandlers([]); | ||||
|           setCombis([]); | ||||
|           setCutters([]); | ||||
|         }} | ||||
|       /> | ||||
|       <div className="container"> | ||||
|         <div className="box two"> | ||||
|           <h2>🥏🏃</h2> | ||||
|           {availablePlayers.length < 1 && ( | ||||
|             <span className="grey hint">all sorted 👍</span> | ||||
|           )} | ||||
|         <div className="box one"> | ||||
|           <PlayerList | ||||
|             list={availablePlayers} | ||||
|             setList={setAvailablePlayers} | ||||
|             group={{ | ||||
|               name: "mvp-shared", | ||||
|               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 | ||||
|             group={"type-shared"} | ||||
|             className="dragbox reservoir" | ||||
|           /> | ||||
|         </div> | ||||
|       </div> | ||||
|  | ||||
|       <button className="submit" onClick={() => handleSubmit()}> | ||||
|       <div className="container"> | ||||
|         <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> | ||||
|       </button> | ||||
|       <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); | ||||
|  | ||||
|   useEffect(() => { | ||||
|     handleGet(); | ||||
|   }, [players]); | ||||
|  | ||||
|   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"); | ||||
|   } | ||||
|  | ||||
|   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; | ||||
|       setRankedPlayers(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", | ||||
|                 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> | ||||
|       )} | ||||
|  | ||||
|       <button className="submit wavering" onClick={() => handleSubmit()}> | ||||
|         💾 <span className="submit_text">submit</span> | ||||
|       </button> | ||||
|       <dialog | ||||
|         ref={dialogRef} | ||||
|         id="MVPDialog" | ||||
|         onClick={(event) => { | ||||
|           event.currentTarget.close(); | ||||
| @@ -268,80 +429,46 @@ export function MVP({ user, players }: PlayerInfoProps) { | ||||
|   ); | ||||
| } | ||||
|  | ||||
| function openPage(pageName: string, color: string) { | ||||
|   // Hide all elements with class="tabcontent" by default */ | ||||
|   var i, tabcontent, tablinks; | ||||
|   tabcontent = document.getElementsByClassName("tabcontent"); | ||||
|   for (i = 0; i < tabcontent.length; i++) { | ||||
|     (tabcontent[i] as HTMLElement).style.display = "none"; | ||||
|   } | ||||
|   // Remove the background color of all tablinks/buttons | ||||
|   tablinks = document.getElementsByClassName("tablink"); | ||||
|   for (i = 0; i < tablinks.length; i++) { | ||||
|     let button = tablinks[i] as HTMLElement; | ||||
|     button.style.backgroundColor = "unset"; | ||||
|     button.style.textDecoration = "unset"; | ||||
|     button.style.fontWeight = "unset"; | ||||
|     button.style.color = "unset"; | ||||
|   } | ||||
|   // Show the specific tab content | ||||
|   (document.getElementById(pageName) as HTMLElement).style.display = "block"; | ||||
|   // Add the specific color to the button used to open the tab content | ||||
|   let activeButton = document.getElementById( | ||||
|     pageName + "Button" | ||||
|   ) as HTMLElement; | ||||
|   activeButton.style.textDecoration = "underline"; | ||||
|   activeButton.style.fontWeight = "bold"; | ||||
|   activeButton.style.backgroundColor = "#3366cc"; | ||||
|   activeButton.style.color = "white"; | ||||
|   document.body.style.backgroundColor = color; | ||||
| interface HeaderControlProps { | ||||
|   onLoad: () => void; | ||||
|   onClear: () => void; | ||||
| } | ||||
| function HeaderControl({ onLoad, onClear }: HeaderControlProps) { | ||||
|   return ( | ||||
|     <> | ||||
|       <div> | ||||
|         <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() { | ||||
|   const [user, setUser] = useState<Player[]>([]); | ||||
|   const [players, setPlayers] = useState<Player[]>([]); | ||||
|   const { user, teams, players } = useSession(); | ||||
|  | ||||
|   async function loadPlayers() { | ||||
|     const response = await fetch(`${baseUrl}player/list`, { | ||||
|       method: "GET", | ||||
|     }); | ||||
|     const data = await response.json(); | ||||
|     setPlayers(data as Player[]); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadPlayers(); | ||||
|   }, []); | ||||
|   const tabs = [ | ||||
|     { id: "Chemistry", label: "🧪 Chemistry" }, | ||||
|     { id: "Type", label: "🃏 Type" }, | ||||
|     { id: "MVP", label: "🏆 MVP" }, | ||||
|   ]; | ||||
|  | ||||
|   return ( | ||||
|     <> | ||||
|       <SelectUser {...{ user, setUser, players, setPlayers }} /> | ||||
|       {user.length === 1 && ( | ||||
|         <> | ||||
|           <div className="container"> | ||||
|             <button | ||||
|               className="tablink" | ||||
|               id="ChemistryButton" | ||||
|               onClick={() => openPage("Chemistry", "aliceblue")} | ||||
|             > | ||||
|               Chemistry | ||||
|             </button> | ||||
|             <button | ||||
|               className="tablink" | ||||
|               id="MVPButton" | ||||
|               onClick={() => openPage("MVP", "aliceblue")} | ||||
|             > | ||||
|               MVP | ||||
|             </button> | ||||
|           </div> | ||||
|  | ||||
|           <div id="Chemistry" className="tabcontent"> | ||||
|             <Chemistry {...{ user, players }} /> | ||||
|           </div> | ||||
|           <div id="MVP" className="tabcontent"> | ||||
|             <MVP {...{ user, players }} /> | ||||
|           </div> | ||||
|         </> | ||||
|       {user && teams && players ? ( | ||||
|         <TabController tabs={tabs}> | ||||
|           <ChemistryDnD {...{ user, teams, players }} /> | ||||
|           <TypeDnD {...{ user, teams, players }} /> | ||||
|           <MVPDnD {...{ user, teams, players }} /> | ||||
|         </TabController> | ||||
|       ) : ( | ||||
|         <span className="loader" /> | ||||
|       )} | ||||
|     </> | ||||
|   ); | ||||
|   | ||||
							
								
								
									
										123
									
								
								src/Session.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								src/Session.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | ||||
| import { | ||||
|   createContext, | ||||
|   ReactNode, | ||||
|   useContext, | ||||
|   useEffect, | ||||
|   useState, | ||||
| } from "react"; | ||||
| import { apiAuth, currentUser, loadPlayers, logout, User } from "./api"; | ||||
| import { Login } from "./Login"; | ||||
| import Header from "./Header"; | ||||
| import { Team } from "./types"; | ||||
|  | ||||
| export interface SessionProviderProps { | ||||
|   children: ReactNode; | ||||
| } | ||||
|  | ||||
| export interface TeamState { | ||||
|   teams: Team[]; | ||||
|   activeTeam: number; | ||||
| } | ||||
|  | ||||
| export interface Session { | ||||
|   user: User | null; | ||||
|   teams: TeamState | null; | ||||
|   setTeams: (teams: TeamState) => void; | ||||
|   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) { | ||||
|   const { children } = props; | ||||
|  | ||||
|   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 [loading, setLoading] = useState(false); | ||||
|  | ||||
|   function loadUser() { | ||||
|     setLoading(true); | ||||
|     currentUser() | ||||
|       .then((user) => { | ||||
|         setUser(user); | ||||
|         setErr(null); | ||||
|       }) | ||||
|       .catch((err) => { | ||||
|         setUser(null); | ||||
|         setErr(err); | ||||
|       }) | ||||
|       .finally(() => setLoading(false)); | ||||
|   } | ||||
|  | ||||
|   async function loadTeam() { | ||||
|     const teams: Team[] = await apiAuth("player/me/teams", null, "GET"); | ||||
|     if (teams) setTeams({ teams: teams, activeTeam: teams[0].id }); | ||||
|   } | ||||
|  | ||||
|   async function reloadPlayers() { | ||||
|     teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data)); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     loadUser(); | ||||
|   }, []); | ||||
|   useEffect(() => { | ||||
|     loadTeam(); | ||||
|   }, [user]); | ||||
|   useEffect(() => { | ||||
|     reloadPlayers(); | ||||
|   }, [teams]); | ||||
|  | ||||
|   function onLogin(user: User) { | ||||
|     setUser(user); | ||||
|     setErr(null); | ||||
|   } | ||||
|  | ||||
|   async function onLogout() { | ||||
|     try { | ||||
|       logout(); | ||||
|       setUser(null); | ||||
|       setErr({ message: "Logged out successfully" }); | ||||
|       console.log("logged out."); | ||||
|     } catch (e) { | ||||
|       console.error(e); | ||||
|       setErr(e); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   let content: ReactNode; | ||||
|   if (loading || (!err && !user)) | ||||
|     content = ( | ||||
|       <> | ||||
|         <Header /> | ||||
|         <span className="loader" /> | ||||
|       </> | ||||
|     ); | ||||
|   else if (err) { | ||||
|     content = <Login onLogin={onLogin} />; | ||||
|   } else | ||||
|     content = ( | ||||
|       <sessionContext.Provider | ||||
|         value={{ user, teams, setTeams, players, reloadPlayers, onLogout }} | ||||
|       > | ||||
|         {children} | ||||
|       </sessionContext.Provider> | ||||
|     ); | ||||
|  | ||||
|   return content; | ||||
| } | ||||
|  | ||||
| export function useSession() { | ||||
|   return useContext(sessionContext); | ||||
| } | ||||
							
								
								
									
										340
									
								
								src/SetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										340
									
								
								src/SetPassword.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,340 @@ | ||||
| import { jwtDecode, JwtPayload } from "jwt-decode"; | ||||
| import { ReactNode, useEffect, useState } from "react"; | ||||
| import { apiAuth, baseUrl, User } from "./api"; | ||||
| import { useNavigate } from "react-router"; | ||||
| import { Eye, EyeSlash } from "./Icons"; | ||||
| import { useSession } from "./Session"; | ||||
| import { relative } from "path"; | ||||
| import Header from "./Header"; | ||||
|  | ||||
| interface PassToken extends JwtPayload { | ||||
|   username: string; | ||||
|   name: string; | ||||
|   team_id: number; | ||||
| } | ||||
|  | ||||
| enum Mode { | ||||
|   register = "register", | ||||
|   set = "set password", | ||||
|   change = "change password", | ||||
| } | ||||
|  | ||||
| export const SetPassword = () => { | ||||
|   const [mode, setMode] = useState<Mode>(); | ||||
|   const [name, setName] = useState("after getting your token."); | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [teamID, setTeamID] = useState<number>(); | ||||
|   const [currentPassword, setCurrentPassword] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [passwordr, setPasswordr] = useState(""); | ||||
|   const [token, setToken] = useState(""); | ||||
|   const [error, setError] = useState(""); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [visible, setVisible] = useState(false); | ||||
|   const newPlayerTemplate = { | ||||
|     username: "", | ||||
|     display_name: "", | ||||
|     number: "", | ||||
|     email: "", | ||||
|   } as User; | ||||
|   const [player, setPlayer] = useState(newPlayerTemplate); | ||||
|   const navigate = useNavigate(); | ||||
|   const { user } = useSession(); | ||||
|  | ||||
|   async function handleSubmit(e: React.FormEvent) { | ||||
|     e.preventDefault(); | ||||
|     if (password === passwordr) { | ||||
|       setLoading(true); | ||||
|       if (mode === Mode.change) { | ||||
|         //====CHANGING PASSWORD==== | ||||
|         const resp = await apiAuth( | ||||
|           "player/change_password", | ||||
|           { current_password: currentPassword, new_password: password }, | ||||
|           "POST" | ||||
|         ); | ||||
|         setLoading(false); | ||||
|         if (resp.detail) setError(resp.detail); | ||||
|         else { | ||||
|           setError(resp); | ||||
|           setTimeout(() => navigate("/"), 2000); | ||||
|         } | ||||
|       } else if (mode === Mode.set) { | ||||
|         //====SETTING PASSWORD==== | ||||
|         const req = new Request(`${baseUrl}api/set_password`, { | ||||
|           method: "POST", | ||||
|           headers: { | ||||
|             "Content-Type": "application/json", | ||||
|           }, | ||||
|           body: JSON.stringify({ token: token, password: password }), | ||||
|         }); | ||||
|         let resp: Response; | ||||
|         try { | ||||
|           resp = await fetch(req); | ||||
|         } catch (e) { | ||||
|           throw new Error(`request failed: ${e}`); | ||||
|         } | ||||
|         setLoading(false); | ||||
|  | ||||
|         if (resp.ok) { | ||||
|           console.log(resp); | ||||
|           navigate("/", { | ||||
|             replace: true, | ||||
|             state: { username: username, password: password }, | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         if (!resp.ok) { | ||||
|           if (resp.status === 401) { | ||||
|             const { detail } = await resp.json(); | ||||
|             if (detail) setError(detail); | ||||
|             else setError("unauthorized"); | ||||
|             throw new Error("Unauthorized"); | ||||
|           } | ||||
|         } | ||||
|       } else if (mode === Mode.register) { | ||||
|         //====REGISTER NEW USER==== | ||||
|         const req = new Request(`${baseUrl}api/register`, { | ||||
|           method: "POST", | ||||
|           headers: { | ||||
|             "Content-Type": "application/json", | ||||
|           }, | ||||
|           body: JSON.stringify({ | ||||
|             ...player, | ||||
|             team_id: teamID, | ||||
|             token: token, | ||||
|             password: password, | ||||
|           }), | ||||
|         }); | ||||
|         let resp: Response; | ||||
|         try { | ||||
|           resp = await fetch(req); | ||||
|         } catch (e) { | ||||
|           throw new Error(`request failed: ${e}`); | ||||
|         } | ||||
|         setLoading(false); | ||||
|  | ||||
|         if (resp.ok) { | ||||
|           console.log(resp); | ||||
|           navigate("/", { | ||||
|             replace: true, | ||||
|             state: { username: player.username, password: password }, | ||||
|           }); | ||||
|         } | ||||
|  | ||||
|         if (!resp.ok) { | ||||
|           const { detail } = await resp.json(); | ||||
|           if (detail) setError(detail); | ||||
|           else setError("unauthorized"); | ||||
|           throw new Error("Unauthorized"); | ||||
|         } | ||||
|       } | ||||
|     } else setError("passwords are not the same"); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { | ||||
|     if (user) { | ||||
|       setUsername(user.username); | ||||
|       setName(user.display_name); | ||||
|       setMode(Mode.change); | ||||
|     } else { | ||||
|       const params = new URLSearchParams(window.location.search); | ||||
|       const token = params.get("token"); | ||||
|       if (token) { | ||||
|         setToken(token); | ||||
|         try { | ||||
|           const payload = jwtDecode<PassToken>(token); | ||||
|           console.log(payload); | ||||
|           switch (payload.sub) { | ||||
|             case "register": | ||||
|               setMode(Mode.register); | ||||
|               if (payload.team_id) setTeamID(payload.team_id); | ||||
|               break; | ||||
|             case "set password": | ||||
|               setMode(Mode.set); | ||||
|               if (payload.username) setUsername(payload.username); | ||||
|               break; | ||||
|           } | ||||
|           if (payload.name) setName(payload.name); | ||||
|         } catch (InvalidTokenError) { | ||||
|           setName("Mr. I-have-no-valid Token"); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, []); | ||||
|  | ||||
|   let header: ReactNode; | ||||
|   switch (mode) { | ||||
|     case Mode.change: | ||||
|       header = <h2>change your password, {name}</h2>; | ||||
|       break; | ||||
|     case Mode.set: | ||||
|       header = ( | ||||
|         <> | ||||
|           <Header /> | ||||
|           <h2>set your password, {name}</h2> | ||||
|         </> | ||||
|       ); | ||||
|       break; | ||||
|     case Mode.register: | ||||
|       header = ( | ||||
|         <> | ||||
|           <Header /> | ||||
|           <h2> | ||||
|             register as a member of <i>{name}</i> | ||||
|           </h2> | ||||
|         </> | ||||
|       ); | ||||
|   } | ||||
|  | ||||
|   let textInputs: ReactNode; | ||||
|   switch (mode) { | ||||
|     case Mode.change: | ||||
|       textInputs = ( | ||||
|         <div> | ||||
|           <input | ||||
|             type={visible ? "text" : "password"} | ||||
|             id="password" | ||||
|             name="password" | ||||
|             placeholder="current password" | ||||
|             minLength={8} | ||||
|             value={currentPassword} | ||||
|             required | ||||
|             onChange={(evt) => { | ||||
|               setError(""); | ||||
|               setCurrentPassword(evt.target.value); | ||||
|             }} | ||||
|           /> | ||||
|           <hr style={{ margin: "8px" }} /> | ||||
|         </div> | ||||
|       ); | ||||
|       break; | ||||
|     case Mode.register: | ||||
|       textInputs = ( | ||||
|         <div className="new-player-inputs"> | ||||
|           <div> | ||||
|             <label>name</label> | ||||
|             <input | ||||
|               type="text" | ||||
|               required | ||||
|               value={player.display_name} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ | ||||
|                   ...player, | ||||
|                   display_name: e.target.value, | ||||
|                   username: e.target.value.toLowerCase().replace(/\W/g, ""), | ||||
|                 }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <label>username</label> | ||||
|             <input | ||||
|               type="text" | ||||
|               required | ||||
|               value={player.username} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ ...player, username: e.target.value }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <label>number (optional)</label> | ||||
|             <input | ||||
|               type="text" | ||||
|               value={player.number || ""} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ ...player, number: e.target.value }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <div> | ||||
|             <label>email (optional)</label> | ||||
|             <input | ||||
|               type="email" | ||||
|               value={player.email || ""} | ||||
|               onChange={(e) => { | ||||
|                 setPlayer({ ...player, email: e.target.value }); | ||||
|               }} | ||||
|             /> | ||||
|           </div> | ||||
|           <hr style={{ margin: "8px" }} /> | ||||
|         </div> | ||||
|       ); | ||||
|       break; | ||||
|   } | ||||
|  | ||||
|   let passwordInputs = ( | ||||
|     <> | ||||
|       <div> | ||||
|         <input | ||||
|           type={visible ? "text" : "password"} | ||||
|           id="password" | ||||
|           name="password" | ||||
|           placeholder="password" | ||||
|           minLength={8} | ||||
|           value={password} | ||||
|           required | ||||
|           onChange={(evt) => { | ||||
|             setError(""); | ||||
|             setPassword(evt.target.value); | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|       <div> | ||||
|         <input | ||||
|           type={visible ? "text" : "password"} | ||||
|           id="password-repeat" | ||||
|           name="password-repeat" | ||||
|           placeholder="repeat password" | ||||
|           minLength={8} | ||||
|           value={passwordr} | ||||
|           required | ||||
|           onChange={(evt) => { | ||||
|             setError(""); | ||||
|             setPasswordr(evt.target.value); | ||||
|           }} | ||||
|         /> | ||||
|       </div> | ||||
|     </> | ||||
|   ); | ||||
|  | ||||
|   return mode ? ( | ||||
|     <> | ||||
|       {header} | ||||
|       <hr style={{ width: "100%" }} /> | ||||
|       <form onSubmit={handleSubmit}> | ||||
|         <div | ||||
|           style={{ | ||||
|             display: "flex", | ||||
|             alignItems: "center", | ||||
|             justifyContent: "center", | ||||
|             flexDirection: "column", | ||||
|           }} | ||||
|         > | ||||
|           {textInputs} | ||||
|           {passwordInputs} | ||||
|           <div | ||||
|             style={{ | ||||
|               background: "unset", | ||||
|               fontSize: "medium", | ||||
|               cursor: "pointer", | ||||
|               display: "flex", | ||||
|               alignItems: "center", | ||||
|               gap: "8px", | ||||
|             }} | ||||
|             onClick={() => setVisible(!visible)} | ||||
|           > | ||||
|             {visible ? <Eye /> : <EyeSlash />} show passwords | ||||
|           </div> | ||||
|         </div> | ||||
|         <div>{error && <span style={{ color: "red" }}>{error}</span>}</div> | ||||
|         <button type="submit" value="login" style={{ fontSize: "small" }}> | ||||
|           submit | ||||
|         </button> | ||||
|         {loading && <span className="loader" />} | ||||
|       </form> | ||||
|     </> | ||||
|   ) : ( | ||||
|     <span className="loader" /> | ||||
|   ); | ||||
| }; | ||||
							
								
								
									
										45
									
								
								src/TabController.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								src/TabController.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| import { Fragment, ReactNode, useState } from "react"; | ||||
|  | ||||
| interface TabProps { | ||||
|   id: string; | ||||
|   label: string; | ||||
| } | ||||
|  | ||||
| interface TabControllerProps { | ||||
|   tabs: TabProps[]; | ||||
|   children: ReactNode[]; | ||||
| } | ||||
|  | ||||
| export default function TabController({ tabs, children }: TabControllerProps) { | ||||
|   const [currentIndex, setCurrentIndex] = useState(0); | ||||
|   const handleTabClick = (index: number) => { | ||||
|     setCurrentIndex(index); | ||||
|   }; | ||||
|  | ||||
|   return ( | ||||
|     <div> | ||||
|       <div> | ||||
|         <div className="container navbar"> | ||||
|           {tabs.map((tab, index) => ( | ||||
|             <button | ||||
|               key={tab.id} | ||||
|               className={ | ||||
|                 currentIndex === index ? "tab-button active" : "tab-button" | ||||
|               } | ||||
|               onClick={() => handleTabClick(index)} | ||||
|             > | ||||
|               {tab.label} | ||||
|             </button> | ||||
|           ))} | ||||
|         </div> | ||||
|       </div> | ||||
|       {children.map((child, index) => ( | ||||
|         <Fragment key={index}> | ||||
|           <div style={{ display: currentIndex === index ? "block" : "none" }}> | ||||
|             {child} | ||||
|           </div> | ||||
|         </Fragment> | ||||
|       ))} | ||||
|     </div> | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										225
									
								
								src/TeamPanel.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								src/TeamPanel.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,225 @@ | ||||
| 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]); | ||||
|   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" | ||||
|                 required | ||||
|                 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 }; | ||||
							
								
								
									
										122
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						
									
										122
									
								
								src/api.ts
									
									
									
									
									
								
							| @@ -1,17 +1,125 @@ | ||||
| import { useSession } from "./Session"; | ||||
|  | ||||
| export const baseUrl = import.meta.env.VITE_BASE_URL as string; | ||||
| export default async function api(path: string, data: any): Promise<any> { | ||||
|   const request = new Request(`${baseUrl}${path}/`, { | ||||
|     method: "POST", | ||||
|  | ||||
| export async function apiAuth( | ||||
|   path: string, | ||||
|   data: any, | ||||
|   method: string = "GET" | ||||
| ): Promise<any> { | ||||
|   const req = new Request(`${baseUrl}api/${path}`, { | ||||
|     method: method, | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     body: JSON.stringify(data), | ||||
|     credentials: "include", | ||||
|     ...(data && { body: JSON.stringify(data) }), | ||||
|   }); | ||||
|   let response: Response; | ||||
|   let resp: Response; | ||||
|   try { | ||||
|     response = await fetch(request); | ||||
|     resp = await fetch(req); | ||||
|   } catch (e) { | ||||
|     throw new Error(`request failed: ${e}`); | ||||
|   } | ||||
|   return response; | ||||
|  | ||||
|   if (!resp.ok) { | ||||
|     if (resp.status === 401) { | ||||
|       const { onLogout } = useSession(); | ||||
|       onLogout(); | ||||
|       throw new Error("Unauthorized"); | ||||
|     } | ||||
|   } | ||||
|   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 = { | ||||
|   id: number; | ||||
|   username: string; | ||||
|   display_name: string; | ||||
|   email: string; | ||||
|   number: string; | ||||
|   gender: Gender; | ||||
|   scopes: string; | ||||
| }; | ||||
|  | ||||
| export async function currentUser(): Promise<User> { | ||||
|   const req = new Request(`${baseUrl}api/player/me`, { | ||||
|     method: "GET", | ||||
|     headers: { | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     credentials: "include", | ||||
|   }); | ||||
|   let resp: Response; | ||||
|   try { | ||||
|     resp = await fetch(req); | ||||
|   } catch (e) { | ||||
|     throw new Error(`request failed: ${e}`); | ||||
|   } | ||||
|  | ||||
|   if (!resp.ok) { | ||||
|     if (resp.status === 401) { | ||||
|       logout(); | ||||
|       throw new Error("Unauthorized"); | ||||
|     } | ||||
|   } | ||||
|   return resp.json() as Promise<User>; | ||||
| } | ||||
|  | ||||
| export async function loadPlayers(teamId: number) { | ||||
|   try { | ||||
|     const data = await apiAuth(`player/${teamId}/list`, null, "GET"); | ||||
|     return data as User[]; | ||||
|   } catch (error) { | ||||
|     console.error(error); | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| export type LoginRequest = { | ||||
|   username: string; | ||||
|   password: string; | ||||
| }; | ||||
|  | ||||
| export const login = async (req: LoginRequest): Promise<void> => { | ||||
|   try { | ||||
|     const response = await fetch(`${baseUrl}api/token`, { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/x-www-form-urlencoded", | ||||
|       }, | ||||
|       body: new URLSearchParams(req).toString(), | ||||
|       credentials: "include", | ||||
|     }); | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`HTTP error! status: ${response.status}`); | ||||
|     } | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|     throw e; // rethrow the error so it can be caught by the caller | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const logout = async () => { | ||||
|   try { | ||||
|     await fetch(`${baseUrl}api/logout`, { | ||||
|       method: "POST", | ||||
|       credentials: "include", | ||||
|     }); | ||||
|   } catch (e) { | ||||
|     console.error(e); | ||||
|   } | ||||
| }; | ||||
|   | ||||
							
								
								
									
										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", | ||||
| }; | ||||
							
								
								
									
										54
									
								
								src/types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| export interface Edge { | ||||
|   from: string; | ||||
|   to: string; | ||||
|   color: string; | ||||
|   relation: "likes" | "dislikes"; | ||||
| } | ||||
| export interface Node { | ||||
|   id: string; | ||||
| } | ||||
| export default interface NetworkData { | ||||
|   nodes: Node[]; | ||||
|   edges: Edge[]; | ||||
| } | ||||
|  | ||||
| export interface PlayerRanking { | ||||
|   name: string; | ||||
|   rank: number; | ||||
|   std: number; | ||||
|   n: number; | ||||
| } | ||||
|  | ||||
| export interface Chemistry { | ||||
|   id: number; | ||||
|   user: number; | ||||
|   hate: number[]; | ||||
|   undecided: number[]; | ||||
|   love: number[]; | ||||
| } | ||||
|  | ||||
| export interface 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; | ||||
| } | ||||
|  | ||||
| export type ErrorState = { | ||||
|   ok: boolean; | ||||
|   message: string; | ||||
| }; | ||||
| @@ -3,10 +3,13 @@ | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", | ||||
|     "target": "ES2020", | ||||
|     "useDefineForClassFields": true, | ||||
|     "lib": ["ES2020", "DOM", "DOM.Iterable"], | ||||
|     "lib": [ | ||||
|       "ES2020", | ||||
|       "DOM", | ||||
|       "DOM.Iterable" | ||||
|     ], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
| @@ -14,7 +17,6 @@ | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|     "jsx": "react-jsx", | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": false, | ||||
| @@ -22,5 +24,7 @@ | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|     "noUncheckedSideEffectImports": true | ||||
|   }, | ||||
|   "include": ["src"] | ||||
|   "include": [ | ||||
|     "src" | ||||
|   ] | ||||
| } | ||||
|   | ||||
| @@ -2,17 +2,17 @@ | ||||
|   "compilerOptions": { | ||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | ||||
|     "target": "ES2022", | ||||
|     "lib": ["ES2023"], | ||||
|     "lib": [ | ||||
|       "ES2023" | ||||
|     ], | ||||
|     "module": "ESNext", | ||||
|     "skipLibCheck": true, | ||||
|  | ||||
|     /* Bundler mode */ | ||||
|     "moduleResolution": "bundler", | ||||
|     "allowImportingTsExtensions": true, | ||||
|     "isolatedModules": true, | ||||
|     "moduleDetection": "force", | ||||
|     "noEmit": true, | ||||
|  | ||||
|     /* Linting */ | ||||
|     "strict": true, | ||||
|     "noUnusedLocals": true, | ||||
| @@ -20,5 +20,7 @@ | ||||
|     "noFallthroughCasesInSwitch": true, | ||||
|     "noUncheckedSideEffectImports": true | ||||
|   }, | ||||
|   "include": ["vite.config.ts"] | ||||
|   "include": [ | ||||
|     "vite.config.ts" | ||||
|   ] | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user