Compare commits
	
		
			31 Commits
		
	
	
		
			floating_b
			...
			15c9a64de2
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||||||
|   | |||||||
							
								
								
									
										154
									
								
								analysis.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								analysis.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,154 @@ | |||||||
|  | from datetime import datetime | ||||||
|  | import io | ||||||
|  | import base64 | ||||||
|  | from fastapi import APIRouter | ||||||
|  | from fastapi.responses import JSONResponse | ||||||
|  | from pydantic import BaseModel, Field | ||||||
|  | from sqlmodel import Session, func, select | ||||||
|  | from sqlmodel.sql.expression import SelectOfScalar | ||||||
|  | from db import Chemistry, Player, engine | ||||||
|  | import networkx as nx | ||||||
|  | import matplotlib | ||||||
|  |  | ||||||
|  | matplotlib.use("agg") | ||||||
|  | import matplotlib.pyplot as plt | ||||||
|  |  | ||||||
|  |  | ||||||
|  | analysis_router = APIRouter(prefix="/analysis") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | C = Chemistry | ||||||
|  | P = Player | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sociogram_json(): | ||||||
|  |     nodes = [] | ||||||
|  |     necessary_nodes = set() | ||||||
|  |     links = [] | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         for p in session.exec(select(P)).fetchall(): | ||||||
|  |             nodes.append({"id": p.name, "appearance": 1}) | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")) | ||||||
|  |             .where(C.time > datetime(2025, 2, 1, 10)) | ||||||
|  |             .group_by(C.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = select(C).join( | ||||||
|  |             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||||
|  |         ) | ||||||
|  |         for c in session.exec(statement2): | ||||||
|  |             # G.add_node(c.user) | ||||||
|  |             necessary_nodes.add(c.user) | ||||||
|  |             for p in c.love: | ||||||
|  |                 # G.add_edge(c.user, p) | ||||||
|  |                 # p_id = session.exec(select(P.id).where(P.name == p)).one() | ||||||
|  |                 necessary_nodes.add(p) | ||||||
|  |                 links.append({"source": c.user, "target": p}) | ||||||
|  |     # nodes = [n for n in nodes if n["name"] in necessary_nodes] | ||||||
|  |     return JSONResponse({"nodes": nodes, "links": links}) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def sociogram_data(show: int | None = 2): | ||||||
|  |     G = nx.DiGraph() | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         for p in session.exec(select(P)).fetchall(): | ||||||
|  |             G.add_node(p.name) | ||||||
|  |         subquery = ( | ||||||
|  |             select(C.user, func.max(C.time).label("latest")) | ||||||
|  |             .where(C.time > datetime(2025, 2, 1, 10)) | ||||||
|  |             .group_by(C.user) | ||||||
|  |             .subquery() | ||||||
|  |         ) | ||||||
|  |         statement2 = ( | ||||||
|  |             select(C) | ||||||
|  |             # .where(C.user.in_(["Kruse", "Franz", "ck"])) | ||||||
|  |             .join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)) | ||||||
|  |         ) | ||||||
|  |         for c in session.exec(statement2): | ||||||
|  |             if show >= 1: | ||||||
|  |                 for i, p in enumerate(c.love): | ||||||
|  |                     G.add_edge(c.user, p, group="love", rank=i, popularity=1 - 0.08 * i) | ||||||
|  |             if show <= 1: | ||||||
|  |                 for i, p in enumerate(c.hate): | ||||||
|  |                     G.add_edge(c.user, p, group="hate", rank=8, popularity=-0.16) | ||||||
|  |     return G | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Params(BaseModel): | ||||||
|  |     node_size: int | None = Field(default=2400, alias="nodeSize") | ||||||
|  |     font_size: int | None = Field(default=10, alias="fontSize") | ||||||
|  |     arrow_size: int | None = Field(default=20, alias="arrowSize") | ||||||
|  |     edge_width: float | None = Field(default=1, alias="edgeWidth") | ||||||
|  |     distance: float | None = 0.2 | ||||||
|  |     weighting: bool | None = True | ||||||
|  |     popularity: bool | None = True | ||||||
|  |     show: int | None = 2 | ||||||
|  |  | ||||||
|  |  | ||||||
|  | ARROWSTYLE = {"love": "-|>", "hate": "-|>"} | ||||||
|  | EDGESTYLE = {"love": "-", "hate": ":"} | ||||||
|  | EDGECOLOR = {"love": "#404040", "hate": "#cc0000"} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def render_sociogram(params: Params): | ||||||
|  |     plt.figure(figsize=(16, 10), facecolor="none") | ||||||
|  |     ax = plt.gca() | ||||||
|  |     ax.set_facecolor("none")  # Set the axis face color to none (transparent) | ||||||
|  |     ax.axis("off")  # Turn off axis ticks and frames | ||||||
|  |  | ||||||
|  |     G = sociogram_data(show=params.show) | ||||||
|  |     pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None) | ||||||
|  |     nodes = nx.draw_networkx_nodes( | ||||||
|  |         G, | ||||||
|  |         pos, | ||||||
|  |         node_color=[ | ||||||
|  |             v for k, v in G.in_degree(weight="popularity" if params.weighting else None) | ||||||
|  |         ] | ||||||
|  |         if params.popularity | ||||||
|  |         else "#99ccff", | ||||||
|  |         edgecolors="#404040", | ||||||
|  |         linewidths=0, | ||||||
|  |         # node_shape="8", | ||||||
|  |         node_size=params.node_size, | ||||||
|  |         cmap="coolwarm", | ||||||
|  |         alpha=0.86, | ||||||
|  |     ) | ||||||
|  |     if params.popularity: | ||||||
|  |         cbar = plt.colorbar(nodes) | ||||||
|  |         cbar.ax.set_xlabel("popularity") | ||||||
|  |     nx.draw_networkx_labels(G, pos, font_size=params.font_size) | ||||||
|  |     nx.draw_networkx_edges( | ||||||
|  |         G, | ||||||
|  |         pos, | ||||||
|  |         arrows=True, | ||||||
|  |         edge_color=[EDGECOLOR[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||||
|  |         arrowsize=params.arrow_size, | ||||||
|  |         node_size=params.node_size, | ||||||
|  |         width=params.edge_width, | ||||||
|  |         style=[EDGESTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||||
|  |         arrowstyle=[ARROWSTYLE[G.edges()[*edge]["group"]] for edge in G.edges()], | ||||||
|  |         connectionstyle="arc3,rad=0.12", | ||||||
|  |         alpha=[1 - 0.08 * G.edges()[*edge]["rank"] for edge in G.edges()] | ||||||
|  |         if params.weighting | ||||||
|  |         else 1, | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |     buf = io.BytesIO() | ||||||
|  |     plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True) | ||||||
|  |     buf.seek(0) | ||||||
|  |     encoded_image = base64.b64encode(buf.read()).decode("UTF-8") | ||||||
|  |     plt.close() | ||||||
|  |  | ||||||
|  |     return {"image": encoded_image} | ||||||
|  |  | ||||||
|  |  | ||||||
|  | analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) | ||||||
|  | analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) | ||||||
|  |  | ||||||
|  | if __name__ == "__main__": | ||||||
|  |     with Session(engine) as session: | ||||||
|  |         statement: SelectOfScalar[P] = select(func.count(P.id)) | ||||||
|  |         print("players in DB: ", session.exec(statement).first()) | ||||||
|  |     G = sociogram_data() | ||||||
|  |     pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42) | ||||||
							
								
								
									
										19
									
								
								db.py
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								db.py
									
									
									
									
									
								
							| @@ -1,5 +1,13 @@ | |||||||
| from datetime import datetime, timezone | from datetime import datetime, timezone | ||||||
| from sqlmodel import ARRAY, Column, Relationship, SQLModel, Field, create_engine, String | from sqlmodel import ( | ||||||
|  |     ARRAY, | ||||||
|  |     Column, | ||||||
|  |     Relationship, | ||||||
|  |     SQLModel, | ||||||
|  |     Field, | ||||||
|  |     create_engine, | ||||||
|  |     String, | ||||||
|  | ) | ||||||
|  |  | ||||||
| with open("db.secrets", "r") as f: | with open("db.secrets", "r") as f: | ||||||
|     db_secrets = f.readline().strip() |     db_secrets = f.readline().strip() | ||||||
| @@ -54,4 +62,13 @@ class MVPRanking(SQLModel, table=True): | |||||||
|     mvps: list[str] = Field(sa_column=Column(ARRAY(String))) |     mvps: list[str] = Field(sa_column=Column(ARRAY(String))) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class User(SQLModel, table=True): | ||||||
|  |     username: str = Field(default=None, primary_key=True) | ||||||
|  |     email: str | None = None | ||||||
|  |     full_name: str | None = None | ||||||
|  |     disabled: bool | None = None | ||||||
|  |     hashed_password: str | None = None | ||||||
|  |     player_id: int | None = Field(default=None, foreign_key="player.id") | ||||||
|  |  | ||||||
|  |  | ||||||
| SQLModel.metadata.create_all(engine) | SQLModel.metadata.create_all(engine) | ||||||
|   | |||||||
							
								
								
									
										33
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								main.py
									
									
									
									
									
								
							| @@ -1,4 +1,4 @@ | |||||||
| from fastapi import APIRouter, FastAPI, status | from fastapi import APIRouter, Depends, FastAPI, status | ||||||
| from fastapi.staticfiles import StaticFiles | from fastapi.staticfiles import StaticFiles | ||||||
| from db import Player, Team, Chemistry, MVPRanking, engine | from db import Player, Team, Chemistry, MVPRanking, engine | ||||||
| from sqlmodel import ( | from sqlmodel import ( | ||||||
| @@ -6,9 +6,17 @@ from sqlmodel import ( | |||||||
|     select, |     select, | ||||||
| ) | ) | ||||||
| from fastapi.middleware.cors import CORSMiddleware | from fastapi.middleware.cors import CORSMiddleware | ||||||
|  | from analysis import analysis_router | ||||||
|  | from security import ( | ||||||
|  |     get_current_active_user, | ||||||
|  |     login_for_access_token, | ||||||
|  |     read_users_me, | ||||||
|  |     read_own_items, | ||||||
|  | ) | ||||||
|  |  | ||||||
|  |  | ||||||
| app = FastAPI(title="cutt") | app = FastAPI(title="cutt") | ||||||
|  | api_router = APIRouter(prefix="/api") | ||||||
| origins = [ | origins = [ | ||||||
|     "*", |     "*", | ||||||
|     "http://localhost", |     "http://localhost", | ||||||
| @@ -46,7 +54,7 @@ def add_players(players: list[Player]): | |||||||
|  |  | ||||||
| def list_players(): | def list_players(): | ||||||
|     with Session(engine) as session: |     with Session(engine) as session: | ||||||
|         statement = select(Player) |         statement = select(Player).order_by(Player.name) | ||||||
|         return session.exec(statement).fetchall() |         return session.exec(statement).fetchall() | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -79,6 +87,21 @@ def submit_chemistry(chemistry: Chemistry): | |||||||
|         session.commit() |         session.commit() | ||||||
|  |  | ||||||
|  |  | ||||||
| app.include_router(player_router) | class SPAStaticFiles(StaticFiles): | ||||||
| app.include_router(team_router) |     async def get_response(self, path: str, scope): | ||||||
| app.mount("/", StaticFiles(directory="dist", html=True), name="site") |         response = await super().get_response(path, scope) | ||||||
|  |         if response.status_code == 404: | ||||||
|  |             response = await super().get_response(".", scope) | ||||||
|  |         return response | ||||||
|  |  | ||||||
|  |  | ||||||
|  | api_router.include_router(player_router) | ||||||
|  | api_router.include_router(team_router) | ||||||
|  | api_router.include_router( | ||||||
|  |     analysis_router, dependencies=[Depends(get_current_active_user)] | ||||||
|  | ) | ||||||
|  | api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) | ||||||
|  | api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"]) | ||||||
|  | api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"]) | ||||||
|  | app.include_router(api_router) | ||||||
|  | app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site") | ||||||
|   | |||||||
| @@ -27,8 +27,14 @@ | |||||||
|     "eslint-plugin-react-hooks": "^5.0.0", |     "eslint-plugin-react-hooks": "^5.0.0", | ||||||
|     "eslint-plugin-react-refresh": "^0.4.16", |     "eslint-plugin-react-refresh": "^0.4.16", | ||||||
|     "globals": "^15.14.0", |     "globals": "^15.14.0", | ||||||
|  |     "react-router": "^7.1.5", | ||||||
|     "typescript": "~5.6.2", |     "typescript": "~5.6.2", | ||||||
|     "typescript-eslint": "^8.18.2", |     "typescript-eslint": "^8.18.2", | ||||||
|     "vite": "^6.0.5" |     "vite": "^6.0.5" | ||||||
|  |   }, | ||||||
|  |   "prettier": { | ||||||
|  |     "trailingComma": "es5", | ||||||
|  |     "tabWidth": 2, | ||||||
|  |     "semi": true | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										41
									
								
								public/gitea.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								public/gitea.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||||
|  | <svg | ||||||
|  |    xml:space="preserve" | ||||||
|  |    width="128" | ||||||
|  |    height="128" | ||||||
|  |    viewBox="0 0 2560 2560" | ||||||
|  |    version="1.1" | ||||||
|  |    id="svg3" | ||||||
|  |    sodipodi:docname="gitea.svg" | ||||||
|  |    inkscape:version="1.4 (e7c3feb100, 2024-10-09)" | ||||||
|  |    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||||
|  |    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||||
|  |    xmlns="http://www.w3.org/2000/svg" | ||||||
|  |    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||||
|  |      id="defs3" /><sodipodi:namedview | ||||||
|  |      id="namedview3" | ||||||
|  |      pagecolor="#ffffff" | ||||||
|  |      bordercolor="#000000" | ||||||
|  |      borderopacity="0.25" | ||||||
|  |      inkscape:showpageshadow="2" | ||||||
|  |      inkscape:pageopacity="0.0" | ||||||
|  |      inkscape:pagecheckerboard="0" | ||||||
|  |      inkscape:deskcolor="#d1d1d1" | ||||||
|  |      inkscape:zoom="2.4221483" | ||||||
|  |      inkscape:cx="89.58989" | ||||||
|  |      inkscape:cy="-60.483497" | ||||||
|  |      inkscape:window-width="1408" | ||||||
|  |      inkscape:window-height="1727" | ||||||
|  |      inkscape:window-x="0" | ||||||
|  |      inkscape:window-y="0" | ||||||
|  |      inkscape:window-maximized="0" | ||||||
|  |      inkscape:current-layer="svg3" /><path | ||||||
|  |      d="m 1569.914,2282.76 -484.616,-232.952 c -47.736,-22.913 -68.358,-80.96 -45.063,-129.078 l 232.952,-484.617 c 22.913,-47.736 80.96,-68.358 129.078,-45.062 65.685,31.696 103.492,49.645 103.492,49.645 l -0.382,-417.022 63.776,-0.382 0.381,447.191 c 0,0 219.204,92.417 317.35,153.138 14.13,8.783 38.952,25.968 49.263,54.992 8.02,23.295 7.638,50.027 -3.818,73.704 l -232.952,484.617 c -23.678,48.5 -81.725,69.121 -129.46,45.826 z" | ||||||
|  |      style="fill:#ffffff;stroke-width:3.81889" | ||||||
|  |      id="path1" /><path | ||||||
|  |      d="m 2436.037,1005.725 c -15.657,-15.657 -36.66,-15.276 -36.66,-15.276 0,0 -447.574,25.205 -679.38,30.552 -50.792,1.145 -101.201,2.29 -151.228,2.673 v 447.573 c -21.004,-9.929 -42.39,-20.24 -63.394,-30.17 0,-139.007 -0.382,-417.021 -0.382,-417.021 -110.747,1.527 -340.644,-8.402 -340.644,-8.402 0,0 -539.99,-27.114 -598.802,-32.46 -37.425,-2.292 -85.924,-8.02 -148.936,5.728 -33.224,6.874 -127.933,28.26 -205.456,102.728 -171.85,153.137 -127.933,396.782 -122.586,433.443 6.492,44.681 26.35,168.795 121.058,276.87 174.905,214.239 551.447,209.275 551.447,209.275 0,0 46.209,110.365 116.858,211.948 95.472,126.405 193.618,224.932 289.09,236.77 240.59,0 721.387,-0.381 721.387,-0.381 0,0 45.827,0.382 108.075,-39.335 53.464,-32.46 101.2,-89.362 101.2,-89.362 0,0 49.264,-52.7 118.004,-172.995 21.004,-37.043 38.57,-72.941 53.846,-106.93 0,0 210.803,-447.19 210.803,-882.543 -4.201,-131.752 -36.662,-155.047 -44.3,-162.685 z M 537.67,1785.159 c -98.91,-32.46 -140.917,-71.413 -140.917,-71.413 0,0 -72.94,-51.173 -109.602,-151.991 -63.012,-168.795 -5.347,-271.905 -5.347,-271.905 0,0 32.079,-85.925 147.027,-114.567 52.701,-14.13 118.386,-11.838 118.386,-11.838 0,0 27.114,226.842 59.956,359.739 27.496,111.511 94.709,296.727 94.709,296.727 0,0 -99.673,-11.838 -164.212,-34.752 z m 1146.81,410.912 c 0,0 -23.294,55.374 -74.85,58.811 -22.149,1.528 -39.334,-4.582 -39.334,-4.582 0,0 -1.145,-0.382 -20.24,-8.02 l -431.152,-210.039 c 0,0 -41.626,-21.767 -48.882,-59.574 -8.401,-30.933 10.311,-69.122 10.311,-69.122 l 207.366,-427.333 c 0,0 18.33,-37.044 46.59,-49.646 2.291,-1.146 8.784,-3.819 17.185,-5.728 30.933,-8.02 68.74,10.693 68.74,10.693 l 422.75,205.074 c 0,0 48.119,21.767 58.43,61.866 7.255,28.26 -1.91,53.464 -6.874,65.685 -24.06,58.81 -210.04,431.916 -210.04,431.916 z" | ||||||
|  |      style="fill:#609926;stroke-width:3.81889" | ||||||
|  |      id="path2" /><path | ||||||
|  |      d="m 1306.029,1885.214 c -31.314,0.382 -58.81,22.15 -66.066,52.7 -7.256,30.552 7.637,62.249 34.751,76.379 29.406,15.275 66.83,6.874 86.69,-20.622 19.476,-27.114 16.42,-64.54 -6.875,-88.217 l 91.653,-187.507 c 5.729,0.382 14.13,0.764 23.677,-1.91 15.658,-3.436 27.115,-13.747 27.115,-13.747 16.039,6.874 32.842,14.511 50.409,23.295 18.33,9.165 35.516,18.712 51.173,27.878 3.437,1.91 6.874,4.2 10.693,7.256 6.11,4.964 12.984,11.838 17.949,21.003 7.255,21.004 -7.256,56.902 -7.256,56.902 -8.784,29.023 -70.268,155.047 -70.268,155.047 -30.933,-0.764 -58.429,19.094 -67.594,47.736 -9.93,30.933 4.2,66.066 33.988,81.342 29.787,15.275 66.449,6.492 85.925,-20.24 19.094,-25.969 17.567,-62.248 -4.2,-86.307 7.255,-14.13 14.129,-28.26 21.385,-43.153 19.094,-39.717 51.555,-116.094 51.555,-116.094 3.437,-6.493 21.768,-39.335 10.31,-81.343 -9.546,-43.535 -48.117,-63.775 -48.117,-63.775 -46.59,-30.17 -111.512,-58.047 -111.512,-58.047 0,0 0,-15.658 -4.2,-27.114 -4.201,-11.839 -10.693,-19.477 -14.894,-24.06 17.949,-37.042 35.897,-73.704 53.846,-110.747 a 2647.928,2647.928 0 0 1 -46.59,-23.295 c -18.33,37.425 -37.043,75.232 -55.374,112.657 -25.587,-0.382 -49.264,13.366 -61.484,35.898 -12.984,24.058 -10.311,53.846 7.256,75.613 z" | ||||||
|  |      style="fill:#609926;stroke-width:3.81889" | ||||||
|  |      id="path3" /></svg> | ||||||
| After Width: | Height: | Size: 4.6 KiB | 
| @@ -1,15 +1,19 @@ | |||||||
| [project] | [project] | ||||||
| name = "cutt" | name = "cutt" | ||||||
| version = "0.1.0" | version = "0.1.1" | ||||||
| description = "Add your description here" | description = "cool ultimate team tool" | ||||||
| author = "julius" | author = "julius" | ||||||
| readme = "README.md" | readme = "README.md" | ||||||
| requires-python = ">=3.13" | requires-python = ">=3.13" | ||||||
| dependencies = [ | dependencies = [ | ||||||
|  |     "argon2-cffi>=23.1.0", | ||||||
|     "fastapi[standard]>=0.115.7", |     "fastapi[standard]>=0.115.7", | ||||||
|     "matplotlib>=3.10.0", |     "matplotlib>=3.10.0", | ||||||
|     "networkx>=3.4.2", |     "networkx>=3.4.2", | ||||||
|  |     "passlib>=1.7.4", | ||||||
|     "psycopg>=3.2.4", |     "psycopg>=3.2.4", | ||||||
|  |     "pydantic-settings>=2.7.1", | ||||||
|  |     "pyjwt>=2.10.1", | ||||||
|     "pyqt6>=6.8.0", |     "pyqt6>=6.8.0", | ||||||
|     "sqlmodel>=0.0.22", |     "sqlmodel>=0.0.22", | ||||||
|     "uvicorn>=0.34.0", |     "uvicorn>=0.34.0", | ||||||
|   | |||||||
							
								
								
									
										133
									
								
								security.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										133
									
								
								security.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,133 @@ | |||||||
|  | from datetime import timedelta, timezone, datetime | ||||||
|  | from typing import Annotated | ||||||
|  | from fastapi import Depends, HTTPException, Response, status | ||||||
|  | from pydantic import BaseModel | ||||||
|  | import jwt | ||||||
|  | from jwt.exceptions import InvalidTokenError | ||||||
|  | from sqlmodel import Session, select | ||||||
|  | from db import engine, User | ||||||
|  | from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm | ||||||
|  | from pydantic_settings import BaseSettings, SettingsConfigDict | ||||||
|  | from passlib.context import CryptContext | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Config(BaseSettings): | ||||||
|  |     secret_key: str = "" | ||||||
|  |     access_token_expire_minutes: int = 30 | ||||||
|  |     model_config = SettingsConfigDict( | ||||||
|  |         env_file=".env", env_file_encoding="utf-8", extra="ignore" | ||||||
|  |     ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | config = Config() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class Token(BaseModel): | ||||||
|  |     access_token: str | ||||||
|  |     token_type: str | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TokenData(BaseModel): | ||||||
|  |     username: str | None = None | ||||||
|  |  | ||||||
|  |  | ||||||
|  | pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def verify_password(plain_password, hashed_password): | ||||||
|  |     return pwd_context.verify(plain_password, hashed_password) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_password_hash(password): | ||||||
|  |     return pwd_context.hash(password) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def get_user(username: str | None): | ||||||
|  |     if username: | ||||||
|  |         with Session(engine) as session: | ||||||
|  |             return session.exec( | ||||||
|  |                 select(User).where(User.username == username) | ||||||
|  |             ).one_or_none() | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def authenticate_user(username: str, password: str): | ||||||
|  |     user = get_user(username) | ||||||
|  |     if not user: | ||||||
|  |         return False | ||||||
|  |     if not verify_password(password, user.hashed_password): | ||||||
|  |         return False | ||||||
|  |     return user | ||||||
|  |  | ||||||
|  |  | ||||||
|  | def create_access_token(data: dict, expires_delta: timedelta | None = None): | ||||||
|  |     to_encode = data.copy() | ||||||
|  |     if expires_delta: | ||||||
|  |         expire = datetime.now(timezone.utc) + expires_delta | ||||||
|  |     else: | ||||||
|  |         expire = datetime.now(timezone.utc) + timedelta(minutes=15) | ||||||
|  |     to_encode.update({"exp": expire}) | ||||||
|  |     encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256") | ||||||
|  |     return encoded_jwt | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): | ||||||
|  |     credentials_exception = HTTPException( | ||||||
|  |         status_code=status.HTTP_401_UNAUTHORIZED, | ||||||
|  |         detail="Could not validate credentials", | ||||||
|  |         headers={"WWW-Authenticate": "Bearer"}, | ||||||
|  |     ) | ||||||
|  |     try: | ||||||
|  |         payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) | ||||||
|  |         username: str = payload.get("sub") | ||||||
|  |         if username is None: | ||||||
|  |             raise credentials_exception | ||||||
|  |         token_data = TokenData(username=username) | ||||||
|  |     except InvalidTokenError: | ||||||
|  |         raise credentials_exception | ||||||
|  |     user = get_user(username=token_data.username) | ||||||
|  |     if user is None: | ||||||
|  |         raise credentials_exception | ||||||
|  |     return user | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def get_current_active_user( | ||||||
|  |     current_user: Annotated[User, Depends(get_current_user)], | ||||||
|  | ): | ||||||
|  |     if current_user.disabled: | ||||||
|  |         raise HTTPException(status_code=400, detail="Inactive user") | ||||||
|  |     return current_user | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def login_for_access_token( | ||||||
|  |     form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response | ||||||
|  | ) -> Token: | ||||||
|  |     user = authenticate_user(form_data.username, form_data.password) | ||||||
|  |     if not user: | ||||||
|  |         raise HTTPException( | ||||||
|  |             status_code=status.HTTP_401_UNAUTHORIZED, | ||||||
|  |             detail="Incorrect username or password", | ||||||
|  |             headers={"WWW-Authenticate": "Bearer"}, | ||||||
|  |         ) | ||||||
|  |     access_token_expires = timedelta(minutes=config.access_token_expire_minutes) | ||||||
|  |     access_token = create_access_token( | ||||||
|  |         data={"sub": user.username}, expires_delta=access_token_expires | ||||||
|  |     ) | ||||||
|  |     response.set_cookie( | ||||||
|  |         "Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none" | ||||||
|  |     ) | ||||||
|  |     return Token(access_token=access_token, token_type="bearer") | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def read_users_me( | ||||||
|  |     current_user: Annotated[User, Depends(get_current_active_user)], | ||||||
|  | ): | ||||||
|  |     return current_user | ||||||
|  |  | ||||||
|  |  | ||||||
|  | async def read_own_items( | ||||||
|  |     current_user: Annotated[User, Depends(get_current_active_user)], | ||||||
|  | ): | ||||||
|  |     return [{"item_id": "Foo", "owner": current_user.username}] | ||||||
							
								
								
									
										215
									
								
								src/Analysis.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										215
									
								
								src/Analysis.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,215 @@ | |||||||
|  | import { useEffect, useState } from "react"; | ||||||
|  | import { apiAuth, baseUrl, token } from "./api"; | ||||||
|  | import useAuthContext from "./AuthContext"; | ||||||
|  |  | ||||||
|  | //const debounce = <T extends (...args: any[]) => void>( | ||||||
|  | //  func: T, | ||||||
|  | //  delay: number | ||||||
|  | //): ((...args: Parameters<T>) => void) => { | ||||||
|  | //  let timeoutId: number | null = null; | ||||||
|  | //  return (...args: Parameters<T>) => { | ||||||
|  | //    if (timeoutId !== null) { | ||||||
|  | //      clearTimeout(timeoutId); | ||||||
|  | //    } | ||||||
|  | //    console.log(timeoutId); | ||||||
|  | //    timeoutId = setTimeout(() => { | ||||||
|  | //      func(...args); | ||||||
|  | //    }, delay); | ||||||
|  | //  }; | ||||||
|  | //}; | ||||||
|  | // | ||||||
|  | interface Prop { | ||||||
|  |   name: string; | ||||||
|  |   min: string; | ||||||
|  |   max: string; | ||||||
|  |   step: string; | ||||||
|  |   value: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface Params { | ||||||
|  |   nodeSize: number; | ||||||
|  |   edgeWidth: number; | ||||||
|  |   arrowSize: number; | ||||||
|  |   fontSize: number; | ||||||
|  |   distance: number; | ||||||
|  |   weighting: boolean; | ||||||
|  |   popularity: boolean; | ||||||
|  |   show: number; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | interface DeferredProps { | ||||||
|  |   timeout: number; | ||||||
|  |   func: () => void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | let timeoutID: number | null = null; | ||||||
|  | export default function Analysis() { | ||||||
|  |   const [image, setImage] = useState(""); | ||||||
|  |   const [params, setParams] = useState<Params>({ | ||||||
|  |     nodeSize: 2000, | ||||||
|  |     edgeWidth: 1, | ||||||
|  |     arrowSize: 16, | ||||||
|  |     fontSize: 10, | ||||||
|  |     distance: 2, | ||||||
|  |     weighting: true, | ||||||
|  |     popularity: true, | ||||||
|  |     show: 2, | ||||||
|  |   }); | ||||||
|  |   const [showControlPanel, setShowControlPanel] = useState(false); | ||||||
|  |   const [loading, setLoading] = useState(true); | ||||||
|  |  | ||||||
|  |   // Function to generate and fetch the graph image | ||||||
|  |   async function loadImage() { | ||||||
|  |     setLoading(true); | ||||||
|  |     await apiAuth("analysis/image", params, "POST") | ||||||
|  |       .then((data) => { | ||||||
|  |         setImage(data.image); | ||||||
|  |         setLoading(false); | ||||||
|  |       }).catch((e) => { | ||||||
|  |         const { checkAuth } = useAuthContext(); | ||||||
|  |         checkAuth(); | ||||||
|  |       }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   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"; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const { user } = useAuthContext()! | ||||||
|  |   console.log(`logged in as ${user.username}`); | ||||||
|  |  | ||||||
|  |   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 > | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										190
									
								
								src/App.css
									
									
									
									
									
								
							
							
						
						
									
										190
									
								
								src/App.css
									
									
									
									
									
								
							| @@ -1,5 +1,5 @@ | |||||||
| * { | * { | ||||||
|   border-radius: 8px; |   border-radius: 16px; | ||||||
| } | } | ||||||
|  |  | ||||||
| body { | body { | ||||||
| @@ -7,21 +7,22 @@ body { | |||||||
|   position: relative; |   position: relative; | ||||||
|   z-index: 0; |   z-index: 0; | ||||||
|   color: black; |   color: black; | ||||||
|  |   text-align: center; | ||||||
|   overflow-wrap: anywhere; |   overflow-wrap: anywhere; | ||||||
|   height: 100%; |   height: 100%; | ||||||
| } | } | ||||||
|  |  | ||||||
| footer { |  | ||||||
|   font-size: x-small; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| #root { | #root { | ||||||
|   max-width: 1280px; |   max-width: 1280px; | ||||||
|   margin: 0 auto; |   margin: 0 auto; | ||||||
|   padding: 8px; |   padding: 8px; | ||||||
|   text-align: center; |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | footer { | ||||||
|  |   font-size: x-small; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
| .grey { | .grey { | ||||||
|   color: #444; |   color: #444; | ||||||
| } | } | ||||||
| @@ -29,7 +30,7 @@ footer { | |||||||
| .hint { | .hint { | ||||||
|   position: absolute; |   position: absolute; | ||||||
|   font-size: 80%; |   font-size: 80%; | ||||||
|   padding: 4px; |   padding: 8px; | ||||||
|   top: auto; |   top: auto; | ||||||
|   left: 4px; |   left: 4px; | ||||||
|   bottom: auto; |   bottom: auto; | ||||||
| @@ -37,19 +38,38 @@ footer { | |||||||
|   z-index: -1; |   z-index: -1; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | input { | ||||||
|  |   padding: 0.2em 16px; | ||||||
|  |   margin-bottom: 0.5em; | ||||||
|  | } | ||||||
|  |  | ||||||
| h1, | h1, | ||||||
| h2, | h2, | ||||||
| h3 { | h3 { | ||||||
|   margin-top: 0px; |   margin-top: 0px; | ||||||
|   margin-bottom: 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 { | .container { | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex-wrap: nowrap; |   flex-wrap: nowrap; | ||||||
|   justify-content: space-evenly; |   width: min(96vw, 900px); | ||||||
|   min-width: 737px; |  | ||||||
| } | } | ||||||
|  |  | ||||||
| .dragbox { | .dragbox { | ||||||
| @@ -59,6 +79,21 @@ h3 { | |||||||
|   height: 92%; |   height: 92%; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | .box { | ||||||
|  |   position: relative; | ||||||
|  |   flex: 1; | ||||||
|  |  | ||||||
|  |   &.one { | ||||||
|  |     max-width: min(96%, 768px); | ||||||
|  |     margin: 4px auto; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   padding: 4px; | ||||||
|  |   margin: 4px 0.5%; | ||||||
|  |   border-style: solid; | ||||||
|  |   border-color: black; | ||||||
|  | } | ||||||
|  |  | ||||||
| .reservoir { | .reservoir { | ||||||
|   flex-direction: unset; |   flex-direction: unset; | ||||||
|   flex-wrap: wrap; |   flex-wrap: wrap; | ||||||
| @@ -66,29 +101,11 @@ h3 { | |||||||
|   width: 100%; |   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 { | .user { | ||||||
|   max-width: 400px; |   max-width: 240px; | ||||||
|   min-width: 200px; |   min-width: 100px; | ||||||
|   margin: 4px auto; |   margin: 4px auto; | ||||||
|  |  | ||||||
|   .item { |   .item { | ||||||
|     font-weight: bold; |     font-weight: bold; | ||||||
|     border-style: solid; |     border-style: solid; | ||||||
| @@ -99,69 +116,109 @@ h3 { | |||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   font-size: small; |   font-size: small; | ||||||
|   border: 3px dashed black; |   border: 3px dashed black; | ||||||
|   border-radius: 4px; |   border-radius: 1.2em; | ||||||
|   margin: 8px auto; |   margin: 8px auto; | ||||||
|   padding: 4px 8px; |   padding: 4px 16px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .extra-margin { | .extra-margin { | ||||||
|   padding: 0px 8px; |   padding: 0px 8px; | ||||||
|  |   margin: auto; | ||||||
| } | } | ||||||
|  |  | ||||||
| button { | button, | ||||||
|  | .button { | ||||||
|   font-weight: bold; |   font-weight: bold; | ||||||
|   font-size: large; |   font-size: large; | ||||||
|   color: ghostwhite; |   color: aliceblue; | ||||||
|   background-color: black; |   background-color: black; | ||||||
|  |   border-radius: 1.2em; | ||||||
|   z-index: 1; |   z-index: 1; | ||||||
|   &:focus { | } | ||||||
|     outline: black; |  | ||||||
|   } | #control-panel { | ||||||
|   &:hover { |   display: none; | ||||||
|     border-color: black; |   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; | ||||||
|  |   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); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| @media only screen and (max-width: 768px) { | @media only screen and (max-width: 768px) { | ||||||
|   .container { |   #control-panel { | ||||||
|     min-width: 96vw; |     grid-template-columns: 1fr; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   .submit_text { |   .submit_text { | ||||||
|     display: none; |     display: none; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   .submit { |   .submit { | ||||||
|     position: fixed; |     position: fixed; | ||||||
|     right: 16px; |     right: 16px; | ||||||
|     bottom: 16px; |     bottom: 16px; | ||||||
|     padding: 0px; |     padding: 0.4em; | ||||||
|     background-color: unset; |     border-radius: 1em; | ||||||
|  |     background-color: rgba(0, 0, 0, 0.3); | ||||||
|     font-size: xx-large; |     font-size: xx-large; | ||||||
|     margin-bottom: 20px; |     margin-bottom: 16px; | ||||||
|     margin-right: 20px; |     margin-right: 16px; | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| ::backdrop { | ::backdrop { | ||||||
|   background-image: linear-gradient( |   background-image: linear-gradient(45deg, | ||||||
|     45deg, |       magenta, | ||||||
|     magenta, |       rebeccapurple, | ||||||
|     rebeccapurple, |       dodgerblue, | ||||||
|     dodgerblue, |       green); | ||||||
|     green |  | ||||||
|   ); |  | ||||||
|   opacity: 0.75; |   opacity: 0.75; | ||||||
| } | } | ||||||
|  |  | ||||||
| .tablink { | .tablink { | ||||||
|   background-color: unset; |   color: white; | ||||||
|   font-weight: unset; |  | ||||||
|   color: black; |  | ||||||
|   border: 2px solid black; |  | ||||||
|   border-radius: unset; |  | ||||||
|   outline: black; |  | ||||||
|   cursor: pointer; |   cursor: pointer; | ||||||
|   padding: 8px 16px; |   flex: 1; | ||||||
|   width: 50%; |   margin: 4px auto; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .navbar { | ||||||
|  |   button { | ||||||
|  |     font-size: medium; | ||||||
|  |     margin: 4px 0.5%; | ||||||
|  |     padding-top: 4px; | ||||||
|  |     padding-bottom: 4px; | ||||||
|  |     opacity: 50%; | ||||||
|  |  | ||||||
|  |     &:hover { | ||||||
|  |       opacity: 75%; | ||||||
|  |     } | ||||||
|  |   } | ||||||
| } | } | ||||||
|  |  | ||||||
| /* Style the tab content (and add height:100% for full page content) */ | /* Style the tab content (and add height:100% for full page content) */ | ||||||
| @@ -182,15 +239,18 @@ button { | |||||||
| .logo { | .logo { | ||||||
|   position: relative; |   position: relative; | ||||||
|   text-align: center; |   text-align: center; | ||||||
|   height: 196px; |   height: 140px; | ||||||
|   margin: auto; |   margin-bottom: 20px; | ||||||
|  |  | ||||||
|   img { |   img { | ||||||
|     display: block; |     display: block; | ||||||
|     margin: auto; |     margin: auto; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   h3 { |   h3 { | ||||||
|     position: absolute; |     position: absolute; | ||||||
|     width: 200px; |     font-size: medium; | ||||||
|  |     width: 140px; | ||||||
|     top: 33%; |     top: 33%; | ||||||
|     left: 50%; |     left: 50%; | ||||||
|     transform: translate(-50%, -50%); |     transform: translate(-50%, -50%); | ||||||
| @@ -211,6 +271,7 @@ button { | |||||||
|   border: 4px solid black; |   border: 4px solid black; | ||||||
|   overflow: hidden; |   overflow: hidden; | ||||||
| } | } | ||||||
|  |  | ||||||
| .loader::after { | .loader::after { | ||||||
|   content: ""; |   content: ""; | ||||||
|   width: 32%; |   width: 32%; | ||||||
| @@ -228,6 +289,7 @@ button { | |||||||
|     left: 0; |     left: 0; | ||||||
|     transform: translateX(-100%); |     transform: translateX(-100%); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   100% { |   100% { | ||||||
|     left: 100%; |     left: 100%; | ||||||
|     transform: translateX(0%); |     transform: translateX(0%); | ||||||
|   | |||||||
							
								
								
									
										46
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										46
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -1,31 +1,31 @@ | |||||||
| import { baseUrl } from "./api"; | import Analysis from "./Analysis"; | ||||||
| import "./App.css"; | import "./App.css"; | ||||||
|  | import { AuthProvider } from "./AuthContext"; | ||||||
|  | import Footer from "./Footer"; | ||||||
|  | import Header from "./Header"; | ||||||
| import Rankings from "./Rankings"; | import Rankings from "./Rankings"; | ||||||
|  | import { BrowserRouter, Routes, Route } from "react-router"; | ||||||
|  |  | ||||||
| function App() { | function App() { | ||||||
|  |   //const [data, setData] = useState({ nodes: [], links: [] } as SociogramData); | ||||||
|  |   //async function loadData() { | ||||||
|  |   //  await fetch(`${baseUrl}api/analysis/json`, { method: "GET" }).then(resp => resp.json() as unknown as SociogramData).then(json => { setData(json) }) | ||||||
|  |   //} | ||||||
|  |   //useEffect(() => { loadData() }, []) | ||||||
|  |   // | ||||||
|   return ( |   return ( | ||||||
|     <> |     <BrowserRouter> | ||||||
|       <div className="logo"> |       <Header /> | ||||||
|         <a href={baseUrl}> |       <Routes> | ||||||
|           <img alt="logo" height="66%" src="logo.svg" /> |         <Route index element={<Rankings />} /> | ||||||
|         </a> |         <Route path="/analysis" element={ | ||||||
|         <h3 className="centered">cutt</h3> |           <AuthProvider> | ||||||
|         <span className="grey">cool ultimate team tool</span> |             <Analysis /> | ||||||
|       </div> |           </AuthProvider> | ||||||
|       <Rankings /> |         } /> | ||||||
|       <footer> |       </Routes> | ||||||
|         <p className="grey"> |       <Footer /> | ||||||
|           something not working? |     </BrowserRouter> | ||||||
|           <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> |  | ||||||
|     </> |  | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
| export default App; | export default App; | ||||||
|   | |||||||
							
								
								
									
										14
									
								
								src/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/Footer.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | export default function Footer() { | ||||||
|  |         return <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> | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								src/Header.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/Header.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import { baseUrl } from "./api"; | ||||||
|  |  | ||||||
|  | export default function Header() { | ||||||
|  |   return <div className="logo"> | ||||||
|  |     <a href={baseUrl}> | ||||||
|  |       <img alt="logo" height="66%" src="logo.svg" /> | ||||||
|  |       <h3 className="centered">cutt</h3> | ||||||
|  |     </a> | ||||||
|  |     <span className="grey">cool ultimate team tool</span> | ||||||
|  |   </div> | ||||||
|  | } | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Dispatch, SetStateAction, useEffect, useState } from "react"; | import { Dispatch, SetStateAction, useEffect, useMemo, useState } from "react"; | ||||||
| import { ReactSortable, ReactSortableProps } from "react-sortablejs"; | import { ReactSortable, ReactSortableProps } from "react-sortablejs"; | ||||||
| import api, { baseUrl } from "./api"; | import api, { baseUrl } from "./api"; | ||||||
|  |  | ||||||
| @@ -136,8 +136,7 @@ export function Chemistry({ user, players }: PlayerInfoProps) { | |||||||
|           <h2>😬</h2> |           <h2>😬</h2> | ||||||
|           {playersLeft.length < 1 && ( |           {playersLeft.length < 1 && ( | ||||||
|             <span className="grey hint"> |             <span className="grey hint"> | ||||||
|               drag people here that you'd rather not play with from worst to ... |               drag people here that you'd rather not play with | ||||||
|               ok |  | ||||||
|             </span> |             </span> | ||||||
|           )} |           )} | ||||||
|           <PlayerList |           <PlayerList | ||||||
| @@ -145,7 +144,6 @@ export function Chemistry({ user, players }: PlayerInfoProps) { | |||||||
|             setList={setPlayersLeft} |             setList={setPlayersLeft} | ||||||
|             group={"shared"} |             group={"shared"} | ||||||
|             className="dragbox" |             className="dragbox" | ||||||
|             orderedList |  | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|         <div className="box three"> |         <div className="box three"> | ||||||
| @@ -268,73 +266,76 @@ 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; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export default function Rankings() { | export default function Rankings() { | ||||||
|   const [user, setUser] = useState<Player[]>([]); |   const [user, setUser] = useState<Player[]>([]); | ||||||
|   const [players, setPlayers] = useState<Player[]>([]); |   const [players, setPlayers] = useState<Player[]>([]); | ||||||
|  |   const [openTab, setOpenTab] = useState("Chemistry"); | ||||||
|  |  | ||||||
|   async function loadPlayers() { |   async function loadPlayers() { | ||||||
|     const response = await fetch(`${baseUrl}player/list`, { |     const response = await fetch(`${baseUrl}api/player/list`, { | ||||||
|       method: "GET", |       method: "GET", | ||||||
|     }); |     }); | ||||||
|     const data = await response.json(); |     const data = await response.json(); | ||||||
|     setPlayers(data as Player[]); |     setPlayers(data as Player[]); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   useEffect(() => { |   useMemo(() => { | ||||||
|     loadPlayers(); |     loadPlayers(); | ||||||
|   }, []); |   }, []); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     user.length === 1 && openPage(openTab, "aliceblue"); | ||||||
|  |   }, [user]); | ||||||
|  |  | ||||||
|  |   function openPage(pageName: string, color: string) { | ||||||
|  |     // Hide all elements with class="tabcontent" by default */ | ||||||
|  |     var i, tabcontent, tablinks; | ||||||
|  |     tabcontent = document.getElementsByClassName("tabcontent"); | ||||||
|  |     for (i = 0; i < tabcontent.length; i++) { | ||||||
|  |       (tabcontent[i] as HTMLElement).style.display = "none"; | ||||||
|  |     } | ||||||
|  |     // Remove the background color of all tablinks/buttons | ||||||
|  |     tablinks = document.getElementsByClassName("tablink"); | ||||||
|  |     for (i = 0; i < tablinks.length; i++) { | ||||||
|  |       let button = tablinks[i] as HTMLElement; | ||||||
|  |       button.style.opacity = "50%"; | ||||||
|  |     } | ||||||
|  |     // Show the specific tab content | ||||||
|  |     (document.getElementById(pageName) as HTMLElement).style.display = "block"; | ||||||
|  |     // Add the specific color to the button used to open the tab content | ||||||
|  |     let activeButton = document.getElementById( | ||||||
|  |       pageName + "Button" | ||||||
|  |     ) as HTMLElement; | ||||||
|  |     activeButton.style.fontWeight = "bold"; | ||||||
|  |     activeButton.style.opacity = "100%"; | ||||||
|  |     document.body.style.backgroundColor = color; | ||||||
|  |     setOpenTab(pageName); | ||||||
|  |   } | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <SelectUser {...{ user, setUser, players, setPlayers }} /> |       <SelectUser {...{ user, setUser, players, setPlayers }} /> | ||||||
|       {user.length === 1 && ( |       {user.length === 1 && ( | ||||||
|         <> |         <> | ||||||
|           <div className="container"> |           <div className="container navbar"> | ||||||
|             <button |             <button | ||||||
|               className="tablink" |               className="tablink" | ||||||
|               id="ChemistryButton" |               id="ChemistryButton" | ||||||
|               onClick={() => openPage("Chemistry", "aliceblue")} |               onClick={() => openPage("Chemistry", "aliceblue")} | ||||||
|             > |             > | ||||||
|               Chemistry |               🧪 Chemistry | ||||||
|             </button> |             </button> | ||||||
|             <button |             <button | ||||||
|               className="tablink" |               className="tablink" | ||||||
|               id="MVPButton" |               id="MVPButton" | ||||||
|               onClick={() => openPage("MVP", "aliceblue")} |               onClick={() => openPage("MVP", "aliceblue")} | ||||||
|             > |             > | ||||||
|               MVP |               🏆 MVP | ||||||
|             </button> |             </button> | ||||||
|           </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 id="Chemistry" className="tabcontent"> |           <div id="Chemistry" className="tabcontent"> | ||||||
|             <Chemistry {...{ user, players }} /> |             <Chemistry {...{ user, players }} /> | ||||||
|           </div> |           </div> | ||||||
|   | |||||||
							
								
								
									
										87
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						
									
										87
									
								
								src/api.ts
									
									
									
									
									
								
							| @@ -1,4 +1,10 @@ | |||||||
|  | import { useContext } from "react"; | ||||||
|  | import useAuthContext from "./AuthContext"; | ||||||
|  | import { createCookie } from "react-router"; | ||||||
|  |  | ||||||
| export const baseUrl = import.meta.env.VITE_BASE_URL as string; | export const baseUrl = import.meta.env.VITE_BASE_URL as string; | ||||||
|  | export const token = () => localStorage.getItem("access_token") as string; | ||||||
|  |  | ||||||
| export default async function api(path: string, data: any): Promise<any> { | export default async function api(path: string, data: any): Promise<any> { | ||||||
|   const request = new Request(`${baseUrl}${path}/`, { |   const request = new Request(`${baseUrl}${path}/`, { | ||||||
|     method: "POST", |     method: "POST", | ||||||
| @@ -15,3 +21,84 @@ export default async function api(path: string, data: any): Promise<any> { | |||||||
|   } |   } | ||||||
|   return response; |   return response; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> { | ||||||
|  |  | ||||||
|  |   const req = new Request(`${baseUrl}api/${path}`, { | ||||||
|  |     method: method, headers: { | ||||||
|  |       "Authorization": `Bearer ${token()} `, | ||||||
|  |       'Content-Type': 'application/json' | ||||||
|  |     }, | ||||||
|  |     body: JSON.stringify(data), | ||||||
|  |   }); | ||||||
|  |   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() | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export type User = { | ||||||
|  |   username: string; | ||||||
|  |   fullName: string; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function currentUser(): Promise<User> { | ||||||
|  |   if (!token()) throw new Error("you have no access token") | ||||||
|  |   const req = new Request(`${baseUrl}api/users/me/`, { | ||||||
|  |     method: "GET", headers: { | ||||||
|  |       "Authorization": `Bearer ${token()} `, | ||||||
|  |       'Content-Type': 'application/json' | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   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 type LoginRequest = { | ||||||
|  |   username: string; | ||||||
|  |   password: string; | ||||||
|  | }; | ||||||
|  | export type Token = { | ||||||
|  |   access_token: string; | ||||||
|  |   token_type: string; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export const login = (req: LoginRequest) => { | ||||||
|  |   fetch(`${baseUrl}api/token`, { | ||||||
|  |     method: "POST", headers: { | ||||||
|  |       'Content-Type': 'application/x-www-form-urlencoded', | ||||||
|  |     }, body: new URLSearchParams(req).toString() | ||||||
|  |   }).then(resp => resp.json() as unknown as Token).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login")); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const cookielogin = (req: LoginRequest) => { | ||||||
|  |   fetch(`${baseUrl}api/token`, { | ||||||
|  |     method: "POST", headers: { | ||||||
|  |       'Content-Type': 'application/x-www-form-urlencoded', | ||||||
|  |     }, body: new URLSearchParams(req).toString() | ||||||
|  |   }).then(resp => { createCookie(resp.headers.getSetCookie()) }).catch((e) => console.log("catch error " + e + " in login")); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const logout = () => localStorage.removeItem("access_token"); | ||||||
|   | |||||||
| @@ -2,17 +2,17 @@ | |||||||
|   "compilerOptions": { |   "compilerOptions": { | ||||||
|     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |     "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", | ||||||
|     "target": "ES2022", |     "target": "ES2022", | ||||||
|     "lib": ["ES2023"], |     "lib": [ | ||||||
|  |       "ES2023" | ||||||
|  |     ], | ||||||
|     "module": "ESNext", |     "module": "ESNext", | ||||||
|     "skipLibCheck": true, |     "skipLibCheck": true, | ||||||
|  |  | ||||||
|     /* Bundler mode */ |     /* Bundler mode */ | ||||||
|     "moduleResolution": "bundler", |     "moduleResolution": "bundler", | ||||||
|     "allowImportingTsExtensions": true, |     "allowImportingTsExtensions": true, | ||||||
|     "isolatedModules": true, |     "isolatedModules": true, | ||||||
|     "moduleDetection": "force", |     "moduleDetection": "force", | ||||||
|     "noEmit": true, |     "noEmit": true, | ||||||
|  |  | ||||||
|     /* Linting */ |     /* Linting */ | ||||||
|     "strict": true, |     "strict": true, | ||||||
|     "noUnusedLocals": true, |     "noUnusedLocals": true, | ||||||
| @@ -20,5 +20,7 @@ | |||||||
|     "noFallthroughCasesInSwitch": true, |     "noFallthroughCasesInSwitch": true, | ||||||
|     "noUncheckedSideEffectImports": true |     "noUncheckedSideEffectImports": true | ||||||
|   }, |   }, | ||||||
|   "include": ["vite.config.ts"] |   "include": [ | ||||||
|  |     "vite.config.ts" | ||||||
|  |   ] | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user