Compare commits

..
40 Commits
Author SHA1 Message Date
nessi 3809bd5425 Refractor 3D Dice 2026-02-08 11:47:32 +01:00
nessi 85bb30ae73 3d dice 2026-02-08 11:39:25 +01:00
nessi 81065f1054 Persist dice state with localStorage and improve rendering layering.
Added functionality to save and load dice state (d1, d2, special) using localStorage for better user experience. Adjusted component styles to improve layering, ensure proper z-index for elements, and enhance 3D dice rendering.
2026-02-08 11:11:50 +01:00
nessi 56944bc8d7 Refactor dice components for true 3D rendering.
Replaced the 2D dice logic with fully 3D-rendered cubes, including animations and proper face-to-value mappings. Updated the design to use CSS transformations and added visual enhancements for a more immersive user experience.
2026-02-08 11:02:43 +01:00
nessi eec4597fda Add rolling animation and interactive functionality to dice.
Enhanced the DicePanel component with interactive rolling logic and animations for a more dynamic experience. Updated styles and functionality, including hover effects, roll results, and a 3D feel for dice components.
2026-02-07 19:01:37 +01:00
nessi 8f912d9edb Add interactive dice panel with custom components
This commit introduces a new DicePanel component, complete with custom dice (DieD6 and HouseDie) for enhanced interactivity. The panel is integrated into the UI with responsive styling, hover effects, and animations, providing a polished and functional user experience. Styles and layout adjustments in `AppLayout.css` support the new feature.
2026-02-07 18:56:20 +01:00
nessi b6e75cedc4 Fix overflow and stacking issues in App rendering
Refactored overflow behavior to allow specific components to stack or clip as needed, improving rendering flexibility. Updated styles and fixed z-index issues for cleaner stacking contexts. Removed unused comments for better code readability.
2026-02-07 18:51:32 +01:00
nessi 1e60656eac Add overflow: "visible" to styles for better element rendering
This ensures that elements can overflow their containers when necessary, improving visual consistency and layout flexibility. Corrected a typo in the second "overflow" property.
2026-02-07 18:43:56 +01:00
nessi 762e2d8300 Add zIndex property to styling in App component
The zIndex property was added to ensure proper layering of elements in the UI. This change enhances design consistency and prevents potential overlapping issues.
2026-02-07 18:40:47 +01:00
nessi 2018b24029 Adjust button margin for improved spacing
Increased the `marginLeft` value from 6 to 20 in the button styling for better alignment and visual consistency in the layout. This change ensures a more polished appearance.
2026-02-07 18:39:52 +01:00
nessi 5b901b0f98 Add margin-left styling to target element
Added a small adjustment by including a `marginLeft` property with a value of 6. This change helps ensure proper spacing and alignment for the styled element.
2026-02-07 18:38:49 +01:00
nessi b18ae57779 Make overflow visible in body and left pane
Changed the `overflow` property from `hidden` to `visible` in both the `body` and `.leftPane` styles. This update ensures content is not clipped and allows for better handling of overflows.
2026-02-07 18:35:21 +01:00
nessi a19d13b1b5 Remove visual effects and adjust HUD overflow behavior
Removed border, background, blur, and shadow effects on specific UI elements to simplify the design. Changed the HUD overflow property from "hidden" to "visible" to allow better flexibility.
2026-02-07 13:01:52 +01:00
nessi 456d22d985 Update PlayerIdentityCard defaults and styles
Changed the default `houseLabel` to "Gryffindor" and the `overflow` style to "visible" for a better visual effect. Removed an unnecessary styled block to clean up the component structure.
2026-02-07 12:57:57 +01:00
nessi 6638622903 Adjust card dimensions in App component.
Updated the card's width and maxWidth to better fit the design requirements. This change ensures a more consistent and visually appealing layout.
2026-02-07 12:55:35 +01:00
nessi f7730df3ec Enhance card UI with improved styles and hover effects
Adjusted styling for better visuals and responsiveness, including background, shadows, and dimensions. Added interactive hover effects for a dynamic user experience and refined card elements like gloss, label, and tag designs.
2026-02-07 12:54:04 +01:00
nessi bc894de505 Add PlayerIdentityCard component and Harry Potter card
Introduced a new PlayerIdentityCard component for displaying themed player cards with customizable options. Added a Harry Potter identity card as the first example, including relevant styling and an image. Integrated the new card into the player HUD in the app layout.
2026-02-07 12:49:50 +01:00
nessi dbca035ca0 Adjust dimensions of dünnes Pergament component
Updated the height to 96 and set the width to 90% for better scaling and alignment of the dünnes Pergament component. These changes ensure improved visual consistency and responsiveness.
2026-02-07 12:43:02 +01:00
nessi c0f0e72967 Fix width styling in App.jsx component
The width was changed from "100%" to 88 to ensure consistent styling and alignment with design specifications. This adjustment prevents visual discrepancies in the rendered component.
2026-02-07 12:40:37 +01:00
nessi 0fa26ddf5a Refactor HUD styling for cleaner and simpler design.
Simplified the background, padding, and shadow styles while adjusting dimensions for a more refined appearance. These changes improve maintainability and provide a sleeker visual presentation.
2026-02-07 12:39:14 +01:00
nessi 145af05471 Adjust layout and overflow settings for consistency
Updated the App component's overflow property to "visible" for better content display and modified grid row sizing in the layout to improve spacing control. These changes ensure better alignment and a more consistent user interface.
2026-02-07 12:36:42 +01:00
nessi b65d25f66a Refine UI styles for improved layout and visual consistency
Updated component and HUD styles to add better alignment, spacing, and consistent radius values. Enhanced background effects and shadows for a more polished and subtle appearance. Adjusted layout properties to ensure proper stretching and alignment of elements.
2026-02-07 12:33:10 +01:00
nessi 598b8b9b6e Improve HUD styling and layout consistency
Adjusted various height, padding, and spacing values for better alignment and responsiveness in the HUD. Increased font sizes and refined positioning to ensure a visually balanced layout. These changes enhance the user experience by improving aesthetic and functional design.
2026-02-07 12:30:29 +01:00
nessi 3e4e26297b Refine HogwartsPointsCard styles for cleaner UI.
Simplified and updated styling for the HogwartsPointsCard component by adjusting spacing, borders, shadows, and fonts. These refinements enhance visual consistency and improve readability across various design elements.
2026-02-07 12:27:17 +01:00
nessi 9d744c8674 Refine HogwartsPointsCard design and styling.
Updated the HogwartsPointsCard component to enhance its styling. Changes include a more subtle and consistent visual appearance by refining colors, borders, shadows, padding, and grid spacing. Adjusted font sizes and shadows for better readability and aesthetics.
2026-02-07 12:24:28 +01:00
nessi 06b6544653 Enhance HogwartsPointsCard design and layout
Refactored the component to improve its visual hierarchy and styling for better usability and aesthetics. Added new design elements like gold accents, updated text shadows, and a refined grid layout. Improved responsiveness and structure to align with overall application design standards.
2026-02-07 12:21:20 +01:00
nessi 86c4cf2639 Add HogwartsPointsCard component with styled design
Introduced a new `HogwartsPointsCard` component to display Hogwarts points with a custom styled design. The component leverages layered backgrounds, gradients, and thoughtful typography to enhance the overall visual appeal. Replaced the placeholder card with this new component in the points section.
2026-02-07 12:18:50 +01:00
nessi b1e5198880 Update CSS overflow properties for visibility
Changed `overflow` from `hidden` to `visible` in multiple elements to ensure proper content display. This adjustment avoids clipping issues and aligns with the desired layout behavior.
2026-02-07 12:14:25 +01:00
nessi f282ac42fb Add playerRailInner wrapper and adjust CSS styling.
Introduced a `playerRailInner` div for better structural organization and improved styling flexibility. Adjusted CSS to manage overflow and improve visual cropping while maintaining a clean radius design.
2026-02-07 12:11:14 +01:00
nessi 393cd94f4b Update player list with avatar images and visual enhancements
Replaced placeholder players with detailed profiles including names, avatars, and unique colors. Updated player icons to display images with improved styling, active state effects, and tooltips for better user experience.
2026-02-07 12:08:16 +01:00
nessi ccb51d750a Enhance panel styling and layout handling.
Updated the panel container to use a flexible column layout with conditional "flex" or "block" display based on its state. This ensures better structure and responsiveness while maintaining current functionality.
2026-02-07 11:58:24 +01:00
nessi a9fe71046c Update board placeholder styling in App.jsx
Adjusted the placeholder component with new styling properties, including changing opacity and adding a flex property. This enhances the basic layout for future integration with the game board and pieces.
2026-02-07 11:57:38 +01:00
nessi a3d052d2c1 Refactor: Introduce unified PlaceholderCard variants
Updated the PlaceholderCard component to support unified variants: "compact," "tile," and "panel," enabling consistent styling throughout the app. Replaced old placeholder logic with the new variant system across various UI sections for better maintainability and scalability.
2026-02-07 11:52:25 +01:00
nessi 15e5869aec [Message]
Refine layout and responsiveness in AppLayout.css

Updated grid settings for better flexibility and spacing across various screen sizes. Adjusted dimensions, padding, and responsive breakpoints to enhance layout appearance and usability, especially for smaller screens. Added minor styling tweaks for improved aesthetics and consistency.
2026-02-07 11:49:27 +01:00
nessi c06ae53b4f Update layout and annotations for player HUD and board tools
Improved clarity of component annotations and adjusted the layout styling in App.jsx and AppLayout.css. Key changes include the introduction of a middle section in the player HUD and refined descriptions for board-related elements. This enhances code readability and structuring consistency.
2026-02-07 11:47:28 +01:00
nessi fd91bbe207 Adjust grid column widths for improved layout consistency
Updated the grid column sizes across the application to ensure a better visual balance and alignment. This includes reducing the minimum and maximum widths for side panels and stacking elements vertically for better usability in the left tools section.
2026-02-07 11:43:00 +01:00
nessi e5f8f00832 Add player rail, dice overlay, and compact card support
This update introduces a player rail to the right of the board, visually representing active and inactive players, and adds a dice overlay beneath the board. Compact variants for placeholder cards were added, with corresponding adjustments to layouts and styles for better responsiveness and a cleaner interface.
2026-02-07 11:38:38 +01:00
nessi e035a99179 Refactor layout for improved responsiveness and scroll control
Updated the app layout to separate fixed and scrollable sections, enabling only the notes panel to scroll. Introduced a new CSS file for a cleaner structure and responsive behavior, ensuring a more consistent user experience across different screen sizes.
2026-02-07 11:31:55 +01:00
nessi 3d7d4c01f7 Update layout and scrolling styles for better responsiveness
Adjusted grid column sizing using "clamp" for improved adaptability to various screen sizes. Minor tweaks to sticky position and maximum height calculation ensure better alignment and consistent scroll behavior.
2026-02-07 11:17:54 +01:00
nessi cf81c25e6e new logic, testing 2026-02-07 11:15:33 +01:00
38 changed files with 1763 additions and 1777 deletions
+11
View 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!
-6
View File
@@ -1,6 +0,0 @@
# Required backend secret. Use a long, random value in your local .env file.
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
# Optional cookie settings for local/internal deployments.
COOKIE_SECURE=false
COOKIE_SAMESITE=Lax
-6
View File
@@ -1,6 +0,0 @@
.env
backend/data/
frontend/node_modules/
frontend/dist/
__pycache__/
*.py[cod]
+1 -221
View File
@@ -1,222 +1,2 @@
# Cluedo HP Webapp # cluedo-hp-webapp
A small multiplayer web app that acts as a digital note sheet for a Harry Potter-inspired Cluedo game. Several logged-in players can join the same game and manage their personal clues independently.
## Features
- Login with user and admin roles
- Admin-managed user creation and deactivation
- Multiple games per user
- Join games using a short join code
- Host-controlled game start with automatic player chips
- Pre-game lobby with live player list, host status, and start confirmation
- Player chips are generated from the first name initial and first two surname letters, e.g. `SNE` for Sascha Nesterovic
- Automatic player list with host indication
- Personal note sheet for each player and game
- Categories for suspects, items, and locations
- Entry status tracking: empty, ruled out, present, or maybe
- Additional notes using `i`, `m`, and `s.<chip>`
- Winner selection by the game host
- Winner badge and confetti animation for all players
- Live updates for new players and winner changes
- Personal game statistics
- Password change functionality
- Harry Potter house themes: Default, Gryffindor, Slytherin, Ravenclaw, and Hufflepuff
- Installable as a Progressive Web App
## Technology
### Frontend
- React 18
- Vite
- Nginx as the production web server
- `canvas-confetti` for the winner animation
- `vite-plugin-pwa` for PWA support
### Backend
- Python 3.12
- FastAPI
- SQLAlchemy 2
- SQLite 3
- Passlib and bcrypt for password hashing
### Deployment
- Docker Compose
- Frontend on port `8081`
- Backend on port `8080`
- SQLite database stored in a persistent Docker volume
## Quick start with Docker
Requirements:
- Docker
- Docker Compose
1. Copy `.env.example` to `.env` and set a strong secret:
```env
BACKEND_SECRET_KEY=use-a-long-random-secret-value
```
2. Build and start the containers:
```bash
docker compose up --build
```
3. Open the application:
- Frontend: http://localhost:8081
- Backend/API: http://localhost:8080
On the first startup, the application opens a setup screen where you create the first administrator with an email address, display name, and password. The default suspects, items, and locations are seeded automatically as well.
The SQLite database is stored in the Docker volume `cluedo-data` and survives normal container restarts.
Start the containers in the background:
```bash
docker compose up -d --build
```
View logs:
```bash
docker compose logs -f backend
```
Stop the containers:
```bash
docker compose down
```
## Local development without Docker
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=sqlite:///./data/cluedo.db
export SECRET_KEY=use-a-long-random-secret-value
uvicorn app.main:app --reload --port 8080
```
On Windows PowerShell, set the environment variables like this:
```powershell
$env:DATABASE_URL = "sqlite:///./data/cluedo.db"
$env:SECRET_KEY = "use-a-long-random-secret-value"
uvicorn app.main:app --reload --port 8080
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
The frontend is normally available at http://localhost:5173. During development, the API must be reachable under `/api`; the production Docker setup provides this through Nginx.
Create a production build:
```bash
npm run build
```
## Project structure
```text
.
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, startup, seed data
│ │ ├── models.py # SQLAlchemy models
│ │ ├── db.py # SQLite engine and sessions
│ │ ├── security.py # Password and cookie logic
│ │ └── routes/ # Auth, admin, and game APIs
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Main UI and game flow
│ │ ├── api/client.js # API client
│ │ ├── components/ # Pages, cards, and modals
│ │ ├── styles/ # Themes and inline styles
│ │ └── utils/ # Small helpers and storage utilities
│ ├── Dockerfile
│ └── nginx.conf
├── docker-compose.yml
└── .env
```
## Data model
- `users`: users, roles, status, display names, and themes
- `games`: game name, join code, host, and winner
- `game_members`: assignment of users to games
- `entries`: seeded suspects, items, and locations
- `sheet_state`: personal status and notes for an entry
Each player has their own set of `sheet_state` records for every game. A player's notes are therefore not visible to other players.
## API overview
### Authentication
- `POST /auth/login`
- `POST /auth/logout`
- `GET /auth/me`
- `PATCH /auth/password`
- `PATCH /auth/theme`
- `GET /auth/me/stats`
### First-run setup
- `GET /setup/status`
- `POST /setup/admin`
The setup endpoint is available only while no administrator exists. After the first administrator has been created, the setup screen is disabled automatically.
### Administration
- `GET /admin/users`
- `POST /admin/users`
- `DELETE /admin/users/{user_id}` deactivates a user
### Games
- `GET /games`
- `POST /games`
- `POST /games/join`
- `GET /games/{game_id}`
- `GET /games/{game_id}/members`
- `PATCH /games/{game_id}/winner`
- `POST /games/{game_id}/start`
- `GET /games/{game_id}/chips`
- `GET /games/{game_id}/sheet`
- `PATCH /games/{game_id}/sheet/{entry_id}`
## SQLite notes
SQLite is a good fit for this application: the data volume is small, the app is intended for internal use, and write operations are short. The database file is stored inside the container at `/app/data/cluedo.db` and persisted through the `cluedo-data` volume.
For larger deployments with many concurrent write operations, PostgreSQL would still be the more robust choice. For the intended private or small-group use case, SQLite is sufficient.
## Security notes
- The values in `.env` are examples and should be changed before production use.
- When using HTTPS, set `COOKIE_SECURE` to `true`.
- The application is intended for an internal or small user group.
- The automatic database migration is intentionally pragmatic and does not replace a migration system such as Alembic.
-1
View File
@@ -1,7 +1,6 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN mkdir -p /app/data
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+2 -17
View File
@@ -1,25 +1,10 @@
import os import os
from pathlib import Path
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/cluedo.db") DATABASE_URL = os.environ["DATABASE_URL"]
engine_options = {"pool_pre_ping": True} engine = create_engine(DATABASE_URL, pool_pre_ping=True)
if DATABASE_URL.startswith("sqlite"):
# SQLite does not allow connections to be used from another thread by
# default. FastAPI/SQLAlchemy may use a connection across request threads.
engine_options["connect_args"] = {"check_same_thread": False}
# Make the parent directory available for the default local database.
if DATABASE_URL.startswith("sqlite:///"):
sqlite_path = DATABASE_URL.removeprefix("sqlite:///")
if sqlite_path not in (":memory:", ""):
Path(sqlite_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(DATABASE_URL, **engine_options)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase): class Base(DeclarativeBase):
+21 -11
View File
@@ -1,3 +1,4 @@
import os
import random import random
import string import string
@@ -7,11 +8,11 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .models import User, Entry, Category, Game, GameMember from .models import User, Entry, Category, Role, Game, GameMember
from .security import hash_password
from .routes.auth import router as auth_router from .routes.auth import router as auth_router
from .routes.admin import router as admin_router from .routes.admin import router as admin_router
from .routes.games import router as games_router from .routes.games import router as games_router
from .routes.setup import router as setup_router
app = FastAPI(title="Cluedo Sheet") app = FastAPI(title="Cluedo Sheet")
@@ -30,7 +31,6 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(games_router) app.include_router(games_router)
app.include_router(setup_router)
def _rand_join_code(n: int = 6) -> str: def _rand_join_code(n: int = 6) -> str:
@@ -136,14 +136,6 @@ Very small, pragmatic auto-migration (no alembic).
except Exception: except Exception:
db.rollback() db.rollback()
# started_at: games are open for joining until the host starts them
if not _has_column(db, "games", "started_at"):
try:
db.execute(text("ALTER TABLE games ADD COLUMN started_at DATETIME"))
db.commit()
except Exception:
db.rollback()
# host_user_id (nice to have for "only host can set winner") # host_user_id (nice to have for "only host can set winner")
if not _has_column(db, "games", "host_user_id"): if not _has_column(db, "games", "host_user_id"):
try: try:
@@ -284,6 +276,23 @@ def seed_entries(db: Session):
db.commit() 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,
theme_key="default",
display_name="Admin",
)
)
db.commit()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
# create new tables # create new tables
@@ -292,6 +301,7 @@ def on_startup():
db = SessionLocal() db = SessionLocal()
try: try:
_auto_migrate(db) _auto_migrate(db)
ensure_admin(db)
seed_entries(db) seed_entries(db)
finally: finally:
db.close() db.close()
-11
View File
@@ -54,17 +54,6 @@ class Game(Base):
code: Mapped[str] = mapped_column(String, unique=True, index=True) code: Mapped[str] = mapped_column(String, unique=True, index=True)
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
started_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class GameChip(Base):
__tablename__ = "game_chips"
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_chip_user"),)
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)
chip: Mapped[str] = mapped_column(String)
class GameMember(Base): class GameMember(Base):
+3 -40
View File
@@ -61,48 +61,11 @@ def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
if not u: if not u:
raise HTTPException(404, "not found") raise HTTPException(404, "not found")
if u.role == Role.admin.value:
raise HTTPException(400, "cannot delete admin user")
# soft delete # soft delete
u.disabled = True u.disabled = True
db.add(u) db.add(u)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.patch("/users/{user_id}")
def update_user(req: Request, user_id: str, data: dict, db: Session = Depends(get_db)):
admin = require_admin(req, db)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "not found")
email = (data.get("email") or user.email).lower().strip()
display_name = (data.get("display_name") if "display_name" in data else user.display_name or "").strip()
role = data.get("role") or user.role
password = data.get("password") or ""
if not email or "@" not in email:
raise HTTPException(400, "valid email required")
if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role")
if password and len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
if admin.id == user_id and role != Role.admin.value:
raise HTTPException(400, "cannot demote yourself")
if admin.id == user_id and data.get("disabled") is True:
raise HTTPException(400, "cannot disable yourself")
duplicate = db.query(User).filter(User.email == email, User.id != user_id).first()
if duplicate:
raise HTTPException(409, "email exists")
user.email = email
user.display_name = display_name
user.role = role
if password:
user.password_hash = hash_password(password)
if "disabled" in data:
user.disabled = bool(data["disabled"])
db.add(user)
db.commit()
return {"ok": True}
+2 -85
View File
@@ -1,12 +1,8 @@
import hashlib import hashlib, random
import random
import re
import unicodedata
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import Game, GameChip, Entry, SheetState, Category, GameMember, User, Role from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"]) router = APIRouter(prefix="/games", tags=["games"])
@@ -31,25 +27,6 @@ def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def make_user_chip(user: User) -> str:
"""Create first-name initial + first two surname letters, e.g. SNE."""
display_name = (user.display_name or "").strip()
if not display_name:
display_name = (user.email or "").split("@", 1)[0].replace(".", " ")
parts = re.split(r"\s+", display_name)
first = parts[0] if parts else "X"
last = parts[-1] if len(parts) > 1 else first
def letters(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
return "".join(c for c in normalized if c.isalpha())
first_letters = letters(first).upper() or "X"
last_letters = letters(last).upper() or "X"
return (first_letters[0] + last_letters[:2]).ljust(3, "X")
def ensure_member(db: Session, game_id: str, user_id: str): def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex: if ex:
@@ -101,8 +78,6 @@ def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
g = db.query(Game).filter(Game.code == code).first() g = db.query(Game).filter(Game.code == code).first()
if not g: if not g:
raise HTTPException(404, "game not found") raise HTTPException(404, "game not found")
if g.started_at:
raise HTTPException(400, "game already started")
ensure_member(db, g.id, uid) ensure_member(db, g.id, uid)
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
@@ -137,7 +112,6 @@ def list_games(req: Request, db: Session = Depends(get_db)):
"host_user_id": g.host_user_id, "host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"started": bool(g.started_at),
} }
) )
return out return out
@@ -165,63 +139,9 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"winner_display_name": winner_display_name, "winner_display_name": winner_display_name,
"started": bool(g.started_at),
"started_at": g.started_at,
} }
@router.post("/{game_id}/start")
def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if g.host_user_id != uid:
raise HTTPException(403, "only host can start the game")
if not g.started_at:
members = (
db.query(User)
.join(GameMember, GameMember.user_id == User.id)
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
.order_by(User.email.asc())
.all()
)
used = set()
for member in members:
base = make_user_chip(member)
chip = base
suffix = 2
while chip in used:
chip = f"{base[:2]}{suffix}"
suffix += 1
used.add(chip)
db.add(GameChip(game_id=game_id, user_id=member.id, chip=chip))
g.started_at = datetime.now(timezone.utc)
db.add(g)
db.commit()
return {"ok": True, "started": True}
@router.get("/{game_id}/chips")
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if not g.started_at:
return []
chips = (
db.query(GameChip, User)
.join(User, User.id == GameChip.user_id)
.filter(GameChip.game_id == game_id)
.order_by(User.display_name.asc(), User.email.asc())
.all()
)
return [{"user_id": chip.user_id, "chip": chip.chip, "display_name": user.display_name, "email": user.email} for chip, user in chips]
@router.get("/{game_id}/members") @router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
@@ -308,9 +228,6 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
uid = require_user(req, db) uid = require_user(req, db)
g = require_game_member(db, game_id, uid) g = require_game_member(db, game_id, uid)
if g.winner_user_id:
raise HTTPException(403, "game finished; sheet is read-only")
status = data.get("status") status = data.get("status")
note_tag = data.get("note_tag") note_tag = data.get("note_tag")
chip = data.get("chip") chip = data.get("chip")
-58
View File
@@ -1,58 +0,0 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Role, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/setup", tags=["setup"])
def _has_admin(db: Session) -> bool:
return db.query(User).filter(User.role == Role.admin.value).first() is not None
@router.get("/status")
def setup_status(db: Session = Depends(get_db)):
return {"setup_required": not _has_admin(db)}
@router.post("/admin")
def create_initial_admin(
data: dict,
resp: Response,
db: Session = Depends(get_db),
):
# The setup endpoint is only open until the first admin exists.
if _has_admin(db):
raise HTTPException(status_code=409, detail="setup already completed")
email = (data.get("email") or "").lower().strip()
display_name = (data.get("display_name") or "").strip()
password = data.get("password") or ""
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise HTTPException(status_code=400, detail="valid email required")
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
if not display_name:
display_name = email.split("@", 1)[0]
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=409, detail="email exists")
user = User(
email=email,
password_hash=hash_password(password),
role=Role.admin.value,
display_name=display_name,
)
db.add(user)
db.commit()
db.refresh(user)
# Log the installer in immediately after successful setup.
set_session(resp, make_session_value(user.id))
return {"ok": True, "id": user.id, "email": user.email}
+1
View File
@@ -1,6 +1,7 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
SQLAlchemy==2.0.34 SQLAlchemy==2.0.34
psycopg[binary]==3.2.2
python-multipart==0.0.9 python-multipart==0.0.9
passlib==1.7.4 passlib==1.7.4
+21 -4
View File
@@ -1,13 +1,30 @@
services: 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: backend:
build: ./backend build: ./backend
environment: environment:
DATABASE_URL: sqlite:////app/data/cluedo.db DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SECRET_KEY: ${BACKEND_SECRET_KEY} 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_SECURE: "false" # intern ohne https; wenn du später https machst -> true
COOKIE_SAMESITE: "Lax" COOKIE_SAMESITE: "Lax"
volumes: depends_on:
- cluedo-data:/app/data db:
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
@@ -19,4 +36,4 @@ services:
- "8081:80" - "8081:80"
volumes: volumes:
cluedo-data: pgdata:
-47
View File
@@ -94,36 +94,6 @@
overflow: hidden; overflow: hidden;
} }
.splash-card::after {
content: "";
position: absolute;
inset: 18% 28%;
border: 1px solid rgba(233, 216, 166, 0.24);
border-radius: 50%;
transform: rotate(-18deg);
animation: orbit 2.8s ease-in-out infinite;
pointer-events: none;
}
.magic-orbit {
position: relative;
width: 74px;
height: 42px;
margin: 0 auto 4px;
}
.magic-orbit::before,
.magic-orbit::after {
content: "✦";
position: absolute;
color: rgba(233, 216, 166, 0.92);
font-size: 16px;
animation: sparkle 1.4s ease-in-out infinite;
}
.magic-orbit::before { left: 8px; top: 14px; }
.magic-orbit::after { right: 8px; top: 3px; animation-delay: .55s; }
.splash-card::before { .splash-card::before {
content: ""; content: "";
position: absolute; position: absolute;
@@ -141,7 +111,6 @@
color: rgba(245, 239, 220, 0.92); color: rgba(245, 239, 220, 0.92);
font-size: 18px; font-size: 18px;
line-height: 1.25; line-height: 1.25;
animation: titleRise 700ms ease-out both;
} }
.splash-sub { .splash-sub {
@@ -187,21 +156,6 @@
0%, 100% { opacity: 0.35; transform: scaleX(0.92); } 0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
50% { opacity: 0.85; transform: scaleX(1.02); } 50% { opacity: 0.85; transform: scaleX(1.02); }
} }
@keyframes orbit {
0%, 100% { transform: rotate(-18deg) scale(.94); opacity: .45; }
50% { transform: rotate(18deg) scale(1.06); opacity: .9; }
}
@keyframes sparkle {
0%, 100% { transform: translateY(3px) scale(.65); opacity: .25; }
50% { transform: translateY(-4px) scale(1.2); opacity: 1; }
}
@keyframes titleRise {
from { opacity: 0; transform: translateY(8px); letter-spacing: .18em; }
to { opacity: 1; transform: translateY(0); letter-spacing: .06em; }
}
</style> </style>
<!-- Theme-Key sofort setzen --> <!-- Theme-Key sofort setzen -->
@@ -216,7 +170,6 @@
<body> <body>
<div id="app-splash" aria-hidden="true"> <div id="app-splash" aria-hidden="true">
<div class="splash-card"> <div class="splash-card">
<div class="magic-orbit" aria-hidden="true"></div>
<div class="splash-title">Zauber-Detektiv Notizbogen</div> <div class="splash-title">Zauber-Detektiv Notizbogen</div>
<div class="splash-sub">Magie wird vorbereitet…</div> <div class="splash-sub">Magie wird vorbereitet…</div>
<div class="loader" aria-label="Laden"></div> <div class="loader" aria-label="Laden"></div>
+1 -6
View File
@@ -7,12 +7,7 @@ server {
} }
location /api/ { location /api/ {
# Resolve the Docker service at request time. This prevents Nginx from proxy_pass http://backend:8080/;
# exiting when the Docker DNS entry is not ready during container startup.
resolver 127.0.0.11 ipv6=off valid=10s;
set $backend_upstream http://backend:8080;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass $backend_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+610 -604
View File
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
/* frontend/src/AppLayout.css */
html, body, #root {
height: 100%;
}
body {
overflow: hidden;
}
.appRoot {
height: 100vh;
display: grid;
/* Links: flexibel | Rechts: Notes clamp */
grid-template-columns: minmax(900px, 1fr) clamp(380px, 32vw, 520px);
gap: 14px;
padding: 14px;
box-sizing: border-box;
overflow: visible;
}
/* LEFT COLUMN */
.leftPane {
overflow: visible;
min-width: 0;
display: grid;
/* Top | Main | HUD */
grid-template-rows: 68px minmax(360px, 1fr) minmax(160px, 190px);
gap: 14px;
}
.topBarRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
min-width: 0;
}
/* MAIN: Tools | Board | PlayerRail */
.mainRow {
min-height: 0;
overflow: visible;
display: grid;
/* Tools bewusst schmaler, Board bekommt Bühne */
grid-template-columns: 260px minmax(560px, 1fr) 92px;
gap: 14px;
min-width: 0;
}
/* Tools left of board */
.leftTools {
min-width: 0;
overflow: hidden;
display: grid;
align-content: start;
}
.leftToolsRow {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
min-width: 0;
}
/* Board */
.boardWrap {
min-height: 0;
overflow: hidden;
border-radius: 22px;
min-width: 0;
position: relative;
}
/* Dice overlay: under board slightly right */
.diceOverlay {
position: absolute;
overflow: visible;
bottom: 14px;
right: 18px;
width: 220px;
pointer-events: auto;
opacity: 0.98;
}
/* Player rail: right of board, before notes (tight) */
.playerRail {
min-height: 0;
border-radius: 22px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.18);
backdrop-filter: blur(10px);
box-shadow: 0 16px 50px rgba(0,0,0,0.35);
padding: 10px 8px;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
gap: 10px;
overflow: visible;
}
.playerRailInner {
width: 100%;
height: 100%;
overflow: visible;
border-radius: 18px;
display: grid;
justify-items: center;
}
.playerRailTitle {
font-weight: 900;
font-size: 12px;
opacity: 0.9;
}
.playerRailList {
min-height: 0;
overflow: visible;
display: grid;
gap: 10px;
align-content: start;
justify-items: center;
padding-top: 4px;
}
/* HUD */
.playerHud {
align-items: stretch;
overflow: visible;
display: grid;
grid-template-columns: 1.1fr 1.7fr 1.1fr;
gap: 14px;
min-width: 0;
}
.playerHudMiddle {
height: 100%;
min-width: 0;
min-height: 0;
overflow: visible;
display: grid;
/* Geheimkarten groß, Hilfkarte etwas kleiner */
grid-template-columns: 1fr 0.78fr;
gap: 14px;
}
/* RIGHT COLUMN */
.notesPane {
overflow: hidden;
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
}
.notesScroll {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.diceOverlay {
position: absolute;
bottom: 14px;
right: 18px;
width: 220px;
pointer-events: auto; /* ✅ damit Hover/Clicks für Würfel gehen */
opacity: 0.98;
}
/* ===== True 3D Cube Dice (transition-based roll) ===== */
.die3d {
transform-style: preserve-3d;
will-change: transform;
}
.die3d:hover:not(:disabled) {
transform: translateY(-3px) rotateX(8deg) rotateY(-10deg);
}
.dieCubeWrap {
width: 44px;
height: 44px;
position: relative;
transform-style: preserve-3d;
}
.dieCube {
width: 44px;
height: 44px;
position: absolute;
inset: 0;
transform-style: preserve-3d;
/* absolute angles (numbers are set in JS) */
transform: rotateX(calc(var(--rx, 0) * 1deg)) rotateY(calc(var(--ry, 0) * 1deg));
/* default: snappy */
transition: transform 260ms cubic-bezier(0.22, 1.0, 0.25, 1);
}
.dieCube.rolling {
/* roll duration */
transition: transform 820ms cubic-bezier(0.18, 0.88, 0.22, 1);
}
/* Faces: remove hard borders / inner rings */
.dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: none; /* ✅ no face border */
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.05), /* very subtle */
inset 0 14px 24px rgba(255,255,255,0.04);
display: grid;
place-items: center;
backface-visibility: hidden;
}
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* Prevent clipping */
.diceRow3d { overflow: visible; }
.dieCube.snap {
transition: none !important;
}
/* one face */
.dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.10);
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.06),
inset 0 12px 20px rgba(255,255,255,0.05);
display: grid;
place-items: center;
backface-visibility: hidden;
}
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
/* special icon */
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* optional: subtle face ring tint via CSS var */
.dieFace {
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.06),
inset 0 0 0 2px var(--ring, rgba(255,255,255,0.00)),
inset 0 12px 20px rgba(255,255,255,0.05);
}
/* Roll animation */
@keyframes dieRoll {
0% {
transform: translateY(0) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}
25% {
transform: translateY(-6px) rotateX(220deg) rotateY(160deg) rotateZ(90deg);
}
55% {
transform: translateY(0px) rotateX(520deg) rotateY(460deg) rotateZ(240deg);
}
100% {
transform: translateY(-2px) rotateX(720deg) rotateY(720deg) rotateZ(360deg);
}
}
.die3dRolling {
animation: dieRoll 820ms cubic-bezier(0.2, 0.9, 0.25, 1) both;
}
/* --- Responsive: shrink gracefully (no scroll outside notes) --- */
@media (max-height: 860px) {
.leftPane {
grid-template-rows: 64px minmax(300px, 1fr) 150px;
}
.diceOverlay {
bottom: 10px;
right: 12px;
width: 200px;
}
}
@media (max-width: 1440px) {
.appRoot {
grid-template-columns: minmax(820px, 1fr) clamp(360px, 34vw, 480px);
}
.mainRow {
grid-template-columns: 240px minmax(520px, 1fr) 88px;
}
}
@media (max-width: 1280px) {
.appRoot {
grid-template-columns: 1fr clamp(340px, 36vw, 460px);
}
.mainRow {
grid-template-columns: 220px minmax(480px, 1fr) 84px;
}
}
@media (max-width: 1180px) {
/* ab hier wirds eng: tools noch schmäler */
.mainRow {
grid-template-columns: 200px minmax(440px, 1fr) 82px;
}
}
-1
View File
@@ -3,7 +3,6 @@ import { API_BASE } from "../constants";
export async function api(path, opts = {}) { export async function api(path, opts = {}) {
const res = await fetch(API_BASE + path, { const res = await fetch(API_BASE + path, {
credentials: "include", credentials: "include",
cache: "no-store",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
...opts, ...opts,
}); });
+154 -116
View File
@@ -1,166 +1,204 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "../api/client"; import { api } from "../api/client";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { createPortal } from "react-dom";
const emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false }; export default function AdminPanel() {
export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [editorOpen, setEditorOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null); const [open, setOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!dashboardOpen && !editorOpen) return; if (!open) return;
const previous = document.body.style.overflow;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = previous; };
}, [dashboardOpen, editorOpen]); return () => {
document.body.style.overflow = prev;
};
}, [open]);
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
useEffect(() => { useEffect(() => {
if (!dashboardOpen) { loadUsers().catch(() => {});
setEditorOpen(false); }, []);
setMsg("");
}
}, [dashboardOpen]);
const loadUsers = async () => setUsers(await api("/admin/users")); const resetForm = () => {
setDisplayName("");
useEffect(() => { loadUsers().catch(() => {}); }, []); setEmail("");
setPassword("");
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value })); setRole("user");
const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
setMsg("");
setEditorOpen(true);
}; };
const openEdit = (user) => { const createUser = async () => {
setEditingUser(user);
setForm({
displayName: user.display_name || "",
email: user.email || "",
password: "",
role: user.role || "user",
disabled: !!user.disabled,
});
setMsg(""); setMsg("");
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
setEditingUser(null);
setMsg("");
};
const saveUser = async () => {
setMsg("");
if (!form.email.trim()) return setMsg("❌ E-Mail ist erforderlich.");
if (!editingUser && form.password.length < 8) return setMsg("❌ Passwort muss mindestens 8 Zeichen haben.");
if (editingUser && form.password && form.password.length < 8) return setMsg("❌ Neues Passwort muss mindestens 8 Zeichen haben.");
setSaving(true);
try { try {
const payload = { await api("/admin/users", {
display_name: form.displayName, method: "POST",
email: form.email, body: JSON.stringify({ display_name: displayName, email, password, role }),
role: form.role,
disabled: form.disabled,
};
if (form.password) payload.password = form.password;
await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", {
method: editingUser ? "PATCH" : "POST",
body: JSON.stringify(payload),
}); });
setMsg("✅ User erstellt.");
await loadUsers(); await loadUsers();
closeEditor(); resetForm();
setOpen(false);
} catch (e) { } catch (e) {
setMsg("❌ " + (e?.message || "Speichern fehlgeschlagen.")); setMsg("❌ Fehler: " + (e?.message || "unknown"));
} finally {
setSaving(false);
} }
}; };
const disableUser = async (user) => { const deleteUser = async (u) => {
if (user.id === currentUserId) return; if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return;
if (!window.confirm(`User wirklich deaktivieren?\n\n${user.display_name || user.email}`)) return;
try { try {
await api(`/admin/users/${user.id}`, { method: "DELETE" }); await api(`/admin/users/${u.id}`, { method: "DELETE" });
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
alert("Fehler: " + (e?.message || "unknown")); alert("Fehler: " + (e?.message || "unknown"));
} }
}; };
if (!dashboardOpen) return null; const closeModal = () => {
setOpen(false);
setMsg("");
};
return createPortal( return (
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.adminWrap}>
<div className="hp-admin-dashboard-card" style={{ ...styles.modalCard, width: "min(780px, 100%)", padding: 0 }} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.adminTop}>
<div style={{ padding: "18px 18px 16px" }}> <div style={styles.adminTitle}>Admin Dashboard</div>
<div style={styles.modalHeader}> <button onClick={() => setOpen(true)} style={styles.primaryBtn}>
<div> + User anlegen
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>Admin Dashboard</div> </button>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>Benutzer, Rollen und Zugangsdaten verwalten</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
</div> </div>
<div style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}> <div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>Vorhandene User <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div> Vorhandene User
<button onClick={openCreate} style={styles.primaryBtn}>+ User anlegen</button>
</div> </div>
<div style={{ marginTop: 10, display: "grid", gap: 9 }}> <div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((user) => ( {users.map((u) => (
<div key={user.id} className="hp-admin-user-row" style={{ ...styles.userRow, alignItems: "center" }}> <div
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{user.display_name || "—"}</div> key={u.id}
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{user.email}</div> style={{
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div> ...styles.userRow,
<div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div> gridTemplateColumns: "1fr 1fr 80px 90px 92px",
<div className="hp-admin-actions"> alignItems: "center",
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title="User bearbeiten" aria-label={`${user.display_name || user.email} bearbeiten`}></button> }}
<button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? "User ist deaktiviert" : "User deaktivieren"} aria-label={`${user.display_name || user.email} deaktivieren`}></button> >
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
</div> </div>
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role}
</div>
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"}
</div>
<button
onClick={() => deleteUser(u)}
style={{
...styles.secondaryBtn,
padding: "8px 10px",
borderRadius: 12,
color: "#ffb3b3",
opacity: u.role === "admin" ? 0.4 : 1,
pointerEvents: u.role === "admin" ? "none" : "auto",
}}
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
>
Löschen
</button>
</div> </div>
))} ))}
</div> </div>
</div>
</div>
{editorOpen && createPortal( {open &&
<div style={styles.modalOverlay} onMouseDown={closeEditor}> createPortal(
<div className="hp-admin-editor-card" style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 18 }}>{editingUser ? "User bearbeiten" : "Neuen User anlegen"}</div> Neuen User anlegen
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{editingUser ? "Profil und Zugangsdaten aktualisieren" : "Ein neues Benutzerkonto erstellen"}</div>
</div> </div>
<button onClick={closeEditor} style={styles.modalCloseBtn} aria-label="Schließen"></button> <button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div> </div>
<div style={{ marginTop: 18, display: "grid", gap: 10 }}> <div
<label style={styles.adminFieldLabel}>Anzeigename<input value={form.displayName} onChange={(e) => setField("displayName", e.target.value)} placeholder="z. B. Sascha Nesterovic" style={styles.input} autoFocus /></label> style={{
<label style={styles.adminFieldLabel}>E-Mail<input value={form.email} onChange={(e) => setField("email", e.target.value)} placeholder="name@example.com" style={styles.input} inputMode="email" /></label> marginTop: 12,
<label style={styles.adminFieldLabel}>{editingUser ? "Neues Passwort (optional)" : "Passwort"}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? "Leer lassen = unverändert" : "Mindestens 8 Zeichen"} type="password" style={styles.input} /></label> display: "grid",
<label style={styles.adminFieldLabel}>Rolle<select value={form.role} onChange={(e) => setField("role", e.target.value)} disabled={editingUser?.id === currentUserId} style={styles.input}><option value="user">User</option><option value="admin">Admin</option></select></label> gap: 8,
{editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> Konto ist aktiv</label>} justifyItems: "center", // <<< zentriert alles
{msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>} }}
<button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? "Speichern …" : editingUser ? "Änderungen speichern" : "User erstellen"}</button> >
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Name (z.B. Sascha)"
style={styles.input}
autoFocus
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
style={styles.input}
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Initial Passwort"
type="password"
style={styles.input}
/>
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
{msg && <div style={{ opacity: 0.9, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button
onClick={() => {
resetForm();
setMsg("");
}}
style={styles.secondaryBtn}
>
Leeren
</button>
<button onClick={createUser} style={styles.primaryBtn}>
User erstellen
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Name wird in TopBar & Siegeranzeige genutzt.
</div>
</div> </div>
</div> </div>
</div>, </div>,
document.body document.body
)} )
</div>, }
document.body </div>
); );
} }
+5 -10
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) { export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) {
if (!chipOpen) return null; if (!chipOpen) return null;
return ( return (
@@ -18,19 +19,13 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip,
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div> <div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}> <div style={styles.chipGrid}>
{chips.map((item) => ( {CHIP_LIST.map((c) => (
<button key={item.user_id || item.chip} onClick={() => chooseChip(item.chip)} style={styles.chipBtn} title={item.display_name || item.email}> <button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
{item.chip} {c}
</button> </button>
))} ))}
</div> </div>
{!chips.length && (
<div style={{ marginTop: 12, color: stylesTokens.textDim }}>
Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden.
</div>
)}
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}> <div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf . Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .
</div> </div>
+147
View File
@@ -0,0 +1,147 @@
// frontend/src/components/Dice/Dice3D.jsx
import React from "react";
export const cubeRotationForD6 = (value) => {
switch (value) {
case 1: return { rx: 0, ry: 0 };
case 2: return { rx: -90, ry: 0 };
case 3: return { rx: 0, ry: -90 };
case 4: return { rx: 0, ry: 90 };
case 5: return { rx: 90, ry: 0 };
case 6: return { rx: 0, ry: 180 };
default: return { rx: 0, ry: 0 };
}
};
export const PipFace = ({ value }) => {
const pos = (gx, gy) => ({ left: `${gx * 50}%`, top: `${gy * 50}%` });
const faces = {
1: [[1, 1]],
2: [[0, 0], [2, 2]],
3: [[0, 0], [1, 1], [2, 2]],
4: [[0, 0], [2, 0], [0, 2], [2, 2]],
5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]],
6: [[0, 0], [0, 1], [0, 2], [2, 0], [2, 1], [2, 2]],
};
const arr = faces[value] || faces[1];
return (
<div className="pipGrid">
{arr.map(([x, y], idx) => (
<div
key={idx}
className="pip"
style={{ ...pos(x, y), transform: "translate(-50%, -50%)" }}
/>
))}
</div>
);
};
export const DieShell = ({ children, rolling = false, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className="die3d"
style={{
width: 64,
height: 64,
borderRadius: 18,
border: "none",
background: "transparent",
boxShadow: "none",
position: "relative",
overflow: "visible",
display: "grid",
placeItems: "center",
cursor: rolling ? "default" : "pointer",
transition: "transform 160ms ease",
padding: 0,
outline: "none",
}}
disabled={rolling}
>
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
</button>
);
};
export const DieD6 = ({ rolling, onClick, ax, ay, onDone, snap = false }) => {
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
<div className="dieFace front"><PipFace value={1} /></div>
<div className="dieFace back"><PipFace value={6} /></div>
<div className="dieFace top"><PipFace value={2} /></div>
<div className="dieFace bottom"><PipFace value={5} /></div>
<div className="dieFace right"><PipFace value={3} /></div>
<div className="dieFace left"><PipFace value={4} /></div>
</div>
</div>
</DieShell>
);
};
export const HouseDie = ({ face, rolling, onClick, ax, ay, onDone, snap = false }) => {
const faces = {
gryffindor: { icon: "🦁", color: "#ef4444" },
slytherin: { icon: "🐍", color: "#22c55e" },
ravenclaw: { icon: "🦅", color: "#3b82f6" },
hufflepuff: { icon: "🦡", color: "#facc15" },
help: { icon: "🃏", color: "#f2d27a" },
dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" },
};
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
{order.map((key, i) => {
const item = faces[key];
const faceClass =
i === 0 ? "front" :
i === 1 ? "top" :
i === 2 ? "right" :
i === 3 ? "left" :
i === 4 ? "bottom" :
"back";
return (
<div key={key} className={`dieFace ${faceClass}`}>
<div
className="specialIcon"
style={{
color: item.color,
textShadow: `0 0 18px ${item.color}55, 0 10px 22px rgba(0,0,0,0.55)`,
}}
>
{item.icon}
</div>
</div>
);
})}
</div>
</div>
</DieShell>
);
};
+188
View File
@@ -0,0 +1,188 @@
// frontend/src/components/Dice/DicePanel.jsx
import React, { useEffect, useRef, useState } from "react";
import { stylesTokens } from "../../styles/theme";
import { cubeRotationForD6, DieD6, HouseDie } from "./Dice/Dice3D.jsx";
export default function DicePanel({ onRoll }) {
const LS_KEY = "hp_cluedo_dice_v1";
const [d1, setD1] = useState(4);
const [d2, setD2] = useState(2);
const [special, setSpecial] = useState("gryffindor");
const [a1, setA1] = useState({ x: 0, y: 90 });
const [a2, setA2] = useState({ x: -90, y: 0 });
const [as, setAs] = useState({ x: 0, y: 0 });
const [rolling, setRolling] = useState(false);
const [snap, setSnap] = useState(false);
const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
// roll bookkeeping
const pendingRef = useRef(null);
const rollIdRef = useRef(0);
const doneForRollRef = useRef({ d1: false, d2: false, s: false });
// restore last
useEffect(() => {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed?.d1) setD1(parsed.d1);
if (parsed?.d2) setD2(parsed.d2);
if (parsed?.special) setSpecial(parsed.special);
if (parsed?.d1) {
const r = cubeRotationForD6(parsed.d1);
setA1({ x: r.rx, y: r.ry });
}
if (parsed?.d2) {
const r = cubeRotationForD6(parsed.d2);
setA2({ x: r.rx, y: r.ry });
}
if (parsed?.special) {
const idx = Math.max(0, order.indexOf(parsed.special));
const r = cubeRotationForD6(idx + 1);
setAs({ x: r.rx, y: r.ry });
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
try {
localStorage.setItem(LS_KEY, JSON.stringify({ d1, d2, special }));
} catch {}
}, [d1, d2, special]);
const normalize360 = (n) => ((n % 360) + 360) % 360;
const rollTo = (currentAngles, targetBase) => {
const spinX = 720 + randInt(0, 2) * 360;
const spinY = 720 + randInt(0, 2) * 360;
const currX = normalize360(currentAngles.x);
const currY = normalize360(currentAngles.y);
const dx = targetBase.rx - currX;
const dy = targetBase.ry - currY;
return { x: currentAngles.x + spinX + dx, y: currentAngles.y + spinY + dy };
};
const rollAll = () => {
if (rolling) return;
const nd1 = randInt(1, 6);
const nd2 = randInt(1, 6);
const ns = specialFaces[randInt(0, specialFaces.length - 1)];
const r1 = cubeRotationForD6(nd1);
const r2 = cubeRotationForD6(nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1);
pendingRef.current = { nd1, nd2, ns };
rollIdRef.current += 1;
doneForRollRef.current = { d1: false, d2: false, s: false };
setRolling(true);
setA1((cur) => rollTo(cur, r1));
setA2((cur) => rollTo(cur, r2));
setAs((cur) => rollTo(cur, rs));
};
const maybeCommit = () => {
const flags = doneForRollRef.current;
if (!flags.d1 || !flags.d2 || !flags.s) return;
const p = pendingRef.current;
if (!p) return;
// ✅ rolling beenden
setRolling(false);
// ✅ Ergebnis state setzen
setD1(p.nd1);
setD2(p.nd2);
setSpecial(p.ns);
// ✅ Winkel auf kleine Basis normalisieren (ohne Transition)
const r1 = cubeRotationForD6(p.nd1);
const r2 = cubeRotationForD6(p.nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(p.ns)) + 1);
setSnap(true);
setA1({ x: r1.rx, y: r1.ry });
setA2({ x: r2.rx, y: r2.ry });
setAs({ x: rs.rx, y: rs.ry });
// Snap nur 1 Frame aktiv lassen
requestAnimationFrame(() => {
requestAnimationFrame(() => setSnap(false));
});
pendingRef.current = null;
onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns });
};
const onDoneD1 = () => {
if (!rolling) return;
if (doneForRollRef.current.d1) return;
doneForRollRef.current.d1 = true;
maybeCommit();
};
const onDoneD2 = () => {
if (!rolling) return;
if (doneForRollRef.current.d2) return;
doneForRollRef.current.d2 = true;
maybeCommit();
};
const onDoneS = () => {
if (!rolling) return;
if (doneForRollRef.current.s) return;
doneForRollRef.current.s = true;
maybeCommit();
};
return (
<div style={{ pointerEvents: "auto" }}>
<div style={{ display: "grid", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 13 }}>Würfel</div>
<div style={{ color: stylesTokens.textDim, fontWeight: 900, fontSize: 11.5, letterSpacing: 0.7, opacity: 0.75 }}>
{rolling ? "ROLL…" : "READY"}
</div>
</div>
<div
className="diceRow3d"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
alignItems: "center",
justifyItems: "center",
overflow: "visible",
}}
>
<DieD6 rolling={rolling} onClick={rollAll} ax={a1.x} ay={a1.y} onDone={onDoneD1} />
<DieD6 rolling={rolling} onClick={rollAll} ax={a2.x} ay={a2.y} onDone={onDoneD2} />
<HouseDie face={special} rolling={rolling} onClick={rollAll} ax={as.x} ay={as.y} onDone={onDoneS} />
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}>Klicken zum Rollen</div>
</div>
</div>
);
}
+2 -101
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
@@ -10,16 +10,8 @@ export default function GamePickerCard({
members = [], members = [],
me, me,
hostUserId, hostUserId,
isHost = false,
started = false,
finished = false,
winnerName = "",
chipCount = 0,
onStartGame,
}) { }) {
const cur = games.find((x) => x.id === gameId); const cur = games.find((x) => x.id === gameId);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState("");
const renderMemberName = (m) => { const renderMemberName = (m) => {
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—"); const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
@@ -50,21 +42,6 @@ export default function GamePickerCard({
whiteSpace: "nowrap", whiteSpace: "nowrap",
}); });
const handleStartGame = async () => {
if (!onStartGame || starting || members.length < 2) return;
if (!window.confirm("Spiel jetzt starten? Danach können keine weiteren Spieler beitreten.")) return;
setStarting(true);
setStartError("");
try {
await onStartGame();
} catch (e) {
setStartError(e?.message || "Das Spiel konnte nicht gestartet werden.");
} finally {
setStarting(false);
}
};
return ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
@@ -108,84 +85,8 @@ export default function GamePickerCard({
</div> </div>
)} )}
{!started ? (
<div
className="hp-lobby"
style={{
margin: "0 12px 12px",
padding: 12,
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(8,8,11,0.28)",
}}
>
<div className="hp-lobby-header" style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 12 }}>
<div>
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 15 }}>
Lobby
</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>
{isHost ? "Du bist der Host dieses Spiels." : "Du bist der Lobby beigetreten."}
</div>
</div>
<div style={{ textAlign: "right", color: stylesTokens.textGold, fontWeight: 1000 }}>
<div style={{ fontSize: 20, lineHeight: 1 }}>{members.length}</div>
<div style={{ fontSize: 11, color: stylesTokens.textDim, fontWeight: 700 }}>Spieler</div>
</div>
</div>
<div className="hp-lobby-slots" style={{ marginTop: 12, display: "grid", gap: 6 }}>
{members.length > 0 ? members.map((m, index) => {
const isMe = String(me?.id) === String(m.id);
const isMemberHost = String(hostUserId) === String(m.id);
const name = ((m.display_name || "").trim() || (m.email || "").trim() || "Spieler");
return (
<div key={m.id} className="hp-lobby-player" style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 10px", borderRadius: 11, background: "rgba(255,255,255,0.055)", border: "1px solid rgba(233,216,166,0.10)" }}>
<span style={{ color: stylesTokens.textDim, fontSize: 12, minWidth: 16 }}>{index + 1}</span>
<span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", color: stylesTokens.textMain, fontWeight: 900 }}>
{name}{isMe ? " (du)" : ""}
</span>
{isMemberHost && <span title="Host" style={{ color: stylesTokens.textGold }}>👑</span>}
</div>
);
}) : (
<div style={{ padding: "10px 4px", color: stylesTokens.textDim, fontSize: 13 }}>
Warte auf Spieler
</div>
)}
</div>
{isHost ? (
<>
<button
onClick={handleStartGame}
style={{ ...styles.primaryBtn, width: "100%", marginTop: 12 }}
disabled={starting || members.length < 2}
>
{starting ? "Spiel wird gestartet …" : "▶ Spiel starten"}
</button>
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
{members.length < 2 ? "Mindestens 2 Spieler werden benötigt." : "Beim Start werden die Spieler-Chips erstellt."}
</div>
</>
) : (
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
Warte, bis der Host das Spiel startet.
</div>
)}
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
</div>
) : (
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
{finished
? `🏆 Spiel beendet${winnerName ? ` · Sieger: ${winnerName}` : ""}`
: `✓ Spiel läuft · ${chipCount} Spieler-Chips erstellt`}
</div>
)}
{/* Spieler */} {/* Spieler */}
{started && members?.length > 0 && ( {members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}> <div style={{ padding: "0 12px 12px" }}>
<div <div
style={{ style={{
@@ -1,45 +0,0 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme";
export default function GameStartCelebration({ open, onClose }) {
useEffect(() => {
if (!open) return;
const burst = () => {
confetti({
particleCount: 70,
spread: 78,
startVelocity: 32,
origin: { y: 0.62 },
colors: ["#e9d8a6", "#7cffa8", "#8fb6ff", "#ffffff"],
});
};
burst();
const second = setTimeout(burst, 480);
const close = setTimeout(onClose, 3000);
return () => {
clearTimeout(second);
clearTimeout(close);
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div className="hp-start-overlay" onClick={onClose} role="dialog" aria-label="Spiel gestartet">
<div className="hp-start-card" onClick={(event) => event.stopPropagation()}>
<div className="hp-start-rune"></div>
<div className="hp-start-kicker">Die Ermittlungen beginnen</div>
<div className="hp-start-title">Spiel gestartet</div>
<div className="hp-start-subtitle">Möge der beste Detektiv gewinnen.</div>
<button onClick={onClose} style={{ marginTop: 18, color: stylesTokens.textGold }}>
Los geht&apos;s
</button>
</div>
</div>,
document.body
);
}
+96 -88
View File
@@ -1,3 +1,4 @@
// src/components/HelpModal.jsx
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
@@ -9,170 +10,177 @@ export default function HelpModal({ open, onClose }) {
<div style={styles.modalOverlay} onMouseDown={onClose}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}> <div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}> <div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Hilfe & Spielablauf</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Hilfe</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button> </button>
</div> </div>
<div style={styles.helpBody}> <div style={styles.helpBody}>
<div style={styles.helpSectionTitle}>1) Lobby & Spielstart</div> {/* ===== 0) Spiele & Navigation ===== */}
<div style={styles.helpSectionTitle}>0) Spiele auswählen / Neues Spiel</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Ein neues Spiel startet zunächst als Lobby. Teile den angezeigten <b>Spiel-Code</b> mit den Oben im Bereich <b>Spiel</b> kannst du zwischen bestehenden Spielen wechseln oder ein neues
anderen Spielern. Sie können dem Spiel beitreten, solange es noch nicht gestartet wurde. Spiel erstellen:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>👑</span>
<div>
Der <b>Host</b> sieht alle beigetretenen Spieler und startet das Spiel.
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
Der Start ist ab <b>zwei Spielern</b> möglich. Danach können keine weiteren Spieler
beitreten.
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>SNE</span>
<div>
Beim Start werden die Spieler-Chips automatisch erstellt. Beispiel: Sascha Nesterovic
wird zu <b>SNE</b>.
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>2) Spiel auswählen</div>
<div style={styles.helpText}>
Mit dem Dropdown im Bereich <b>Spiel</b> wechselst du zwischen deinen Spielen. Über
<b> New Game</b> kannst du ein neues Spiel erstellen oder einem bestehenden Spiel beitreten.
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div> <div>
<b>Spiel-Auswahl</b> = vorhandene Spiele öffnen <b>Spiel-Auswahl</b> (Dropdown neben dem Hilfe-Button) = vorhandene / alte Spiele öffnen
</div> </div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div> <div>
<b>New Game</b> = neues Spiel erstellen oder per Code beitreten <b>Neues Spiel</b> = erstellt ein neues Spiel und öffnet es automatisch
</div> </div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>3) Namen antippen Status</div> {/* ===== 1) Status per Tippen ===== */}
<div style={styles.helpSectionTitle}>1) Namen antippen (Status)</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Tippe während des laufenden Spiels auf den Namen eines Eintrags. Die Statusfolge ist: Tippe auf einen Namen, um den Status zu ändern. Reihenfolge:
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}></span> <span
<div><b>Grün</b> = bestätigt / vorhanden</div> style={{
...styles.helpBadge,
background: "rgba(0,190,80,0.18)",
color: "#baf3c9",
}}
>
</span>
<div>
<b>Grün</b> = bestätigt / fix richtig
</div> </div>
<div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}></span>
<div><b>Rot</b> = ausgeschlossen / nicht vorhanden</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(140,140,140,0.14)", color: "rgba(233,216,166,0.85)" }}>?</span> <span
<div><b>Grau</b> = unsicher / vielleicht</div> style={{
...styles.helpBadge,
background: "rgba(255,35,35,0.18)",
color: "#ffb3b3",
}}
>
</span>
<div>
<b>Rot</b> = ausgeschlossen / fix falsch
</div> </div>
</div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={{ ...styles.helpBadge, background: "rgba(255,255,255,0.08)", color: "rgba(233,216,166,0.75)" }}></span> <span
<div><b>Leer</b> = noch nicht bewertet</div> style={{
...styles.helpBadge,
background: "rgba(140,140,140,0.14)",
color: "rgba(233,216,166,0.85)",
}}
>
?
</span>
<div>
<b>Grau</b> = unsicher / vielleicht
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,255,255,0.08)",
color: "rgba(233,216,166,0.75)",
}}
>
</span>
<div>
<b>Leer</b> = noch nicht bewertet
</div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>4) Notizen & Spieler-Chips</div> {/* ===== 2) i / m / s Notizen ===== */}
<div style={styles.helpSectionTitle}>2) i / m / s Button (Notizen)</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Der Button rechts in jeder Zeile rotiert durch deine persönlichen Notizen: Rechts pro Zeile gibt es einen Button, der durch diese Werte rotiert:
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>i</span> <span style={styles.helpMiniTag}>i</span>
<div><b>i</b> = Ich habe diese Karte</div> <div>
<b>i</b> = Ich habe diese Karte
</div> </div>
</div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>m</span> <span style={styles.helpMiniTag}>m</span>
<div><b>m</b> = Karte aus dem mittleren Deck</div> <div>
<b>m</b> = Karte aus dem mittleren Deck
</div> </div>
</div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>s</span> <span style={styles.helpMiniTag}>s</span>
<div><b>s</b> = Ein anderer Spieler hat die Karte; danach wählst du den passenden Chip</div> <div>
<b>s</b> = Ein anderer Spieler hat die Karte danach Chip auswählen (z.B. <b>s.AL</b>)
</div> </div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>SNE</span>
<div><b>s.SNE</b> = Sascha Nesterovic besitzt die Karte</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div><b></b> = keine Zusatznotiz</div> <div>
<b></b> = keine Notiz
</div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>5) Spielende</div> {/* ===== 3) User-Menü ===== */}
<div style={styles.helpSectionTitle}>3) User-Menü (Passwort / Logout)</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Nur der Host kann den Sieger festlegen. Sobald ein Sieger gespeichert wurde, gilt das Spiel Oben rechts im <b>User</b>-Menü findest du persönliche Einstellungen:
als beendet:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🔒</span>
<div>Alle Notizzettel werden <b>schreibgeschützt</b>.</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🏆</span>
<div>Der Sieger wird für alle Spieler angezeigt.</div>
</div>
</div> </div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>6) User-Menü</div>
<div style={styles.helpText}>
Oben rechts findest du abhängig von deiner Rolle das <b>User</b>- oder <b>Admin</b>-Menü.
</div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>📊</span> <span style={styles.helpMiniTag}>👤</span>
<div><b>Statistik</b> = persönliche Spiele- und Siegstatistik</div> <div>
<b>User</b> öffnen = zeigt die aktuell verwendete Email-Adresse
</div> </div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🎨</span>
<div><b>Design ändern</b> = Theme auswählen</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🔒</span> <span style={styles.helpMiniTag}>🔒</span>
<div><b>Passwort setzen</b> = eigenes Passwort ändern</div> <div>
<b>Passwort setzen</b> = eigenes Passwort ändern
</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span> <span style={styles.helpMiniTag}></span>
<div><b>Logout</b> = abmelden</div> <div>
<b>Logout</b> = ausloggen
</div> </div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🛡</span>
<div><b>Admin Dashboard</b> = User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren</div>
</div> </div>
</div> </div>
<div style={{ ...styles.helpText, marginTop: 16 }}> <div style={styles.helpDivider} />
Deine Notizen bleiben privat. Jeder Spieler sieht nur seinen eigenen Notizzettel.
<div style={styles.helpText}>
Tipp: Jeder Spieler sieht nur seine eigenen Notizen andere Spieler können nicht in deinen
Zettel schauen.
</div> </div>
</div> </div>
</div> </div>
+2 -75
View File
@@ -9,21 +9,7 @@ export default function LoginPage({
showPw, showPw,
setShowPw, setShowPw,
doLogin, doLogin,
setupRequired,
setupEmail,
setSetupEmail,
setupDisplayName,
setSetupDisplayName,
setupPassword,
setSetupPassword,
setupPasswordConfirm,
setSetupPasswordConfirm,
setupError,
setupSaving,
doSetup,
}) { }) {
const isSetup = setupRequired === true;
return ( return (
<div style={styles.loginPage}> <div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true"> <div style={styles.bgFixed} aria-hidden="true">
@@ -35,66 +21,8 @@ export default function LoginPage({
<div style={styles.loginCard}> <div style={styles.loginCard}>
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div> <div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
<div style={styles.loginSubtitle}> <div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
{setupRequired === null
? "Initialisiere Anwendung …"
: isSetup
? "Richte den ersten Administrator ein"
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"}
</div>
{isSetup ? (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder="Display name"
style={styles.loginInput}
autoComplete="name"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="Admin email"
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Password (min. 8 characters)"
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Confirm password"
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}>
{setupSaving ? "Setting up …" : "✦ Create administrator"}
</button>
</div>
) : (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}> <div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}> <div style={styles.loginFieldWrap}>
<input <input
@@ -133,10 +61,9 @@ export default function LoginPage({
Anmelden Anmelden
</button> </button>
</div> </div>
)}
<div style={styles.loginHint}> <div style={styles.loginHint}>
Your notes remain private every player only sees their own sheet. Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel.
</div> </div>
</div> </div>
</div> </div>
+4 -7
View File
@@ -10,7 +10,6 @@ export default function SheetSection({
onCycleStatus, onCycleStatus,
onToggleTag, onToggleTag,
displayTag, displayTag,
readOnly = false,
}) { }) {
const getRowBg = (status) => { const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg; if (status === 1) return stylesTokens.rowNoBg;
@@ -71,15 +70,14 @@ export default function SheetSection({
}} }}
> >
<div <div
onClick={() => !readOnly && onCycleStatus(e)} onClick={() => onCycleStatus(e)}
style={{ style={{
...styles.name, ...styles.name,
textDecoration: effectiveStatus === 1 ? "line-through" : "none", textDecoration: effectiveStatus === 1 ? "line-through" : "none",
color: getNameColor(effectiveStatus), color: getNameColor(effectiveStatus),
opacity: effectiveStatus === 1 ? 0.8 : 1, opacity: effectiveStatus === 1 ? 0.8 : 1,
cursor: readOnly ? "default" : "pointer",
}} }}
title={readOnly ? "Spiel beendet Notizzettel ist schreibgeschützt" : "Klick: Grün → Rot → Grau → Leer"} title="Klick: Grün → Rot → Grau → Leer"
> >
{e.label} {e.label}
</div> </div>
@@ -97,10 +95,9 @@ export default function SheetSection({
</div> </div>
<button <button
onClick={() => !readOnly && onToggleTag(e)} onClick={() => onToggleTag(e)}
style={styles.tagBtn} style={styles.tagBtn}
disabled={readOnly} title="— → i → m → s.(Chip) → —"
title={readOnly ? "Spiel beendet Notizzettel ist schreibgeschützt" : "— → i → m → s.(Chip) → —"}
> >
{displayTag(e)} {displayTag(e)}
</button> </button>
+7 -26
View File
@@ -9,16 +9,13 @@ export default function TopBar({
openPwModal, openPwModal,
openDesignModal, openDesignModal,
openStatsModal, openStatsModal,
openAdminPanel,
doLogout, doLogout,
onOpenNewGame, onOpenNewGame,
}) { }) {
const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
const isAdmin = me?.role === "admin";
const roleLabel = isAdmin ? "Admin" : "User";
return ( return (
<div className="hp-topbar" style={styles.topBar}> <div style={styles.topBar}>
<div> <div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
@@ -26,21 +23,20 @@ export default function TopBar({
</div> </div>
</div> </div>
<div className="hp-topbar-actions" style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu> <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div className="hp-user-menu-wrap" style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button <button
className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)} onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn} style={styles.userBtn}
title="User Menü" title="User Menü"
> >
<span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span> <span style={{ fontSize: 16 }}>👤</span>
<span>{roleLabel}</span> <span>User</span>
<span style={{ opacity: 0.7 }}></span> <span style={{ opacity: 0.7 }}></span>
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div className="hp-user-dropdown" style={styles.userDropdown}> <div style={styles.userDropdown}>
<div <div
style={{ style={{
padding: "10px 12px", padding: "10px 12px",
@@ -63,21 +59,6 @@ export default function TopBar({
Statistik Statistik
</button> </button>
{isAdmin && (
<>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
setUserMenuOpen(false);
openAdminPanel?.();
}}
style={styles.userDropdownItem}
>
Admin Dashboard
</button>
</>
)}
<div style={styles.userDropdownDivider} /> <div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}> <button onClick={openPwModal} style={styles.userDropdownItem}>
@@ -103,7 +84,7 @@ export default function TopBar({
)} )}
</div> </div>
<button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}> <button onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game New Game
</button> </button>
</div> </div>
+1
View File
@@ -1 +1,2 @@
export const API_BASE = "/api"; export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
+2 -72
View File
@@ -2,8 +2,6 @@ import { useEffect } from "react";
import { stylesTokens } from "../theme"; import { stylesTokens } from "../theme";
import { applyTheme } from "../themes"; import { applyTheme } from "../themes";
export function useHpGlobalStyles() { export function useHpGlobalStyles() {
// Google Fonts // Google Fonts
useEffect(() => { useEffect(() => {
@@ -76,78 +74,10 @@ export function useHpGlobalStyles() {
color: ${stylesTokens.textMain}; color: ${stylesTokens.textMain};
} }
body { body {
overflow-x: hidden; overflow: hidden;
-webkit-overflow-scrolling: touch;
} }
#root { background: transparent; } #root { background: transparent; }
*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; } * { -webkit-tap-highlight-color: transparent; }
button, input, select { font-family: inherit; }
button { transition: transform 140ms ease, filter 140ms ease, border-color 140ms ease, background 140ms ease; }
button:not(:disabled):hover { filter: brightness(1.12); transform: translateY(-1px); }
button:not(:disabled):active { transform: translateY(0); filter: brightness(0.98); }
button:disabled { cursor: not-allowed; opacity: 0.55; }
input:focus, select:focus { border-color: var(--hp-textGold) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--hp-textGold) 16%, transparent); }
.hp-row { transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease; }
.hp-row:hover { box-shadow: inset 0 0 0 1px rgba(233,216,166,0.12); }
.hp-start-overlay { position: fixed; inset: 0; z-index: 2147483646; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at center, rgba(233,216,166,0.12), rgba(4,4,7,0.88) 48%, rgba(0,0,0,0.96)); animation: hpStartFade 260ms ease-out; }
.hp-start-card { position: relative; width: min(420px, 100%); padding: 30px 22px 24px; border: 1px solid rgba(233,216,166,0.34); border-radius: 24px; text-align: center; background: linear-gradient(145deg, rgba(34,31,38,0.96), rgba(10,10,14,0.96)); box-shadow: 0 24px 90px rgba(0,0,0,0.72), 0 0 60px rgba(233,216,166,0.12); animation: hpStartCard 520ms cubic-bezier(.2,.8,.2,1); overflow: hidden; }
.hp-start-card::before, .hp-start-card::after { content: ""; position: absolute; left: 12%; right: 12%; height: 1px; background: linear-gradient(90deg, transparent, rgba(233,216,166,0.72), transparent); animation: hpStartLine 1.8s ease-in-out infinite; }
.hp-start-card::before { top: 12px; }
.hp-start-card::after { bottom: 12px; animation-delay: .45s; }
.hp-start-rune { color: var(--hp-textGold); font-size: 32px; line-height: 1; animation: hpStartRune 1.4s ease-in-out infinite; }
.hp-start-kicker { margin-top: 14px; color: var(--hp-textDim); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; }
.hp-start-title { margin-top: 8px; color: var(--hp-textGold); font-family: "Cinzel Decorative", "IM Fell English", system-ui; font-size: 25px; font-weight: 900; }
.hp-start-subtitle { margin-top: 8px; color: var(--hp-textMain); font-size: 15px; }
.hp-start-card button { border: 1px solid rgba(233,216,166,0.26); border-radius: 12px; background: rgba(233,216,166,0.10); padding: 10px 16px; font-weight: 900; cursor: pointer; }
@keyframes hpStartFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes hpStartCard { from { opacity: 0; transform: translateY(18px) scale(.92) rotateX(8deg); } to { opacity: 1; transform: translateY(0) scale(1) rotateX(0); } }
@keyframes hpStartRune { 0%, 100% { transform: rotate(0) scale(1); opacity: .7; } 50% { transform: rotate(180deg) scale(1.22); opacity: 1; } }
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
.hp-topbar-actions { margin-left: auto; }
.hp-user-menu-wrap { min-width: 0; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.45fr) 62px 62px 78px; white-space: nowrap; }
.hp-admin-action { min-width: 0; }
.hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
.hp-admin-icon-action { width: 34px; height: 34px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; }
.hp-admin-name, .hp-admin-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; }
.hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
.hp-admin-editor-card { overflow: hidden; }
.hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; }
.hp-admin-status { color: #baf3c9 !important; font-size: 13px; }
.hp-admin-status--disabled { color: #ffb3b3 !important; }
@media (max-width: 600px) {
.hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; }
.hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
.hp-topbar > div:first-child { min-width: 0; flex: 1; }
.hp-topbar > div:first-child > div:last-child { max-width: 105px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px !important; }
.hp-topbar-actions { width: auto; flex: 0 0 auto; margin-left: 0; gap: 5px !important; }
.hp-user-menu-wrap { flex: 0 0 auto; min-width: 0; }
.hp-topbar-user, .hp-topbar-new { flex: 0 0 auto; min-height: 40px; width: auto; padding: 8px 9px !important; font-size: 13px; }
.hp-topbar-user { justify-content: center; }
.hp-user-dropdown { left: 50% !important; right: auto !important; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
.hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 11px !important; border-radius: 15px !important; }
.hp-admin-name { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; }
.hp-admin-email { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px !important; }
.hp-admin-role { grid-column: 2; grid-row: 1; }
.hp-admin-status { grid-column: 2; grid-row: 2; }
.hp-admin-actions { grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: flex-end; }
.hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; }
.hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; }
.hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; }
.hp-admin-editor-card button { min-height: 40px; }
.hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
.hp-row button { min-height: 40px; }
button { min-height: 42px; }
input, select { min-height: 44px; box-sizing: border-box; }
.hp-topbar + * { margin-top: 12px !important; }
}
@media (max-width: 360px) {
.hp-row { grid-template-columns: minmax(0, 1fr) 38px 56px !important; font-size: 14px; }
.hp-topbar { gap: 5px !important; }
.hp-topbar-user, .hp-topbar-new { padding-left: 7px !important; padding-right: 7px !important; font-size: 12px; }
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
}, []); }, []);
+65 -81
View File
@@ -10,8 +10,8 @@ export const styles = {
shell: { shell: {
fontFamily: '"IM Fell English", system-ui', fontFamily: '"IM Fell English", system-ui',
padding: "22px 16px 42px", padding: 16,
maxWidth: 760, maxWidth: 680,
margin: "0 auto", margin: "0 auto",
}, },
@@ -22,38 +22,34 @@ export const styles = {
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
padding: "14px 16px", padding: 12,
borderRadius: 18, borderRadius: 16,
background: `linear-gradient(135deg, rgba(31, 28, 32, 0.90), ${stylesTokens.panelBg})`, background: stylesTokens.panelBg,
border: `1px solid ${stylesTokens.headerBorder}`, border: `1px solid ${stylesTokens.panelBorder}`,
boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(14px) saturate(1.12)", backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(14px) saturate(1.12)",
}, },
card: { card: {
borderRadius: 20, borderRadius: 18,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid ${stylesTokens.panelBorder}`,
background: `linear-gradient(145deg, rgba(25, 24, 28, 0.88), ${stylesTokens.panelBg})`, background: "rgba(18, 18, 20, 0.50)",
boxShadow: "0 20px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.07)", boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
cardBody: { cardBody: {
padding: "14px 16px", padding: 12,
display: "flex", display: "flex",
gap: 10, gap: 10,
alignItems: "center", alignItems: "center",
}, },
sectionHeader: { sectionHeader: {
padding: "13px 16px", padding: "11px 14px",
fontWeight: 1000, fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui', fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.5, letterSpacing: 1.0,
fontSize: 14,
color: stylesTokens.textGold, color: stylesTokens.textGold,
// WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert // WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert
@@ -61,16 +57,16 @@ export const styles = {
borderBottom: `1px solid ${stylesTokens.headerBorder}`, borderBottom: `1px solid ${stylesTokens.headerBorder}`,
textTransform: "uppercase", textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.72)", textShadow: "0 1px 0 rgba(0,0,0,0.6)",
}, },
row: { row: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 54px 68px", gridTemplateColumns: "1fr 54px 68px",
gap: 10, gap: 10,
padding: "13px 16px", padding: "12px 14px",
alignItems: "center", alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.10)", borderBottom: "1px solid rgba(233,216,166,0.08)",
borderLeft: "4px solid rgba(0,0,0,0)", borderLeft: "4px solid rgba(0,0,0,0)",
}, },
@@ -104,9 +100,9 @@ export const styles = {
tagBtn: { tagBtn: {
padding: "8px 0", padding: "8px 0",
fontWeight: 1000, fontWeight: 1000,
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
@@ -114,9 +110,9 @@ export const styles = {
helpBtn: { helpBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -126,31 +122,31 @@ export const styles = {
input: { input: {
width: "100%", width: "100%",
padding: "11px 12px", padding: "10px 12px",
borderRadius: 13, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(7,7,10,0.64)", background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 15, fontSize: 15,
}, },
primaryBtn: { primaryBtn: {
padding: "11px 15px", padding: "10px 12px",
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.34)`, border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))", background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
}, },
secondaryBtn: { secondaryBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
fontWeight: 900, fontWeight: 900,
cursor: "pointer", cursor: "pointer",
@@ -162,13 +158,12 @@ export const styles = {
position: "relative", position: "relative",
zIndex: 1, zIndex: 1,
marginTop: 14, marginTop: 14,
padding: 14, padding: 12,
borderRadius: 20, borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid rgba(233,216,166,0.14)`,
background: `linear-gradient(145deg, rgba(25, 24, 28, 0.86), ${stylesTokens.panelBg})`, background: "rgba(18, 18, 20, 0.40)",
boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(14px) saturate(1.08)", backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
adminTop: { adminTop: {
display: "flex", display: "flex",
@@ -180,20 +175,13 @@ export const styles = {
fontWeight: 1000, fontWeight: 1000,
color: stylesTokens.textGold, color: stylesTokens.textGold,
}, },
adminFieldLabel: {
display: "grid",
gap: 5,
color: stylesTokens.textDim,
fontSize: 12,
fontWeight: 800,
},
userRow: { userRow: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 80px 90px", gridTemplateColumns: "1fr 80px 90px",
gap: 8, gap: 8,
padding: 10, padding: 10,
borderRadius: 12, borderRadius: 12,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
border: `1px solid rgba(233,216,166,0.10)`, border: `1px solid rgba(233,216,166,0.10)`,
}, },
@@ -215,12 +203,12 @@ export const styles = {
modalCard: { modalCard: {
width: "min(560px, 100%)", width: "min(560px, 100%)",
borderRadius: 20, borderRadius: 18,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(145deg, rgba(27,25,30,0.98), rgba(11,11,14,0.98))", background: "rgba(12,12,14,0.96)",
boxShadow: "0 24px 70px rgba(0,0,0,0.78), inset 0 1px 0 rgba(255,255,255,0.07)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 16, padding: 14,
maxHeight: "calc(100dvh - 32px)", maxHeight: "calc(100vh - 32px)",
overflow: "auto", overflow: "auto",
}, },
modalHeader: { modalHeader: {
@@ -317,14 +305,13 @@ export const styles = {
width: "100%", width: "100%",
maxWidth: 420, maxWidth: 420,
padding: 26, padding: 26,
borderRadius: 24, borderRadius: 22,
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
border: `1px solid ${stylesTokens.headerBorder}`, border: `1px solid rgba(233,216,166,0.18)`,
background: `linear-gradient(145deg, rgba(29, 27, 31, 0.92), ${stylesTokens.panelBg})`, background: "rgba(18, 18, 20, 0.55)",
boxShadow: "0 24px 80px rgba(0,0,0,0.72), inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
backdropFilter: "blur(16px) saturate(1.12)", backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(16px) saturate(1.12)",
animation: "popIn 240ms ease-out", animation: "popIn 240ms ease-out",
color: stylesTokens.textMain, color: stylesTokens.textMain,
}, },
@@ -351,24 +338,24 @@ export const styles = {
}, },
loginInput: { loginInput: {
width: "100%", width: "100%",
padding: "11px 12px", padding: 10,
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(7,7,10,0.64)", background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 16, fontSize: 16,
}, },
loginBtn: { loginBtn: {
padding: "12px 15px", padding: "12px 14px",
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.34)`, border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))", background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
fontSize: 16, fontSize: 16,
cursor: "pointer", cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
}, },
loginHint: { loginHint: {
marginTop: 18, marginTop: 18,
@@ -444,10 +431,7 @@ export const styles = {
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
filter: "saturate(0.72) contrast(1.12) brightness(0.42) blur(1.2px)", filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
transform: "scale(1.015)",
backgroundColor: "rgba(8, 7, 12, 0.34)",
backgroundBlendMode: "multiply",
}, },
chipGrid: { chipGrid: {
@@ -459,9 +443,9 @@ export const styles = {
chipBtn: { chipBtn: {
padding: "10px 14px", padding: "10px 14px",
borderRadius: 13, borderRadius: 12,
border: "1px solid rgba(233,216,166,0.18)", border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -473,7 +457,7 @@ export const styles = {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
padding: "10px 12px", padding: "10px 12px",
borderRadius: 14, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)", background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
@@ -490,11 +474,11 @@ export const styles = {
minWidth: 220, minWidth: 220,
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(145deg, rgba(29,27,33,0.98), rgba(10,10,13,0.96))", background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden", overflow: "hidden",
zIndex: 99999, zIndex: 99999,
backdropFilter: "blur(14px)", backdropFilter: "blur(8px)",
}, },
userDropdownItem: { userDropdownItem: {