Compare commits
	
		
			28 Commits
		
	
	
		
			e89a2eea20
			...
			feat/inter
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | 
							
								
								
									
										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 | ||||
|   | ||||
							
								
								
									
										112
									
								
								analysis.py
									
									
									
									
									
								
							
							
						
						
									
										112
									
								
								analysis.py
									
									
									
									
									
								
							| @@ -8,12 +8,12 @@ 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") | ||||
|  | ||||
|  | ||||
| @@ -24,10 +24,10 @@ P = Player | ||||
| def sociogram_json(): | ||||
|     nodes = [] | ||||
|     necessary_nodes = set() | ||||
|     links = [] | ||||
|     edges = [] | ||||
|     with Session(engine) as session: | ||||
|         for p in session.exec(select(P)).fetchall(): | ||||
|             nodes.append({"id": p.name, "appearance": 1}) | ||||
|             nodes.append({"id": p.name, "label": p.name}) | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")) | ||||
|             .where(C.time > datetime(2025, 2, 1, 10)) | ||||
| @@ -44,18 +44,55 @@ def sociogram_json(): | ||||
|                 # 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}) | ||||
|                 edges.append({"from": c.user, "to": p, "relation": "likes"}) | ||||
|             for p in c.hate: | ||||
|                 edges.append({"from": c.user, "to": p, "relation": "dislikes"}) | ||||
|     # nodes = [n for n in nodes if n["name"] in necessary_nodes] | ||||
|     return JSONResponse({"nodes": nodes, "links": links}) | ||||
|     return JSONResponse({"nodes": nodes, "edges": edges}) | ||||
|  | ||||
|  | ||||
| def sociogram_data(): | ||||
| def graph_json(): | ||||
|     nodes = [] | ||||
|     links = [] | ||||
|     edges = [] | ||||
|     with Session(engine) as session: | ||||
|         for p in session.exec(select(P)).fetchall(): | ||||
|             nodes.append({"id": p.name, "label": p.name}) | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")) | ||||
|             .where(C.time > datetime(2025, 2, 1, 10)) | ||||
|             .group_by(C.user) | ||||
|             .subquery() | ||||
|         ) | ||||
|         statement2 = select(C).join( | ||||
|             subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) | ||||
|         ) | ||||
|         for c in session.exec(statement2): | ||||
|             for p in c.love: | ||||
|                 edges.append( | ||||
|                     { | ||||
|                         "id": f"{c.user}->{p}", | ||||
|                         "source": c.user, | ||||
|                         "target": p, | ||||
|                         "relation": "likes", | ||||
|                     } | ||||
|                 ) | ||||
|             continue | ||||
|             for p in c.hate: | ||||
|                 edges.append( | ||||
|                     { | ||||
|                         id: f"{c.user}-x>{p}", | ||||
|                         "source": c.user, | ||||
|                         "target": p, | ||||
|                         "relation": "dislikes", | ||||
|                     } | ||||
|                 ) | ||||
|     return JSONResponse({"nodes": nodes, "edges": edges}) | ||||
|  | ||||
|  | ||||
| def sociogram_data(show: int | None = 2): | ||||
|     G = nx.DiGraph() | ||||
|     with Session(engine) as session: | ||||
|         for p in session.exec(select(P)).fetchall(): | ||||
|             nodes.append({"id": p.name}) | ||||
|             G.add_node(p.name) | ||||
|         subquery = ( | ||||
|             select(C.user, func.max(C.time).label("latest")) | ||||
| @@ -65,13 +102,16 @@ def sociogram_data(): | ||||
|         ) | ||||
|         statement2 = ( | ||||
|             select(C) | ||||
|             .where(C.user.in_(["Kruse", "Franz", "ck"])) | ||||
|             # .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): | ||||
|             for p in c.love: | ||||
|                 G.add_edge(c.user, p) | ||||
|                 links.append({"source": c.user, "target": p}) | ||||
|             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 | ||||
|  | ||||
|  | ||||
| @@ -81,34 +121,57 @@ class Params(BaseModel): | ||||
|     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 | ||||
|  | ||||
|  | ||||
| def sociogram_image(params: Params): | ||||
| 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() | ||||
|     G = sociogram_data(show=params.show) | ||||
|     pos = nx.spring_layout(G, scale=2, k=params.distance, iterations=50, seed=None) | ||||
|     nx.draw_networkx_nodes( | ||||
|     nodes = nx.draw_networkx_nodes( | ||||
|         G, | ||||
|         pos, | ||||
|         node_color="#99ccff", | ||||
|         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=1, | ||||
|         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="#404040", | ||||
|         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() | ||||
| @@ -121,7 +184,8 @@ def sociogram_image(params: Params): | ||||
|  | ||||
|  | ||||
| analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) | ||||
| analysis_router.add_api_route("/image", endpoint=sociogram_image, methods=["POST"]) | ||||
| analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"]) | ||||
| analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|     with Session(engine) as session: | ||||
| @@ -129,13 +193,3 @@ if __name__ == "__main__": | ||||
|         print("players in DB: ", session.exec(statement).first()) | ||||
|     G = sociogram_data() | ||||
|     pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42) | ||||
|     edges = nx.draw_networkx_edges( | ||||
|         G, | ||||
|         pos, | ||||
|         arrows=True, | ||||
|         arrowsize=12, | ||||
|     ) | ||||
|     nx.draw_networkx( | ||||
|         G, pos, with_labels=True, node_color="#99ccff", font_size=8, node_size=2000 | ||||
|     ) | ||||
|     plt.show() | ||||
|   | ||||
							
								
								
									
										21
									
								
								db.py
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								db.py
									
									
									
									
									
								
							| @@ -1,10 +1,18 @@ | ||||
| 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: | ||||
|     db_secrets = f.readline().strip() | ||||
|  | ||||
| engine = create_engine(db_secrets) | ||||
| engine = create_engine(db_secrets, connect_args={"connect_timeout": 8}) | ||||
| del db_secrets | ||||
|  | ||||
|  | ||||
| @@ -54,4 +62,13 @@ class MVPRanking(SQLModel, table=True): | ||||
|     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) | ||||
|   | ||||
							
								
								
									
										15
									
								
								main.py
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								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 db import Player, Team, Chemistry, MVPRanking, engine | ||||
| from sqlmodel import ( | ||||
| @@ -7,6 +7,12 @@ from sqlmodel import ( | ||||
| ) | ||||
| 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") | ||||
| @@ -91,6 +97,11 @@ class SPAStaticFiles(StaticFiles): | ||||
|  | ||||
| api_router.include_router(player_router) | ||||
| api_router.include_router(team_router) | ||||
| api_router.include_router(analysis_router) | ||||
| api_router.include_router( | ||||
|     analysis_router, dependencies=[Depends(get_current_active_user)] | ||||
| ) | ||||
| api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"]) | ||||
| api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"]) | ||||
| api_router.add_api_route("/users/me/items/", endpoint=read_own_items, methods=["GET"]) | ||||
| app.include_router(api_router) | ||||
| app.mount("/", SPAStaticFiles(directory="dist", html=True), name="site") | ||||
|   | ||||
| @@ -10,15 +10,14 @@ | ||||
|     "preview": "vite preview" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "d3": "^7.9.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/sortablejs": "^1.15.8", | ||||
| @@ -27,7 +26,7 @@ | ||||
|     "eslint-plugin-react-hooks": "^5.0.0", | ||||
|     "eslint-plugin-react-refresh": "^0.4.16", | ||||
|     "globals": "^15.14.0", | ||||
|     "react-router-dom": "^7.1.5", | ||||
|     "react-router": "^7.1.5", | ||||
|     "typescript": "~5.6.2", | ||||
|     "typescript-eslint": "^8.18.2", | ||||
|     "vite": "^6.0.5" | ||||
|   | ||||
| @@ -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", | ||||
|   | ||||
							
								
								
									
										137
									
								
								security.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								security.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| from datetime import timedelta, timezone, datetime | ||||
| from typing import Annotated | ||||
| from fastapi import Depends, HTTPException, Response, status | ||||
| from pydantic import BaseModel | ||||
| import jwt | ||||
| from jwt.exceptions import InvalidTokenError | ||||
| from sqlmodel import Session, select | ||||
| from db import engine, User | ||||
| from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm | ||||
| from pydantic_settings import BaseSettings, SettingsConfigDict | ||||
| from passlib.context import CryptContext | ||||
| from sqlalchemy.exc import OperationalError | ||||
|  | ||||
|  | ||||
| class Config(BaseSettings): | ||||
|     secret_key: str = "" | ||||
|     access_token_expire_minutes: int = 30 | ||||
|     model_config = SettingsConfigDict( | ||||
|         env_file=".env", env_file_encoding="utf-8", extra="ignore" | ||||
|     ) | ||||
|  | ||||
|  | ||||
| config = Config() | ||||
|  | ||||
|  | ||||
| class Token(BaseModel): | ||||
|     access_token: str | ||||
|     token_type: str | ||||
|  | ||||
|  | ||||
| class TokenData(BaseModel): | ||||
|     username: str | None = None | ||||
|  | ||||
|  | ||||
| pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") | ||||
|  | ||||
|  | ||||
| oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token") | ||||
|  | ||||
|  | ||||
| def verify_password(plain_password, hashed_password): | ||||
|     return pwd_context.verify(plain_password, hashed_password) | ||||
|  | ||||
|  | ||||
| def get_password_hash(password): | ||||
|     return pwd_context.hash(password) | ||||
|  | ||||
|  | ||||
| def get_user(username: str | None): | ||||
|     if username: | ||||
|         try: | ||||
|             with Session(engine) as session: | ||||
|                 return session.exec( | ||||
|                     select(User).where(User.username == username) | ||||
|                 ).one_or_none() | ||||
|         except OperationalError: | ||||
|             return | ||||
|  | ||||
|  | ||||
| def authenticate_user(username: str, password: str): | ||||
|     user = get_user(username) | ||||
|     if not user: | ||||
|         return False | ||||
|     if not verify_password(password, user.hashed_password): | ||||
|         return False | ||||
|     return user | ||||
|  | ||||
|  | ||||
| def create_access_token(data: dict, expires_delta: timedelta | None = None): | ||||
|     to_encode = data.copy() | ||||
|     if expires_delta: | ||||
|         expire = datetime.now(timezone.utc) + expires_delta | ||||
|     else: | ||||
|         expire = datetime.now(timezone.utc) + timedelta(minutes=15) | ||||
|     to_encode.update({"exp": expire}) | ||||
|     encoded_jwt = jwt.encode(to_encode, config.secret_key, algorithm="HS256") | ||||
|     return encoded_jwt | ||||
|  | ||||
|  | ||||
| async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]): | ||||
|     credentials_exception = HTTPException( | ||||
|         status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|         detail="Could not validate credentials", | ||||
|         headers={"WWW-Authenticate": "Bearer"}, | ||||
|     ) | ||||
|     try: | ||||
|         payload = jwt.decode(token, config.secret_key, algorithms=["HS256"]) | ||||
|         username: str = payload.get("sub") | ||||
|         if username is None: | ||||
|             raise credentials_exception | ||||
|         token_data = TokenData(username=username) | ||||
|     except InvalidTokenError: | ||||
|         raise credentials_exception | ||||
|     user = get_user(username=token_data.username) | ||||
|     if user is None: | ||||
|         raise credentials_exception | ||||
|     return user | ||||
|  | ||||
|  | ||||
| async def get_current_active_user( | ||||
|     current_user: Annotated[User, Depends(get_current_user)], | ||||
| ): | ||||
|     if current_user.disabled: | ||||
|         raise HTTPException(status_code=400, detail="Inactive user") | ||||
|     return current_user | ||||
|  | ||||
|  | ||||
| async def login_for_access_token( | ||||
|     form_data: Annotated[OAuth2PasswordRequestForm, Depends()], response: Response | ||||
| ) -> Token: | ||||
|     user = authenticate_user(form_data.username, form_data.password) | ||||
|     if not user: | ||||
|         raise HTTPException( | ||||
|             status_code=status.HTTP_401_UNAUTHORIZED, | ||||
|             detail="Incorrect username or password", | ||||
|             headers={"WWW-Authenticate": "Bearer"}, | ||||
|         ) | ||||
|     access_token_expires = timedelta(minutes=config.access_token_expire_minutes) | ||||
|     access_token = create_access_token( | ||||
|         data={"sub": user.username}, expires_delta=access_token_expires | ||||
|     ) | ||||
|     response.set_cookie( | ||||
|         "Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none" | ||||
|     ) | ||||
|     return Token(access_token=access_token, token_type="bearer") | ||||
|  | ||||
|  | ||||
| async def read_users_me( | ||||
|     current_user: Annotated[User, Depends(get_current_active_user)], | ||||
| ): | ||||
|     return current_user | ||||
|  | ||||
|  | ||||
| async def read_own_items( | ||||
|     current_user: Annotated[User, Depends(get_current_active_user)], | ||||
| ): | ||||
|     return [{"item_id": "Foo", "owner": current_user.username}] | ||||
							
								
								
									
										122
									
								
								src/Analysis.tsx
									
									
									
									
									
								
							
							
						
						
									
										122
									
								
								src/Analysis.tsx
									
									
									
									
									
								
							| @@ -1,6 +1,22 @@ | ||||
| import { useEffect, useState } from "react"; | ||||
| import { baseUrl } from "./api"; | ||||
| import { apiAuth } from "./api"; | ||||
|  | ||||
| //const debounce = <T extends (...args: any[]) => void>( | ||||
| //  func: T, | ||||
| //  delay: number | ||||
| //): ((...args: Parameters<T>) => void) => { | ||||
| //  let timeoutId: number | null = null; | ||||
| //  return (...args: Parameters<T>) => { | ||||
| //    if (timeoutId !== null) { | ||||
| //      clearTimeout(timeoutId); | ||||
| //    } | ||||
| //    console.log(timeoutId); | ||||
| //    timeoutId = setTimeout(() => { | ||||
| //      func(...args); | ||||
| //    }, delay); | ||||
| //  }; | ||||
| //}; | ||||
| // | ||||
| interface Prop { | ||||
|   name: string; | ||||
|   min: string; | ||||
| @@ -15,46 +31,109 @@ interface Params { | ||||
|   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: 20, | ||||
|     arrowSize: 16, | ||||
|     fontSize: 10, | ||||
|     distance: 0.2, | ||||
|     distance: 2, | ||||
|     weighting: true, | ||||
|     popularity: true, | ||||
|     show: 2, | ||||
|   }); | ||||
|   const [showControlPanel, setShowControlPanel] = useState(false); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   // Function to generate and fetch the graph image | ||||
|   async function loadImage() { | ||||
|     setLoading(true); | ||||
|     await fetch(`${baseUrl}api/analysis/image`, { | ||||
|       method: "POST", | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       body: JSON.stringify(params) | ||||
|     }) | ||||
|       .then((resp) => resp.json()) | ||||
|     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 {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" : "closed"}> | ||||
|       <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> | ||||
| @@ -65,7 +144,6 @@ export default function Analysis() { | ||||
|             step="0.05" | ||||
|             value={params.distance} | ||||
|             onChange={(evt) => setParams({ ...params, distance: Number(evt.target.value) })} | ||||
|             onMouseUp={() => loadImage()} | ||||
|           /> | ||||
|           <span>{params.distance}</span></div> | ||||
|  | ||||
| @@ -77,7 +155,6 @@ export default function Analysis() { | ||||
|             max="3000" | ||||
|             value={params.nodeSize} | ||||
|             onChange={(evt) => setParams({ ...params, nodeSize: Number(evt.target.value) })} | ||||
|             onMouseUp={() => loadImage()} | ||||
|           /> | ||||
|           <span>{params.nodeSize}</span> | ||||
|         </div> | ||||
| @@ -90,7 +167,6 @@ export default function Analysis() { | ||||
|             max="24" | ||||
|             value={params.fontSize} | ||||
|             onChange={(evt) => setParams({ ...params, fontSize: Number(evt.target.value) })} | ||||
|             onMouseUp={() => loadImage()} | ||||
|           /> | ||||
|           <span>{params.fontSize}</span> | ||||
|         </div> | ||||
| @@ -104,7 +180,6 @@ export default function Analysis() { | ||||
|             step="0.1" | ||||
|             value={params.edgeWidth} | ||||
|             onChange={(evt) => setParams({ ...params, edgeWidth: Number(evt.target.value) })} | ||||
|             onMouseUp={() => loadImage()} | ||||
|           /> | ||||
|           <span>{params.edgeWidth}</span> | ||||
|         </div> | ||||
| @@ -117,18 +192,19 @@ export default function Analysis() { | ||||
|             max="50" | ||||
|             value={params.arrowSize} | ||||
|             onChange={(evt) => setParams({ ...params, arrowSize: Number(evt.target.value) })} | ||||
|             onMouseUp={() => loadImage()} | ||||
|           /> | ||||
|           <span>{params.arrowSize}</span> | ||||
|         </div> | ||||
|  | ||||
|       </div> | ||||
|       <button onClick={() => loadImage()}>reload ↻</button> | ||||
|       {loading ? ( | ||||
|       { | ||||
|         loading ? ( | ||||
|           <span className="loader"></span> | ||||
|         ) : ( | ||||
|           <img src={"data:image/png;base64," + image} width="86%" /> | ||||
|       )} | ||||
|         ) | ||||
|       } | ||||
|     </div > | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										47
									
								
								src/App.css
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								src/App.css
									
									
									
									
									
								
							| @@ -12,16 +12,18 @@ body { | ||||
|   height: 100%; | ||||
| } | ||||
|  | ||||
| footer { | ||||
|   font-size: x-small; | ||||
| } | ||||
|  | ||||
| #root { | ||||
|   max-width: 1280px; | ||||
|   margin: 0 auto; | ||||
|   padding: 8px; | ||||
| } | ||||
|  | ||||
| footer { | ||||
|   margin-top: 24px; | ||||
|   font-size: x-small; | ||||
| } | ||||
|  | ||||
|  | ||||
| .grey { | ||||
|   color: #444; | ||||
| } | ||||
| @@ -37,6 +39,11 @@ footer { | ||||
|   z-index: -1; | ||||
| } | ||||
|  | ||||
| input { | ||||
|   padding: 0.2em 16px; | ||||
|   margin-bottom: 0.5em; | ||||
| } | ||||
|  | ||||
| h1, | ||||
| h2, | ||||
| h3 { | ||||
| @@ -50,7 +57,7 @@ h3 { | ||||
|  | ||||
|   button, | ||||
|   img { | ||||
|     padding: 0 1em; | ||||
|     padding: 0px 1em 4px 1em; | ||||
|     margin: 3px auto; | ||||
|   } | ||||
| } | ||||
| @@ -120,7 +127,8 @@ h3 { | ||||
|   margin: auto; | ||||
| } | ||||
|  | ||||
| button { | ||||
| button, | ||||
| .button { | ||||
|   font-weight: bold; | ||||
|   font-size: large; | ||||
|   color: aliceblue; | ||||
| @@ -130,29 +138,30 @@ button { | ||||
| } | ||||
|  | ||||
| #control-panel { | ||||
|   display: grid; | ||||
|   display: none; | ||||
|   overflow: hidden; | ||||
|   margin: auto; | ||||
|   gap: 16px; | ||||
|   grid-template-columns: repeat(3, 1fr); | ||||
|   transition: display 1s ease-out 0s; | ||||
| } | ||||
|  | ||||
| .opened { | ||||
|   max-height: 100vw; | ||||
|   transition: max-height 1s ease-in; | ||||
| } | ||||
|  | ||||
| .closed { | ||||
|   max-height: 0px; | ||||
|   transition: max-height 0.5s ease-out; | ||||
| #control-panel.opened { | ||||
|   display: grid; | ||||
| } | ||||
|  | ||||
| .control { | ||||
|   display: flex; | ||||
|   flex-direction: column; | ||||
|   align-items: center; | ||||
|   border: 1px solid #404040; | ||||
|   padding: 8px; | ||||
|   justify-content: center; | ||||
|   border: 2px solid #404040; | ||||
|   padding: 8px 16px; | ||||
| } | ||||
|  | ||||
| #three-slider input { | ||||
|   margin: 4px; | ||||
|   width: 50%; | ||||
| } | ||||
|  | ||||
| @media only screen and (max-width: 1000px) { | ||||
| @@ -200,6 +209,10 @@ button { | ||||
| } | ||||
|  | ||||
| .navbar { | ||||
|   span { | ||||
|     padding: 4px; | ||||
|   } | ||||
|  | ||||
|   button { | ||||
|     font-size: medium; | ||||
|     margin: 4px 0.5%; | ||||
|   | ||||
							
								
								
									
										21
									
								
								src/App.tsx
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								src/App.tsx
									
									
									
									
									
								
							| @@ -3,21 +3,26 @@ import "./App.css"; | ||||
| import Footer from "./Footer"; | ||||
| import Header from "./Header"; | ||||
| import Rankings from "./Rankings"; | ||||
| import { BrowserRouter, Routes, Route } from "react-router-dom"; | ||||
| import { BrowserRouter, Routes, Route } from "react-router"; | ||||
| import { SessionProvider } from "./Session"; | ||||
| import { GraphComponent } from "./Network"; | ||||
|  | ||||
| function App() { | ||||
|   //const [data, setData] = useState({ nodes: [], links: [] } as SociogramData); | ||||
|   //async function loadData() { | ||||
|   //  await fetch(`${baseUrl}api/analysis/json`, { method: "GET" }).then(resp => resp.json() as unknown as SociogramData).then(json => { setData(json) }) | ||||
|   //} | ||||
|   //useEffect(() => { loadData() }, []) | ||||
|   // | ||||
|   return ( | ||||
|     <BrowserRouter> | ||||
|       <Header /> | ||||
|       <Routes> | ||||
|         <Route index element={<Rankings />} /> | ||||
|         <Route path="/analysis" element={<Analysis />} /> | ||||
|         <Route path="/network" element={ | ||||
|           <SessionProvider> | ||||
|             <GraphComponent /> | ||||
|           </SessionProvider> | ||||
|         } /> | ||||
|         <Route path="/analysis" element={ | ||||
|           <SessionProvider> | ||||
|             <Analysis /> | ||||
|           </SessionProvider> | ||||
|         } /> | ||||
|       </Routes> | ||||
|       <Footer /> | ||||
|     </BrowserRouter> | ||||
|   | ||||
| @@ -1,6 +1,13 @@ | ||||
| import { Link } from "react-router"; | ||||
|  | ||||
| export default function Footer() { | ||||
|         return <footer> | ||||
|                 <p className="grey"> | ||||
|                 <div className="navbar"> | ||||
|                         <Link to="/" ><span>Form</span></Link> | ||||
|                         <span>|</span> | ||||
|                         <Link to="/network" ><span>Trainer Analysis</span></Link> | ||||
|                 </div> | ||||
|                 <p className="grey extra-margin"> | ||||
|                         something not working? | ||||
|                         <br /> | ||||
|                         message <a href="https://t.me/x0124816">me</a>. | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import { baseUrl } from "./api"; | ||||
|  | ||||
| export default function Header() { | ||||
|   return <div className="logo"> | ||||
|     <a href={baseUrl}> | ||||
|     <a href={"/"}> | ||||
|       <img alt="logo" height="66%" src="logo.svg" /> | ||||
|       <h3 className="centered">cutt</h3> | ||||
|     </a> | ||||
|   | ||||
							
								
								
									
										86
									
								
								src/Login.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								src/Login.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| import { FormEvent, useContext, useState } from "react"; | ||||
| import { useNavigate } from "react-router"; | ||||
| import { currentUser, login, LoginRequest, User } from "./api"; | ||||
|  | ||||
| export interface LoginProps { | ||||
|   onLogin: (user: User) => void; | ||||
| } | ||||
|  | ||||
| export const Login = ({ onLogin }: LoginProps) => { | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|   const [error, setError] = useState<unknown>(null); | ||||
|   const [loading, setLoading] = useState(false); | ||||
|  | ||||
|   async function doLogin() { | ||||
|     setLoading(true); | ||||
|     setError(null); | ||||
|     const timeout = new Promise((r) => setTimeout(r, 1500)); | ||||
|     let user: User; | ||||
|     try { | ||||
|       login({ username, password }); | ||||
|       user = await currentUser(); | ||||
|     } catch (e) { | ||||
|       await timeout; | ||||
|       setError(e); | ||||
|       setLoading(false); | ||||
|       return | ||||
|     } | ||||
|  | ||||
|     await timeout; | ||||
|     onLogin(user); | ||||
|   } | ||||
|  | ||||
|   function handleClick() { | ||||
|     doLogin(); | ||||
|   } | ||||
|  | ||||
|   function handleSubmit(e: React.FormEvent) { | ||||
|     e.preventDefault(); | ||||
|     doLogin(); | ||||
|   } | ||||
|  | ||||
|   return ( | ||||
|     <form onSubmit={handleSubmit}> | ||||
|       <div> | ||||
|         <input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} /> | ||||
|       </div> | ||||
|       <div> | ||||
|         <input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} /> | ||||
|       </div> | ||||
|       <button type="submit" value="login" style={{ fontSize: "small" }} onClick={handleClick} >login</button> | ||||
|       {loading && <span className="loader" />} | ||||
|     </form> | ||||
|   ) | ||||
| } | ||||
|  | ||||
| /* | ||||
| export default function Login(props: { onLogin: (user: User) => void }) { | ||||
|   const { onLogin } = props; | ||||
|   const [username, setUsername] = useState(""); | ||||
|   const [password, setPassword] = useState(""); | ||||
|  | ||||
|   async function handleLogin(e: FormEvent) { | ||||
|     e.preventDefault() | ||||
|     const timeout = new Promise((r) => setTimeout(r, 1500)); | ||||
|     let user: User; | ||||
|     try { | ||||
|       login({ username, password }) | ||||
|       user = await currentUser() | ||||
|     } catch (e) { await timeout; return } | ||||
|     await timeout; | ||||
|     onLogin(user); | ||||
|   } | ||||
|  | ||||
|   return <div> | ||||
|     <form onSubmit={handleLogin}> | ||||
|       <div> | ||||
|         <input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} /> | ||||
|       </div> | ||||
|       <div> | ||||
|         <input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} /> | ||||
|       </div> | ||||
|       <input className="button" type="submit" value="login" onSubmit={handleLogin} /> | ||||
|     </form> | ||||
|   </div> | ||||
| } */ | ||||
							
								
								
									
										46
									
								
								src/Network.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										46
									
								
								src/Network.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,46 @@ | ||||
| import { useEffect, useRef, useState } from "react"; | ||||
| import { apiAuth } from "./api"; | ||||
| import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, useSelection } from "reagraph"; | ||||
|  | ||||
| interface NetworkData { | ||||
|   nodes: GraphNode[], | ||||
|   edges: GraphEdge[], | ||||
| } | ||||
|  | ||||
| export const GraphComponent = () => { | ||||
|   const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData); | ||||
|   const [loading, setLoading] = useState(true); | ||||
|  | ||||
|   async function loadData() { | ||||
|     setLoading(true); | ||||
|     await apiAuth("analysis/graph_json", null) | ||||
|       .then(json => json as Promise<NetworkData>).then(json => { setData(json) }) | ||||
|     setLoading(false); | ||||
|   } | ||||
|  | ||||
|   useEffect(() => { loadData() }, []) | ||||
|  | ||||
|   const graphRef = useRef<GraphCanvasRef | null>(null); | ||||
|  | ||||
|   const { selections, actives, onNodeClick, onCanvasClick } = useSelection({ | ||||
|     ref: graphRef, | ||||
|     nodes: data.nodes, | ||||
|     edges: data.edges, | ||||
|     pathSelectionType: 'out' | ||||
|   }); | ||||
|  | ||||
|   return ( | ||||
|     <GraphCanvas | ||||
|       draggable | ||||
|       ref={graphRef} | ||||
|       nodes={data.nodes} | ||||
|       edges={data.edges} | ||||
|       selections={selections} | ||||
|       actives={actives} | ||||
|       onCanvasClick={onCanvasClick} | ||||
|       onNodeClick={onNodeClick} | ||||
|     /> | ||||
|   ); | ||||
|  | ||||
| } | ||||
|  | ||||
| @@ -334,6 +334,9 @@ export default function Rankings() { | ||||
|             </button> | ||||
|           </div> | ||||
|  | ||||
|           <span className="grey">assign as many or as few players as you want<br /> | ||||
|             and don't forget to <b>submit</b> (💾) when you're done :)</span> | ||||
|  | ||||
|           <div id="Chemistry" className="tabcontent"> | ||||
|             <Chemistry {...{ user, players }} /> | ||||
|           </div> | ||||
|   | ||||
							
								
								
									
										40
									
								
								src/Session.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								src/Session.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| import { createContext, ReactNode, useContext, useLayoutEffect, useState } from "react"; | ||||
| import { currentUser, User } from "./api"; | ||||
| import { Login } from "./Login"; | ||||
|  | ||||
| export interface SessionProviderProps { | ||||
|   children: ReactNode; | ||||
| } | ||||
|  | ||||
| const sessionContext = createContext<User | null>(null); | ||||
|  | ||||
| export function SessionProvider(props: SessionProviderProps) { | ||||
|   const { children } = props; | ||||
|  | ||||
|   const [user, setUser] = useState<User | null>(null); | ||||
|   const [err, setErr] = useState<unknown>(null); | ||||
|  | ||||
|   function loadUser() { | ||||
|     currentUser() | ||||
|       .then((user) => { setUser(user); setErr(null); }) | ||||
|       .catch((err) => { setUser(null); setErr(err); }); | ||||
|   } | ||||
|  | ||||
|   useLayoutEffect(() => { loadUser(); }, [err]); | ||||
|  | ||||
|   function onLogin(user: User) { | ||||
|     setUser(user); | ||||
|     setErr(null); | ||||
|   } | ||||
|  | ||||
|   let content: ReactNode; | ||||
|   if (!err && !user) content = <span className="loader" />; | ||||
|   else if (err) content = <Login onLogin={onLogin} />; | ||||
|   else content = <sessionContext.Provider value={user}>{children}</sessionContext.Provider>; | ||||
|  | ||||
|   return content; | ||||
| } | ||||
|  | ||||
| export function useSession() { | ||||
|   return useContext(sessionContext); | ||||
| } | ||||
							
								
								
									
										79
									
								
								src/api.ts
									
									
									
									
									
								
							
							
						
						
									
										79
									
								
								src/api.ts
									
									
									
									
									
								
							| @@ -1,4 +1,6 @@ | ||||
| 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> { | ||||
|   const request = new Request(`${baseUrl}${path}/`, { | ||||
|     method: "POST", | ||||
| @@ -15,3 +17,80 @@ export default async function api(path: string, data: any): Promise<any> { | ||||
|   } | ||||
|   return response; | ||||
| } | ||||
|  | ||||
| export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> { | ||||
|  | ||||
|   const req = new Request(`${baseUrl}api/${path}`, | ||||
|     { | ||||
|       method: method, | ||||
|       headers: { | ||||
|         "Authorization": `Bearer ${token()} `, | ||||
|         'Content-Type': 'application/json' | ||||
|       }, | ||||
|       ...(data && { body: JSON.stringify(data) }) | ||||
|     } | ||||
|   ); | ||||
|   let resp: Response; | ||||
|   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 Promise<Token>).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login")); | ||||
|   return Promise<void> | ||||
| } | ||||
|  | ||||
| export const logout = () => localStorage.removeItem("access_token"); | ||||
|   | ||||
		Reference in New Issue
	
	Block a user