dev #4
@@ -1,9 +1,14 @@
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .db import Base, engine, SessionLocal
|
||||
from .models import User, Entry, Category, Role
|
||||
from .models import User, Entry, Category, Role, Game, GameMember
|
||||
from .security import hash_password
|
||||
from .routes.auth import router as auth_router
|
||||
from .routes.admin import router as admin_router
|
||||
@@ -14,7 +19,10 @@ app = FastAPI(title="Cluedo Sheet")
|
||||
# Intern: Frontend läuft auf :8081
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"],
|
||||
allow_origins=[
|
||||
"http://localhost:8081",
|
||||
"http://127.0.0.1:8081",
|
||||
],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
@@ -24,9 +32,71 @@ app.include_router(auth_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(games_router)
|
||||
|
||||
def _rand_join_code(n: int = 6) -> str:
|
||||
# digits only (kahoot style)
|
||||
return "".join(random.choice(string.digits) for _ in range(n))
|
||||
|
||||
def _auto_migrate(db: Session):
|
||||
"""
|
||||
Very small, pragmatic auto-migration (no alembic).
|
||||
- creates missing tables via create_all
|
||||
- adds missing columns via ALTER TABLE (best effort)
|
||||
"""
|
||||
# Users.theme_key
|
||||
try:
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# Games.join_code + winner_user_id
|
||||
try:
|
||||
db.execute(text("ALTER TABLE games ADD COLUMN join_code VARCHAR"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
try:
|
||||
db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# SheetState.chip_code
|
||||
try:
|
||||
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip_code VARCHAR"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# Ensure unique index for join_code (best effort)
|
||||
try:
|
||||
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
# Backfill join_code for existing games
|
||||
games = db.query(Game).filter((Game.join_code == None) | (Game.join_code == "")).all() # noqa: E711
|
||||
if games:
|
||||
used = set([r[0] for r in db.execute(text("SELECT join_code FROM games WHERE join_code IS NOT NULL")).all() if r[0]])
|
||||
for g in games:
|
||||
code = _rand_join_code()
|
||||
while code in used:
|
||||
code = _rand_join_code()
|
||||
g.join_code = code
|
||||
used.add(code)
|
||||
db.commit()
|
||||
|
||||
# Backfill membership: ensure owner is member
|
||||
all_games = db.query(Game).all()
|
||||
for g in all_games:
|
||||
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id).first()
|
||||
if not exists:
|
||||
db.add(GameMember(game_id=g.id, user_id=g.owner_user_id))
|
||||
db.commit()
|
||||
|
||||
def seed_entries(db: Session):
|
||||
# Du kannst hier deine HP-Edition Einträge reinschreiben.
|
||||
# (Für rein private Nutzung ok – öffentlich würde ich’s generisch machen.)
|
||||
if db.query(Entry).count() > 0:
|
||||
return
|
||||
suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"]
|
||||
@@ -46,14 +116,24 @@ def ensure_admin(db: Session):
|
||||
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
|
||||
u = db.query(User).filter(User.email == admin_email).first()
|
||||
if not u:
|
||||
db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value))
|
||||
db.add(
|
||||
User(
|
||||
email=admin_email,
|
||||
password_hash=hash_password(admin_pw),
|
||||
role=Role.admin.value,
|
||||
theme_key="default",
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
# create new tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
_auto_migrate(db)
|
||||
ensure_admin(db)
|
||||
seed_entries(db)
|
||||
finally:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import enum
|
||||
import uuid
|
||||
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
from .db import Base
|
||||
|
||||
@@ -21,14 +21,39 @@ class User(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String)
|
||||
role: Mapped[str] = mapped_column(String, default=Role.user.value)
|
||||
disabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# UI preferences (persisted server-side)
|
||||
theme_key: Mapped[str] = mapped_column(String, default="default")
|
||||
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Game(Base):
|
||||
__tablename__ = "games"
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
|
||||
# Creator/owner (for audit), membership controls access
|
||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
|
||||
name: Mapped[str] = mapped_column(String)
|
||||
seed: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
# "Kahoot"-style join code
|
||||
join_code: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||
|
||||
# Winner (shared for the game)
|
||||
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class GameMember(Base):
|
||||
__tablename__ = "game_members"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("game_id", "user_id", name="uq_game_member"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
class Entry(Base):
|
||||
@@ -46,6 +71,10 @@ class SheetState(Base):
|
||||
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
|
||||
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
|
||||
entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True)
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed
|
||||
|
||||
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed, 3 maybe
|
||||
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
||||
|
||||
# Frontend "s.XX" chip selection (persisted)
|
||||
chip_code: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User
|
||||
from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password
|
||||
from ..security import (
|
||||
verify_password,
|
||||
make_session_value,
|
||||
set_session,
|
||||
clear_session,
|
||||
get_session_user_id,
|
||||
hash_password,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@@ -10,11 +18,11 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
def login(data: dict, resp: Response, db: Session = Depends(get_db)):
|
||||
email = (data.get("email") or "").lower().strip()
|
||||
password = data.get("password") or ""
|
||||
user = db.query(User).filter(User.email == email, User.disabled == False).first()
|
||||
user = db.query(User).filter(User.email == email, User.disabled == False).first() # noqa: E712
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||||
set_session(resp, make_session_value(user.id))
|
||||
return {"ok": True, "role": user.role, "email": user.email}
|
||||
return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
|
||||
|
||||
@router.post("/logout")
|
||||
def logout(resp: Response):
|
||||
@@ -29,7 +37,7 @@ def me(req: Request, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.id == uid).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
return {"id": user.id, "email": user.email, "role": user.role}
|
||||
return {"id": user.id, "email": user.email, "role": user.role, "theme_key": user.theme_key}
|
||||
|
||||
@router.patch("/password")
|
||||
def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
@@ -41,7 +49,7 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
if len(password) < 8:
|
||||
raise HTTPException(status_code=400, detail="password too short (min 8)")
|
||||
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
@@ -50,3 +58,25 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
@router.patch("/theme")
|
||||
def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
|
||||
"""Persist user design selection server-side."""
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
theme_key = (data.get("theme_key") or "").strip()
|
||||
if not theme_key:
|
||||
raise HTTPException(status_code=400, detail="theme_key required")
|
||||
|
||||
user = db.query(User).filter(User.id == uid, User.disabled == False).first() # noqa: E712
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
|
||||
user.theme_key = theme_key
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "theme_key": user.theme_key}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import hashlib, random
|
||||
import hashlib
|
||||
import random
|
||||
import string
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Game, Entry, SheetState, Category
|
||||
from ..models import Game, GameMember, Entry, SheetState, Category, User, Role
|
||||
from ..security import get_session_user_id
|
||||
|
||||
router = APIRouter(prefix="/games", tags=["games"])
|
||||
|
||||
def require_user(req: Request, db: Session):
|
||||
def require_user(req: Request, db: Session) -> str:
|
||||
uid = get_session_user_id(req)
|
||||
if not uid:
|
||||
raise HTTPException(status_code=401, detail="not logged in")
|
||||
@@ -17,30 +20,139 @@ def stable_order(seed: int, user_id: str, entry_id: str) -> str:
|
||||
s = f"{seed}:{user_id}:{entry_id}".encode()
|
||||
return hashlib.sha256(s).hexdigest()
|
||||
|
||||
def _rand_join_code(n: int = 6) -> str:
|
||||
return "".join(random.choice(string.digits) for _ in range(n))
|
||||
|
||||
def _new_unique_join_code(db: Session) -> str:
|
||||
for _ in range(50):
|
||||
code = _rand_join_code()
|
||||
if not db.query(Game).filter(Game.join_code == code).first():
|
||||
return code
|
||||
raise HTTPException(500, "failed to generate join code")
|
||||
|
||||
def require_member(req: Request, db: Session, game_id: str) -> tuple[str, Game]:
|
||||
uid = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
m = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
|
||||
if not m:
|
||||
raise HTTPException(403, "not a member of this game")
|
||||
return uid, g
|
||||
|
||||
@router.post("")
|
||||
def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
name = data.get("name") or "Neues Spiel"
|
||||
seed = random.randint(1, 2_000_000_000)
|
||||
g = Game(owner_user_id=uid, name=name, seed=seed)
|
||||
db.add(g); db.commit()
|
||||
return {"id": g.id, "name": g.name}
|
||||
join_code = _new_unique_join_code(db)
|
||||
|
||||
g = Game(owner_user_id=uid, name=name, seed=seed, join_code=join_code)
|
||||
db.add(g)
|
||||
db.commit()
|
||||
|
||||
# creator becomes member
|
||||
db.add(GameMember(game_id=g.id, user_id=uid))
|
||||
db.commit()
|
||||
|
||||
return {"id": g.id, "name": g.name, "join_code": g.join_code}
|
||||
|
||||
@router.post("/join")
|
||||
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
code = (data.get("code") or "").strip()
|
||||
if not code or len(code) < 4:
|
||||
raise HTTPException(400, "code required")
|
||||
|
||||
g = db.query(Game).filter(Game.join_code == code).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
|
||||
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == uid).first()
|
||||
if not exists:
|
||||
db.add(GameMember(game_id=g.id, user_id=uid))
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "game": {"id": g.id, "name": g.name, "join_code": g.join_code}}
|
||||
|
||||
@router.get("")
|
||||
def list_games(req: Request, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
games = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all()
|
||||
return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games]
|
||||
|
||||
games = (
|
||||
db.query(Game)
|
||||
.join(GameMember, GameMember.game_id == Game.id)
|
||||
.filter(GameMember.user_id == uid)
|
||||
.order_by(Game.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
out = []
|
||||
for g in games:
|
||||
winner = None
|
||||
if g.winner_user_id:
|
||||
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
||||
if wu:
|
||||
winner = {"id": wu.id, "email": wu.email}
|
||||
out.append({"id": g.id, "name": g.name, "seed": g.seed, "join_code": g.join_code, "winner": winner})
|
||||
return out
|
||||
|
||||
@router.get("/{game_id}/meta")
|
||||
def game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid, g = require_member(req, db, game_id)
|
||||
|
||||
winner = None
|
||||
if g.winner_user_id:
|
||||
wu = db.query(User).filter(User.id == g.winner_user_id).first()
|
||||
if wu:
|
||||
winner = {"id": wu.id, "email": wu.email}
|
||||
return {"id": g.id, "name": g.name, "join_code": g.join_code, "winner": winner}
|
||||
|
||||
@router.get("/{game_id}/players")
|
||||
def list_players(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
_uid, g = require_member(req, db, game_id)
|
||||
|
||||
# only non-admin users (admin doesn't play)
|
||||
players = (
|
||||
db.query(User)
|
||||
.join(GameMember, GameMember.user_id == User.id)
|
||||
.filter(GameMember.game_id == g.id, User.disabled == False, User.role == Role.user.value) # noqa: E712
|
||||
.order_by(User.email.asc())
|
||||
.all()
|
||||
)
|
||||
return [{"id": u.id, "email": u.email} for u in players]
|
||||
|
||||
@router.patch("/{game_id}/winner")
|
||||
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
_uid, g = require_member(req, db, game_id)
|
||||
|
||||
winner_user_id = data.get("winner_user_id")
|
||||
if winner_user_id is not None:
|
||||
# must be a member + non-admin
|
||||
u = db.query(User).filter(User.id == winner_user_id, User.disabled == False).first() # noqa: E712
|
||||
if not u or u.role != Role.user.value:
|
||||
raise HTTPException(400, "invalid winner_user_id")
|
||||
|
||||
member = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == u.id).first()
|
||||
if not member:
|
||||
raise HTTPException(400, "winner is not in this game")
|
||||
|
||||
g.winner_user_id = winner_user_id
|
||||
db.add(g)
|
||||
db.commit()
|
||||
|
||||
return {"ok": True, "winner_user_id": g.winner_user_id}
|
||||
|
||||
@router.get("/{game_id}/sheet")
|
||||
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
uid, g = require_member(req, db, game_id)
|
||||
|
||||
entries = db.query(Entry).all()
|
||||
states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all()
|
||||
states = (
|
||||
db.query(SheetState)
|
||||
.filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid)
|
||||
.all()
|
||||
)
|
||||
state_map = {st.entry_id: st for st in states}
|
||||
|
||||
out = {"suspect": [], "item": [], "location": []}
|
||||
@@ -51,6 +163,7 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
"label": e.label,
|
||||
"status": st.status if st else 0,
|
||||
"note_tag": st.note_tag if st else None,
|
||||
"chip_code": st.chip_code if st else None,
|
||||
"order": stable_order(g.seed, uid, e.id),
|
||||
}
|
||||
out[e.category].append(item)
|
||||
@@ -65,19 +178,22 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
@router.patch("/{game_id}/sheet/{entry_id}")
|
||||
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
|
||||
uid = require_user(req, db)
|
||||
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first()
|
||||
if not g:
|
||||
raise HTTPException(404, "game not found")
|
||||
uid, g = require_member(req, db, game_id)
|
||||
|
||||
status = data.get("status")
|
||||
note_tag = data.get("note_tag")
|
||||
chip_code = data.get("chip_code")
|
||||
|
||||
if note_tag not in (None, "i", "m", "s"):
|
||||
raise HTTPException(400, "invalid note_tag")
|
||||
if status is not None and status not in (0, 1, 2, 3):
|
||||
raise HTTPException(400, "invalid status")
|
||||
|
||||
# chip_code only allowed if note_tag == 's' (or if note_tag not provided but current is 's')
|
||||
if chip_code is not None:
|
||||
if not isinstance(chip_code, str) or len(chip_code) > 16:
|
||||
raise HTTPException(400, "invalid chip_code")
|
||||
|
||||
st = db.query(SheetState).filter(
|
||||
SheetState.game_id == g.id,
|
||||
SheetState.owner_user_id == uid,
|
||||
@@ -85,13 +201,24 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
|
||||
).first()
|
||||
|
||||
if not st:
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None)
|
||||
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip_code=None)
|
||||
db.add(st)
|
||||
|
||||
if status is not None:
|
||||
st.status = status
|
||||
|
||||
if "note_tag" in data:
|
||||
st.note_tag = note_tag
|
||||
# if leaving 's', clear chip
|
||||
if note_tag != "s":
|
||||
st.chip_code = None
|
||||
|
||||
if "chip_code" in data:
|
||||
# chip_code is only meaningful when note_tag is 's'
|
||||
effective_tag = st.note_tag
|
||||
if effective_tag != "s":
|
||||
raise HTTPException(400, "chip_code requires note_tag 's'")
|
||||
st.chip_code = chip_code
|
||||
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
// src/App.jsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { api } from "./api/client";
|
||||
import { cycleTag } from "./utils/cycleTag";
|
||||
import { getChipLS, setChipLS, clearChipLS } from "./utils/chipStorage";
|
||||
|
||||
import { getWinnerLS, setWinnerLS, clearWinnerLS } from "./utils/winnerStorage";
|
||||
|
||||
import { useHpGlobalStyles } from "./styles/hooks/useHpGlobalStyles";
|
||||
import { styles } from "./styles/styles";
|
||||
|
||||
import { applyTheme, loadThemeKey, saveThemeKey, DEFAULT_THEME_KEY } from "./styles/themes";
|
||||
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
|
||||
|
||||
import AdminPanel from "./components/AdminPanel";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
@@ -23,6 +20,7 @@ import SheetSection from "./components/SheetSection";
|
||||
import DesignModal from "./components/DesignModal";
|
||||
import WinnerCard from "./components/WinnerCard";
|
||||
import WinnerBadge from "./components/WinnerBadge";
|
||||
import JoinGameModal from "./components/JoinGameModal";
|
||||
|
||||
export default function App() {
|
||||
useHpGlobalStyles();
|
||||
@@ -39,8 +37,10 @@ export default function App() {
|
||||
const [sheet, setSheet] = useState(null);
|
||||
const [pulseId, setPulseId] = useState(null);
|
||||
|
||||
// Winner (per game)
|
||||
const [winnerName, setWinnerName] = useState("");
|
||||
// Game meta / players / winner
|
||||
const [gameMeta, setGameMeta] = useState(null);
|
||||
const [players, setPlayers] = useState([]);
|
||||
const [winnerUserId, setWinnerUserId] = useState(null);
|
||||
|
||||
// Modals
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
@@ -60,13 +60,20 @@ export default function App() {
|
||||
const [designOpen, setDesignOpen] = useState(false);
|
||||
const [themeKey, setThemeKey] = useState(DEFAULT_THEME_KEY);
|
||||
|
||||
// Join game
|
||||
const [joinOpen, setJoinOpen] = useState(false);
|
||||
|
||||
const currentGame = useMemo(
|
||||
() => games.find((g) => String(g.id) === String(gameId)) || null,
|
||||
[games, gameId]
|
||||
);
|
||||
|
||||
// ===== Data loaders =====
|
||||
const load = async () => {
|
||||
const m = await api("/auth/me");
|
||||
setMe(m);
|
||||
|
||||
// Theme pro User laden & anwenden
|
||||
const tk = loadThemeKey(m?.email);
|
||||
const tk = m?.theme_key || DEFAULT_THEME_KEY;
|
||||
setThemeKey(tk);
|
||||
applyTheme(tk);
|
||||
|
||||
@@ -82,6 +89,19 @@ export default function App() {
|
||||
setSheet(sh);
|
||||
};
|
||||
|
||||
const reloadMeta = async () => {
|
||||
if (!gameId) return;
|
||||
const meta = await api(`/games/${gameId}/meta`);
|
||||
setGameMeta(meta);
|
||||
setWinnerUserId(meta?.winner?.id || null);
|
||||
};
|
||||
|
||||
const reloadPlayers = async () => {
|
||||
if (!gameId) return;
|
||||
const ps = await api(`/games/${gameId}/players`);
|
||||
setPlayers(ps);
|
||||
};
|
||||
|
||||
// ===== Effects =====
|
||||
|
||||
// Dropdown outside click
|
||||
@@ -106,19 +126,13 @@ export default function App() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// load sheet + winner when game changes
|
||||
// load sheet/meta when game changes
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!gameId) return;
|
||||
|
||||
try {
|
||||
await reloadSheet();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Sieger pro Game aus localStorage laden
|
||||
setWinnerName(getWinnerLS(gameId));
|
||||
await Promise.all([reloadSheet(), reloadMeta(), reloadPlayers()]);
|
||||
} catch {}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gameId]);
|
||||
@@ -138,7 +152,9 @@ export default function App() {
|
||||
setGames([]);
|
||||
setGameId(null);
|
||||
setSheet(null);
|
||||
setWinnerName("");
|
||||
setGameMeta(null);
|
||||
setPlayers([]);
|
||||
setWinnerUserId(null);
|
||||
};
|
||||
|
||||
// ===== Password change =====
|
||||
@@ -184,10 +200,19 @@ export default function App() {
|
||||
setUserMenuOpen(false);
|
||||
};
|
||||
|
||||
const selectTheme = (key) => {
|
||||
const selectTheme = async (key) => {
|
||||
setThemeKey(key);
|
||||
applyTheme(key);
|
||||
saveThemeKey(me?.email, key);
|
||||
|
||||
try {
|
||||
await api("/auth/theme", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ theme_key: key }),
|
||||
});
|
||||
setMe((prev) => (prev ? { ...prev, theme_key: key } : prev));
|
||||
} catch {
|
||||
// ignore; UI already switched
|
||||
}
|
||||
};
|
||||
|
||||
// ===== Game actions =====
|
||||
@@ -196,29 +221,34 @@ export default function App() {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
|
||||
});
|
||||
|
||||
const gs = await api("/games");
|
||||
setGames(gs);
|
||||
setGameId(g.id);
|
||||
|
||||
// Neues Spiel -> Sieger leer
|
||||
clearWinnerLS(g.id);
|
||||
setWinnerName("");
|
||||
};
|
||||
|
||||
// ===== Winner actions =====
|
||||
const saveWinner = () => {
|
||||
const openJoinModal = () => {
|
||||
setJoinOpen(true);
|
||||
setUserMenuOpen(false);
|
||||
};
|
||||
|
||||
const joinGame = async (code) => {
|
||||
const res = await api("/games/join", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
const gs = await api("/games");
|
||||
setGames(gs);
|
||||
setGameId(res?.game?.id || null);
|
||||
};
|
||||
|
||||
// ===== Winner actions (shared per game) =====
|
||||
const saveWinner = async () => {
|
||||
if (!gameId) return;
|
||||
const v = (winnerName || "").trim();
|
||||
|
||||
if (!v) {
|
||||
clearWinnerLS(gameId);
|
||||
setWinnerName("");
|
||||
return;
|
||||
}
|
||||
|
||||
setWinnerLS(gameId, v);
|
||||
setWinnerName(v);
|
||||
await api(`/games/${gameId}/winner`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
|
||||
});
|
||||
await reloadMeta();
|
||||
};
|
||||
|
||||
// ===== Sheet actions =====
|
||||
@@ -248,8 +278,6 @@ export default function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (next === null) clearChipLS(gameId, entry.entry_id);
|
||||
|
||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note_tag: next }),
|
||||
@@ -265,12 +293,10 @@ export default function App() {
|
||||
setChipOpen(false);
|
||||
setChipEntry(null);
|
||||
|
||||
setChipLS(gameId, entry.entry_id, chip);
|
||||
|
||||
try {
|
||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ note_tag: "s" }),
|
||||
body: JSON.stringify({ note_tag: "s", chip_code: chip }),
|
||||
});
|
||||
} finally {
|
||||
await reloadSheet();
|
||||
@@ -287,8 +313,6 @@ export default function App() {
|
||||
setChipOpen(false);
|
||||
setChipEntry(null);
|
||||
|
||||
clearChipLS(gameId, entry.entry_id);
|
||||
|
||||
try {
|
||||
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||
method: "PATCH",
|
||||
@@ -303,8 +327,7 @@ export default function App() {
|
||||
const t = entry.note_tag;
|
||||
if (!t) return "—";
|
||||
if (t === "s") {
|
||||
const chip = getChipLS(gameId, entry.entry_id);
|
||||
return chip ? `s.${chip}` : "s";
|
||||
return entry.chip_code ? `s.${entry.chip_code}` : "s";
|
||||
}
|
||||
return t;
|
||||
};
|
||||
@@ -332,6 +355,8 @@ export default function App() {
|
||||
]
|
||||
: [];
|
||||
|
||||
const winnerObj = gameMeta?.winner || null;
|
||||
|
||||
return (
|
||||
<div style={styles.page}>
|
||||
<div style={styles.bgFixed} aria-hidden="true">
|
||||
@@ -345,6 +370,7 @@ export default function App() {
|
||||
setUserMenuOpen={setUserMenuOpen}
|
||||
openPwModal={openPwModal}
|
||||
openDesignModal={openDesignModal}
|
||||
openJoinModal={openJoinModal}
|
||||
doLogout={doLogout}
|
||||
newGame={newGame}
|
||||
/>
|
||||
@@ -355,13 +381,13 @@ export default function App() {
|
||||
games={games}
|
||||
gameId={gameId}
|
||||
setGameId={setGameId}
|
||||
joinCode={currentGame?.join_code || ""}
|
||||
onOpenHelp={() => setHelpOpen(true)}
|
||||
/>
|
||||
|
||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
{winnerObj && <WinnerBadge winnerEmail={winnerObj.email} />}
|
||||
|
||||
{/* Sieger Badge: nur wenn gesetzt */}
|
||||
<WinnerBadge winner={(winnerName || "").trim()} />
|
||||
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
|
||||
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
|
||||
{sections.map((sec) => (
|
||||
@@ -377,8 +403,13 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Sieger ganz unten */}
|
||||
<WinnerCard value={winnerName} setValue={setWinnerName} onSave={saveWinner} />
|
||||
{/* Sieger (shared per Spiel) */}
|
||||
<WinnerCard
|
||||
players={players}
|
||||
winnerUserId={winnerUserId}
|
||||
setWinnerUserId={setWinnerUserId}
|
||||
onSave={saveWinner}
|
||||
/>
|
||||
|
||||
<div style={{ height: 24 }} />
|
||||
</div>
|
||||
@@ -399,12 +430,21 @@ export default function App() {
|
||||
open={designOpen}
|
||||
onClose={() => setDesignOpen(false)}
|
||||
themeKey={themeKey}
|
||||
onSelect={(k) => {
|
||||
selectTheme(k);
|
||||
onSelect={async (k) => {
|
||||
await selectTheme(k);
|
||||
setDesignOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<JoinGameModal
|
||||
open={joinOpen}
|
||||
onClose={() => setJoinOpen(false)}
|
||||
onJoin={async (code) => {
|
||||
await joinGame(code);
|
||||
setJoinOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChipModal
|
||||
chipOpen={chipOpen}
|
||||
closeChipModalToDash={closeChipModalToDash}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// src/components/GamePickerCard.jsx
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function GamePickerCard({
|
||||
games,
|
||||
gameId,
|
||||
setGameId,
|
||||
onOpenHelp,
|
||||
}) {
|
||||
export default function GamePickerCard({ games, gameId, setGameId, joinCode, onOpenHelp }) {
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={styles.card}>
|
||||
@@ -30,6 +26,19 @@ export default function GamePickerCard({
|
||||
Hilfe
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!!joinCode && (
|
||||
<div
|
||||
style={{
|
||||
padding: "0 12px 12px",
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
color: stylesTokens.textDim,
|
||||
}}
|
||||
>
|
||||
Spiel-Code: <b style={{ color: stylesTokens.textGold }}>{joinCode}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
74
frontend/src/components/JoinGameModal.jsx
Normal file
74
frontend/src/components/JoinGameModal.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// src/components/JoinGameModal.jsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function JoinGameModal({ open, onClose, onJoin }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [msg, setMsg] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCode("");
|
||||
setMsg("");
|
||||
setBusy(false);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const doJoin = async () => {
|
||||
const c = (code || "").trim();
|
||||
if (!c) return setMsg("❌ Bitte Code eingeben.");
|
||||
setBusy(true);
|
||||
setMsg("");
|
||||
try {
|
||||
await onJoin(c);
|
||||
} catch (e) {
|
||||
setMsg("❌ Fehler: " + (e?.message || "unknown"));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.modalOverlay} onMouseDown={onClose}>
|
||||
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div style={styles.modalHeader}>
|
||||
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Spiel beitreten</div>
|
||||
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="z.B. 123456"
|
||||
style={styles.input}
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") doJoin();
|
||||
}}
|
||||
/>
|
||||
|
||||
{msg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{msg}</div>}
|
||||
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
|
||||
<button onClick={onClose} style={styles.secondaryBtn} disabled={busy}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button onClick={doJoin} style={styles.primaryBtn} disabled={busy}>
|
||||
{busy ? "Beitreten..." : "Beitreten"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||
Tipp: Der Spiel-Code steht beim Host unter dem Spiel-Dropdown.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,16 +8,19 @@ export default function TopBar({
|
||||
setUserMenuOpen,
|
||||
openPwModal,
|
||||
openDesignModal,
|
||||
openJoinModal,
|
||||
doLogout,
|
||||
newGame,
|
||||
}) {
|
||||
return (
|
||||
<div style={styles.topBar}>
|
||||
{/* LINKS */}
|
||||
<div>
|
||||
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>{me.email}</div>
|
||||
</div>
|
||||
|
||||
{/* RECHTS */}
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
|
||||
<div style={{ position: "relative" }}>
|
||||
<button onClick={() => setUserMenuOpen((v) => !v)} style={styles.userBtn} title="User Menü">
|
||||
@@ -28,13 +31,14 @@ export default function TopBar({
|
||||
|
||||
{userMenuOpen && (
|
||||
<div style={styles.userDropdown}>
|
||||
{/* Email Info */}
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
fontSize: 13,
|
||||
opacity: 0.85,
|
||||
color: stylesTokens.textDim,
|
||||
borderBottom: "1px solid rgba(233,216,166,0.12)",
|
||||
borderBottom: `1px solid ${stylesTokens.goldLine}`,
|
||||
}}
|
||||
>
|
||||
{me.email}
|
||||
@@ -44,10 +48,26 @@ export default function TopBar({
|
||||
Passwort setzen
|
||||
</button>
|
||||
|
||||
<button onClick={openDesignModal} style={styles.userDropdownItem}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openDesignModal();
|
||||
}}
|
||||
style={styles.userDropdownItem}
|
||||
>
|
||||
Design ändern
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openJoinModal();
|
||||
}}
|
||||
style={styles.userDropdownItem}
|
||||
>
|
||||
Spiel beitreten
|
||||
</button>
|
||||
|
||||
<div style={styles.userDropdownDivider} />
|
||||
|
||||
<button
|
||||
@@ -64,7 +84,7 @@ export default function TopBar({
|
||||
</div>
|
||||
|
||||
<button onClick={newGame} style={styles.primaryBtn}>
|
||||
✦ New Game
|
||||
✦ Neues Spiel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,27 @@
|
||||
// src/components/WinnerBadge.jsx
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function WinnerBadge({ winner }) {
|
||||
const w = (winner || "").trim();
|
||||
if (!w) return null;
|
||||
export default function WinnerBadge({ winnerEmail }) {
|
||||
if (!winnerEmail) return null;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div
|
||||
style={{
|
||||
...styles.card,
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>🏆 Sieger</div>
|
||||
<div style={{ marginTop: 2, color: stylesTokens.textMain, opacity: 0.95 }}>{w}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
borderRadius: 999,
|
||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||
background: stylesTokens.panelBg,
|
||||
color: stylesTokens.textGold,
|
||||
fontWeight: 1000,
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
Gewonnen
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: "12px 14px",
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${stylesTokens.panelBorder}`,
|
||||
background: stylesTokens.panelBg,
|
||||
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
|
||||
backdropFilter: "blur(6px)",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>🏆</span>
|
||||
<span style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Sieger:</span>
|
||||
<span style={{ color: stylesTokens.textMain }}>{winnerEmail}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,55 @@
|
||||
// src/components/WinnerCard.jsx
|
||||
import React from "react";
|
||||
import { styles } from "../styles/styles";
|
||||
import { stylesTokens } from "../styles/theme";
|
||||
|
||||
export default function WinnerCard({ value, setValue, onSave }) {
|
||||
/**
|
||||
* props:
|
||||
* - players: [{id,email}]
|
||||
* - winnerUserId: string|null
|
||||
* - setWinnerUserId: fn
|
||||
* - onSave: fn (async ok)
|
||||
*/
|
||||
export default function WinnerCard({ players, winnerUserId, setWinnerUserId, onSave }) {
|
||||
const hasPlayers = Array.isArray(players) && players.length > 0;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
<div style={styles.card}>
|
||||
<div style={styles.sectionHeader}>Sieger</div>
|
||||
|
||||
<div style={styles.cardBody}>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Name des Siegers"
|
||||
style={{ ...styles.input, flex: 1 }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onSave();
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: 12, display: "grid", gap: 10 }}>
|
||||
{!hasPlayers ? (
|
||||
<div style={{ color: stylesTokens.textDim, opacity: 0.9 }}>
|
||||
Keine Spieler gefunden (Admin wird nicht angezeigt).
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={winnerUserId || ""}
|
||||
onChange={(e) => setWinnerUserId(e.target.value || null)}
|
||||
style={styles.input}
|
||||
>
|
||||
<option value="">— kein Sieger gesetzt —</option>
|
||||
{players.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.email}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button onClick={onSave} style={styles.primaryBtn} title="Speichern">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
|
||||
<button onClick={() => setWinnerUserId(null)} style={styles.secondaryBtn}>
|
||||
Leeren
|
||||
</button>
|
||||
<button onClick={onSave} style={styles.primaryBtn} disabled={!hasPlayers}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim }}>
|
||||
Wird pro Spiel lokal gespeichert.
|
||||
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
|
||||
Der Sieger wird im Spiel gespeichert und ist für alle Spieler sichtbar.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user