Compare commits

...

4 Commits

Author SHA1 Message Date
8b4ee3b289
feat: Team management panel
the display name of a player is the same for all teams... change that?
2025-03-24 14:11:58 +01:00
e88eb02ef1
fix: allow for nodes without any edges (e.g. new player) 2025-03-24 13:35:36 +01:00
c04a1e03f2
feat: improve TeamPanel input placement 2025-03-24 11:57:51 +01:00
691b99daa8
feat: add a Team Panel 2025-03-23 15:01:26 +01:00
10 changed files with 418 additions and 34 deletions

View File

@ -116,6 +116,7 @@ def graph_json(
status_code=status.HTTP_404_NOT_FOUND, detail="no entries found"
)
G = nx.DiGraph()
G.add_nodes_from([n["id"] for n in nodes])
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
in_degrees = G.in_degree(weight="weight")
nodes = [

View File

@ -1,20 +1,122 @@
from typing import Annotated
from fastapi import APIRouter, Depends, Security
from fastapi import APIRouter, Depends, HTTPException, Security, status
from fastapi.responses import PlainTextResponse
from pydantic import BaseModel
from sqlmodel import Session, select
from cutt.db import Player, Team, engine
from cutt.security import change_password, get_current_active_user, read_player_me
from cutt.db import Player, PlayerTeamLink, Team, engine
from cutt.security import (
TeamScopedRequest,
change_password,
get_current_active_user,
read_player_me,
verify_team_scope,
)
P = Player
player_router = APIRouter(prefix="/player", tags=["player"])
def add_player(player: P):
class PlayerRequest(BaseModel):
display_name: str
username: str
number: str
email: str
class AddPlayerRequest(PlayerRequest): ...
def add_player(
r: AddPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
with Session(engine) as session:
session.add(player)
if session.exec(select(P).where(P.username == r.username)).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="username not available"
)
stmt = (
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.display_name == r.display_name)
)
if session.exec(stmt).one_or_none():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="the name is already taken on this team",
)
team = session.exec(select(Team).where(Team.id == request.team_id)).one()
new_player = Player(
username=r.username,
display_name=r.display_name,
email=r.email if r.email else None,
number=r.number,
teams=[team],
)
session.add(new_player)
session.commit()
return PlainTextResponse(f"added {new_player.display_name}")
class ModifyPlayerRequest(PlayerRequest):
id: int
def modify_player(
r: ModifyPlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
with Session(engine) as session:
player = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.id == r.id, P.username == r.username)
).one_or_none()
if player:
player.display_name = r.display_name.strip()
player.number = r.number.strip()
player.email = r.email.strip()
session.add(player)
session.commit()
return PlainTextResponse("modification successful")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no such player found in your team",
)
class DisablePlayerRequest(BaseModel):
player_id: int
def disable_player(
r: DisablePlayerRequest,
request: Annotated[TeamScopedRequest, Depends(verify_team_scope)],
):
with Session(engine) as session:
player = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == request.team_id, P.id == r.player_id)
).one_or_none()
if player:
player.disabled = True
session.add(player)
session.commit()
return PlainTextResponse(f"disabled {player.display_name}")
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="no such player found in your team",
)
def add_player_to_team(player_id: int, team_id: int):
@ -44,12 +146,19 @@ async def list_all_players():
async def list_players(team_id: int):
with Session(engine) as session:
statement = select(Team).where(Team.id == team_id)
players = [t.players for t in session.exec(statement)][0]
players = session.exec(
select(P)
.join(PlayerTeamLink)
.join(Team)
.where(Team.id == team_id, P.disabled == False)
).all()
if players:
return [
player.model_dump(include={"id", "display_name", "number"})
player.model_dump(
include={"id", "display_name", "username", "number", "email"}
)
for player in players
if not player.disabled
]
@ -59,13 +168,22 @@ def read_teams_me(user: Annotated[P, Depends(get_current_active_user)]):
player_router.add_api_route(
"/add",
"/{team_id}",
endpoint=add_player,
methods=["POST"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
player_router.add_api_route(
"/list/{team_id}",
"/{team_id}",
endpoint=modify_player,
methods=["PUT"],
)
player_router.add_api_route(
"/{team_id}",
endpoint=disable_player,
methods=["DELETE"],
)
player_router.add_api_route(
"/{team_id}/list",
endpoint=list_players,
methods=["GET"],
dependencies=[Depends(get_current_active_user)],
@ -77,7 +195,7 @@ player_router.add_api_route(
dependencies=[Security(get_current_active_user, scopes=["admin"])],
)
player_router.add_api_route(
"/add/{player_id}/{team_id}",
"/add/{team_id}/{player_id}",
endpoint=add_player_to_team,
methods=["GET"],
dependencies=[Security(get_current_active_user, scopes=["admin"])],

View File

@ -471,7 +471,6 @@ button {
}
}
.networkroute {
z-index: 3;
position: absolute;
@ -479,6 +478,69 @@ button {
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% {

View File

@ -9,6 +9,7 @@ import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
import { SetPassword } from "./SetPassword";
import { ThemeProvider } from "./ThemeProvider";
import { TeamPanel } from "./TeamPanel";
const Maintenance = () => {
return (
@ -37,6 +38,7 @@ function App() {
<Route path="/analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} />
<Route path="/changepassword" element={<SetPassword />} />
<Route path="/team" element={<TeamPanel />} />
</Routes>
<Footer />
</SessionProvider>

View File

@ -44,7 +44,7 @@ const UserInfo = (user: User, teams: TeamState | undefined) => {
>
{teams.teams.map((team, index) => (
<li>
{teams.activeTeam === index ? <b>{team.name}</b> : team.name} (
{<b>{team.name}</b>} (
{team.location || team.country || "location unknown"})
</li>
))}

View File

@ -114,7 +114,7 @@ export const Login = ({ onLogin }: LoginProps) => {
{visible ? <Eye /> : <EyeSlash />}
</div>
</div>
<div>{error && <span style={{ color: "red" }}>{error}</span>}</div>
{error && <span style={{ color: "red" }}>{error}</span>}
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>

View File

@ -7,7 +7,7 @@ import {
useState,
} from "react";
import { ReactSortable, ReactSortableProps } from "react-sortablejs";
import { apiAuth, User } from "./api";
import { apiAuth, loadPlayers, User } from "./api";
import { TeamState, useSession } from "./Session";
import { Chemistry, MVPRanking } from "./types";
import TabController from "./TabController";
@ -307,23 +307,10 @@ export default function Rankings() {
const { user, teams } = useSession();
const [players, setPlayers] = useState<User[] | null>(null);
async function loadPlayers() {
if (teams) {
try {
const data = await apiAuth(
`player/list/${teams?.activeTeam}`,
null,
"GET"
);
setPlayers(data as User[]);
} catch (error) {
console.error(error);
}
}
}
useEffect(() => {
loadPlayers();
if (teams) {
loadPlayers(teams.activeTeam).then((data) => setPlayers(data));
}
}, [user, teams]);
const tabs = [

201
src/TeamPanel.tsx Normal file
View File

@ -0,0 +1,201 @@
import { FormEvent, useEffect, useState } from "react";
import { apiAuth, loadPlayers, User } from "./api";
import { useSession } from "./Session";
import { ErrorState } from "./types";
export const TeamPanel = () => {
const { teams } = useSession();
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" />;
};

View File

@ -47,9 +47,7 @@ export type User = {
id: number;
username: string;
display_name: string;
full_name: string;
email: string;
player_id: number;
number: string;
scopes: string;
};
@ -78,6 +76,16 @@ export async function currentUser(): Promise<User> {
return resp.json() as Promise<User>;
}
export async function loadPlayers(teamId: number) {
try {
const data = await apiAuth(`player/${teamId}/list`, null, "GET");
return data as User[];
} catch (error) {
console.error(error);
return null;
}
}
export type LoginRequest = {
username: string;
password: string;

View File

@ -39,3 +39,8 @@ export interface Team {
location: string;
country: string;
}
export type ErrorState = {
ok: boolean;
message: string;
};