54 Commits

Author SHA1 Message Date
5dc2b17619 fix spacing for desktop 2025-12-19 10:30:47 +01:00
4b4a9ba8d4 Merge branch 'main' of git.0124816.xyz:julius/cutt 2025-12-19 09:22:06 +00:00
caaf180ca4 upgrade postgres 2025-12-19 09:21:25 +00:00
a703a12ebf improve spacing 2025-12-19 09:34:10 +01:00
8fd11901c2 remove unnecessary volume mount 2025-12-19 09:19:28 +01:00
9f9641c32b extend .env example 2025-12-19 09:19:15 +01:00
fa94d4ba7a close burger menu on click 2025-12-19 09:18:39 +01:00
e2677b60a3 restyle TeamPanel and Calendar 2025-12-19 09:18:16 +01:00
1968c21c96 add Lucide icons ♡ 2025-12-19 07:23:41 +01:00
a43cb1cdc3 respect .python-version during uv sync 2025-12-18 19:56:33 +01:00
d5e8d0825f pin python 3.13 due to psycopg-binary only having wheels for 3.13 2025-12-18 19:54:46 +01:00
192edcea1f fix minor error, remove App.{tsx,css} 2025-12-18 19:49:21 +01:00
86f494f840 run with docker compose 2025-12-18 19:46:57 +01:00
c9f227c70c read database connection string from .env 2025-12-18 19:30:57 +01:00
25c1728c27 remove dev origin 2025-12-18 19:30:29 +01:00
7df09f580a move PyQt6 to dev deps 2025-12-18 19:29:38 +01:00
407b778131 move public dir to frontend 2025-12-18 19:29:24 +01:00
a38fd042ba build frontend in node docker container 2025-12-18 19:28:39 +01:00
45a842b6fe move frontend stuff to its own directory 2025-12-18 16:47:47 +01:00
4d07dde87a minor styling 2025-12-18 13:29:07 +01:00
5a4918330e add logo to login page (header not available then) 2025-12-18 13:28:18 +01:00
b6ce89b712 add more routes 2025-12-18 13:27:57 +01:00
2f68785a01 pull letters apart 2025-12-18 13:27:35 +01:00
d452809c44 new design 2025-12-18 12:29:49 +01:00
ed30bf6bb1 upgrade deps 2025-12-17 13:27:15 +01:00
8cc35455a9 v1 beta 2025-12-17 12:28:28 +01:00
1626751083 feat: exponentially decreasing edge weight
and adjustment of negative weight
2025-06-20 14:01:04 +02:00
710b0770cc chore: remove old Analysis page 2025-05-26 07:53:58 +02:00
56c1ba11fc chore: remove unused 2025-05-26 07:51:44 +02:00
ad2b2993df fix: try to make sure order when mixed changes 2025-05-26 07:48:33 +02:00
638e8bf20c feat: gender-separated MVPChart in mixed teams 2025-05-26 07:33:31 +02:00
a4ea0dfc41 fix: don't show times of players not in the team 2025-05-25 21:13:28 +02:00
62ba89c599 feat: require gender when registering 2025-05-23 22:09:49 +02:00
05bdc5c44c feat: support mixed teams in MVP ranking 2025-05-23 22:01:08 +02:00
105b3778e1 fix: find display_name 2025-05-23 09:44:22 +02:00
003f401320 fix: go back to forms if not team captain 2025-05-22 12:45:49 +02:00
2195e7324d feat: exclude players from analysis that aren't in the team 2025-05-22 12:33:28 +02:00
ba26e7c9e6 feat: bigger "Events" 2025-05-21 15:18:01 +02:00
64d6edd9f5 feat: order players alphabetically 2025-05-21 15:17:34 +02:00
b781408c18 feat: disable dark mode for text inputs 2025-05-21 15:08:36 +02:00
a0c8e0cd18 feat: decrease calendar size 2025-05-21 15:04:06 +02:00
de8dc6b9b9 feat: load players in session 2025-05-21 14:55:13 +02:00
241f6fa7eb feat: show active player 2025-05-21 14:37:07 +02:00
a42fff807c feat: useSession for players 2025-05-21 14:36:51 +02:00
369cf0b727 feat: calendar display for latest submissions 2025-05-21 14:26:35 +02:00
a6dfab47d5 fix: props.list empty 2025-05-19 14:59:10 +02:00
4c78ede7c2 feat: increase dnd box 2025-05-19 14:50:24 +02:00
8c8a88e72c fix: handle players removed from team 2025-05-19 14:45:31 +02:00
b9efd4f7a3 feat: add player type survey 2025-05-19 14:32:30 +02:00
a6ebc28d47 fix: gender and previous state for DEMO 2025-05-18 16:01:05 +02:00
48f282423f feat: add gender in DEMO 2025-05-18 13:19:50 +02:00
881e015c1f Merge branch 'feat/demo' 2025-05-18 13:18:46 +02:00
4e2e0dd2a5 feat: add gender 2025-05-18 13:18:02 +02:00
b739246129 feat: load previously submitted by default 2025-05-18 11:59:07 +02:00
60 changed files with 2406 additions and 2005 deletions

View File

@@ -1,3 +1,8 @@
VITE_BASE_URL= VITE_BASE_URL=
SECRET_KEY="tg07Cp0daCpcbeeFw+lI4RG2lEuT8oq5xOwB5ETs9YaJTylG1euC4ogjTK4zym/d" SECRET_KEY="tg07Cp0daCpcbeeFw+lI4RG2lEuT8oq5xOwB5ETs9YaJTylG1euC4ogjTK4zym/d"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 ACCESS_TOKEN_EXPIRE_MINUTES=30
DB_HOST=db
DB_NAME=cutt
DB_USER=postgres
DB_PASS=password
DB_PORT=5432

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

16
Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM ghcr.io/astral-sh/uv:alpine
EXPOSE 8000
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PORT=8000 \
UV_NO_DEV=1
WORKDIR /app
COPY pyproject.toml .python-version /app
RUN uv sync
COPY . /app
CMD uv run fastapi run cutt/main.py

48
compose.yml Normal file
View File

@@ -0,0 +1,48 @@
services:
frontend-build:
build:
context: ./frontend
dockerfile: Dockerfile.frontend
container_name: cutt-frontend
environment:
VITE_BASE_URL: ${VITE_BASE_URL}
volumes:
- dist:/app/dist
backend:
build: .
container_name: cutt-backend
depends_on:
frontend-build:
condition: service_completed_successfully
db:
condition: service_healthy
restart: true
restart: unless-stopped
env_file:
- .env
volumes:
- dist:/app/dist
ports:
- 8000:8000
db:
image: postgres:18
container_name: cutt-db
restart: unless-stopped
volumes:
- db:/var/lib/postgresql
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASS}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 10s
retries: 5
start_period: 30s
timeout: 10s
volumes:
dist:
db:

View File

@@ -8,7 +8,15 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlmodel import Session, func, select from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar from sqlmodel.sql.expression import SelectOfScalar
from cutt.db import Chemistry, MVPRanking, Player, PlayerTeamLink, Team, engine from cutt.db import (
Chemistry,
MVPRanking,
Player,
PlayerTeamLink,
PlayerType,
Team,
engine,
)
import networkx as nx import networkx as nx
import numpy as np import numpy as np
import matplotlib import matplotlib
@@ -25,6 +33,7 @@ analysis_router = APIRouter(prefix="/analysis", tags=["analysis"])
C = Chemistry C = Chemistry
R = MVPRanking R = MVPRanking
PT = PlayerType
P = Player P = Player
@@ -144,30 +153,37 @@ def graph_json(
) )
for c in session.exec(statement2): for c in session.exec(statement2):
if c.user not in player_map:
continue
user = player_map[c.user] user = player_map[c.user]
for i, p_id in enumerate(c.love): for i, p_id in enumerate(c.love):
if p_id not in player_map:
continue
p = player_map[p_id] p = player_map[p_id]
weight = 0.9**i
edges.append( edges.append(
{ {
"id": f"{user}->{p}", "id": f"{user}->{p}",
"source": user, "source": user,
"target": p, "target": p,
"size": max(1.0 - 0.1 * i, 0.3), "size": weight,
"data": { "data": {
"relation": 2, "relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3), "origSize": weight,
"origFill": "#bed4ff", "origFill": "#bed4ff",
}, },
} }
) )
for p_id in c.hate: for p_id in c.hate:
if p_id not in player_map:
continue
p = player_map[p_id] p = player_map[p_id]
edges.append( edges.append(
{ {
"id": f"{user}-x>{p}", "id": f"{user}-x>{p}",
"source": user, "source": user,
"target": p, "target": p,
"size": 0.3, "size": 0.5,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"}, "data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"fill": "#ff7c7c", "fill": "#ff7c7c",
} }
@@ -293,25 +309,68 @@ async def render_sociogram(params: Params):
return {"image": encoded_image} return {"image": encoded_image}
def mvp( translate_tablename = {
R.__tablename__: "🏆",
C.__tablename__: "🧪",
PT.__tablename__: "🃏",
}
def last_submissions(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)], request: Annotated[TeamScopedRequest, Security(verify_team_scope)],
): ):
ranks = dict() times = {}
with Session(engine) as session:
player_ids = session.exec(
select(P.id)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.disabled == False)
).all()
for survey in [C, PT, R]:
subquery = (
select(survey.user, func.max(survey.time).label("latest"))
.where(survey.team == request.team_id)
.where(survey.user.in_(player_ids))
.group_by(survey.user)
.subquery()
)
statement2 = select(survey).join(
subquery,
(survey.user == subquery.c.user) & (survey.time == subquery.c.latest),
)
for r in session.exec(statement2):
if r.time.date() not in times:
times[r.time.date()] = {}
times[r.time.date()][r.user] = (
times[r.time.date()].get(r.user, "")
+ " "
+ translate_tablename[survey.__tablename__]
)
return times
def mvp(
request: Annotated[TeamScopedRequest, Security(verify_team_scope)], mixed=False
):
if request.team_id == 42: if request.team_id == 42:
ranks = {}
random.seed(42) random.seed(42)
players = [request.user] + demo_players players = [request.user] + demo_players
for p in players: for p in players:
random.shuffle(players) random.shuffle(players)
for i, p in enumerate(players): for i, p in enumerate(players):
ranks[p.display_name] = ranks.get(p.display_name, []) + [i + 1] ranks[p.id] = ranks.get(p.id, []) + [i + 1]
return [ return [
[
{ {
"name": p, "p_id": p_id,
"rank": f"{np.mean(v):.02f}", "rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}", "std": f"{np.std(v):.02f}",
"n": len(v), "n": len(v),
} }
for p, v in ranks.items() for i, (p_id, v) in enumerate(ranks.items())
]
] ]
with Session(engine) as session: with Session(engine) as session:
@@ -323,7 +382,7 @@ def mvp(
).all() ).all()
if not players: if not players:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
player_map = {p.id: p.display_name for p in players} player_map = {p.id: p for p in players}
subquery = ( subquery = (
select(R.user, func.max(R.time).label("latest")) select(R.user, func.max(R.time).label("latest"))
.where(R.team == request.team_id) .where(R.team == request.team_id)
@@ -333,23 +392,45 @@ def mvp(
statement2 = select(R).join( statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest) subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
) )
if mixed:
all_ranks = []
for gender in ["fmp", "mmp"]:
ranks = {}
for r in session.exec(statement2):
mvps = [
p_id
for p_id in r.mvps
if p_id in player_map and player_map[p_id].gender == gender
]
for i, p_id in enumerate(mvps):
p = player_map[p_id]
ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks.append(ranks)
else:
ranks = {}
for r in session.exec(statement2): for r in session.exec(statement2):
for i, p_id in enumerate(r.mvps): for i, p_id in enumerate(r.mvps):
if p_id not in player_map:
continue
p = player_map[p_id] p = player_map[p_id]
ranks[p] = ranks.get(p, []) + [i + 1] ranks[p_id] = ranks.get(p_id, []) + [i + 1]
all_ranks = [ranks]
if not ranks: if not all_ranks:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found" status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
) )
return [ return [
[
{ {
"name": p, "p_id": p_id,
"rank": f"{np.mean(v):.02f}", "rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}", "std": f"{np.std(v):.02f}",
"n": len(v), "n": len(v),
} }
for p, v in ranks.items() for p_id, v in ranks.items()
]
for ranks in all_ranks
] ]
@@ -424,6 +505,9 @@ analysis_router.add_api_route(
description="Request Most Valuable Players stats", description="Request Most Valuable Players stats",
) )
analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"]) analysis_router.add_api_route("/turnout/{team_id}", endpoint=turnout, methods=["GET"])
analysis_router.add_api_route(
"/times/{team_id}", endpoint=last_submissions, methods=["GET"]
)
if __name__ == "__main__": if __name__ == "__main__":
with Session(engine) as session: with Session(engine) as session:

View File

@@ -1,6 +1,8 @@
import os
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlmodel import ( from sqlmodel import (
ARRAY, ARRAY,
CHAR,
Column, Column,
Integer, Integer,
Relationship, Relationship,
@@ -9,8 +11,9 @@ from sqlmodel import (
create_engine, create_engine,
) )
with open("db.secrets", "r") as f: # with open("db.secrets", "r") as f:
db_secrets = f.readline().strip() # db_secrets = f.readline().strip()
db_secrets = f"postgresql+psycopg://{os.environ['DB_USER']}:{os.environ['DB_PASS']}@{os.environ['DB_HOST']}:{os.environ['DB_PORT']}/{os.environ['DB_NAME']}"
engine = create_engine( engine = create_engine(
db_secrets, db_secrets,
@@ -37,6 +40,7 @@ class Team(SQLModel, table=True):
name: str name: str
location: str | None location: str | None
country: str | None country: str | None
mixed: bool = False
players: list["Player"] | None = Relationship( players: list["Player"] | None = Relationship(
back_populates="teams", link_model=PlayerTeamLink back_populates="teams", link_model=PlayerTeamLink
) )
@@ -48,6 +52,7 @@ class Player(SQLModel, table=True):
display_name: str display_name: str
email: str | None = None email: str | None = None
full_name: str | None = None full_name: str | None = None
gender: str | None = Field(default=None, sa_column=Column(CHAR(3)))
disabled: bool | None = None disabled: bool | None = None
hashed_password: str | None = None hashed_password: str | None = None
number: str | None = None number: str | None = None
@@ -67,6 +72,16 @@ class Chemistry(SQLModel, table=True):
team: int = Field(default=None, foreign_key="team.id") team: int = Field(default=None, foreign_key="team.id")
class PlayerType(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime)
user: int = Field(default=None, foreign_key="player.id")
handlers: list[int] = Field(sa_column=Column(ARRAY(Integer)))
combis: list[int] = Field(sa_column=Column(ARRAY(Integer)))
cutters: list[int] = Field(sa_column=Column(ARRAY(Integer)))
team: int = Field(default=None, foreign_key="team.id")
class MVPRanking(SQLModel, table=True): class MVPRanking(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True) id: int | None = Field(default=None, primary_key=True)
time: datetime | None = Field(default_factory=utctime) time: datetime | None = Field(default_factory=utctime)

View File

@@ -2,26 +2,27 @@ import random
from cutt.db import Player from cutt.db import Player
names = [ names = [
"August", ("August", "mmp"),
"Beate", ("Beate", "fmp"),
"Ceasar", ("Ceasar", "mmp"),
"Daedalus", ("Daedalus", "mmp"),
"Elli", ("Elli", "fmp"),
"Ford P.", ("Ford P.", ""),
"Gabriel", ("Gabriel", "mmp"),
"Hugo", ("Hugo", "mmp"),
"Ivar Johansson", ("Ivar Johansson", "mmp"),
"Jürgen Gordon Malinauskas", ("Jürgen Gordon Malinauskas", "mmp"),
] ]
demo_players = [ demo_players = [
Player.model_validate( Player.model_validate(
{ {
"id": i, "id": i + 4200,
"display_name": name, "display_name": name,
"username": name.lower().replace(" ", "").replace(".", ""), "username": name.lower().replace(" ", "").replace(".", ""),
"gender": gender,
"number": str(random.randint(0, 100)), "number": str(random.randint(0, 100)),
"email": name.lower().replace(" ", "").replace(".", "") + "@example.org", "email": name.lower().replace(" ", "").replace(".", "") + "@example.org",
} }
) )
for i, name in enumerate(names) for i, (name, gender) in enumerate(names)
] ]

View File

@@ -2,7 +2,7 @@ from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status from fastapi import APIRouter, Depends, FastAPI, HTTPException, Security, status
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from cutt.db import Player, Team, Chemistry, MVPRanking, engine from cutt.db import Player, PlayerType, Team, Chemistry, MVPRanking, engine
from sqlmodel import ( from sqlmodel import (
Session, Session,
func, func,
@@ -20,6 +20,7 @@ from cutt.security import (
from cutt.player import player_router from cutt.player import player_router
C = Chemistry C = Chemistry
PT = PlayerType
R = MVPRanking R = MVPRanking
P = Player P = Player
@@ -27,10 +28,7 @@ app = FastAPI(
title="cutt", swagger_ui_parameters={"syntaxHighlight": {"theme": "monokai"}} title="cutt", swagger_ui_parameters={"syntaxHighlight": {"theme": "monokai"}}
) )
api_router = APIRouter(prefix="/api") api_router = APIRouter(prefix="/api")
origins = [ origins = ["https://cutt.0124816.xyz"]
"https://cutt.0124816.xyz",
"http://localhost:5173",
]
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -114,7 +112,7 @@ def get_mvps(
return mvps return mvps
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found", detail="no previous state was found",
) )
@@ -158,11 +156,61 @@ def get_chemistry(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
) )
chemistry = session.exec(statement2).one_or_none() chemistry = session.exec(statement2).one_or_none()
if chemistry: if chemistry is not None:
return chemistry return chemistry
else: else:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found",
)
@api_router.put("/playertype", tags=["analysis"])
def submit_playertype(
playertype: PlayerType, user: Annotated[Player, Depends(get_current_active_user)]
):
if playertype.team == 42:
return JSONResponse("DEMO team, nothing happens")
if user.id == playertype.user:
with Session(engine) as session:
statement = select(Team).where(Team.id == playertype.team)
players = [t.players for t in session.exec(statement)][0]
if players:
player_ids = {p.id for p in players}
if player_ids >= (
set(playertype.handlers)
| set(playertype.combis)
| set(playertype.cutters)
):
session.add(playertype)
session.commit()
return JSONResponse("success!")
raise somethings_fishy
else:
raise wrong_user_id_exception
@api_router.get("/playertype/{team_id}", tags=["analysis"])
def get_playertype(
team_id: int, user: Annotated[Player, Depends(get_current_active_user)]
):
with Session(engine) as session:
subquery = (
select(PT.user, func.max(PT.time).label("latest"))
.where(PT.user == user.id)
.where(PT.team == team_id)
.group_by(PT.user)
.subquery()
)
statement2 = select(PT).join(
subquery, (PT.user == subquery.c.user) & (PT.time == subquery.c.latest)
)
playertype = session.exec(statement2).one_or_none()
if playertype is not None:
return playertype
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="no previous state was found", detail="no previous state was found",
) )

View File

@@ -22,8 +22,9 @@ player_router = APIRouter(prefix="/player", tags=["player"])
class PlayerRequest(BaseModel): class PlayerRequest(BaseModel):
display_name: str display_name: str
username: str username: str
gender: str | None
number: str number: str
email: str email: str | None
class AddPlayerRequest(PlayerRequest): ... class AddPlayerRequest(PlayerRequest): ...
@@ -61,6 +62,7 @@ def add_player(
new_player = Player( new_player = Player(
username=r.username, username=r.username,
display_name=r.display_name, display_name=r.display_name,
gender=r.gender if r.gender else None,
email=r.email if r.email else None, email=r.email if r.email else None,
number=r.number, number=r.number,
disabled=False, disabled=False,
@@ -89,9 +91,11 @@ def modify_player(
.where(Team.id == request.team_id, P.id == r.id, P.username == r.username) .where(Team.id == request.team_id, P.id == r.id, P.username == r.username)
).one_or_none() ).one_or_none()
if player: if player:
print(r)
player.display_name = r.display_name.strip() player.display_name = r.display_name.strip()
player.number = r.number.strip() player.number = r.number.strip()
player.email = r.email.strip() player.gender = r.gender.strip() if r.gender else None
player.email = r.email.strip() if r.email else None
session.add(player) session.add(player)
session.commit() session.commit()
return PlainTextResponse("modification successful") return PlainTextResponse("modification successful")
@@ -162,7 +166,7 @@ async def list_players(
if team_id == 42: if team_id == 42:
return [ return [
user.model_dump( user.model_dump(
include={"id", "display_name", "username", "number", "email"} include={"id", "display_name", "gender", "username", "number", "email"}
) )
] + demo_players ] + demo_players
@@ -184,11 +188,19 @@ async def list_players(
.join(PlayerTeamLink) .join(PlayerTeamLink)
.join(Team) .join(Team)
.where(Team.id == team_id, P.disabled == False) .where(Team.id == team_id, P.disabled == False)
.order_by(P.display_name)
).all() ).all()
if players: if players:
return [ return [
player.model_dump( player.model_dump(
include={"id", "display_name", "username", "number", "email"} include={
"id",
"display_name",
"username",
"gender",
"number",
"email",
}
) )
for player in players for player in players
if not player.disabled if not player.disabled

1
frontend/.env Normal file
View File

@@ -0,0 +1 @@
VITE_BASE_URL=

View File

@@ -0,0 +1,10 @@
FROM node:alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "cutt",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"bulma": "^1.0.4",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.562.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-sortablejs": "^6.1.4",
"reagraph": "^4.30.7",
"sortablejs": "^1.15.6"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/sortablejs": "^1.15.9",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"react-router": "^7.10.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.50.0",
"vite": "^7.3.0"
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true
}
}

58
frontend/public/cutt.svg Normal file
View File

@@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="80"
height="50"
viewBox="0 0 80 50"
version="1.1"
id="svg1"
sodipodi:docname="cutt.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
inkscape:export-filename="cutt.svg"
inkscape:export-xdpi="362.84"
inkscape:export-ydpi="362.84"
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="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="12.413459"
inkscape:cx="38.909381"
inkscape:cy="55.786224"
inkscape:window-width="1408"
inkscape:window-height="1727"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg1" />
<ellipse
cx="40"
cy="25"
rx="20.262579"
ry="20.632982"
style="fill:#c7d6f1;fill-opacity:1;stroke:#3366cc;stroke-width:8.73336"
id="ellipse1" />
<path
d="m -3.4e-4,17.669765 h 80.00068 v 14.66047 H -3.4e-4 Z"
style="fill:#000000;stroke-width:3.56018;paint-order:stroke fill markers"
id="path1" />
<text
xml:space="preserve"
x="39.788086"
y="29.819336"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14px;line-height:1;font-family:Sans;-inkscape-font-specification:'Sans Bold';text-align:center;letter-spacing:2.83px;writing-mode:lr-tb;direction:ltr;text-anchor:middle;fill:#ffffff;stroke:#ffffff;stroke-width:0.655;stroke-dasharray:none;stroke-opacity:1;paint-order:stroke fill markers"
id="text1"><tspan
x="39.788086"
y="29.819336"
style="font-style:normal;font-variant:normal;font-weight:bold;font-stretch:normal;font-size:14px;font-family:Sans;-inkscape-font-specification:'Sans Bold';letter-spacing:2.83px;fill:#ffffff;stroke:#ffffff;stroke-width:0.655;stroke-dasharray:none;stroke-opacity:1"
id="tspan1">CUTT</tspan></text>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

Before

Width:  |  Height:  |  Size: 214 B

After

Width:  |  Height:  |  Size: 214 B

View File

@@ -22,6 +22,10 @@ const UserInfo = (user: User, teams: TeamState | undefined) => {
<b>display name: </b> <b>display name: </b>
</div> </div>
<div>{user?.display_name}</div> <div>{user?.display_name}</div>
<div>
<b>gender: </b>
</div>
<div>{user?.gender?.toUpperCase() || "-"}</div>
<div> <div>
<b>number: </b> <b>number: </b>
</div> </div>

46
frontend/src/CUTT.tsx Normal file
View File

@@ -0,0 +1,46 @@
import "./main.css";
import Header from "./Header";
import Footer from "./Footer";
import { BrowserRouter, Route, Routes } from "react-router";
import { SessionProvider } from "./Session";
import Rankings from "./Form";
import TeamPanel from "./TeamPanel";
import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
const Maintenance = () => {
return (
<section className="hero is-large">
<div className="hero-body has-text-centered">
<p className="title is-1">🚧</p>
<p className="subtitle">We are under maintenance.</p>
<p>Please check back later. Thank you for your patience.</p>
</div>
</section>
);
};
function App() {
return (
<BrowserRouter>
<Routes>
<Route
path="/*"
element={
<SessionProvider>
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="network" element={<GraphComponent />} />
<Route path="mvp" element={<MVPChart />} />
<Route path="team" element={<TeamPanel />} />
</Routes>
<Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter>
);
}
export default App;

205
frontend/src/Calendar.tsx Normal file
View File

@@ -0,0 +1,205 @@
import { JSX, useEffect, useState } from "react";
import { apiAuth } from "./api";
import { useSession } from "./Session";
interface Datum {
[id: number]: string;
}
interface Events {
[key: string]: Datum;
}
const Calendar = ({ playerId }: { playerId: number }) => {
const [selectedDate, setSelectedDate] = useState(new Date());
const [events, setEvents] = useState<Events>();
const { teams, players } = useSession();
async function loadSubmissionDates() {
if (teams?.activeTeam) {
const data = await apiAuth(`analysis/times/${teams?.activeTeam}`, null);
if (data.detail) {
console.log(data.detail);
} else {
setEvents(data as Events);
}
}
}
useEffect(() => {
loadSubmissionDates();
}, [players]);
const getEventsForDay = (date: Date) => {
return events && events[date.toISOString().split("T")[0]];
};
// Handle day click
const handleDayClick = (date: Date) => {
setSelectedDate(date);
};
// Navigate to previous month
const handlePrevMonth = () => {
const date = new Date(selectedDate);
date.setMonth(date.getMonth() - 1);
setSelectedDate(date);
};
// Navigate to next month
const handleNextMonth = () => {
const date = new Date(selectedDate);
date.setMonth(date.getMonth() + 1);
setSelectedDate(date);
};
// Render month navigation
const renderMonthNavigation = () => {
return (
<div className="field has-addons">
<p className="control">
<button
className="button is-light is-size-7-mobile"
onClick={handlePrevMonth}
>
&lt;
</button>
</p>
<p className="control">
<button
className="button is-light is-size-7-mobile"
onClick={() => setSelectedDate(new Date())}
>
📅{" "}
{selectedDate.toLocaleString("default", {
month: "long",
year: "numeric",
})}
</button>
</p>
<p className="control">
<button
className="button is-light is-size-7-mobile"
onClick={handleNextMonth}
>
&gt;
</button>
</p>
</div>
);
};
// Render the calendar
const renderCalendar = () => {
const firstDayOfMonth = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth(),
0
).getDay();
const lastDateOfMonth = new Date(
selectedDate.getFullYear(),
selectedDate.getMonth() + 1,
0
).getDate();
let days: JSX.Element[] = [];
let day = 1;
for (let i = 0; i < 7; i++) {
const date = new Date(0);
date.setDate(i + 5);
days.push(
<button
key={"weekday_" + i}
className="button is-size-7-mobile is-white is-static"
>
{date.toLocaleString("default", {
weekday: "narrow",
})}
</button>
);
}
// Add empty cells for the first week
for (let i = 0; i < firstDayOfMonth; i++) {
days.push(<div key={"prev" + i} className="empty"></div>);
}
// Render each day of the month
while (day <= lastDateOfMonth) {
const date = new Date(selectedDate);
date.setDate(day);
const todaysEvents = getEventsForDay(date);
days.push(
<button
key={date.getDate()}
className={
"cell button is-size-7-mobile" +
(date.toDateString() === selectedDate.toDateString()
? " is-focused is-active is-primary is-light"
: " is-white") +
(date.toDateString() === new Date().toDateString()
? " is-danger has-text-weight-extrabold"
: "") +
(todaysEvents ? " is-warning is-light" : "") +
(todaysEvents && playerId in todaysEvents
? " is-hovered has-text-weight-semibold"
: "")
}
onClick={() => handleDayClick(date)}
>
{day}
</button>
);
day++;
}
return (
<div className="fixed-grid has-7-cols">
<div className="grid is-gap-0.5">{days}</div>
</div>
);
};
// Render events for the selected day
const renderEvents = () => {
const eventsForDay = getEventsForDay(selectedDate);
return (
<div className="events">
{eventsForDay &&
Object.entries(eventsForDay).map(([id, sub]) => {
const name = players?.find((p) => p.id === Number(id));
return (
<p className="field">
<div className="control" key={id}>
<div className="tags are-medium has-addons">
<span className="tag is-warning is-size-7-mobile">
{name !== undefined ? name.display_name : ""}
</span>
<span className="tag is-primary is-light is-size-7-mobile">
{sub}
</span>
</div>
</div>
</p>
);
})}
</div>
);
};
return (
<div className="block is-size-7-mobile">
<h2 className="title is-4">Latest Submissions</h2>
<div className="columns is-6">
<div className="column" style={{ maxWidth: 600 }}>
{renderMonthNavigation()}
{renderCalendar()}
</div>
<div className="column is-narrow">{renderEvents()}</div>
</div>
</div>
);
};
export default Calendar;

25
frontend/src/Footer.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { useLocation } from "react-router";
import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() {
const location = useLocation();
const { user, teams } = useSession();
return (
<footer className="footer">
<div className="content has-text-centered">
<p className="grey">
something not working? message <a href="https://t.me/x0124816">me </a>
or fix it here:&nbsp;
<a
className="icon is-small"
href="https://git.0124816.xyz/julius/cutt"
key="gitea"
>
<img src="gitea.svg" alt="gitea" />
</a>
</p>
</div>
</footer>
);
}

528
frontend/src/Form.tsx Normal file
View File

@@ -0,0 +1,528 @@
import { useEffect, useRef, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session";
import TabController from "./TabController";
import { Chemistry, MVPRanking, PlayerType } from "./types";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
gender?: boolean;
};
function PlayerList(props: PlayerListProps) {
const fmps = props.list?.filter((item) => item.gender === "fmp").length;
return (
<ReactSortable
{...props}
className="buttons is-centered is-clearfix"
direction={"vertical"}
animation={200}
swapThreshold={1}
handle=".handle"
draggable=".handle"
>
{props.list &&
props.list.map((item, index) => (
<div className="handle" key={item.id}>
<button
className={
"button is-primary is-light " +
(props.gender ? item.gender : "")
}
>
{props.orderedList
? props.gender
? index +
1 -
(item.gender !== "fmp" ? fmps! : 0) +
". " +
item.display_name
: index + 1 + ". " + item.display_name
: item.display_name}
</button>
</div>
))}
</ReactSortable>
);
}
function PlayerMenuList(props: PlayerListProps) {
const fmps = props.list?.filter((item) => item.gender === "fmp").length;
return (
<ReactSortable
{...props}
className="menu-list"
animation={200}
swapThreshold={1}
>
{props.list &&
props.list.map((item, index) => (
<p
key={item.id}
className={
"menu-item is-primary is-light " +
(props.gender ? item.gender : "")
}
>
{props.orderedList
? props.gender
? index +
1 -
(item.gender !== "fmp" ? fmps! : 0) +
". " +
item.display_name
: index + 1 + ". " + item.display_name
: item.display_name}
</p>
))}
</ReactSortable>
);
}
function filterSort(list: User[], ids: number[]): User[] {
const objectMap = new Map(list.map((obj) => [obj.id, obj]));
const filteredAndSortedObjects = ids
.map((id) => objectMap.get(id))
.filter((obj) => obj !== undefined);
return filteredAndSortedObjects;
}
interface PlayerInfoProps {
user: User;
teams: TeamState;
players: User[];
}
function TypeDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [handlers, setHandlers] = useState<User[]>([]);
const [combis, setCombis] = useState<User[]>([]);
const [cutters, setCutters] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let handlerlist = handlers.map(({ id }) => id);
let combilist = combis.map(({ id }) => id);
let cutterlist = cutters.map(({ id }) => id);
const data = {
user: user.id,
handlers: handlerlist,
combis: combilist,
cutters: cutterlist,
team: teams.activeTeam,
};
const response = await apiAuth("playertype", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`playertype/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
} else {
const playertype = data as PlayerType;
setAvailablePlayers(
players.filter(
(player) =>
!playertype.handlers.includes(player.id) &&
!playertype.combis.includes(player.id) &&
!playertype.cutters.includes(player.id)
)
);
setHandlers(filterSort(players, playertype.handlers));
setCombis(filterSort(players, playertype.combis));
setCutters(filterSort(players, playertype.cutters));
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
}}
onSubmit={handleSubmit}
/>
<div className="columns container is-multiline is-mobile is-1-mobile">
<div className="column is-full is-flex is-justify-content-center">
<div className="box" style={{ maxWidth: 800 }}>
<p className="subtitle is-6 is-uppercase has-text-weight-light">
available players
</p>
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={"type-shared"}
className="dragbox reservoir"
/>
</div>
</div>
</div>
<div className="column">
<div className="columns">
<div className="column">
<div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light">
handler
</p>
<PlayerList
list={handlers}
setList={setHandlers}
group={"type-shared"}
className="dragbox"
/>
</div>
</div>
<div className="column">
<div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light">
combi
</p>
<PlayerList
list={combis}
setList={setCombis}
group={"type-shared"}
className="middle dragbox"
/>
</div>
</div>
<div className="column">
<div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light">
cutter
</p>
<PlayerList
list={cutters}
setList={setCutters}
group={"type-shared"}
className="dragbox"
/>
</div>
</div>
</div>
</div>
<dialog
ref={dialogRef}
id="PlayerTypeDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [mixed, setMixed] = useState(false);
useEffect(() => {
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed);
handleGet();
}, [players]);
useEffect(() => {
handleGet();
// setMixedList(rankedPlayers);
}, [mixed]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again");
}
const setMixedList = (newList: User[]) =>
mixed
? setRankedPlayers(
newList.sort((a, b) =>
a.gender && b.gender ? a.gender.localeCompare(b.gender) : -1
)
)
: setRankedPlayers(newList);
async function handleGet() {
setLoading(true);
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setRankedPlayers([]);
} else {
const mvps = data as MVPRanking;
setMixedList(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}}
onSubmit={handleSubmit}
/>
{loading ? (
<progress className="progress is-primary" max="100"></progress>
) : (
<div className="columns container is-mobile is-1-mobile">
<div className="column">
<div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light">
available players
</p>
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
}}
className="dragbox"
gender={mixed}
/>
</div>
</div>
<div className="column">
<div className="box">
<p className="subtitle is-2 has-text-centered is-uppercase has-text-weight-light">
🏆
</p>
<div className="menu">
<PlayerMenuList
list={rankedPlayers}
setList={setMixedList}
group={{
name: "mvp-shared",
}}
className="dragbox"
orderedList
gender={mixed}
/>
</div>
</div>
</div>
</div>
)}
<dialog
ref={dialogRef}
id="MVPDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function ChemistryDnDMobile({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ id }) => id);
const data = {
user: user.id,
hate: left,
undecided: middle,
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
} else {
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(
otherPlayers.filter(
(player) =>
!chemistry.hate.includes(player.id) &&
!chemistry.love.includes(player.id)
)
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
}}
onSubmit={handleSubmit}
/>
{loading ? (
<progress className="progress is-primary" max="100"></progress>
) : (
<div className="columns container is-multiline is-mobile is-1-mobile">
<div className="column is-full is-flex is-justify-content-center">
<div className="box" style={{ maxWidth: 800 }}>
<p className="subtitle is-6 is-uppercase has-text-weight-light">
neutral
</p>
<PlayerList
list={playersMiddle}
setList={setPlayersMiddle}
group={"shared"}
/>
</div>
</div>
<div className="column">
<div className="columns is-mobile">
<div className="column">
<div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light">
rather not
</p>
<PlayerList
list={playersLeft}
setList={setPlayersLeft}
group={"shared"}
/>
</div>
</div>
<div className="column">
<div className="box">
<p className="subtitle is-6 is-uppercase has-text-weight-light">
yes, please
</p>
<PlayerList
list={playersRight}
setList={setPlayersRight}
group={"shared"}
orderedList
/>
</div>
</div>
</div>
</div>
</div>
)}
<dialog
ref={dialogRef}
id="ChemistryDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
interface HeaderControlProps {
onLoad: () => void;
onClear: () => void;
onSubmit: () => void;
}
function HeaderControl({ onLoad, onClear, onSubmit }: HeaderControlProps) {
return (
<div className="buttons is-centered">
<button className="button is-small is-light" onClick={onLoad}>
🗃 &nbsp; restore previous
</button>
<button className="button is-small is-light" onClick={onClear}>
🗑&nbsp; start over
</button>
<button className="button is-small is-light" onClick={onSubmit}>
💾 &nbsp; submit
</button>
</div>
);
}
export default function Rankings() {
const { user, teams, players } = useSession();
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
{ id: "MVP", label: "🏆 MVP" },
{ id: "Type", label: "🃏 Type" },
];
return (
<div className="container block">
<p className="notification is-warning is-light">
assign as many or as few players as you want and don't forget to 💾
<strong> submit</strong> when you're done :)
</p>
{user && teams && players ? (
<TabController tabs={tabs}>
<ChemistryDnDMobile {...{ user, teams, players }} />
<MVPDnD {...{ user, teams, players }} />
<TypeDnD {...{ user, teams, players }} />
</TabController>
) : (
<progress className="progress is-primary" max="100"></progress>
)}
</div>
);
}

97
frontend/src/Header.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { Link } from "react-router";
import { useSession } from "./Session";
import { useState } from "react";
export default function Header() {
const { user, teams, setTeams, players, onLogout } = useSession();
const [burgerActive, setBurgerActive] = useState(false);
return (
<nav className="navbar" role="navigation" aria-label="main navigation">
<div className="navbar-brand">
<Link className="navbar-item" to="/">
<img
style={{ maxHeight: "unset" }}
className="image"
alt="cool ultimate team tool"
src="cutt.svg"
/>
</Link>
<a
role="button"
className={"navbar-burger" + (burgerActive ? " is-active" : "")}
aria-label="menu"
aria-expanded="false"
data-target="navbar"
onClick={() => setBurgerActive(!burgerActive)}
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div
className={"navbar-menu" + (burgerActive ? " is-active" : "")}
id="navbar"
>
{user?.scopes.includes(`team:${teams?.activeTeam}`) && (
<div className="navbar-start">
<Link
onClick={() => setBurgerActive(false)}
className="navbar-item"
to="/network"
>
<span>Sociogram</span>
</Link>
<Link
onClick={() => setBurgerActive(false)}
className="navbar-item"
to="/mvp"
>
<span>MVP</span>
</Link>
<Link
onClick={() => setBurgerActive(false)}
className="navbar-item"
to="/team"
>
<span>Team</span>
</Link>
</div>
)}
<div className="navbar-end">
{teams && (
<div className="navbar-item has-dropdown is-hoverable">
<a className="navbar-link">my teams</a>
<div className="navbar-dropdown">
{teams.teams.map((team, index) => (
<a
onClick={() => setTeams({ ...teams, activeTeam: team.id })}
className={
"navbar-item" +
(team.id === teams.activeTeam
? " is-bold has-text-weight-extrabold"
: "")
}
>
{team.name}
</a>
))}
</div>
</div>
)}
<div className="navbar-item">
{user?.username}
<div className="buttons">
<button className="button is-light" onClick={onLogout}>
Log out
</button>
</div>
</div>
</div>
</div>
</nav>
);
}

104
frontend/src/Login.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { currentUser, login, User } from "./api";
import Header from "./Header";
import { useLocation, useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
export interface LoginProps {
onLogin: (user: User) => void;
}
export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const navigate = useNavigate();
const location = useLocation();
async function doLogin() {
setLoading(true);
setError("");
const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User;
try {
await login({ username, password });
user = await currentUser();
} catch (e) {
await timeout;
setError("failed");
setLoading(false);
return;
}
await timeout;
onLogin(user);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doLogin();
}
useEffect(() => {
if (location.state) {
const queryUsername = location.state.username;
const queryPassword = location.state.password;
if (queryUsername) setUsername(queryUsername);
if (queryPassword) setPassword(queryPassword);
navigate(location.pathname, { replace: true });
}
}, []);
return (
<form onSubmit={handleSubmit}>
<div className="field">
<p className="control">
<input
className="input"
type="text"
id="username"
name="username"
placeholder="username"
required
value={username}
onChange={(evt) => {
setError("");
setUsername(evt.target.value);
}}
/>
</p>
</div>
<div className="field">
<p className="control">
<input
className="input"
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</p>
{error && <p className="help is-danger">{error}</p>}
</div>
<div className="control">
<button
className="button"
type="submit"
value="login"
style={{ fontSize: "small" }}
>
login
</button>
</div>
{loading && <span className="loader" />}
</form>
);
};

View File

@@ -6,12 +6,13 @@ import { useSession } from "./Session";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
const MVPChart = () => { const MVPChart = () => {
let initialData = {} as PlayerRanking[]; let initialData = {} as PlayerRanking[][];
const [data, setData] = useState(initialData); const [data, setData] = useState(initialData);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [showStd, setShowStd] = useState(false); const [showStd, setShowStd] = useState(false);
const { user, teams } = useSession(); const { user, teams } = useSession();
const [mixed, setMixed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) || user?.scopes.includes(`team:${teams?.activeTeam}`) ||
@@ -19,21 +20,30 @@ const MVPChart = () => {
navigate("/", { replace: true }); navigate("/", { replace: true });
}, [user]); }, [user]);
useEffect(() => {
if (teams) {
const activeTeam = teams.teams.find(
(team) => team.id == teams.activeTeam
);
activeTeam && setMixed(activeTeam.mixed);
}
}, [teams]);
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
if (teams) { if (teams) {
await apiAuth(`analysis/mvp/${teams?.activeTeam}`, null) await apiAuth(`analysis/mvp/${teams?.activeTeam}?mixed=${mixed}`, null)
.then((data) => { .then((data) => {
if (data.detail) { if (data.detail) {
setError(data.detail); setError(data.detail);
return initialData; return initialData;
} else { } else {
setError(""); setError("");
return data as Promise<PlayerRanking[]>; return data as Promise<PlayerRanking[][]>;
} }
}) })
.then((data) => { .then((data) => {
setData(data.sort((a, b) => a.rank - b.rank)); setData(data.map((_data) => _data.sort((a, b) => a.rank - b.rank)));
}) })
.catch(() => setError("no access")); .catch(() => setError("no access"));
setLoading(false); setLoading(false);
@@ -46,7 +56,8 @@ const MVPChart = () => {
if (loading) return <span className="loader" />; if (loading) return <span className="loader" />;
else if (error) return <span>{error}</span>; else if (error) return <span>{error}</span>;
else return <RaceChart std={showStd} players={data} />; else
return data.map((_data) => <RaceChart std={showStd} playerRanks={_data} />);
}; };
export default MVPChart; export default MVPChart;

103
frontend/src/RaceChart.tsx Normal file
View File

@@ -0,0 +1,103 @@
import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types";
import { useSession } from "./Session";
interface RaceChartProps {
playerRanks: PlayerRanking[];
std: boolean;
}
const determineNiceWidth = (width: number) => {
const max = 1080;
if (width >= max) return max;
else if (width > 768) return width * 0.8;
else return width * 0.96;
};
const RaceChart: FC<RaceChartProps> = ({ playerRanks, std }) => {
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
const height = (playerRanks.length + 1) * 40;
const { players } = useSession();
useEffect(() => {
const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth));
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const padding = 24;
const gap = 8;
const maxValue = Math.max(...playerRanks.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / playerRanks.length;
const fontSize = Math.min(barHeight - 1.5 * gap, width / 22);
return (
<svg width={width} height={height} id="RaceChartSVG">
{playerRanks.map((playerRank, index) => (
<rect
key={String(index)}
x={4}
y={index * barHeight + padding}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
stroke="aliceblue"
strokeWidth={4}
paintOrder={"stroke fill"}
/>
))}
{playerRanks.map((playerRank, index) => {
const player = players!.find((p) => p.id === playerRank.p_id);
return (
<g key={"group" + index}>
<text
key={index + "_name"}
x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={fontSize}
fill="aliceblue"
stroke="#36c"
strokeWidth={4}
fontWeight={"bold"}
paintOrder={"stroke fill"}
fontFamily="monospace"
style={{ whiteSpace: "pre" }}
>
{`${String(index + 1).padStart(2)}. ${player?.display_name}`}
</text>
<text
key={index + "_value"}
x={
8 +
(4 +
Math.max(...players!.map((p, _) => p.display_name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - playerRank.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={0.8 * fontSize}
fill="aliceblue"
stroke="#36c"
fontWeight={"bold"}
fontFamily="monospace"
strokeWidth={4}
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${String(playerRank.rank).padStart(5)} ± ${playerRank.std} N = ${playerRank.n}`}
</text>
</g>
);
})}
</svg>
);
};
export default RaceChart;

500
frontend/src/Rankings.tsx Normal file
View File

@@ -0,0 +1,500 @@
import { ButtonHTMLAttributes, useEffect, useRef, useState } from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking, PlayerType } from "./types";
import TabController from "./TabController";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
gender?: boolean;
};
function PlayerList(props: PlayerListProps) {
const fmps = props.list?.filter((item) => item.gender === "fmp").length;
return (
<ReactSortable
{...props}
animation={200}
swapThreshold={0.2}
style={{ minHeight: props.list && props.list?.length < 1 ? 64 : 32 }}
>
{props.list &&
props.list.map((item, index) => (
<div
key={item.id}
className={"item " + (props.gender ? item.gender : "")}
>
{props.orderedList
? props.gender
? index +
1 -
(item.gender !== "fmp" ? fmps! : 0) +
". " +
item.display_name
: index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
</ReactSortable>
);
}
const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button {...props} style={{ padding: "4px 16px" }}>
🗃 restore previous
</button>
);
};
const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button {...props} style={{ padding: "4px 16px" }}>
🗑 start over
</button>
);
};
function filterSort(list: User[], ids: number[]): User[] {
const objectMap = new Map(list.map((obj) => [obj.id, obj]));
const filteredAndSortedObjects = ids
.map((id) => objectMap.get(id))
.filter((obj) => obj !== undefined);
return filteredAndSortedObjects;
}
interface PlayerInfoProps {
user: User;
teams: TeamState;
players: User[];
}
function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ id }) => id);
const data = {
user: user.id,
hate: left,
undecided: middle,
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
} else {
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(
otherPlayers.filter(
(player) =>
!chemistry.hate.includes(player.id) &&
!chemistry.love.includes(player.id)
)
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
}}
/>
{loading ? (
<span className="loader" style={{ width: 300 }} />
) : (
<div className="container">
<div className="box three">
<h2>😬</h2>
{playersLeft.length < 1 && (
<span className="grey hint">
drag people here that you'd rather not play with
</span>
)}
<PlayerList
list={playersLeft}
setList={setPlayersLeft}
group={"shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h2>🤷</h2>
<PlayerList
list={playersMiddle}
setList={setPlayersMiddle}
group={"shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h2>😍</h2>
{playersRight.length < 1 && (
<span className="grey hint">
drag people here that you love playing with from best to ... ok
</span>
)}
<PlayerList
list={playersRight}
setList={setPlayersRight}
group={"shared"}
className="dragbox"
orderedList
/>
</div>
</div>
)}
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="ChemistryDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function TypeDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [handlers, setHandlers] = useState<User[]>([]);
const [combis, setCombis] = useState<User[]>([]);
const [cutters, setCutters] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
handleGet();
}, [players]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let handlerlist = handlers.map(({ id }) => id);
let combilist = combis.map(({ id }) => id);
let cutterlist = cutters.map(({ id }) => id);
const data = {
user: user.id,
handlers: handlerlist,
combis: combilist,
cutters: cutterlist,
team: teams.activeTeam,
};
const response = await apiAuth("playertype", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
setLoading(true);
const data = await apiAuth(`playertype/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
} else {
const playertype = data as PlayerType;
setAvailablePlayers(
players.filter(
(player) =>
!playertype.handlers.includes(player.id) &&
!playertype.combis.includes(player.id) &&
!playertype.cutters.includes(player.id)
)
);
setHandlers(filterSort(players, playertype.handlers));
setCombis(filterSort(players, playertype.combis));
setCutters(filterSort(players, playertype.cutters));
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setHandlers([]);
setCombis([]);
setCutters([]);
}}
/>
<div className="container">
<div className="box one">
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={"type-shared"}
className="dragbox reservoir"
/>
</div>
</div>
<div className="container">
<div className="box three">
<h4>handler</h4>
{handlers.length < 1 && (
<span className="grey hint">
drag people here that you like to see as handlers
</span>
)}
<PlayerList
list={handlers}
setList={setHandlers}
group={"type-shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h4>combi</h4>
{combis.length < 1 && (
<span className="grey hint">
drag people here that switch between handling and cutting
</span>
)}
<PlayerList
list={combis}
setList={setCombis}
group={"type-shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h4>cutter</h4>
{cutters.length < 1 && (
<span className="grey hint">
drag people here that you think are the best cutters
</span>
)}
<PlayerList
list={cutters}
setList={setCutters}
group={"type-shared"}
className="dragbox"
/>
</div>
</div>
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="PlayerTypeDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
const [loading, setLoading] = useState(false);
const [mixed, setMixed] = useState(false);
useEffect(() => {
const activeTeam = teams.teams.find((team) => team.id == teams.activeTeam);
activeTeam && setMixed(activeTeam.mixed);
handleGet();
}, [players]);
useEffect(() => {
handleGet();
// setMixedList(rankedPlayers);
}, [mixed]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again");
}
const setMixedList = (newList: User[]) =>
mixed
? setRankedPlayers(
newList.sort((a, b) =>
a.gender && b.gender ? a.gender.localeCompare(b.gender) : -1
)
)
: setRankedPlayers(newList);
async function handleGet() {
setLoading(true);
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) {
console.log(data.detail);
setAvailablePlayers(players);
setRankedPlayers([]);
} else {
const mvps = data as MVPRanking;
setMixedList(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
}
setLoading(false);
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}}
/>
{loading ? (
<span className="loader" style={{ width: 300 }} />
) : (
<div className="container">
<div className="box two">
<h2>🥏🏃</h2>
{availablePlayers.length < 1 && (
<span className="grey hint">all sorted 👍</span>
)}
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
}}
className="dragbox"
gender={mixed}
/>
</div>
<div className="box two">
<h1>🏆</h1>
{rankedPlayers.length < 1 && (
<span className="grey hint">
carefully place as many of the <i>Most Valuable Players</i>{" "}
(according to your humble opinion) in this box
</span>
)}
<PlayerList
list={rankedPlayers}
setList={setMixedList}
group={{
name: "mvp-shared",
}}
className="dragbox"
orderedList
gender={mixed}
/>
</div>
</div>
)}
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="MVPDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
interface HeaderControlProps {
onLoad: () => void;
onClear: () => void;
}
function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
return (
<>
<div>
<ClearButton onClick={onClear} />
<LoadButton onClick={onLoad} />
</div>
<div>
<span className="grey">
assign as many or as few players as you want and don't forget to{" "}
<b>submit</b> 💾 when you're done :)
</span>
</div>
</>
);
}
export default function Rankings() {
const { user, teams, players } = useSession();
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
{ id: "Type", label: "🃏 Type" },
{ id: "MVP", label: "🏆 MVP" },
];
return (
<>
{user && teams && players ? (
<TabController tabs={tabs}>
<ChemistryDnD {...{ user, teams, players }} />
<TypeDnD {...{ user, teams, players }} />
<MVPDnD {...{ user, teams, players }} />
</TabController>
) : (
<span className="loader" />
)}
</>
);
}

View File

@@ -5,7 +5,7 @@ import {
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { apiAuth, currentUser, logout, User } from "./api"; import { apiAuth, currentUser, loadPlayers, logout, User } from "./api";
import { Login } from "./Login"; import { Login } from "./Login";
import Header from "./Header"; import Header from "./Header";
import { Team } from "./types"; import { Team } from "./types";
@@ -23,6 +23,8 @@ export interface Session {
user: User | null; user: User | null;
teams: TeamState | null; teams: TeamState | null;
setTeams: (teams: TeamState) => void; setTeams: (teams: TeamState) => void;
players: User[] | null;
reloadPlayers: () => void;
onLogout: () => void; onLogout: () => void;
} }
@@ -30,6 +32,8 @@ const sessionContext = createContext<Session>({
user: null, user: null,
teams: null, teams: null,
setTeams: () => {}, setTeams: () => {},
players: null,
reloadPlayers: () => {},
onLogout: () => {}, onLogout: () => {},
}); });
@@ -38,6 +42,7 @@ export function SessionProvider(props: SessionProviderProps) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [teams, setTeams] = useState<TeamState | null>(null); const [teams, setTeams] = useState<TeamState | null>(null);
const [players, setPlayers] = useState<User[] | null>(null);
const [err, setErr] = useState<unknown>(null); const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -60,12 +65,19 @@ export function SessionProvider(props: SessionProviderProps) {
if (teams) setTeams({ teams: teams, activeTeam: teams[0].id }); if (teams) setTeams({ teams: teams, activeTeam: teams[0].id });
} }
async function reloadPlayers() {
teams && loadPlayers(teams?.activeTeam).then((data) => setPlayers(data));
}
useEffect(() => { useEffect(() => {
loadUser(); loadUser();
}, []); }, []);
useEffect(() => { useEffect(() => {
loadTeam(); loadTeam();
}, [user]); }, [user]);
useEffect(() => {
reloadPlayers();
}, [teams]);
function onLogin(user: User) { function onLogin(user: User) {
setUser(user); setUser(user);
@@ -93,10 +105,28 @@ export function SessionProvider(props: SessionProviderProps) {
</> </>
); );
else if (err) { else if (err) {
content = <Login onLogin={onLogin} />; content = (
<section className="section is-medium">
<div className="container is-max-tablet">
<div className="block">
<p className="level-item has-text-centered">
<img
className="image"
alt="cool ultimate team tool"
src="cutt.svg"
style={{ width: 200 }}
/>
</p>
</div>
<Login onLogin={onLogin} />
</div>
</section>
);
} else } else
content = ( content = (
<sessionContext.Provider value={{ user, teams, setTeams, onLogout }}> <sessionContext.Provider
value={{ user, teams, setTeams, players, reloadPlayers, onLogout }}
>
{children} {children}
</sessionContext.Provider> </sessionContext.Provider>
); );

View File

@@ -1,6 +1,6 @@
import { jwtDecode, JwtPayload } from "jwt-decode"; import { jwtDecode, JwtPayload } from "jwt-decode";
import { ReactNode, useEffect, useState } from "react"; import { ReactNode, useEffect, useState } from "react";
import { apiAuth, baseUrl, User } from "./api"; import { apiAuth, baseUrl, Gender, User } from "./api";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons"; import { Eye, EyeSlash } from "./Icons";
import { useSession } from "./Session"; import { useSession } from "./Session";
@@ -237,6 +237,21 @@ export const SetPassword = () => {
}} }}
/> />
</div> </div>
<div>
<label>gender</label>
<select
name="gender"
required
value={player.gender}
onChange={(e) => {
setPlayer({ ...player, gender: e.target.value as Gender });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
<div> <div>
<label>number (optional)</label> <label>number (optional)</label>
<input <input

View File

@@ -0,0 +1,41 @@
import { Fragment, ReactNode, useState } from "react";
interface TabProps {
id: string;
label: string;
}
interface TabControllerProps {
tabs: TabProps[];
children: ReactNode[];
}
export default function TabController({ tabs, children }: TabControllerProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const handleTabClick = (index: number) => {
setCurrentIndex(index);
};
return (
<div className="block">
<div className="tabs is-boxed is-centered">
<ul>
{tabs.map((tab, index) => (
<li className={currentIndex === index ? "is-active" : ""}>
<a onClick={() => handleTabClick(index)}>{tab.label}</a>
</li>
))}
</ul>
</div>
<div>
{children.map((child, index) => (
<Fragment key={index}>
<div style={{ display: currentIndex === index ? "block" : "none" }}>
{child}
</div>
</Fragment>
))}
</div>
</div>
);
}

245
frontend/src/TeamPanel.tsx Normal file
View File

@@ -0,0 +1,245 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, Gender, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
import Calendar from "./Calendar";
const TeamPanel = () => {
const { user, teams, players, reloadPlayers } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user, teams]);
const newPlayerTemplate = {
id: 0,
username: "",
display_name: "",
gender: undefined,
number: "",
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [player, setPlayer] = useState(newPlayerTemplate);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (teams) {
if (player.id === 0) {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "POST");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
reloadPlayers();
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
reloadPlayers();
}
}
}
}
async function handleDisable(e: FormEvent) {
e.preventDefault();
if (teams && player.id !== 0) {
var confirmation = confirm("are you sure?");
if (confirmation) {
const r = await apiAuth(
`player/${teams?.activeTeam}`,
{ player_id: player.id },
"DELETE"
);
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
setPlayer(newPlayerTemplate);
reloadPlayers();
}
}
}
}
if (teams && players) {
const activeTeam = teams.teams.filter(
(team) => team.id == teams?.activeTeam
)[0];
return (
<>
<section className="section">
<div className="container">
<h1 className="title">{activeTeam.name}</h1>
<h2 className="subtitle">
{activeTeam.location}, {activeTeam.country}
</h2>
<div className="box">
<h2 className="title is-4">Players</h2>
{players ? (
<div className="buttons">
{players.map((p) => (
<button
className={
"button is-primary is-light " +
p.gender +
(p.id === player.id ? " is-focused is-active" : "")
}
key={p.id}
onClick={() => {
setPlayer(p);
setError({ ok: true, message: "" });
}}
>
{p.display_name}
</button>
))}
<button
className="button is-success is-light new-player"
key="add-player"
onClick={() => {
setPlayer(newPlayerTemplate);
setError({ ok: true, message: "" });
}}
>
+
</button>
</div>
) : (
<span className="loader" />
)}
</div>
<form className="container block" onSubmit={handleSubmit}>
<div className="field">
<label className="label">name</label>
<div className="control">
<input
className="input"
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
...(player.id === 0 && {
username: e.target.value
.toLowerCase()
.replace(/\W/g, ""),
}),
display_name: e.target.value,
});
setError({ ok: true, message: "" });
}}
/>
</div>
</div>
<div className="field">
<label className="label">username</label>
<div className="control">
<input
className="input"
type="text"
required
disabled={player.id !== 0}
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
</div>
<div className="field">
<label className="label">gender</label>
<div className="control">
<div className="select">
<select
name="gender"
value={player.gender}
onChange={(e) => {
setPlayer({
...player,
gender: e.target.value as Gender,
});
setError({ ok: true, message: "" });
}}
>
<option value={undefined}></option>
<option value="fmp">FMP</option>
<option value="mmp">MMP</option>
</select>
</div>
</div>
</div>
<div className="field">
<label className="label">number (optional)</label>
<div className="control">
<input
className="input"
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
</div>
<div className="field">
<label className="label">email (optional)</label>
<div className="control">
<input
className="input"
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
{error?.message && (
<p
className={
"help" + (error.ok ? " is-success" : " is-danger")
}
>
{error.message}
</p>
)}
</div>
<div className="field is-grouped">
<button
className={
"button is-light" +
(player.id === 0 ? " is-success" : " is-link")
}
>
{player.id === 0 ? "add player" : "modify player"}
</button>
{player.id !== 0 && (
<button
className="button is-danger is-light"
onClick={handleDisable}
>
remove player
</button>
)}
</div>
</form>
</div>
</section>
<section className="section">
<div className="container">
<Calendar playerId={player.id} />
</div>
</section>
</>
);
} else <span className="loader" />;
};
export default TeamPanel;

View File

@@ -1,6 +1,6 @@
import { useSession } from "./Session"; import { useSession } from "./Session";
export const baseUrl = import.meta.env.VITE_BASE_URL as string; export const baseUrl = "";
export async function apiAuth( export async function apiAuth(
path: string, path: string,
@@ -43,12 +43,15 @@ export async function apiAuth(
} }
} }
export type Gender = "fmp" | "mmp" | undefined;
export type User = { export type User = {
id: number; id: number;
username: string; username: string;
display_name: string; display_name: string;
email: string; email: string;
number: string; number: string;
gender: Gender;
scopes: string; scopes: string;
}; };
@@ -92,6 +95,7 @@ export type LoginRequest = {
}; };
export const login = async (req: LoginRequest): Promise<void> => { export const login = async (req: LoginRequest): Promise<void> => {
console.log("baseUrl", baseUrl);
try { try {
const response = await fetch(`${baseUrl}api/token`, { const response = await fetch(`${baseUrl}api/token`, {
method: "POST", method: "POST",

16
frontend/src/main.css Normal file
View File

@@ -0,0 +1,16 @@
@import "bulma/css/bulma.css";
:root {
--bulma-primary-h: 220deg;
--bulma-primary-s: 60%;
--bulma-primary-l: 50%;
}
.navbar {
--bulma-navbar-dropdown-border-color: var(--bulma-primary);
}
.overflow-y {
overflow-y: auto;
max-height: 30vh;
}

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./CUTT.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -13,7 +13,7 @@ export default interface NetworkData {
} }
export interface PlayerRanking { export interface PlayerRanking {
name: string; p_id: number;
rank: number; rank: number;
std: number; std: number;
n: number; n: number;
@@ -27,6 +27,14 @@ export interface Chemistry {
love: number[]; love: number[];
} }
export interface PlayerType {
id: number;
user: number;
handlers: number[];
combis: number[];
cutters: number[];
}
export interface MVPRanking { export interface MVPRanking {
id: number; id: number;
user: number; user: number;
@@ -38,6 +46,7 @@ export interface Team {
name: string; name: string;
location: string; location: string;
country: string; country: string;
mixed: boolean;
} }
export type ErrorState = { export type ErrorState = {

View File

@@ -1,41 +0,0 @@
{
"name": "cutt",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"jwt-decode": "^4.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-sortablejs": "^6.1.4",
"reagraph": "^4.21.2",
"sortablejs": "^1.15.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/node": "^22.13.10",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"react-router": "^7.1.5",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.5"
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true
}
}

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "cutt" name = "cutt"
version = "0.1.1" version = "1b0"
description = "cool ultimate team tool" description = "cool ultimate team tool"
author = "julius" author = "julius"
readme = "README.md" readme = "README.md"
@@ -11,10 +11,14 @@ dependencies = [
"matplotlib>=3.10.0", "matplotlib>=3.10.0",
"networkx>=3.4.2", "networkx>=3.4.2",
"passlib>=1.7.4", "passlib>=1.7.4",
"psycopg>=3.2.4", "psycopg[binary]>=3.2.4",
"pydantic-settings>=2.7.1", "pydantic-settings>=2.7.1",
"pyjwt>=2.10.1", "pyjwt>=2.10.1",
"pyqt6>=6.8.0",
"sqlmodel>=0.0.22", "sqlmodel>=0.0.22",
"uvicorn>=0.34.0", "uvicorn>=0.34.0",
] ]
[dependency-groups]
dev = [
"pyqt6>=6.10.1",
]

View File

@@ -1,228 +0,0 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
//const debounce = <T extends (...args: any[]) => void>(
// func: T,
// delay: number
//): ((...args: Parameters<T>) => void) => {
// let timeoutId: number | null = null;
// return (...args: Parameters<T>) => {
// if (timeoutId !== null) {
// clearTimeout(timeoutId);
// }
// console.log(timeoutId);
// timeoutId = setTimeout(() => {
// func(...args);
// }, delay);
// };
//};
//
interface Params {
nodeSize: number;
edgeWidth: number;
arrowSize: number;
fontSize: number;
distance: number;
weighting: boolean;
popularity: boolean;
show: number;
}
let timeoutID: NodeJS.Timeout | null = null;
export default function Analysis() {
const [image, setImage] = useState("");
const [params, setParams] = useState<Params>({
nodeSize: 2000,
edgeWidth: 1,
arrowSize: 16,
fontSize: 10,
distance: 2,
weighting: true,
popularity: true,
show: 2,
});
const [showControlPanel, setShowControlPanel] = useState(false);
const [loading, setLoading] = useState(true);
// Function to generate and fetch the graph image
async function loadImage() {
setLoading(true);
await apiAuth("analysis/image", params, "POST")
.then((data) => {
setImage(data.image);
setLoading(false);
})
.catch((e) => {
console.log("best to just reload... ", e);
});
}
useEffect(() => {
if (timeoutID) {
clearTimeout(timeoutID);
}
timeoutID = setTimeout(() => {
loadImage();
}, 1000);
}, [params]);
function showLabel() {
switch (params.show) {
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
return (
<div className="stack column dropdown">
<button onClick={() => setShowControlPanel(!showControlPanel)}>
Parameters{" "}
<svg
viewBox="0 0 24 24"
height="1.2em"
style={{
fill: "#ffffff",
display: "inline",
top: "0.2em",
position: "relative",
transform: showControlPanel ? "rotate(180deg)" : "unset",
}}
>
{" "}
<path d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z"> </path>
</svg>
</button>
<div id="control-panel" className={showControlPanel ? "opened" : ""}>
<div className="control">
<datalist id="markers">
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
</datalist>
<div id="three-slider">
<label>😬</label>
<input
type="range"
list="markers"
min="0"
max="2"
step="1"
width="16px"
onChange={(evt) =>
setParams({ ...params, show: Number(evt.target.value) })
}
/>
<label>😍</label>
</div>
{showLabel()}
</div>
<div className="control">
<div className="checkBox">
<input
type="checkbox"
checked={params.weighting}
onChange={(evt) =>
setParams({ ...params, weighting: evt.target.checked })
}
/>
<label>weighting</label>
</div>
<div className="checkBox">
<input
type="checkbox"
checked={params.popularity}
onChange={(evt) =>
setParams({ ...params, popularity: evt.target.checked })
}
/>
<label>popularity</label>
</div>
</div>
<div className="control">
<label>distance between nodes</label>
<input
type="range"
min="0.01"
max="3.001"
step="0.05"
value={params.distance}
onChange={(evt) =>
setParams({ ...params, distance: Number(evt.target.value) })
}
/>
<span>{params.distance}</span>
</div>
<div className="control">
<label>node size</label>
<input
type="range"
min="500"
max="3000"
value={params.nodeSize}
onChange={(evt) =>
setParams({ ...params, nodeSize: Number(evt.target.value) })
}
/>
<span>{params.nodeSize}</span>
</div>
<div className="control">
<label>font size</label>
<input
type="range"
min="4"
max="24"
value={params.fontSize}
onChange={(evt) =>
setParams({ ...params, fontSize: Number(evt.target.value) })
}
/>
<span>{params.fontSize}</span>
</div>
<div className="control">
<label>edge width</label>
<input
type="range"
min="1"
max="5"
step="0.1"
value={params.edgeWidth}
onChange={(evt) =>
setParams({ ...params, edgeWidth: Number(evt.target.value) })
}
/>
<span>{params.edgeWidth}</span>
</div>
<div className="control">
<label>arrow size</label>
<input
type="range"
min="10"
max="50"
value={params.arrowSize}
onChange={(evt) =>
setParams({ ...params, arrowSize: Number(evt.target.value) })
}
/>
<span>{params.arrowSize}</span>
</div>
</div>
<button onClick={() => loadImage()}>reload </button>
{loading ? (
<span className="loader"></span>
) : (
<img src={"data:image/png;base64," + image} width="86%" />
)}
</div>
);
}

View File

@@ -1,640 +0,0 @@
body {
background-color: aliceblue;
position: relative;
z-index: 0;
color: black;
text-align: center;
overflow-wrap: anywhere;
height: 100%;
}
#root {
max-width: 1280px;
margin: 0 auto;
padding: 8px;
}
footer {
margin-top: 24px;
font-size: x-small;
}
.fixed-footer {
position: absolute;
bottom: 4px;
left: 8px;
}
dialog {
border-radius: 1em;
}
/*=========Network Controls=========*/
.infobutton {
position: fixed;
right: 8px;
bottom: 8px;
padding: 0.4em;
border-radius: 1em;
background-color: rgba(0, 0, 0, 0.3);
font-size: medium;
margin-bottom: 16px;
margin-right: 16px;
}
.controls {
z-index: 9;
position: absolute;
color: black;
top: 1vh;
right: 0px;
padding: 8px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.control {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 240px;
margin: 0px;
background-color: #F0F8FFdd;
.slider,
span {
padding-left: 4px;
padding-right: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
margin: auto;
justify-content: center;
align-items: center;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 48px;
height: 24px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 34px;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 3px;
bottom: 3px;
background-color: white;
border-radius: 50%;
-webkit-transition: .4s;
transition: .4s;
}
input:checked+.slider {
background-color: #2196F3;
}
input:focus+.slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked+.slider:before {
-webkit-transform: translateX(24px);
-ms-transform: translateX(24px);
transform: translateX(24px);
}
.grey {
opacity: 66%;
}
.hint {
position: absolute;
font-size: 80%;
padding: 8px;
top: auto;
left: 4px;
bottom: auto;
right: 4px;
z-index: -1;
}
input {
padding: 0.2em 16px;
margin-top: 0.25em;
margin-bottom: 0.25em;
border-radius: 1em;
}
h1,
h2,
h3 {
margin-top: 0px;
margin-bottom: 0px;
padding: 8px 16px;
}
.stack {
display: flex;
button,
img {
padding: 0px 1em 4px 1em;
margin: 3px auto;
}
}
.column {
flex-direction: column;
}
.container {
display: flex;
flex-wrap: nowrap;
width: min(96vw, 900px);
}
.dragbox {
display: flex;
flex-direction: column;
min-height: 32px;
height: 92%;
}
.box {
position: relative;
flex: 1;
border-width: 3px;
border-style: solid;
border-radius: 16px;
&.one {
max-width: min(96%, 768px);
margin: 4px auto;
}
padding: 4px;
margin: 4px 0.5%;
}
.reservoir {
flex-direction: unset;
flex-wrap: wrap;
justify-content: space-around;
width: 100%;
}
.item {
cursor: pointer;
font-size: medium;
border: 2px solid;
border-radius: 1em;
margin: 3px auto;
padding: 5px 0.8em;
}
.extra-margin {
padding: 0px 8px;
margin: auto;
}
button {
margin: 4px;
font-weight: bold;
color: aliceblue;
background-color: black;
border-radius: 1.2em;
z-index: 1;
&:hover {
opacity: 80%;
}
}
#control-panel {
display: none;
overflow: hidden;
margin: auto;
gap: 16px;
grid-template-columns: repeat(3, 1fr);
transition: display 1s ease-out 0s;
}
#control-panel.opened {
display: grid;
}
.control {
display: flex;
border-radius: 16px;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px solid #404040;
padding: 8px 16px;
}
#three-slider input {
margin: 4px;
width: 50%;
}
@media only screen and (max-width: 1000px) {
#control-panel {
grid-template-columns: repeat(2, 1fr);
}
.control {
font-size: 80%;
margin: 0px;
}
}
@media only screen and (max-width: 768px) {
#control-panel {
grid-template-columns: 1fr;
}
.networkroute {
display: none;
}
.submit_text {
display: none;
}
.submit {
position: fixed;
right: 16px;
bottom: 16px;
padding: 0.4em;
border-radius: 1em;
background-color: #36c8;
font-size: xx-large;
margin-bottom: 16px;
margin-right: 16px;
}
.wavering {
animation: blink 40s infinite;
}
}
::backdrop {
background-image: linear-gradient(45deg,
magenta,
rebeccapurple,
dodgerblue,
green);
opacity: 0.75;
}
.tab-button {
color: black;
flex: 1;
background-color: #bfbfbf;
border: none;
margin: 4px auto;
cursor: pointer;
opacity: 80%;
}
.tab-button.active {
opacity: unset;
font-weight: bold;
background-color: black;
color: white;
}
.navbar {
span {
padding: 4px;
}
button {
font-size: medium;
margin: 4px 0.5%;
padding-top: 4px;
padding-bottom: 4px;
opacity: 50%;
&:hover {
opacity: 80%;
}
}
}
/* Style the tab content (and add height:100% for full page content) */
.tabcontent {
display: none;
height: 100%;
}
.renew {
cursor: pointer;
font-weight: bold;
position: absolute;
top: 0;
right: 8px;
font-size: 150%;
}
/*======LOGO=======*/
.logo {
position: relative;
text-align: center;
height: 140px;
span {
display: block;
margin: 2px;
}
img {
display: block;
margin: auto;
}
h3 {
position: absolute;
font-size: medium;
width: 140px;
top: 33%;
left: 50%;
transform: translate(-50%, -50%);
color: aliceblue;
background-color: black;
border-radius: unset;
letter-spacing: 8px;
padding: 0px 40px;
font-family: monospace;
}
}
.avatars {
margin: 16px auto;
}
.avatar {
background-color: #f0f8ff88;
font-weight: bold;
font-size: 110%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
}
.group-avatar {
background-color: #f0f8ff88;
color: inherit;
font-weight: bold;
font-size: 90%;
padding: 3px 1em;
width: fit-content;
border: 3px solid;
border-radius: 1em;
margin: 4px auto;
}
.user-info {
display: grid;
grid-template-columns: 8em 12em;
gap: 2px 16px;
div {
text-align: left;
}
}
/*=======CONTEXT MENU=======*/
.context-menu {
z-index: 3;
min-width: 8em;
position: absolute;
background: aliceblue;
box-shadow: 4px 4px black;
color: black;
border: 3px solid black;
border-radius: 16px;
padding: 0;
margin: 0;
list-style: none;
li {
padding: 4px 0.5em;
border-bottom: 2px solid #0008;
border-radius: 0;
cursor: pointer;
}
li:last-child {
border-bottom: none;
}
}
.networkroute {
z-index: 3;
position: absolute;
top: 24px;
left: 48px;
}
/*========TEAM PANEL========*/
.team-panel {
max-width: 800px;
padding: 1em;
border: 3px solid black;
box-shadow: 8px 8px black;
margin: 1em;
input {
max-width: 300px;
margin: 0.2em auto;
}
}
.team-player {
color: black;
background-color: #36c4;
border: 1px solid black;
border-radius: 1.4em;
margin: 4px;
padding: 0.2em 0.5em;
&:hover {
background-color: #36c8;
}
&.new-player {
background-color: #3838;
}
&.disable-player {
background-color: #e338;
}
}
.new-player-inputs {
display: flex;
flex-direction: column;
margin: auto;
div {
display: grid;
grid-template-columns: 20ch auto;
@media only screen and (max-width: 768px) {
grid-template-columns: auto;
place-items: center;
}
label {
text-align: left;
width: 20ch;
margin: auto 1em;
}
input {
width: 90%;
margin: 4px 0;
}
}
}
@keyframes blink {
0% {
background-color: #8888;
}
13% {
background-color: #8888;
}
15% {
background-color: #f00a;
}
17% {
background-color: #8888;
}
38% {
background-color: #8888;
}
40% {
background-color: #ff0a;
}
42% {
background-color: #8888;
}
63% {
background-color: #8888;
}
65% {
background-color: #248f24aa;
}
67% {
background-color: #8888;
}
88% {
background-color: #8888;
}
90% {
background-color: #4700b3aa;
}
92% {
background-color: #8888;
}
100% {
background-color: #8888;
}
}
/*======SPINNER=======*/
.loader {
display: block;
border-radius: 16px;
position: relative;
height: 12px;
width: 96%;
margin: auto;
border: 4px solid black;
overflow: hidden;
}
.loader::after {
content: "";
width: 32%;
height: 120%;
background: #36c;
position: absolute;
top: -2px;
left: 0;
box-sizing: border-box;
animation: animloader 2s linear infinite;
}
@keyframes animloader {
0% {
left: 0;
transform: translateX(-100%);
}
100% {
left: 100%;
transform: translateX(0%);
}
}

View File

@@ -1,52 +0,0 @@
import Analysis from "./Analysis";
import "./App.css";
import Footer from "./Footer";
import Header from "./Header";
import Rankings from "./Rankings";
import { BrowserRouter, Routes, Route } from "react-router";
import { SessionProvider } from "./Session";
import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword";
import { ThemeProvider } from "./ThemeProvider";
import TeamPanel from "./TeamPanel";
const Maintenance = () => {
return (
<div style={{ textAlign: "center", padding: "20px" }}>
<h2>We are under maintenance.</h2>
<p>Please check back later. Thank you for your patience.</p>
<span style={{ fontSize: "xx-large" }}>🚧</span>
</div>
);
};
function App() {
return (
<ThemeProvider>
<BrowserRouter>
<Routes>
<Route path="/password" element={<SetPassword />} />
<Route
path="/*"
element={
<SessionProvider>
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="network" element={<GraphComponent />} />
<Route path="analysis" element={<Analysis />} />
<Route path="mvp" element={<MVPChart />} />
<Route path="changepassword" element={<SetPassword />} />
<Route path="team" element={<TeamPanel />} />
</Routes>
<Footer />
</SessionProvider>
}
/>
</Routes>
</BrowserRouter>
</ThemeProvider>
);
}
export default App;

View File

@@ -1,95 +0,0 @@
import { FC } from 'react';
import { PlayerRanking } from './types';
interface BarChartProps {
players: PlayerRanking[];
width: number;
height: number;
std: boolean;
}
const BarChart: FC<BarChartProps> = ({ players, width, height, std }) => {
const padding = 24;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barWidth = (width - 2 * padding) / players.length;
return (
<svg width={width} height={height}>
{players.map((player, index) => (
<rect
key={index}
x={index * barWidth + padding}
y={height - (1 - player.rank / maxValue) * height}
width={barWidth - 8} // subtract 2 for some spacing between bars
height={(1 - player.rank / maxValue) * height}
fill="#69f"
/>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 - 4 + padding}
y={height - (1 - player.rank / maxValue) * height - 5}
textAnchor="middle"
//transform='rotate(-27)'
//style={{ transformOrigin: "center", transformBox: "fill-box" }}
fontSize="16px"
fill="#404040"
>
{player.name}
</text>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 + padding - 4}
y={height - 8}
textAnchor="middle"
fontSize="12px"
fill="#404040"
>
{player.rank}
</text>
))}
{std && players.map((player, index) => (
<line
key={`error-${index}`}
x1={index * barWidth + barWidth / 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-top`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-bottom`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
</svg>
);
};
export default BarChart;

View File

@@ -1,42 +0,0 @@
import { useLocation } from "react-router";
import { Link } from "react-router";
import { useSession } from "./Session";
export default function Footer() {
const location = useLocation();
const { user, teams } = useSession();
return (
<footer className={location.pathname === "/network" ? "fixed-footer" : ""}>
{(user?.scopes.split(" ").includes("analysis") ||
teams?.activeTeam === 42) && (
<div className="navbar">
<Link to="/">
<span>Form</span>
</Link>
<span>|</span>
<Link to="/network">
<span>Trainer Analysis</span>
</Link>
<span>|</span>
<Link to="/mvp">
<span>MVP</span>
</Link>
<span>|</span>
<Link to="/team">
<span>Team</span>
</Link>
</div>
)}
<p className="grey extra-margin">
something not working?
<br />
message <a href="https://t.me/x0124816">me</a>.
<br />
or fix it here:{" "}
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
<img src="gitea.svg" alt="gitea" height="16" />
</a>
</p>
</footer>
);
}

View File

@@ -1,18 +0,0 @@
import { Link, useLocation } from "react-router";
import Avatar from "./Avatar";
export default function Header() {
const location = useLocation();
return (
<div className={location.pathname === "/network" ? "networkroute" : ""}>
<div className="logo">
<Link to="/">
<img alt="logo" height="66%" src="logo.svg" />
<h3 className="centered">cutt</h3>
</Link>
<span className="grey">cool ultimate team tool</span>
</div>
<Avatar />
</div>
);
}

View File

@@ -1,125 +0,0 @@
import { useEffect, useState } from "react";
import { currentUser, login, User } from "./api";
import Header from "./Header";
import { useLocation, useNavigate } from "react-router";
import { Eye, EyeSlash } from "./Icons";
export interface LoginProps {
onLogin: (user: User) => void;
}
export const Login = ({ onLogin }: LoginProps) => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [visible, setVisible] = useState(false);
const navigate = useNavigate();
const location = useLocation();
async function doLogin() {
setLoading(true);
setError("");
const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User;
try {
await login({ username, password });
user = await currentUser();
} catch (e) {
await timeout;
setError("failed");
setLoading(false);
return;
}
await timeout;
onLogin(user);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doLogin();
}
useEffect(() => {
if (location.state) {
const queryUsername = location.state.username;
const queryPassword = location.state.password;
if (queryUsername) setUsername(queryUsername);
if (queryPassword) setPassword(queryPassword);
navigate(location.pathname, { replace: true });
}
}, []);
return (
<>
<Header />
<form onSubmit={handleSubmit}>
<div
style={{
position: "relative",
display: "flex",
alignItems: "end",
}}
>
<div
style={{
width: "100%",
marginRight: "8px",
display: "flex",
justifyContent: "center",
flexDirection: "column",
}}
>
<div>
<input
type="text"
id="username"
name="username"
placeholder="username"
required
value={username}
onChange={(evt) => {
setError("");
setUsername(evt.target.value);
}}
/>
</div>
<div>
<input
type={visible ? "text" : "password"}
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => {
setError("");
setPassword(evt.target.value);
}}
/>
</div>
</div>
<div
style={{
position: "absolute",
right: "-1em",
margin: "auto 4px",
background: "unset",
fontSize: "x-large",
cursor: "pointer",
}}
onClick={() => setVisible(!visible)}
>
{visible ? <Eye /> : <EyeSlash />}
</div>
</div>
{error && <span style={{ color: "red" }}>{error}</span>}
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>
{loading && <span className="loader" />}
</form>
</>
);
};

View File

@@ -1,99 +0,0 @@
import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types";
interface RaceChartProps {
players: PlayerRanking[];
std: boolean;
}
const determineNiceWidth = (width: number) => {
const max = 1080;
if (width >= max) return max;
else if (width > 768) return width * 0.8;
else return width * 0.96;
};
const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight);
const height = (players.length + 1) * 40;
useEffect(() => {
const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth));
//setHeight(window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const padding = 24;
const gap = 8;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / players.length;
const fontSize = Math.min(barHeight - 1.5 * gap, width / 22);
return (
<svg width={width} height={height} id="RaceChartSVG">
{players.map((player, index) => (
<rect
key={String(index)}
x={4}
y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
stroke="aliceblue"
strokeWidth={4}
paintOrder={"stroke fill"}
/>
))}
{players.map((player, index) => (
<g key={"group" + index}>
<text
key={index + "_name"}
x={8}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={fontSize}
fill="aliceblue"
stroke="#36c"
strokeWidth={4}
fontWeight={"bold"}
paintOrder={"stroke fill"}
fontFamily="monospace"
style={{ whiteSpace: "pre" }}
>
{`${String(index + 1).padStart(2)}. ${player.name}`}
</text>
<text
key={index + "_value"}
x={
8 +
(4 + Math.max(...players.map((p, _) => p.name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={0.8 * fontSize}
fill="aliceblue"
stroke="#36c"
fontWeight={"bold"}
fontFamily="monospace"
strokeWidth={4}
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${String(player.rank).padStart(5)} ± ${player.std} N = ${player.n}`}
</text>
</g>
))}
</svg>
);
};
export default RaceChart;

View File

@@ -1,333 +0,0 @@
import {
ButtonHTMLAttributes,
Fragment,
ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, loadPlayers, User } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types";
import TabController from "./TabController";
type PlayerListProps = Partial<ReactSortableProps<any>> & {
orderedList?: boolean;
};
function PlayerList(props: PlayerListProps) {
return (
<ReactSortable {...props} animation={200} swapThreshold={0.4}>
{props.list?.map((item, index) => (
<div key={item.id} className="item">
{props.orderedList
? index + 1 + ". " + item.display_name
: item.display_name}
</div>
))}
</ReactSortable>
);
}
const LoadButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button {...props} style={{ padding: "4px 16px" }}>
🗃 restore previous
</button>
);
};
const ClearButton = (props: ButtonHTMLAttributes<HTMLButtonElement>) => {
return (
<button {...props} style={{ padding: "4px 16px" }}>
🗑 start over
</button>
);
};
function filterSort(list: User[], ids: number[]): User[] {
const objectMap = new Map(list.map((obj) => [obj.id, obj]));
const filteredAndSortedObjects = ids
.map((id) => objectMap.get(id))
.filter((obj) => obj !== undefined);
return filteredAndSortedObjects;
}
interface PlayerInfoProps {
user: User;
teams: TeamState;
players: User[];
}
function ChemistryDnD({ user, teams, players }: PlayerInfoProps) {
var otherPlayers = players.filter((player) => player.id !== user.id);
const [playersLeft, setPlayersLeft] = useState<User[]>([]);
const [playersMiddle, setPlayersMiddle] = useState<User[]>(otherPlayers);
const [playersRight, setPlayersRight] = useState<User[]>([]);
useEffect(() => {
setPlayersMiddle(otherPlayers);
}, [players]);
useEffect(() => {
setPlayersLeft([]);
setPlayersMiddle(otherPlayers);
setPlayersRight([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let left = playersLeft.map(({ id }) => id);
let middle = playersMiddle.map(({ id }) => id);
let right = playersRight.map(({ id }) => id);
const data = {
user: user.id,
hate: left,
undecided: middle,
love: right,
team: teams.activeTeam,
};
const response = await apiAuth("chemistry", data, "PUT");
setDialog(response || "try sending again");
}
async function handleGet() {
const data = await apiAuth(`chemistry/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
const chemistry = data as Chemistry;
setPlayersLeft(filterSort(otherPlayers, chemistry.hate));
setPlayersMiddle(
otherPlayers.filter(
(player) =>
!chemistry.hate.includes(player.id) &&
!chemistry.love.includes(player.id)
)
);
setPlayersRight(filterSort(otherPlayers, chemistry.love));
}
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setPlayersRight([]);
setPlayersMiddle(otherPlayers);
setPlayersLeft([]);
}}
/>
<div className="container">
<div className="box three">
<h2>😬</h2>
{playersLeft.length < 1 && (
<span className="grey hint">
drag people here that you'd rather not play with
</span>
)}
<PlayerList
list={playersLeft}
setList={setPlayersLeft}
group={"shared"}
className="dragbox"
/>
</div>
<div className="box three">
<h2>🤷</h2>
<PlayerList
list={playersMiddle}
setList={setPlayersMiddle}
group={"shared"}
className="middle dragbox"
/>
</div>
<div className="box three">
<h2>😍</h2>
{playersRight.length < 1 && (
<span className="grey hint">
drag people here that you love playing with from best to ... ok
</span>
)}
<PlayerList
list={playersRight}
setList={setPlayersRight}
group={"shared"}
className="dragbox"
orderedList
/>
</div>
</div>
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="ChemistryDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
function MVPDnD({ user, teams, players }: PlayerInfoProps) {
const [availablePlayers, setAvailablePlayers] = useState<User[]>(players);
const [rankedPlayers, setRankedPlayers] = useState<User[]>([]);
useEffect(() => {
setAvailablePlayers(players);
}, [players]);
useEffect(() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}, [teams]);
const [dialog, setDialog] = useState("dialog");
const dialogRef = useRef<HTMLDialogElement>(null);
async function handleSubmit() {
if (dialogRef.current) dialogRef.current.showModal();
setDialog("sending...");
let mvps = rankedPlayers.map(({ id }) => id);
const data = { user: user.id, mvps: mvps, team: teams.activeTeam };
const response = await apiAuth("mvps", data, "PUT");
response ? setDialog(response) : setDialog("try sending again");
}
async function handleGet() {
const data = await apiAuth(`mvps/${teams.activeTeam}`, null, "GET");
if (data.detail) alert(data.detail);
else {
const mvps = data as MVPRanking;
setRankedPlayers(filterSort(players, mvps.mvps));
setAvailablePlayers(
players.filter((user) => !mvps.mvps.includes(user.id))
);
}
}
return (
<>
<HeaderControl
onLoad={handleGet}
onClear={() => {
setAvailablePlayers(players);
setRankedPlayers([]);
}}
/>
<div className="container">
<div className="box two">
<h2>🥏🏃</h2>
{availablePlayers.length < 1 && (
<span className="grey hint">all sorted 👍</span>
)}
<PlayerList
list={availablePlayers}
setList={setAvailablePlayers}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
/>
</div>
<div className="box two">
<h1>🏆</h1>
{rankedPlayers.length < 1 && (
<span className="grey hint">
carefully place as many of the <i>Most Valuable Players</i>{" "}
(according to your humble opinion) in this box
</span>
)}
<PlayerList
list={rankedPlayers}
setList={setRankedPlayers}
group={{
name: "mvp-shared",
pull: function (to) {
return to.el.classList.contains("putclone") ? "clone" : true;
},
}}
className="dragbox"
orderedList
/>
</div>
</div>
<button className="submit wavering" onClick={() => handleSubmit()}>
💾 <span className="submit_text">submit</span>
</button>
<dialog
ref={dialogRef}
id="MVPDialog"
onClick={(event) => {
event.currentTarget.close();
}}
>
{dialog}
</dialog>
</>
);
}
interface HeaderControlProps {
onLoad: () => void;
onClear: () => void;
}
function HeaderControl({ onLoad, onClear }: HeaderControlProps) {
return (
<>
<div>
<LoadButton onClick={onLoad} />
<ClearButton onClick={onClear} />
</div>
<div>
<span className="grey">
assign as many or as few players as you want and don't forget to{" "}
<b>submit</b> 💾 when you're done :)
</span>
</div>
</>
);
}
export default function Rankings() {
const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null);
useEffect(() => {
if (teams) {
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [user, teams]);
const tabs = [
{ id: "Chemistry", label: "🧪 Chemistry" },
{ id: "MVP", label: "🏆 MVP" },
];
return (
<>
{user && teams && players ? (
<TabController tabs={tabs}>
<ChemistryDnD {...{ user, teams, players }} />
<MVPDnD {...{ user, teams, players }} />
</TabController>
) : (
<span className="loader" />
)}
</>
);
}

View File

@@ -1,45 +0,0 @@
import { Fragment, ReactNode, useState } from "react";
interface TabProps {
id: string;
label: string;
}
interface TabControllerProps {
tabs: TabProps[];
children: ReactNode[];
}
export default function TabController({ tabs, children }: TabControllerProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const handleTabClick = (index: number) => {
setCurrentIndex(index);
};
return (
<div>
<div>
<div className="container navbar">
{tabs.map((tab, index) => (
<button
key={tab.id}
className={
currentIndex === index ? "tab-button active" : "tab-button"
}
onClick={() => handleTabClick(index)}
>
{tab.label}
</button>
))}
</div>
</div>
{children.map((child, index) => (
<Fragment key={index}>
<div style={{ display: currentIndex === index ? "block" : "none" }}>
{child}
</div>
</Fragment>
))}
</div>
);
}

View File

@@ -1,209 +0,0 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, loadPlayers, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
import { useNavigate } from "react-router";
const TeamPanel = () => {
const { user, teams } = useSession();
const navigate = useNavigate();
useEffect(() => {
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
teams?.activeTeam === 42 ||
navigate("/", { replace: true });
}, [user]);
const newPlayerTemplate = {
id: 0,
username: "",
display_name: "",
number: "",
email: "",
} as User;
const [error, setError] = useState<ErrorState>();
const [players, setPlayers] = useState<User[] | null>(null);
const [player, setPlayer] = useState(newPlayerTemplate);
useEffect(() => {
if (teams) {
setError({ ok: true, message: "" });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [teams]);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (teams) {
if (player.id === 0) {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "POST");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
} else {
const r = await apiAuth(`player/${teams?.activeTeam}`, player, "PUT");
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}
}
}
async function handleDisable(e: FormEvent) {
e.preventDefault();
if (teams && player.id !== 0) {
var confirmation = confirm("are you sure?");
if (confirmation) {
const r = await apiAuth(
`player/${teams?.activeTeam}`,
{ player_id: player.id },
"DELETE"
);
if (r.detail) setError({ ok: false, message: r.detail });
else {
setError({ ok: true, message: r });
setPlayer(newPlayerTemplate);
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}
}
}
if (teams && players) {
const activeTeam = teams.teams.filter(
(team) => team.id == teams?.activeTeam
)[0];
return (
<div className="team-panel">
<h1>{activeTeam.name}</h1>
<div>
<input type="text" value={activeTeam.location || ""} disabled />
<br />
<input type="text" value={activeTeam.country || ""} disabled />
<hr style={{ width: "100%" }} />
<h2>players</h2>
{players ? (
<div
style={{
display: "flex",
flexWrap: "wrap",
justifyContent: "center",
}}
>
{players &&
players.map((p) => (
<button
className="team-player"
key={p.id}
onClick={() => {
setPlayer(p);
setError({ ok: true, message: "" });
}}
>
{p.display_name}
</button>
))}
<button
className="team-player new-player"
key="add-player"
onClick={() => {
setPlayer(newPlayerTemplate);
setError({ ok: true, message: "" });
}}
>
+
</button>
</div>
) : (
<span className="loader" />
)}
<hr style={{ width: "100%" }} />
<form className="new-player-inputs" onSubmit={handleSubmit}>
<div>
<label>name</label>
<input
type="text"
required
value={player.display_name}
onChange={(e) => {
setPlayer({
...player,
display_name: e.target.value,
username: e.target.value.toLowerCase().replace(/\W/g, ""),
});
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>username</label>
<input
type="text"
required
disabled={player.id !== 0}
value={player.username}
onChange={(e) => {
setPlayer({ ...player, username: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>number (optional)</label>
<input
type="text"
value={player.number || ""}
onChange={(e) => {
setPlayer({ ...player, number: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div>
<label>email (optional)</label>
<input
type="email"
value={player.email || ""}
onChange={(e) => {
setPlayer({ ...player, email: e.target.value });
setError({ ok: true, message: "" });
}}
/>
</div>
<div style={{ margin: "auto" }}>
{error?.message && (
<span
style={{
color: error.ok ? "green" : "red",
}}
>
{error.message}
</span>
)}
</div>
<div style={{ margin: "auto" }}>
<button className="team-player new-player">
{player.id === 0 ? "add player" : "modify player"}
</button>
</div>
{player.id !== 0 && (
<div style={{ margin: "auto" }}>
<button
className="team-player disable-player"
onClick={handleDisable}
>
remove player
</button>
</div>
)}
</form>
</div>
</div>
);
} else <span className="loader" />;
};
export default TeamPanel;

View File

@@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)