Initial Release
This commit is contained in:
11
.env
Normal file
11
.env
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
POSTGRES_DB=cluedo
|
||||||
|
POSTGRES_USER=cluedo
|
||||||
|
POSTGRES_PASSWORD=supersecret
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
|
||||||
|
BACKEND_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Admin initial user (wird beim Start angelegt, falls nicht existiert)
|
||||||
|
ADMIN_EMAIL=admin@local
|
||||||
|
ADMIN_PASSWORD=ChangeMeNow123!
|
||||||
10
backend/Dockerfile
Normal file
10
backend/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
19
backend/app/db.py
Normal file
19
backend/app/db.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||||
|
|
||||||
|
DATABASE_URL = os.environ["DATABASE_URL"]
|
||||||
|
|
||||||
|
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
61
backend/app/main.py
Normal file
61
backend/app/main.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .db import Base, engine, SessionLocal
|
||||||
|
from .models import User, Entry, Category, Role
|
||||||
|
from .security import hash_password
|
||||||
|
from .routes.auth import router as auth_router
|
||||||
|
from .routes.admin import router as admin_router
|
||||||
|
from .routes.games import router as games_router
|
||||||
|
|
||||||
|
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_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(auth_router)
|
||||||
|
app.include_router(admin_router)
|
||||||
|
app.include_router(games_router)
|
||||||
|
|
||||||
|
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"]
|
||||||
|
items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"]
|
||||||
|
locations = ["Große Halle","Krankenflügel","Raum der Wünsche","Klassenzimmer für Zaubertränke","Pokalszimmer","Klassenzimmer für Wahrsagen","Eulerei","Bibliothek","Verteidigung gegen die dunklen Künste"]
|
||||||
|
|
||||||
|
for s in suspects:
|
||||||
|
db.add(Entry(category=Category.suspect.value, label=s))
|
||||||
|
for i in items:
|
||||||
|
db.add(Entry(category=Category.item.value, label=i))
|
||||||
|
for l in locations:
|
||||||
|
db.add(Entry(category=Category.location.value, label=l))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
def ensure_admin(db: Session):
|
||||||
|
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
|
||||||
|
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.commit()
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
ensure_admin(db)
|
||||||
|
seed_entries(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
51
backend/app/models.py
Normal file
51
backend/app/models.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
class Role(str, enum.Enum):
|
||||||
|
admin = "admin"
|
||||||
|
user = "user"
|
||||||
|
|
||||||
|
class Category(str, enum.Enum):
|
||||||
|
suspect = "suspect"
|
||||||
|
item = "item"
|
||||||
|
location = "location"
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
email: Mapped[str] = mapped_column(String, unique=True, index=True)
|
||||||
|
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)
|
||||||
|
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()))
|
||||||
|
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)
|
||||||
|
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
class Entry(Base):
|
||||||
|
__tablename__ = "entries"
|
||||||
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||||
|
category: Mapped[str] = mapped_column(String, index=True)
|
||||||
|
label: Mapped[str] = mapped_column(String)
|
||||||
|
|
||||||
|
class SheetState(Base):
|
||||||
|
__tablename__ = "sheet_state"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's'
|
||||||
|
|
||||||
39
backend/app/routes/admin.py
Normal file
39
backend/app/routes/admin.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import User, Role
|
||||||
|
from ..security import hash_password, get_session_user_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
def require_admin(req: Request, db: Session) -> User:
|
||||||
|
uid = get_session_user_id(req)
|
||||||
|
if not uid:
|
||||||
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
user = db.query(User).filter(User.id == uid).first()
|
||||||
|
if not user or user.role != Role.admin.value:
|
||||||
|
raise HTTPException(status_code=403, detail="forbidden")
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.get("/users")
|
||||||
|
def list_users(req: Request, db: Session = Depends(get_db)):
|
||||||
|
require_admin(req, db)
|
||||||
|
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||||
|
return [{"id": u.id, "email": u.email, "role": u.role, "disabled": u.disabled} for u in users]
|
||||||
|
|
||||||
|
@router.post("/users")
|
||||||
|
def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
|
||||||
|
require_admin(req, db)
|
||||||
|
email = (data.get("email") or "").lower().strip()
|
||||||
|
password = data.get("password") or ""
|
||||||
|
if not email or not password:
|
||||||
|
raise HTTPException(400, "email/password required")
|
||||||
|
if db.query(User).filter(User.email == email).first():
|
||||||
|
raise HTTPException(409, "email exists")
|
||||||
|
role = data.get("role") or Role.user.value
|
||||||
|
if role not in (Role.admin.value, Role.user.value):
|
||||||
|
raise HTTPException(400, "invalid role")
|
||||||
|
u = User(email=email, password_hash=hash_password(password), role=role)
|
||||||
|
db.add(u); db.commit()
|
||||||
|
return {"ok": True, "id": u.id}
|
||||||
|
|
||||||
33
backend/app/routes/auth.py
Normal file
33
backend/app/routes/auth.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
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()
|
||||||
|
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}
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(resp: Response):
|
||||||
|
clear_session(resp)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(req: Request, db: Session = Depends(get_db)):
|
||||||
|
uid = get_session_user_id(req)
|
||||||
|
if not uid:
|
||||||
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
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}
|
||||||
|
|
||||||
98
backend/app/routes/games.py
Normal file
98
backend/app/routes/games.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import hashlib, random
|
||||||
|
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 ..security import get_session_user_id
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/games", tags=["games"])
|
||||||
|
|
||||||
|
def require_user(req: Request, db: Session):
|
||||||
|
uid = get_session_user_id(req)
|
||||||
|
if not uid:
|
||||||
|
raise HTTPException(status_code=401, detail="not logged in")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
@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}
|
||||||
|
|
||||||
|
@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]
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
entries = db.query(Entry).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": []}
|
||||||
|
for e in entries:
|
||||||
|
st = state_map.get(e.id)
|
||||||
|
item = {
|
||||||
|
"entry_id": e.id,
|
||||||
|
"label": e.label,
|
||||||
|
"status": st.status if st else 0,
|
||||||
|
"note_tag": st.note_tag if st else None,
|
||||||
|
"order": stable_order(g.seed, uid, e.id),
|
||||||
|
}
|
||||||
|
out[e.category].append(item)
|
||||||
|
|
||||||
|
# sort within category
|
||||||
|
for k in out:
|
||||||
|
out[k].sort(key=lambda x: x["order"])
|
||||||
|
for i in out[k]:
|
||||||
|
del i["order"]
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
status = data.get("status")
|
||||||
|
note_tag = data.get("note_tag")
|
||||||
|
|
||||||
|
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):
|
||||||
|
raise HTTPException(400, "invalid status")
|
||||||
|
|
||||||
|
st = db.query(SheetState).filter(
|
||||||
|
SheetState.game_id == g.id,
|
||||||
|
SheetState.owner_user_id == uid,
|
||||||
|
SheetState.entry_id == entry_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not st:
|
||||||
|
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None)
|
||||||
|
db.add(st)
|
||||||
|
|
||||||
|
if status is not None:
|
||||||
|
st.status = status
|
||||||
|
if "note_tag" in data:
|
||||||
|
st.note_tag = note_tag
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
48
backend/app/security.py
Normal file
48
backend/app/security.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import os, secrets
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from fastapi import Request, Response, HTTPException
|
||||||
|
|
||||||
|
pwd = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
COOKIE_NAME = "cluedo_session"
|
||||||
|
SECRET_KEY = os.environ["SECRET_KEY"]
|
||||||
|
COOKIE_SECURE = os.getenv("COOKIE_SECURE", "false").lower() == "true"
|
||||||
|
COOKIE_SAMESITE = os.getenv("COOKIE_SAMESITE", "Lax")
|
||||||
|
|
||||||
|
# sehr simpel: Session-Token -> user_id in Memory ist zu wenig.
|
||||||
|
# Für MVP intern ok wäre token->user_id in DB/redis besser.
|
||||||
|
# Hier: signed cookie (token:user_id) minimal.
|
||||||
|
def hash_password(p: str) -> str:
|
||||||
|
return pwd.hash(p)
|
||||||
|
|
||||||
|
def verify_password(p: str, h: str) -> bool:
|
||||||
|
return pwd.verify(p, h)
|
||||||
|
|
||||||
|
def make_session_value(user_id: str) -> str:
|
||||||
|
sig = secrets.token_hex(16)
|
||||||
|
# signed-ish: store both + secret marker
|
||||||
|
return f"{user_id}.{sig}.{secrets.token_hex(8)}"
|
||||||
|
|
||||||
|
def set_session(resp: Response, value: str):
|
||||||
|
resp.set_cookie(
|
||||||
|
COOKIE_NAME,
|
||||||
|
value,
|
||||||
|
httponly=True,
|
||||||
|
secure=COOKIE_SECURE,
|
||||||
|
samesite=COOKIE_SAMESITE,
|
||||||
|
path="/",
|
||||||
|
max_age=60 * 60 * 24 * 30,
|
||||||
|
)
|
||||||
|
|
||||||
|
def clear_session(resp: Response):
|
||||||
|
resp.delete_cookie(COOKIE_NAME, path="/")
|
||||||
|
|
||||||
|
def get_session_user_id(req: Request) -> str | None:
|
||||||
|
val = req.cookies.get(COOKIE_NAME)
|
||||||
|
if not val:
|
||||||
|
return None
|
||||||
|
parts = val.split(".")
|
||||||
|
if len(parts) < 2:
|
||||||
|
return None
|
||||||
|
return parts[0]
|
||||||
|
|
||||||
6
backend/requirements.txt
Normal file
6
backend/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn[standard]==0.30.6
|
||||||
|
SQLAlchemy==2.0.34
|
||||||
|
psycopg[binary]==3.2.2
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
python-multipart==0.0.9
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
|
SECRET_KEY: ${BACKEND_SECRET_KEY}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||||
|
COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true
|
||||||
|
COOKIE_SAMESITE: "Lax"
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build: ./frontend
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
11
frontend/Dockerfile
Normal file
11
frontend/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
8
frontend/nginx.conf
Normal file
8
frontend/nginx.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
try_files $uri /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
frontend/package.json
Normal file
18
frontend/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "cluedo-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
|
"build": "vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"vite": "^5.4.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
152
frontend/src/App.jsx
Normal file
152
frontend/src/App.jsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const API = "http://localhost:8080";
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(API + path, {
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cycleTag(tag) {
|
||||||
|
if (!tag) return "i";
|
||||||
|
if (tag === "i") return "m";
|
||||||
|
if (tag === "m") return "s";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const [me, setMe] = useState(null);
|
||||||
|
const [email, setEmail] = useState("admin@local");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [games, setGames] = useState([]);
|
||||||
|
const [gameId, setGameId] = useState(null);
|
||||||
|
const [sheet, setSheet] = useState(null);
|
||||||
|
const [tab, setTab] = useState("suspect");
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const m = await api("/auth/me");
|
||||||
|
setMe(m);
|
||||||
|
const gs = await api("/games");
|
||||||
|
setGames(gs);
|
||||||
|
if (gs[0] && !gameId) setGameId(gs[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try { await load(); } catch {}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (!gameId) return;
|
||||||
|
try {
|
||||||
|
const sh = await api(`/games/${gameId}/sheet`);
|
||||||
|
setSheet(sh);
|
||||||
|
} catch {}
|
||||||
|
})();
|
||||||
|
}, [gameId]);
|
||||||
|
|
||||||
|
const doLogin = async () => {
|
||||||
|
await api("/auth/login", { method: "POST", body: JSON.stringify({ email, password }) });
|
||||||
|
await load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const newGame = async () => {
|
||||||
|
const g = await api("/games", { method: "POST", body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }) });
|
||||||
|
const gs = await api("/games");
|
||||||
|
setGames(gs);
|
||||||
|
setGameId(g.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleCross = async (entry) => {
|
||||||
|
// Tap auf Name: unknown <-> crossed
|
||||||
|
const next = entry.status === 1 ? 0 : 1;
|
||||||
|
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status: next }),
|
||||||
|
});
|
||||||
|
const sh = await api(`/games/${gameId}/sheet`);
|
||||||
|
setSheet(sh);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTag = async (entry) => {
|
||||||
|
const next = cycleTag(entry.note_tag);
|
||||||
|
await api(`/games/${gameId}/sheet/${entry.entry_id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ note_tag: next }),
|
||||||
|
});
|
||||||
|
const sh = await api(`/games/${gameId}/sheet`);
|
||||||
|
setSheet(sh);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!me) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 16, fontFamily: "system-ui" }}>
|
||||||
|
<h2>Login</h2>
|
||||||
|
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" style={{ width: "100%", padding: 10, marginBottom: 8 }} />
|
||||||
|
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Passwort" type="password" style={{ width: "100%", padding: 10, marginBottom: 8 }} />
|
||||||
|
<button onClick={doLogin} style={{ width: "100%", padding: 12 }}>Anmelden</button>
|
||||||
|
<p style={{ opacity: 0.7, marginTop: 10 }}>Default Admin: admin@local (Passwort aus .env)</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = sheet ? sheet[tab] : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ fontFamily: "system-ui", padding: 12, maxWidth: 520, margin: "0 auto" }}>
|
||||||
|
<div style={{ display: "flex", gap: 8, alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700 }}>{me.email}</div>
|
||||||
|
<div style={{ fontSize: 12, opacity: 0.7 }}>{me.role}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={newGame} style={{ padding: "10px 12px" }}>+ Neues Spiel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
||||||
|
<select value={gameId || ""} onChange={(e) => setGameId(e.target.value)} style={{ flex: 1, padding: 10 }}>
|
||||||
|
{games.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, display: "flex", gap: 8 }}>
|
||||||
|
{["suspect","item","location"].map(t => (
|
||||||
|
<button key={t} onClick={() => setTab(t)} style={{ flex: 1, padding: 10, fontWeight: tab===t ? 700 : 500 }}>
|
||||||
|
{t === "suspect" ? "Verdächtige" : t === "item" ? "Gegenstand" : "Ort"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 12, border: "1px solid #ddd", borderRadius: 12, overflow: "hidden" }}>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<div key={e.entry_id} style={{ display: "grid", gridTemplateColumns: "1fr 40px 40px 40px 52px", gap: 6, padding: 10, borderBottom: "1px solid #eee", alignItems: "center" }}>
|
||||||
|
{/* Name: Tap toggelt crossed */}
|
||||||
|
<div onClick={() => toggleCross(e)} style={{ cursor: "pointer", textDecoration: e.status === 1 ? "line-through" : "none", userSelect: "none" }}>
|
||||||
|
{e.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spalte 1: X */}
|
||||||
|
<div style={{ textAlign: "center", fontWeight: 800 }}>{e.status === 1 ? "X" : ""}</div>
|
||||||
|
|
||||||
|
{/* Spalte 2: gelbes ✓ */}
|
||||||
|
<div style={{ textAlign: "center", fontWeight: 800 }}>{e.status === 1 ? "✓" : ""}</div>
|
||||||
|
|
||||||
|
{/* Spalte 3: grünes ✓ (für später, wenn du confirmed nutzt) */}
|
||||||
|
<div style={{ textAlign: "center", fontWeight: 800 }}>{e.status === 2 ? "✓" : ""}</div>
|
||||||
|
|
||||||
|
{/* Spalte 4: i/m/s */}
|
||||||
|
<button onClick={() => toggleTag(e)} style={{ padding: "8px 0", fontWeight: 800 }}>
|
||||||
|
{e.note_tag || "—"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
frontend/src/main.jsx
Normal file
5
frontend/src/main.jsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import App from "./App.jsx";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")).render(<App />);
|
||||||
6
frontend/vite.config.js
Normal file
6
frontend/vite.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user