Compare commits

..
22 Commits
Author SHA1 Message Date
nessi e7d288ff12 Add SMTP security mode selector with SSL/STARTTLS/none options and automatic port switching
Replaced boolean `smtp_use_tls` with explicit `smtp_security` enum field supporting "none", "starttls", and "ssl" modes. Added database migration to create `smtp_security` column with "starttls" default. Updated mailer to use SMTP_SSL client for direct SSL connections on port 465 and SMTP with STARTTLS for port 587. Modified admin settings UI to show dropdown selector with encryption options and auto-adjust
2026-08-02 10:38:50 +02:00
nessi 33caa8f792 Add entry label translation system for German-to-English game sheet items
Implemented `translateEntryLabel` helper function with mapping dictionary for translating German game entry labels (spells, potions, locations) to English equivalents. Added language parameter extraction in SheetSection component and applied translation to entry labels based on current language setting. Maintains German as source labels with English translations provided via lookup table.
2026-08-02 10:36:44 +02:00
nessi 33a972e502 Add internationalization support with English/German language toggle across all components
Integrated i18n system throughout the application with `useLanguage` hook providing translation function and language state. Added bilingual support to all user-facing strings in App.jsx (password validation, error messages, game creation, sheet sections), AdminPanel (user management, form validation, status messages), AdminSettingsModal (SMTP configuration), ChipModal, DesignModal, GamePickerCard (lobby interface,
2026-08-02 10:33:40 +02:00
nessi d96b75ca82 Add email invitation system with SMTP configuration and invite token workflow
Implemented comprehensive email invitation functionality for new users. Added SMTP settings management in admin panel with configuration UI for host, port, credentials, sender details, and TLS options. Created invite token system with 48-hour expiration and single-use enforcement. Added mailer module with HTML email templates in German theme styling (radial gradient backgrounds, gold accents, bordered cards). Implemented invite
2026-08-02 10:24:34 +02:00
nessi ca4648b25a Refactor admin panel with icon-based actions, improved mobile layout, and enhanced text truncation
Replaced text action buttons with icon-based buttons (edit ✎, disable ⏻) with tooltips and aria-labels for accessibility. Adjusted admin user grid columns for better space distribution and added text truncation to name/email fields to prevent overflow. Enhanced mobile responsiveness with larger touch targets (36px), reduced editor card padding, and increased input/button heights (40px). Added box
2026-08-02 10:16:56 +02:00
nessi a532cae9bd Add user editing functionality to admin panel with improved UI and self-protection safeguards
Implemented comprehensive user editing in admin dashboard with PATCH endpoint for updating email, display name, role, password, and disabled status. Added validation to prevent admins from demoting or disabling themselves, and duplicate email detection. Refactored AdminPanel to modal-based editor with separate create/edit modes, form state management, and save/cancel actions. Enhanced UI with field labels, checkbox for account status, action
2026-08-02 10:11:38 +02:00
nessi 630a816df0 Restructure HelpModal with lobby workflow and improve content organization
Reorganized help sections to match game flow: lobby/start, game selection, status marking, notes/chips, game end, and user menu. Updated section titles and descriptions for clarity, added lobby mechanics explanation with host controls and chip generation, and included game completion details with read-only enforcement. Removed source file comment, simplified badge styling with inline styles, and refined text for better readability. Moved privacy tip to end and adjusted section numb
2026-08-02 10:08:07 +02:00
nessi f24489b1e5 Convert AdminPanel to modal overlay with close button and add admin dashboard menu option
Refactored AdminPanel from inline component to modal overlay triggered from user menu. Added `open` and `onClose` props to control visibility, wrapped panel in modal overlay with backdrop click-to-close behavior, and added close button to header. Implemented admin-only menu item in TopBar user dropdown with shield icon and role label. Added state management for dashboard visibility in App.jsx with proper cleanup
2026-08-02 10:04:58 +02:00
nessi 295282c2bf Center user dropdown menu horizontally on mobile devices using transform positioning 2026-08-02 09:59:48 +02:00
nessi a8335e2034 Prevent sheet editing after game completion and add game start celebration with enhanced splash screen animations
Added read-only enforcement for finished games by checking `winner_user_id` in backend PATCH endpoint and frontend sheet interactions. Implemented GameStartCelebration component with confetti burst, animated overlay card, and pulsing rune icon. Enhanced splash screen with orbiting border animation, sparkle effects, and title rise transition. Added haptic feedback (vibration) to sheet
2026-08-02 09:28:16 +02:00
nessi 29e063d88d Implement dynamic lobby polling with faster refresh rate and prevent concurrent requests
Added faster polling interval (700ms) for pre-game lobby to show joining players in near real-time, while keeping slower 2.5s refresh for active games. Implemented pending request flag to prevent concurrent API calls from overlapping. Added `cache: "no-store"` to fetch configuration to ensure fresh data on every request. Updated useEffect dependency array to re-initialize polling when game start state changes.
2026-08-02 09:22:48 +02:00
nessi 1bbbe31500 Hide game sheet sections and winner selection until game has started
Wrapped WinnerBadge, SheetSection grid, and WinnerCard components in conditional rendering based on `gameStarted` flag derived from game metadata's `started` property. Prevents display of game content in lobby state before host initiates gameplay.
2026-08-02 09:21:01 +02:00
nessi 46c6044948 Add game completion status display with winner information to GamePickerCard
Implemented finished game state detection and winner display in the game picker UI. Added `finished` and `winnerName` props to GamePickerCard component, passed through from game metadata's `winner_user_id` and winner display name/email. Updated status message styling to show trophy icon and winner name when game is complete, with distinct background color (gold tint) to differentiate from active games (green tint).
2026-08-02 09:20:26 +02:00
nessi fd94753dcb Add pre-game lobby with live player list, host status, and start confirmation
Implemented comprehensive lobby UI for games that haven't started yet. Added player count display, numbered player list with host crown indicator, and minimum 2-player requirement for game start. Included confirmation dialog before starting and error handling with user feedback. Enhanced visual styling with dedicated lobby container, player slots, and status messages. Updated README to reflect new lobby feature.
2026-08-02 09:17:17 +02:00
nessi e479e5b2a8 Refine TopBar mobile layout with improved spacing and text truncation
Adjusted TopBar mobile styling to prevent wrapping and optimize space usage on narrow screens. Updated flex properties to prevent button stretching, reduced padding and gaps for tighter layout, and added text truncation for game title with 105px max-width. Decreased minimum button height from 44px to 40px and refined font sizes for better fit on small devices.
2026-08-01 18:29:56 +02:00
nessi a7ac55c598 Add game start functionality with automatic player chip generation
Implemented host-controlled game start mechanism that generates unique player chips from user names. Added `started_at` timestamp to games and new `game_chips` table to store player identifiers. Chips are created using first name initial plus first two surname letters (e.g., SNE for Sascha Nesterovic) with automatic conflict resolution. Updated frontend to display start button for hosts, show game status, and populate chip selection modal
2026-08-01 18:29:02 +02:00
nessi 3904ba403a Add conditional styling for disabled user status in AdminPanel
Added CSS class to distinguish disabled users with red text color while active users display in green. Enhanced admin user row styling with pill-shaped role badges, improved mobile layout with adjusted grid positioning, and refined typography sizing for better visual hierarchy on smaller screens.
2026-08-01 18:22:37 +02:00
nessi b0263437e7 Add responsive user menu styling and improve mobile overflow handling
Enhanced TopBar user menu with CSS classes for targeted styling. Implemented responsive dropdown positioning and sizing for mobile devices with proper viewport constraints. Added text overflow handling for long usernames and emails, improved flex layout behavior, and ensured touch-friendly button sizing on narrow screens.
2026-08-01 18:18:34 +02:00
nessi 39245b7ee7 Add responsive mobile layout and improve viewport handling
Introduced CSS classes for targeted styling and implemented responsive breakpoints for mobile devices. Enhanced TopBar and AdminPanel layouts to adapt to smaller screens with reorganized grid structures and improved touch targets. Updated shell container to use dynamic viewport height (dvh) and added safe area insets for better mobile browser compatibility. Adjusted button sizes, spacing, and element wrapping for improved usability on narrow
2026-08-01 18:15:03 +02:00
nessi c176ccc904 Refine UI styling with enhanced visual polish and consistency
Updated global styles with improved button interactions, focus states, and hover effects. Enhanced component styling across cards, headers, inputs, and buttons with refined gradients, shadows, border radii, and backdrop filters. Adjusted spacing, padding, and typography for better visual hierarchy. Improved background image treatment with enhanced filters and blend modes for better contrast and readability.
2026-08-01 18:13:28 +02:00
nessi f43ab711fd Fix Nginx backend proxy resolution and prevent startup failures
Added dynamic DNS resolution for the backend service to prevent Nginx from failing when the backend container is not yet available during startup. Implemented Docker's internal resolver with a 10-second validity period and moved the backend URL to a variable for runtime resolution. Replaced direct proxy_pass with rewrite rule for proper path handling.
2026-08-01 18:08:27 +02:00
nessi 873627c757 Remove PostgreSQL and move to SQLite3 2026-08-01 18:02:37 +02:00
52 changed files with 2586 additions and 1847 deletions
-11
View File
@@ -1,11 +0,0 @@
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
@@ -0,0 +1,6 @@
# 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
@@ -0,0 +1,6 @@
.env
backend/data/
frontend/node_modules/
frontend/dist/
__pycache__/
*.py[cod]
+221 -1
View File
@@ -1,2 +1,222 @@
# 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,6 +1,7 @@
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
+17 -2
View File
@@ -1,10 +1,25 @@
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.environ["DATABASE_URL"] DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/cluedo.db")
engine = create_engine(DATABASE_URL, pool_pre_ping=True) engine_options = {"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):
+67
View File
@@ -0,0 +1,67 @@
import html
import smtplib
import ssl
from email.message import EmailMessage
from email.utils import formataddr
from .models import AppSettings, User
def _settings_ready(settings: AppSettings | None) -> bool:
return bool(settings and settings.smtp_host and settings.smtp_from_email)
def send_html_email(settings: AppSettings, recipient: str, subject: str, body_html: str, body_text: str):
if not _settings_ready(settings):
raise ValueError("SMTP settings are incomplete")
message = EmailMessage()
message["Subject"] = subject
message["From"] = formataddr((settings.smtp_from_name or "Cluedo HP", settings.smtp_from_email))
message["To"] = recipient
message.set_content(body_text)
message.add_alternative(body_html, subtype="html")
context = ssl.create_default_context()
security = getattr(settings, "smtp_security", "") or ("starttls" if settings.smtp_use_tls else "none")
port = settings.smtp_port or (465 if security == "ssl" else 587)
smtp_client = smtplib.SMTP_SSL if security == "ssl" else smtplib.SMTP
connection = smtp_client(settings.smtp_host, port, timeout=20, context=context) if security == "ssl" else smtp_client(settings.smtp_host, port, timeout=20)
with connection as server:
server.ehlo()
if security == "starttls":
server.starttls(context=context)
server.ehlo()
if settings.smtp_username:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
def send_user_invite(settings: AppSettings, user: User, invite_url: str):
name = html.escape(user.display_name or user.email)
safe_url = html.escape(invite_url, quote=True)
from_name = html.escape(settings.smtp_from_name or "Cluedo HP")
body_html = f"""
<!doctype html>
<html><body style="margin:0;background:#0b0b0f;color:#f5efdc;font-family:Georgia,serif;">
<div style="padding:36px 16px;background:radial-gradient(circle at top,#312b24 0,#0b0b0f 58%);">
<div style="max-width:580px;margin:0 auto;border:1px solid #6f603f;border-radius:22px;overflow:hidden;background:#17161b;box-shadow:0 18px 55px rgba(0,0,0,.55);">
<div style="padding:28px 30px;text-align:center;background:linear-gradient(135deg,#29231c,#141319);border-bottom:1px solid #6f603f;">
<div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div>
<div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div>
</div>
<div style="padding:30px;line-height:1.55;">
<div style="font-size:20px;color:#e9d8a6;">Hallo {name},</div>
<p>du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
<p>Richte über den folgenden Button dein persönliches Passwort ein:</p>
<p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p>
<p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p>
<p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p>
</div>
<div style="padding:16px 30px;color:#8e8777;font-size:12px;border-top:1px solid #302c2b;text-align:center;">Deine Notizen bleiben privat.</div>
</div>
</div>
</body></html>
"""
body_text = f"Hallo {user.display_name or user.email},\n\nbitte richte dein Passwort ein: {invite_url}\n\nDer Link ist 48 Stunden gültig."
send_html_email(settings, user.email, "Deine Einladung zum Zauber-Detektiv-Notizbogen", body_html, body_text)
+21 -21
View File
@@ -1,4 +1,3 @@
import os
import random import random
import string import string
@@ -8,11 +7,12 @@ 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, Role, Game, GameMember from .models import User, Entry, Category, 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
from .routes.invites import router as invites_router
app = FastAPI(title="Cluedo Sheet") app = FastAPI(title="Cluedo Sheet")
@@ -31,6 +31,8 @@ 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)
app.include_router(invites_router)
def _rand_join_code(n: int = 6) -> str: def _rand_join_code(n: int = 6) -> str:
@@ -86,6 +88,14 @@ Very small, pragmatic auto-migration (no alembic).
- supports old schema (join_code/chip_code) and new schema (code/chip) - supports old schema (join_code/chip_code) and new schema (code/chip)
""" """
# --- app settings: explicit SMTP security mode (none/starttls/ssl) ---
if not _has_column(db, "app_settings", "smtp_security"):
try:
db.execute(text("ALTER TABLE app_settings ADD COLUMN smtp_security VARCHAR DEFAULT 'starttls'"))
db.commit()
except Exception:
db.rollback()
# --- users.display_name --- # --- users.display_name ---
if not _has_column(db, "users", "display_name"): if not _has_column(db, "users", "display_name"):
try: try:
@@ -136,6 +146,14 @@ 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:
@@ -276,23 +294,6 @@ 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
@@ -301,7 +302,6 @@ 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()
+37
View File
@@ -41,6 +41,32 @@ class User(Base):
display_name: Mapped[str] = mapped_column(String, default="") display_name: Mapped[str] = mapped_column(String, default="")
class AppSettings(Base):
__tablename__ = "app_settings"
id: Mapped[str] = mapped_column(String, primary_key=True, default="default")
smtp_host: Mapped[str] = mapped_column(String, default="")
smtp_port: Mapped[int] = mapped_column(Integer, default=587)
smtp_username: Mapped[str] = mapped_column(String, default="")
smtp_password: Mapped[str] = mapped_column(String, default="")
smtp_from_email: Mapped[str] = mapped_column(String, default="")
smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP")
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
smtp_security: Mapped[str] = mapped_column(String, default="starttls")
app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081")
class InviteToken(Base):
__tablename__ = "invite_tokens"
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
token_hash: Mapped[str] = mapped_column(String, index=True)
expires_at: Mapped[str] = mapped_column(DateTime(timezone=True))
used_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
@@ -54,6 +80,17 @@ 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):
+156 -9
View File
@@ -1,7 +1,12 @@
import hashlib
import secrets
from datetime import datetime, timedelta, 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 User, Role from ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, InviteToken, User, Role
from ..security import hash_password, get_session_user_id from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -37,18 +42,47 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
password = data.get("password") or "" password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip() display_name = (data.get("display_name") or "").strip()
if not email or not password: if not email or not display_name:
raise HTTPException(400, "email/password required") raise HTTPException(400, "name/email required")
if db.query(User).filter(User.email == email).first(): if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists") raise HTTPException(409, "email exists")
role = data.get("role") or Role.user.value role = data.get("role")
if not role:
raise HTTPException(400, "role required")
if role not in (Role.admin.value, Role.user.value): if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role") raise HTTPException(400, "invalid role")
settings = db.query(AppSettings).filter(AppSettings.id == "default").first() or AppSettings(id="default")
invite_sent = not password
raw_token = None
if invite_sent:
if not settings.smtp_host or not settings.smtp_from_email:
raise HTTPException(400, "configure SMTP settings before sending invites")
raw_token = secrets.token_urlsafe(32)
password = secrets.token_urlsafe(32)
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name) u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
db.add(u); db.commit() db.add(u)
return {"ok": True, "id": u.id} db.flush()
if invite_sent:
invite = InviteToken(
user_id=u.id,
token_hash=hashlib.sha256(raw_token.encode()).hexdigest(),
expires_at=datetime.now(timezone.utc) + timedelta(hours=48),
)
db.add(invite)
invite_url = f"{(settings.app_base_url or 'http://localhost:8081').rstrip('/')}/invite/{raw_token}"
try:
send_user_invite(settings, u, invite_url)
except Exception as exc:
db.rollback()
raise HTTPException(502, f"invite email could not be sent: {exc}")
db.commit()
return {"ok": True, "id": u.id, "invite_sent": invite_sent}
@router.delete("/users/{user_id}") @router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
@@ -61,11 +95,124 @@ 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}
def get_settings(db: Session) -> AppSettings:
settings = db.query(AppSettings).filter(AppSettings.id == "default").first()
if not settings:
settings = AppSettings(id="default")
db.add(settings)
db.commit()
db.refresh(settings)
return settings
@router.get("/settings/smtp")
def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
return {
"smtp_host": settings.smtp_host,
"smtp_port": settings.smtp_port,
"smtp_username": settings.smtp_username,
"smtp_password_configured": bool(settings.smtp_password),
"smtp_from_email": settings.smtp_from_email,
"smtp_from_name": settings.smtp_from_name,
"smtp_security": getattr(settings, "smtp_security", "starttls") or ("starttls" if settings.smtp_use_tls else "none"),
"smtp_use_tls": settings.smtp_use_tls,
"app_base_url": settings.app_base_url,
}
@router.patch("/settings/smtp")
def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
settings.smtp_host = (data.get("smtp_host") or "").strip()
try:
settings.smtp_port = int(data.get("smtp_port") or 587)
except (TypeError, ValueError):
raise HTTPException(400, "SMTP port must be a number")
settings.smtp_username = (data.get("smtp_username") or "").strip()
settings.smtp_from_email = (data.get("smtp_from_email") or "").strip()
settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip()
security = data.get("smtp_security") or ("starttls" if data.get("smtp_use_tls", True) else "none")
if security not in ("none", "starttls", "ssl"):
raise HTTPException(400, "invalid SMTP security mode")
settings.smtp_security = security
settings.smtp_use_tls = security == "starttls"
settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/")
if "smtp_password" in data and data.get("smtp_password"):
settings.smtp_password = data["smtp_password"]
if settings.smtp_host and not settings.smtp_from_email:
raise HTTPException(400, "from email required when SMTP is configured")
db.add(settings)
db.commit()
return {"ok": True}
@router.post("/settings/smtp/test")
def test_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db)
settings = get_settings(db)
recipient = (data.get("recipient") or "").strip()
if not recipient:
raise HTTPException(400, "recipient required")
try:
send_html_email(
settings,
recipient,
"SMTP-Test Cluedo HP",
"<div style='font-family:Georgia,serif;padding:24px;background:#17161b;color:#f5efdc'><h2 style='color:#e9d8a6'>SMTP funktioniert</h2><p>Der Einladungsversand ist bereit.</p></div>",
"SMTP funktioniert. Der Einladungsversand ist bereit.",
)
except Exception as exc:
raise HTTPException(502, f"SMTP test failed: {exc}")
return {"ok": True}
+85 -2
View File
@@ -1,8 +1,12 @@
import hashlib, random import hashlib
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, Entry, SheetState, Category, GameMember, User, Role from ..models import Game, GameChip, 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"])
@@ -27,6 +31,25 @@ 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:
@@ -78,6 +101,8 @@ 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}
@@ -112,6 +137,7 @@ 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
@@ -139,9 +165,63 @@ 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)
@@ -228,6 +308,9 @@ 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")
+47
View File
@@ -0,0 +1,47 @@
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import InviteToken, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/auth/invite", tags=["invites"])
def get_invite(token: str, db: Session):
token_hash = hashlib.sha256(token.encode()).hexdigest()
invite = db.query(InviteToken).filter(InviteToken.token_hash == token_hash).first()
expires_at = invite.expires_at if invite else None
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if not invite or invite.used_at or expires_at <= datetime.now(timezone.utc):
raise HTTPException(410, "invite is invalid or expired")
user = db.query(User).filter(User.id == invite.user_id, User.disabled == False).first()
if not user:
raise HTTPException(410, "invite is invalid or expired")
return invite, user
@router.get("/{token}")
def invite_info(token: str, db: Session = Depends(get_db)):
_invite, user = get_invite(token, db)
return {"valid": True, "display_name": user.display_name, "email": user.email, "role": user.role}
@router.post("/{token}")
def accept_invite(token: str, data: dict, resp: Response, db: Session = Depends(get_db)):
invite, user = get_invite(token, db)
password = data.get("password") or ""
if len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
user.password_hash = hash_password(password)
invite.used_at = datetime.now(timezone.utc)
db.add(user)
db.add(invite)
db.commit()
set_session(resp, make_session_value(user.id))
return {"ok": True}
+58
View File
@@ -0,0 +1,58 @@
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,7 +1,6 @@
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
+4 -21
View File
@@ -1,30 +1,13 @@
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: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: sqlite:////app/data/cluedo.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"
depends_on: volumes:
db: - cluedo-data:/app/data
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
@@ -36,4 +19,4 @@ services:
- "8081:80" - "8081:80"
volumes: volumes:
pgdata: cluedo-data:
+47
View File
@@ -94,6 +94,36 @@
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;
@@ -111,6 +141,7 @@
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 {
@@ -156,6 +187,21 @@
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 -->
@@ -170,6 +216,7 @@
<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>
+6 -1
View File
@@ -7,7 +7,12 @@ server {
} }
location /api/ { location /api/ {
proxy_pass http://backend:8080/; # Resolve the Docker service at request time. This prevents Nginx from
# 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.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+639 -628
View File
File diff suppressed because it is too large Load Diff
-379
View File
@@ -1,379 +0,0 @@
/* 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,6 +3,7 @@ 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,
}); });
+136 -166
View File
@@ -1,204 +1,174 @@
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"; import { useLanguage } from "../i18n";
export default function AdminPanel() { const emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false };
export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) {
const { language, t } = useLanguage();
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [editorOpen, setEditorOpen] = useState(false);
const [open, setOpen] = useState(false); const [editingUser, setEditingUser] = useState(null);
const [displayName, setDisplayName] = useState(""); const [form, setForm] = useState(emptyForm);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => { useEffect(() => {
if (!open) return; if (!dashboardOpen && !editorOpen) 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; };
return () => { }, [dashboardOpen, editorOpen]);
document.body.style.overflow = prev;
};
}, [open]);
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
useEffect(() => { useEffect(() => {
loadUsers().catch(() => {}); if (!dashboardOpen) {
}, []); setEditorOpen(false);
setMsg("");
}
}, [dashboardOpen]);
const resetForm = () => { const loadUsers = async () => setUsers(await api("/admin/users"));
setDisplayName("");
setEmail(""); useEffect(() => { loadUsers().catch(() => {}); }, []);
setPassword("");
setRole("user"); const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
setMsg("");
setNotice("");
setEditorOpen(true);
}; };
const createUser = async () => { const openEdit = (user) => {
setEditingUser(user);
setForm({
displayName: user.display_name || "",
email: user.email || "",
password: "",
role: user.role || "user",
disabled: !!user.disabled,
});
setMsg(""); setMsg("");
setNotice("");
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
setEditingUser(null);
setMsg("");
};
const saveUser = async () => {
setMsg("");
if (!form.email.trim()) return setMsg(`${t("email")} ${language === "en" ? "is required." : "ist erforderlich."}`);
if (!editingUser && form.password && form.password.length < 8) return setMsg(`${t("password")} ${language === "en" ? "must be at least 8 characters." : "muss mindestens 8 Zeichen haben."}`);
if (editingUser && form.password && form.password.length < 8) return setMsg(`${language === "en" ? "New password must be at least 8 characters." : "Neues Passwort muss mindestens 8 Zeichen haben."}`);
setSaving(true);
try { try {
await api("/admin/users", { const payload = {
method: "POST", display_name: form.displayName,
body: JSON.stringify({ display_name: displayName, email, password, role }), email: form.email,
role: form.role,
disabled: form.disabled,
};
if (form.password) payload.password = form.password;
const result = await api(editingUser ? `/admin/users/${editingUser.id}` : "/admin/users", {
method: editingUser ? "PATCH" : "POST",
body: JSON.stringify(payload),
}); });
setMsg("✅ User erstellt.");
await loadUsers(); await loadUsers();
resetForm(); setNotice(result.invite_sent ? (language === "en" ? "✅ Invitation sent by email." : "✅ Einladung wurde per E-Mail versendet.") : (language === "en" ? "✅ User saved." : "✅ User gespeichert."));
setOpen(false); closeEditor();
} catch (e) { } catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown")); setMsg("❌ " + (e?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen.")));
} finally {
setSaving(false);
} }
}; };
const deleteUser = async (u) => { const disableUser = async (user) => {
if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return; if (user.id === currentUserId) return;
if (!window.confirm(`User wirklich deaktivieren?\n\n${user.display_name || user.email}`)) return;
try { try {
await api(`/admin/users/${u.id}`, { method: "DELETE" }); await api(`/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers(); await loadUsers();
} catch (e) { } catch (e) {
alert("Fehler: " + (e?.message || "unknown")); alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} }
}; };
const closeModal = () => { if (!dashboardOpen) return null;
setOpen(false);
setMsg("");
};
return ( return createPortal(
<div style={styles.adminWrap}> <div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.adminTop}> <div className="hp-admin-dashboard-card" style={{ ...styles.modalCard, width: "min(780px, 100%)", padding: 0 }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.adminTitle}>Admin Dashboard</div> <div style={{ padding: "18px 18px 16px" }}>
<button onClick={() => setOpen(true)} style={styles.primaryBtn}> <div style={styles.modalHeader}>
+ User anlegen <div>
</button> <div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>{t("adminDashboard")}</div>
</div> <div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{t("adminDashboardSubtitle")}</div>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
Vorhandene User
</div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((u) => (
<div
key={u.id}
style={{
...styles.userRow,
gridTemplateColumns: "1fr 1fr 80px 90px 92px",
alignItems: "center",
}}
>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
</div> </div>
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div> <button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
<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 style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{t("existingUsers")} <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div>
<button onClick={openCreate} style={styles.primaryBtn}>+ {t("createUser")}</button>
</div>
{notice && <div style={{ marginTop: 10, color: "#baf3c9", fontSize: 13 }}>{notice}</div>}
<div style={{ marginTop: 10, display: "grid", gap: 9 }}>
{users.map((user) => (
<div key={user.id} className="hp-admin-user-row" style={{ ...styles.userRow, alignItems: "center" }}>
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{user.display_name || "—"}</div>
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{user.email}</div>
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div>
<div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div>
<div className="hp-admin-actions">
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></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 ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button>
</div>
</div>
))}
</div>
</div>
</div> </div>
{open && {editorOpen && createPortal(
createPortal( <div style={styles.modalOverlay} onMouseDown={closeEditor}>
<div style={styles.modalOverlay} onMouseDown={closeModal}> <div className="hp-admin-editor-card" style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<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 ? t("editUser") : t("newUser")}</div>
Neuen User anlegen <div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{editingUser ? (language === "en" ? "Update profile and credentials" : "Profil und Zugangsdaten aktualisieren") : (language === "en" ? "Create a new user account" : "Ein neues Benutzerkonto erstellen")}</div>
</div>
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div
style={{
marginTop: 12,
display: "grid",
gap: 8,
justifyItems: "center", // <<< zentriert alles
}}
>
<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>
<button onClick={closeEditor} style={styles.modalCloseBtn} aria-label={t("close")}></button>
</div> </div>
</div>,
document.body <div style={{ marginTop: 18, display: "grid", gap: 10 }}>
) <label style={styles.adminFieldLabel}>{t("displayName")}<input value={form.displayName} onChange={(e) => setField("displayName", e.target.value)} placeholder={language === "en" ? "e.g. Sascha Nesterovic" : "z. B. Sascha Nesterovic"} style={styles.input} autoFocus /></label>
} <label style={styles.adminFieldLabel}>{t("email")}<input value={form.email} onChange={(e) => setField("email", e.target.value)} placeholder="name@example.com" style={styles.input} inputMode="email" /></label>
</div> <label style={styles.adminFieldLabel}>{editingUser ? `${t("password")} (${t("optional")})` : `${t("password")} (${t("optional")})`}<input value={form.password} onChange={(e) => setField("password", e.target.value)} placeholder={editingUser ? (language === "en" ? "Leave blank = unchanged" : "Leer lassen = unverändert") : t("inviteHint")} type="password" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("role")}<select value={form.role} onChange={(e) => setField("role", e.target.value)} disabled={editingUser?.id === currentUserId} style={styles.input}><option value="user">{t("user")}</option><option value="admin">{t("admin")}</option></select></label>
{editingUser && <label className="hp-admin-check"><input type="checkbox" checked={!form.disabled} onChange={(e) => setField("disabled", !e.target.checked)} /> {t("accountActive")}</label>}
{msg && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{msg}</div>}
{!editingUser && <div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{t("inviteNotice")}</div>}
<button onClick={saveUser} style={{ ...styles.primaryBtn, width: "100%", marginTop: 4 }} disabled={saving}>{saving ? (language === "en" ? "Saving …" : "Speichern …") : editingUser ? (language === "en" ? "Save changes" : "Änderungen speichern") : t("createUser")}</button>
</div>
</div>
</div>,
document.body
)}
</div>,
document.body
); );
} }
@@ -0,0 +1,90 @@
import React, { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { api } from "../api/client";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
const initial = {
smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password: "",
smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_security: "starttls",
app_base_url: window.location.origin,
};
export default function AdminSettingsModal({ open, onClose }) {
const { language, t } = useLanguage();
const [form, setForm] = useState(initial);
const [configured, setConfigured] = useState(false);
const [recipient, setRecipient] = useState("");
const [message, setMessage] = useState("");
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
useEffect(() => {
if (!open) return;
setMessage("");
api("/admin/settings/smtp").then((data) => {
setForm({ ...initial, ...data, smtp_password: "" });
setConfigured(!!data.smtp_password_configured);
}).catch((error) => setMessage("❌ " + (error?.message || (language === "en" ? "Loading failed." : "Laden fehlgeschlagen."))));
}, [open]);
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const save = async () => {
setSaving(true); setMessage("");
try {
await api("/admin/settings/smtp", { method: "PATCH", body: JSON.stringify(form) });
setConfigured(!!form.smtp_password || configured);
setMessage("✅ SMTP-Einstellungen gespeichert.");
} catch (error) { setMessage("❌ " + (error?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen."))); }
finally { setSaving(false); }
};
const test = async () => {
if (!recipient.trim()) return setMessage(`${language === "en" ? "Please enter a test email." : "Bitte eine Test-E-Mail angeben."}`);
setTesting(true); setMessage("");
try {
await api("/admin/settings/smtp/test", { method: "POST", body: JSON.stringify({ recipient }) });
setMessage("✅ Test-E-Mail wurde versendet.");
} catch (error) { setMessage("❌ " + (error?.message || (language === "en" ? "Test failed." : "Test fehlgeschlagen."))); }
finally { setTesting(false); }
};
if (!open) return null;
return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div className="hp-admin-settings-card" style={{ ...styles.modalCard, width: "min(620px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div><div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>{t("adminSettings")}</div><div style={{ color: stylesTokens.textDim, fontSize: 12, marginTop: 3 }}>{t("adminSettingsSubtitle")}</div></div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}></button>
</div>
<div style={{ marginTop: 16, display: "grid", gap: 10 }}>
<div className="hp-settings-grid">
<label style={styles.adminFieldLabel}>{t("smtpServer")}<input value={form.smtp_host} onChange={(e) => setField("smtp_host", e.target.value)} placeholder="smtp.example.com" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("port")}<input type="number" value={form.smtp_port} onChange={(e) => setField("smtp_port", e.target.value)} style={styles.input} /></label>
</div>
<div className="hp-settings-grid">
<label style={styles.adminFieldLabel}>{t("smtpUsername")}<input value={form.smtp_username} onChange={(e) => setField("smtp_username", e.target.value)} placeholder={t("optional")} style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("password")}<input type="password" value={form.smtp_password} onChange={(e) => setField("smtp_password", e.target.value)} placeholder={configured ? "••••••••" : t("optional")} style={styles.input} /></label>
</div>
<div className="hp-settings-grid">
<label style={styles.adminFieldLabel}>{t("senderEmail")}<input type="email" value={form.smtp_from_email} onChange={(e) => setField("smtp_from_email", e.target.value)} placeholder="noreply@example.com" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("senderName")}<input value={form.smtp_from_name} onChange={(e) => setField("smtp_from_name", e.target.value)} style={styles.input} /></label>
</div>
<label style={styles.adminFieldLabel}>{t("appUrl")}<input value={form.app_base_url} onChange={(e) => setField("app_base_url", e.target.value)} placeholder="https://notizbogen.example.com" style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{language === "en" ? "SMTP encryption" : "SMTP-Verschlüsselung"}<select value={form.smtp_security || "starttls"} onChange={(e) => { const security = e.target.value; setField("smtp_security", security); if (security === "ssl" && String(form.smtp_port) === "587") setField("smtp_port", 465); if (security === "starttls" && String(form.smtp_port) === "465") setField("smtp_port", 587); }} style={styles.input}>
<option value="none">{language === "en" ? "None" : "Keine Verschlüsselung"}</option>
<option value="starttls">STARTTLS</option>
<option value="ssl">{language === "en" ? "TLS/SSL (direct, port 465)" : "TLS/SSL (direkt, Port 465)"}</option>
</select></label>
<div className="hp-settings-test">
<label style={{ ...styles.adminFieldLabel, flex: 1 }}>{t("testEmail")}<input type="email" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="you@example.com" style={styles.input} /></label>
<button onClick={test} style={styles.secondaryBtn} disabled={testing}>{testing ? (language === "en" ? "Sending …" : "Sende …") : t("sendTest")}</button>
</div>
{message && <div style={{ color: message.startsWith("✅") ? "#baf3c9" : "#ffb3b3", fontSize: 13 }}>{message}</div>}
<button onClick={save} style={{ ...styles.primaryBtn, width: "100%" }} disabled={saving}>{saving ? (language === "en" ? "Saving …" : "Speichern …") : t("saveSettings")}</button>
</div>
</div>
</div>, document.body
);
}
+16 -9
View File
@@ -1,33 +1,40 @@
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"; import { useLanguage } from "../i18n";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) { export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) {
const { language, t } = useLanguage();
if (!chipOpen) return null; if (!chipOpen) return null;
return ( return (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}> <div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
<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 }}>Wer hat die Karte?</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("whoHasCard")}</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div> <div style={{ marginTop: 12, color: stylesTokens.textMain }}>{t("selectChip")}:</div>
<div style={styles.chipGrid}> <div style={styles.chipGrid}>
{CHIP_LIST.map((c) => ( {chips.map((item) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}> <button key={item.user_id || item.chip} onClick={() => chooseChip(item.chip)} style={styles.chipBtn} title={item.display_name || item.email}>
{c} {item.chip}
</button> </button>
))} ))}
</div> </div>
{!chips.length && (
<div style={{ marginTop: 12, color: stylesTokens.textDim }}>
{language === "en" ? "The game has not started yet or no player chips exist." : "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 . {language === "en" ? <>Tip: Tap the note button again to go from <b>s</b> back to .</> : <>Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .</>}
</div> </div>
</div> </div>
</div> </div>
+5 -3
View File
@@ -2,8 +2,10 @@ 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 { THEMES } from "../styles/themes"; import { THEMES } from "../styles/themes";
import { useLanguage } from "../i18n";
export default function DesignModal({ open, onClose, themeKey, onSelect }) { export default function DesignModal({ open, onClose, themeKey, onSelect }) {
const { language, t } = useLanguage();
if (!open) return null; if (!open) return null;
const themeEntries = Object.entries(THEMES); const themeEntries = Object.entries(THEMES);
@@ -12,14 +14,14 @@ export default function DesignModal({ open, onClose, themeKey, onSelect }) {
<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 }}>Design ändern</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("changeDesign")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
<div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}>
Wähle dein Theme: {language === "en" ? "Choose your theme:" : "Wähle dein Theme:"}
</div> </div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}> <div style={{ marginTop: 12, display: "grid", gap: 10 }}>
-147
View File
@@ -1,147 +0,0 @@
// 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
@@ -1,188 +0,0 @@
// 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>
);
}
+107 -6
View File
@@ -1,6 +1,7 @@
import React from "react"; import React, { useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GamePickerCard({ export default function GamePickerCard({
games, games,
@@ -10,8 +11,17 @@ export default function GamePickerCard({
members = [], members = [],
me, me,
hostUserId, hostUserId,
isHost = false,
started = false,
finished = false,
winnerName = "",
chipCount = 0,
onStartGame,
}) { }) {
const { language, t } = useLanguage();
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() || "—");
@@ -42,10 +52,25 @@ export default function GamePickerCard({
whiteSpace: "nowrap", whiteSpace: "nowrap",
}); });
const handleStartGame = async () => {
if (!onStartGame || starting || members.length < 2) return;
if (!window.confirm(language === "en" ? "Start the game now? No more players can join afterwards." : "Spiel jetzt starten? Danach können keine weiteren Spieler beitreten.")) return;
setStarting(true);
setStartError("");
try {
await onStartGame();
} catch (e) {
setStartError(e?.message || (language === "en" ? "The game could not be started." : "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}>
<div style={styles.sectionHeader}>Spiel</div> <div style={styles.sectionHeader}>{t("game")}</div>
<div style={styles.cardBody}> <div style={styles.cardBody}>
<select <select
@@ -60,8 +85,8 @@ export default function GamePickerCard({
))} ))}
</select> </select>
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe"> <button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}>
Hilfe {t("help")}
</button> </button>
</div> </div>
@@ -85,8 +110,84 @@ 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 }}>
{t("lobby")}
</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>
{isHost ? (language === "en" ? "You are the host of this game." : "Du bist der Host dieses Spiels.") : (language === "en" ? "You joined the lobby." : "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 }}>{t("players")}</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() || t("players"));
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 }}>
{language === "en" ? "Waiting for players …" : "Warte auf Spieler …"}
</div>
)}
</div>
{isHost ? (
<>
<button
onClick={handleStartGame}
style={{ ...styles.primaryBtn, width: "100%", marginTop: 12 }}
disabled={starting || members.length < 2}
>
{starting ? (language === "en" ? "Starting game …" : "Spiel wird gestartet …") : `${t("startGame")}`}
</button>
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
{members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
</div>
</>
) : (
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
{t("waitingForHost")}
</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
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
: `${t("started")} · ${chipCount} ${t("chipsCreated")}`}
</div>
)}
{/* Spieler */} {/* Spieler */}
{members?.length > 0 && ( {started && members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}> <div style={{ padding: "0 12px 12px" }}>
<div <div
style={{ style={{
@@ -99,7 +200,7 @@ export default function GamePickerCard({
gap: 10, gap: 10,
}} }}
> >
<div>Spieler</div> <div>{t("players")}</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
{members.length} {members.length}
</div> </div>
@@ -0,0 +1,47 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GameStartCelebration({ open, onClose }) {
const { t, language } = useLanguage();
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={t("gameStarted")}>
<div className="hp-start-card" onClick={(event) => event.stopPropagation()}>
<div className="hp-start-rune"></div>
<div className="hp-start-kicker">{t("investigationsBegin")}</div>
<div className="hp-start-title">{t("gameStarted")}</div>
<div className="hp-start-subtitle">{t("bestDetective")}</div>
<button onClick={onClose} style={{ marginTop: 18, color: stylesTokens.textGold }}>
{language === "en" ? "Lets go" : "Los geht's"}
</button>
</div>
</div>,
document.body
);
}
+94 -100
View File
@@ -1,186 +1,180 @@
// 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";
import { useLanguage } from "../i18n";
export default function HelpModal({ open, onClose }) { export default function HelpModal({ open, onClose }) {
const { language, t } = useLanguage();
if (!open) return null; if (!open) return null;
const en = language === "en";
return ( return (
<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</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("helpTitle")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
<div style={styles.helpBody}> <div style={styles.helpBody}>
{/* ===== 0) Spiele & Navigation ===== */} <div style={styles.helpSectionTitle}>1) {en ? "Lobby & starting the game" : "Lobby & Spielstart"}</div>
<div style={styles.helpSectionTitle}>0) Spiele auswählen / Neues Spiel</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Oben im Bereich <b>Spiel</b> kannst du zwischen bestehenden Spielen wechseln oder ein neues {en ? <>A new game starts as a lobby. Share the displayed <b>game code</b> with the other players. They can join until the host starts the game.</> : <>Ein neues Spiel startet zunächst als Lobby. Teile den angezeigten <b>Spiel-Code</b> mit den 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>
{en ? <>The <b>host</b> sees all joined players and starts the game.</> : <>Der <b>Host</b> sieht alle beigetretenen Spieler und startet das Spiel.</>}
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
{en ? <>The game can start with <b>two players</b>. No additional players can join afterwards.</> : <>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>
{en ? <>Player chips are created automatically when the game starts. Example: Sascha Nesterovic becomes <b>SNE</b>.</> : <>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) {en ? "Selecting a game" : "Spiel auswählen"}</div>
<div style={styles.helpText}>
{en ? <>Use the <b>Game</b> dropdown to switch between your games. With <b>New Game</b> you can create or join a game.</> : <>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> (Dropdown neben dem Hilfe-Button) = vorhandene / alte Spiele öffnen <b>{en ? "Game selection" : "Spiel-Auswahl"}</b> = {en ? "open existing games" : "vorhandene 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>Neues Spiel</b> = erstellt ein neues Spiel und öffnet es automatisch <b>New Game</b> = {en ? "create a game or join with a code" : "neues Spiel erstellen oder per Code beitreten"}
</div> </div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
{/* ===== 1) Status per Tippen ===== */} <div style={styles.helpSectionTitle}>3) {en ? "Tap a name status" : "Namen antippen Status"}</div>
<div style={styles.helpSectionTitle}>1) Namen antippen (Status)</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Tippe auf einen Namen, um den Status zu ändern. Reihenfolge: {en ? "During the game, tap an entry name. The status cycle is:" : "Tippe während des laufenden Spiels auf den Namen eines Eintrags. Die Statusfolge ist:"}
</div> </div>
<div style={styles.helpList}> <div style={styles.helpList}>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span <span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}></span>
style={{ <div><b>{en ? "Green" : "Grün"}</b> = {en ? "confirmed / present" : "bestätigt / vorhanden"}</div>
...styles.helpBadge,
background: "rgba(0,190,80,0.18)",
color: "#baf3c9",
}}
>
</span>
<div>
<b>Grün</b> = bestätigt / fix richtig
</div>
</div> </div>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span <span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}></span>
style={{ <div><b>{en ? "Red" : "Rot"}</b> = {en ? "excluded / not present" : "ausgeschlossen / nicht vorhanden"}</div>
...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 <span style={{ ...styles.helpBadge, background: "rgba(140,140,140,0.14)", color: "rgba(233,216,166,0.85)" }}>?</span>
style={{ <div><b>{en ? "Grey" : "Grau"}</b> = {en ? "uncertain / maybe" : "unsicher / vielleicht"}</div>
...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>
<div style={styles.helpListRow}> <div style={styles.helpListRow}>
<span <span style={{ ...styles.helpBadge, background: "rgba(255,255,255,0.08)", color: "rgba(233,216,166,0.75)" }}></span>
style={{ <div><b>{en ? "Empty" : "Leer"}</b> = {en ? "not evaluated yet" : "noch nicht bewertet"}</div>
...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} />
{/* ===== 2) i / m / s Notizen ===== */} <div style={styles.helpSectionTitle}>4) {en ? "Notes & player chips" : "Notizen & Spieler-Chips"}</div>
<div style={styles.helpSectionTitle}>2) i / m / s Button (Notizen)</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Rechts pro Zeile gibt es einen Button, der durch diese Werte rotiert: {en ? "The button on the right of each row cycles through your personal notes:" : "Der Button rechts in jeder Zeile rotiert durch deine persönlichen Notizen:"}
</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> <div><b>i</b> = {en ? "I have this card" : "Ich habe diese Karte"}</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> <div><b>m</b> = {en ? "card from the middle deck" : "Karte aus dem mittleren Deck"}</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> <div><b>s</b> = {en ? "another player has the card; then choose the matching chip" : "Ein anderer Spieler hat die Karte; danach wählst du den passenden Chip"}</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> = {en ? "Sascha Nesterovic has the card" : "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> <div><b></b> = keine Zusatznotiz</div>
<b></b> = keine Notiz
</div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={styles.helpDivider} />
{/* ===== 3) User-Menü ===== */} <div style={styles.helpSectionTitle}>5) {en ? "Game end" : "Spielende"}</div>
<div style={styles.helpSectionTitle}>3) User-Menü (Passwort / Logout)</div>
<div style={styles.helpText}> <div style={styles.helpText}>
Oben rechts im <b>User</b>-Menü findest du persönliche Einstellungen: {en ? "Only the host can select the winner. Once a winner is saved, the game is finished:" : "Nur der Host kann den Sieger festlegen. Sobald ein Sieger gespeichert wurde, gilt das Spiel als beendet:"}
</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>{en ? <>All note sheets become <b>read-only</b>.</> : <>Alle Notizzettel werden <b>schreibgeschützt</b>.</>}</div>
<b>User</b> öffnen = zeigt die aktuell verwendete Email-Adresse </div>
</div> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🏆</span>
<div>{en ? "The winner is shown to all players." : "Der Sieger wird für alle Spieler angezeigt."}</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>6) {en ? "User menu" : "User-Menü"}</div>
<div style={styles.helpText}>
{en ? <>At the top right, you will find the <b>User</b> or <b>Admin</b> menu depending on your role.</> : <>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.helpListRow}>
<span style={styles.helpMiniTag}>📊</span>
<div><b>{t("statistics")}</b> = {en ? "personal game and win statistics" : "persönliche Spiele- und Siegstatistik"}</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🎨</span>
<div><b>{t("changeDesign")}</b> = {en ? "choose a theme" : "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> <div><b>{t("setPassword")}</b> = {en ? "change your password" : "eigenes Passwort ändern"}</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> <div><b>{t("logout")}</b> = {en ? "sign out" : "abmelden"}</div>
<b>Logout</b> = ausloggen </div>
</div> <div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🛡</span>
<div><b>{t("adminDashboard")}</b> = {en ? "create and edit users, change roles, set passwords and disable accounts" : "User anlegen, bearbeiten, Rollen ändern, Passwörter setzen und Konten deaktivieren"}</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div><b>{t("adminSettings")}</b> = {en ? "configure SMTP and send invitations without a preset password" : "SMTP konfigurieren und Einladungen ohne vorgegebenes Passwort versenden"}</div>
</div> </div>
</div> </div>
<div style={styles.helpDivider} /> <div style={{ ...styles.helpText, marginTop: 16 }}>
{en ? "Your notes remain private. Each player sees only their own note sheet." : "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>
+42
View File
@@ -0,0 +1,42 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function InvitePage({ token }) {
const { language, t } = useLanguage();
const [info, setInfo] = useState(null);
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [message, setMessage] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => { api(`/auth/invite/${encodeURIComponent(token)}`).then(setInfo).catch((e) => setMessage("❌ " + (e?.message || "Diese Einladung ist nicht mehr gültig."))); }, [token]);
const submit = async (event) => {
event.preventDefault(); setMessage("");
if (password.length < 8) return setMessage(`${language === "en" ? "Password must be at least 8 characters." : "Das Passwort muss mindestens 8 Zeichen haben."}`);
if (password !== confirm) return setMessage(`${language === "en" ? "Passwords do not match." : "Die Passwörter stimmen nicht überein."}`);
setSaving(true);
try { await api(`/auth/invite/${encodeURIComponent(token)}`, { method: "POST", body: JSON.stringify({ password }) }); setMessage("✅ Passwort gesetzt. Du wirst weitergeleitet …"); setTimeout(() => { window.location.href = "/"; }, 700); }
catch (e) { setMessage("❌ " + (e?.message || "Einladung konnte nicht aktiviert werden.")); setSaving(false); }
};
return <div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true"><div style={styles.bgMap} /></div>
<main className="hp-invite-card" style={{ ...styles.loginCard, position: "relative", zIndex: 1 }}>
<div className="hp-invite-seal"></div>
<div style={{ color: stylesTokens.textGold, fontWeight: 1000, fontSize: 24 }}>Notizbogen</div>
<div style={{ color: stylesTokens.textDim, marginTop: 5 }}>{t("invite")}</div>
{info ? <>
<div style={{ marginTop: 22, padding: "14px 16px", borderRadius: 14, background: "rgba(233,216,166,.08)", border: "1px solid rgba(233,216,166,.18)", textAlign: "left" }}><div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 18 }}>{info.display_name}</div><div style={{ color: stylesTokens.textDim, fontSize: 13, marginTop: 4 }}>{info.email} · {info.role}</div></div>
<form onSubmit={submit} style={{ marginTop: 18, display: "grid", gap: 10, textAlign: "left" }}>
<label style={styles.adminFieldLabel}>{t("password")}<input autoFocus type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("passwordMin")} style={styles.input} /></label>
<label style={styles.adminFieldLabel}>{t("confirmPassword")}<input type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} placeholder={t("passwordAgain")} style={styles.input} /></label>
{message && <div style={{ color: message.startsWith("✅") ? "#baf3c9" : "#ffb3b3", fontSize: 13 }}>{message}</div>}
<button type="submit" style={{ ...styles.primaryBtn, width: "100%", marginTop: 5 }} disabled={saving}>{saving ? (language === "en" ? "Activating …" : "Wird aktiviert …") : t("acceptInvite")}</button>
</form>
</> : <div style={{ marginTop: 24, color: message ? "#ffb3b3" : stylesTokens.textDim }}>{message || "Einladung wird geprüft …"}</div>}
</main>
</div>;
}
+6 -4
View File
@@ -2,8 +2,10 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function JoinGameModal({ open, onClose, onJoin }) { export default function JoinGameModal({ open, onClose, onJoin }) {
const { language, t } = useLanguage();
const [code, setCode] = useState(""); const [code, setCode] = useState("");
const [msg, setMsg] = useState(""); const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@@ -19,13 +21,13 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
const doJoin = async () => { const doJoin = async () => {
const c = (code || "").trim(); const c = (code || "").trim();
if (!c) return setMsg("Bitte Code eingeben."); if (!c) return setMsg(`${language === "en" ? "Please enter a code." : "Bitte Code eingeben."}`);
setBusy(true); setBusy(true);
setMsg(""); setMsg("");
try { try {
await onJoin(c); await onJoin(c);
} catch (e) { } catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown")); setMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setBusy(false); setBusy(false);
} }
}; };
@@ -34,8 +36,8 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
<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 }}>Spiel beitreten</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("joinGame")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
+84 -9
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { useLanguage } from "../i18n";
export default function LoginPage({ export default function LoginPage({
loginEmail, loginEmail,
@@ -9,7 +10,22 @@ export default function LoginPage({
showPw, showPw,
setShowPw, setShowPw,
doLogin, doLogin,
setupRequired,
setupEmail,
setSetupEmail,
setupDisplayName,
setSetupDisplayName,
setupPassword,
setSetupPassword,
setupPasswordConfirm,
setSetupPasswordConfirm,
setupError,
setupSaving,
doSetup,
}) { }) {
const { language, t } = useLanguage();
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">
@@ -21,14 +37,72 @@ 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}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div> <div style={styles.loginSubtitle}>
{setupRequired === null
? (language === "en" ? "Initializing application …" : "Initialisiere Anwendung …")
: isSetup
? (language === "en" ? "Set up the first administrator" : "Richte den ersten Administrator ein")
: (language === "en" ? "Log in to open your Cluedo sheet" : "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen")}
</div>
<div style={{ marginTop: 18, display: "grid", gap: 12 }}> {isSetup ? (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder={t("displayName")}
style={styles.loginInput}
autoComplete="name"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder={language === "en" ? "Admin email" : "Admin-E-Mail"}
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder={t("passwordMin")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder={t("confirmPassword")}
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 ? (language === "en" ? "Setting up …" : "Einrichtung …") : (language === "en" ? "✦ Create administrator" : "✦ Administrator erstellen")}
</button>
</div>
) : (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}> <div style={styles.loginFieldWrap}>
<input <input
value={loginEmail} value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)} onChange={(e) => setLoginEmail(e.target.value)}
placeholder="Email" placeholder={t("email")}
style={styles.loginInput} style={styles.loginInput}
inputMode="email" inputMode="email"
autoComplete="username" autoComplete="username"
@@ -40,7 +114,7 @@ export default function LoginPage({
<input <input
value={loginPassword} value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)} onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Passwort" placeholder={t("password")}
type={showPw ? "text" : "password"} type={showPw ? "text" : "password"}
style={styles.inputInRow} style={styles.inputInRow}
autoComplete="current-password" autoComplete="current-password"
@@ -49,8 +123,8 @@ export default function LoginPage({
type="button" type="button"
onClick={() => setShowPw((v) => !v)} onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn} style={styles.pwToggleBtn}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"} aria-label={showPw ? (language === "en" ? "Hide password" : "Passwort verstecken") : (language === "en" ? "Show password" : "Passwort anzeigen")}
title={showPw ? "Verstecken" : "Anzeigen"} title={showPw ? (language === "en" ? "Hide" : "Verstecken") : (language === "en" ? "Show" : "Anzeigen")}
> >
{showPw ? "🙈" : "👁"} {showPw ? "🙈" : "👁"}
</button> </button>
@@ -58,12 +132,13 @@ export default function LoginPage({
</div> </div>
<button onClick={doLogin} style={styles.loginBtn}> <button onClick={doLogin} style={styles.loginBtn}>
Anmelden {t("login")}
</button> </button>
</div> </div>
)}
<div style={styles.loginHint}> <div style={styles.loginHint}>
Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel. {language === "en" ? "Your notes remain private every player only sees their own sheet." : "Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Notizzettel."}
</div> </div>
</div> </div>
</div> </div>
+24 -22
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function NewGameModal({ export default function NewGameModal({
open, open,
@@ -13,6 +14,7 @@ export default function NewGameModal({
gameFinished = false, gameFinished = false,
hasGame = false, hasGame = false,
}) { }) {
const { language, t } = useLanguage();
// modes: running | choice | create | join // modes: running | choice | create | join
const [mode, setMode] = useState("choice"); const [mode, setMode] = useState("choice");
const [joinCode, setJoinCode] = useState(""); const [joinCode, setJoinCode] = useState("");
@@ -53,7 +55,7 @@ export default function NewGameModal({
setCreated({ code: res.code }); setCreated({ code: res.code });
setMode("create"); setMode("create");
} catch (e) { } catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown")); setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} }
}; };
@@ -63,16 +65,16 @@ export default function NewGameModal({
await onJoin(joinCode.trim().toUpperCase()); await onJoin(joinCode.trim().toUpperCase());
onClose(); onClose();
} catch (e) { } catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown")); setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} }
}; };
const copyText = async (text, okMsg = "✅ Code kopiert") => { const copyText = async (text, okMsg = language === "en" ? "✅ Code copied" : "✅ Code kopiert") => {
try { try {
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(text || "");
showToast(okMsg); showToast(okMsg);
} catch { } catch {
showToast("❌ Copy nicht möglich"); showToast(language === "en" ? "❌ Copy failed" : "❌ Copy nicht möglich");
} }
}; };
@@ -85,9 +87,9 @@ export default function NewGameModal({
<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 }}> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Spiel {t("game")}
</div> </div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
@@ -116,7 +118,7 @@ export default function NewGameModal({
{mode === "running" && ( {mode === "running" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Das Spiel läuft noch. Hier ist der <b>Join-Code</b>: {language === "en" ? <>The game is in progress. Here is the <b>join code</b>:</> : <>Das Spiel läuft noch. Hier ist der <b>Join-Code</b>:</>}
</div> </div>
<div <div
@@ -131,7 +133,7 @@ export default function NewGameModal({
}} }}
> >
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code {t("gameCode")}
</div> </div>
<div <div
@@ -150,19 +152,19 @@ export default function NewGameModal({
onClick={() => copyText(codeToShow)} onClick={() => copyText(codeToShow)}
style={styles.primaryBtn} style={styles.primaryBtn}
disabled={!codeToShow} disabled={!codeToShow}
title={!codeToShow ? "Kein Code verfügbar" : "Code kopieren"} title={!codeToShow ? (language === "en" ? "No code available" : "Kein Code verfügbar") : (language === "en" ? "Copy code" : "Code kopieren")}
> >
Code kopieren {t("copy")} {language === "en" ? "code" : "Code"}
</button> </button>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten. {language === "en" ? "Once a winner is selected, you can create or join a new game here." : "Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten."}
</div> </div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}> <button onClick={onClose} style={styles.primaryBtn}>
Fertig {language === "en" ? "Done" : "Fertig"}
</button> </button>
</div> </div>
</> </>
@@ -173,15 +175,15 @@ export default function NewGameModal({
{mode === "choice" && ( {mode === "choice" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>? {language === "en" ? <>Would you like to <b>create</b> a game or <b>join</b> one?</> : <>Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>?</>}
</div> </div>
<button onClick={doCreate} style={styles.primaryBtn}> <button onClick={doCreate} style={styles.primaryBtn}>
Spiel erstellen {t("createGame")}
</button> </button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}> <button onClick={() => setMode("join")} style={styles.secondaryBtn}>
Spiel beitreten {t("joinGame")}
</button> </button>
</> </>
)} )}
@@ -189,7 +191,7 @@ export default function NewGameModal({
{mode === "join" && ( {mode === "join" && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Gib den <b>Code</b> ein: {language === "en" ? <>Enter the <b>code</b>:</> : <>Gib den <b>Code</b> ein:</>}
</div> </div>
<input <input
@@ -202,10 +204,10 @@ export default function NewGameModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}> <button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
Zurück {language === "en" ? "Back" : "Zurück"}
</button> </button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}> <button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
Beitreten {t("join")}
</button> </button>
</div> </div>
</> </>
@@ -214,7 +216,7 @@ export default function NewGameModal({
{mode === "create" && created && ( {mode === "create" && created && (
<> <>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}> <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Dein Spiel wurde erstellt. Dieser Code bleibt auch bei Alte Spiele sichtbar: {language === "en" ? "Your game was created. This code will remain visible in your old games:" : "Dein Spiel wurde erstellt. Dieser Code bleibt auch bei „Alte Spiele“ sichtbar:"}
</div> </div>
<div <div
@@ -229,7 +231,7 @@ export default function NewGameModal({
}} }}
> >
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code {t("gameCode")}
</div> </div>
<div <div
@@ -245,13 +247,13 @@ export default function NewGameModal({
</div> </div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}> <button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren {t("copy")} {language === "en" ? "code" : "Code"}
</button> </button>
</div> </div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}> <button onClick={onClose} style={styles.primaryBtn}>
Fertig {language === "en" ? "Done" : "Fertig"}
</button> </button>
</div> </div>
</> </>
+9 -7
View File
@@ -1,6 +1,7 @@
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 { useLanguage } from "../i18n";
export default function PasswordModal({ export default function PasswordModal({
pwOpen, pwOpen,
@@ -13,14 +14,15 @@ export default function PasswordModal({
pwSaving, pwSaving,
savePassword, savePassword,
}) { }) {
const { language, t } = useLanguage();
if (!pwOpen) return null; if (!pwOpen) return null;
return ( return (
<div style={styles.modalOverlay} onMouseDown={closePwModal}> <div style={styles.modalOverlay} onMouseDown={closePwModal}>
<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 }}>Passwort setzen</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("setPasswordTitle")}</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={closePwModal} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
@@ -29,7 +31,7 @@ export default function PasswordModal({
<input <input
value={pw1} value={pw1}
onChange={(e) => setPw1(e.target.value)} onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort" placeholder={language === "en" ? "New password" : "Neues Passwort"}
type="password" type="password"
style={styles.input} style={styles.input}
autoFocus autoFocus
@@ -37,7 +39,7 @@ export default function PasswordModal({
<input <input
value={pw2} value={pw2}
onChange={(e) => setPw2(e.target.value)} onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen" placeholder={t("passwordAgain")}
type="password" type="password"
style={styles.input} style={styles.input}
onKeyDown={(e) => { onKeyDown={(e) => {
@@ -49,15 +51,15 @@ export default function PasswordModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}> <button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen {t("cancel")}
</button> </button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}> <button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"} {pwSaving ? (language === "en" ? "Saving…" : "Speichern") : t("save")}
</button> </button>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Mindestens 8 Zeichen empfohlen. {language === "en" ? "Note: At least 8 characters are recommended." : "Hinweis: Mindestens 8 Zeichen empfohlen."}
</div> </div>
</div> </div>
</div> </div>
+10 -5
View File
@@ -2,6 +2,7 @@
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 { translateEntryLabel, useLanguage } from "../i18n";
export default function SheetSection({ export default function SheetSection({
title, title,
@@ -10,7 +11,9 @@ export default function SheetSection({
onCycleStatus, onCycleStatus,
onToggleTag, onToggleTag,
displayTag, displayTag,
readOnly = false,
}) { }) {
const { language, t } = useLanguage();
const getRowBg = (status) => { const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg; if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg; if (status === 2) return stylesTokens.rowOkBg;
@@ -70,16 +73,17 @@ export default function SheetSection({
}} }}
> >
<div <div
onClick={() => onCycleStatus(e)} onClick={() => !readOnly && 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="Klick: Grün → Rot → Grau → Leer" title={readOnly ? t("readOnly") : t("cycleStatus")}
> >
{e.label} {translateEntryLabel(e.label, language)}
</div> </div>
<div style={styles.statusCell}> <div style={styles.statusCell}>
@@ -95,9 +99,10 @@ export default function SheetSection({
</div> </div>
<button <button
onClick={() => onToggleTag(e)} onClick={() => !readOnly && onToggleTag(e)}
style={styles.tagBtn} style={styles.tagBtn}
title="— → i → m → s.(Chip) → —" disabled={readOnly}
title={readOnly ? t("readOnly") : t("cycleNote")}
> >
{displayTag(e)} {displayTag(e)}
</button> </button>
+9 -7
View File
@@ -2,6 +2,7 @@ import React from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
function Tile({ label, value, sub }) { function Tile({ label, value, sub }) {
return ( return (
@@ -48,6 +49,7 @@ function Tile({ label, value, sub }) {
} }
export default function StatsModal({ open, onClose, me, stats, loading, error }) { export default function StatsModal({ open, onClose, me, stats, loading, error }) {
const { language, t } = useLanguage();
if (!open) return null; if (!open) return null;
const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
@@ -57,13 +59,13 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
<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> <div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div> <div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("statistics")}</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName} {displayName}
</div> </div>
</div> </div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen"> <button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button> </button>
</div> </div>
@@ -71,7 +73,7 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
<div style={{ marginTop: 12 }}> <div style={{ marginTop: 12 }}>
{loading ? ( {loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}> <div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik {language === "en" ? "Loading statistics…" : "Lade Statistik…"}
</div> </div>
) : error ? ( ) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div> <div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
@@ -83,10 +85,10 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
gap: 10, gap: 10,
}} }}
> >
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} /> <Tile label={language === "en" ? "Games played" : "Gespielte Spiele"} value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} /> <Tile label={language === "en" ? "Wins" : "Siege"} value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} /> <Tile label={language === "en" ? "Losses" : "Verluste"} value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" /> <Tile label={language === "en" ? "Win rate" : "Siegerate"} value={`${stats?.winrate ?? 0}%`} sub={language === "en" ? "finished games only" : "nur beendete Spiele"} />
</div> </div>
)} )}
</div> </div>
+55 -13
View File
@@ -1,6 +1,7 @@
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 { useLanguage } from "../i18n";
export default function TopBar({ export default function TopBar({
me, me,
@@ -9,13 +10,18 @@ export default function TopBar({
openPwModal, openPwModal,
openDesignModal, openDesignModal,
openStatsModal, openStatsModal,
openAdminPanel,
openAdminSettings,
doLogout, doLogout,
onOpenNewGame, onOpenNewGame,
}) { }) {
const { toggleLanguage, t } = useLanguage();
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 ? t("admin") : t("user");
return ( return (
<div style={styles.topBar}> <div className="hp-topbar" 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 }}>
@@ -23,20 +29,21 @@ export default function TopBar({
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu> <div className="hp-topbar-actions" style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}> <div className="hp-user-menu-wrap" 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={t("user") + " menu"}
> >
<span style={{ fontSize: 16 }}>👤</span> <span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span>
<span>User</span> <span>{roleLabel}</span>
<span style={{ opacity: 0.7 }}></span> <span style={{ opacity: 0.7 }}></span>
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div style={styles.userDropdown}> <div className="hp-user-dropdown" style={styles.userDropdown}>
<div <div
style={{ style={{
padding: "10px 12px", padding: "10px 12px",
@@ -56,21 +63,56 @@ export default function TopBar({
}} }}
style={styles.userDropdownItem} style={styles.userDropdownItem}
> >
Statistik {t("statistics")}
</button> </button>
{isAdmin && (
<>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
setUserMenuOpen(false);
openAdminPanel?.();
}}
style={styles.userDropdownItem}
>
{t("adminDashboard")}
</button>
<button
onClick={() => {
setUserMenuOpen(false);
openAdminSettings?.();
}}
style={styles.userDropdownItem}
>
{t("adminSettings")}
</button>
</>
)}
<div style={styles.userDropdownDivider} /> <div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}> <button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen {t("setPassword")}
</button> </button>
<button onClick={openDesignModal} style={styles.userDropdownItem}> <button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern {t("changeDesign")}
</button> </button>
<div style={styles.userDropdownDivider} /> <div style={styles.userDropdownDivider} />
<button
onClick={() => {
toggleLanguage();
setUserMenuOpen(false);
}}
style={styles.userDropdownItem}
title={`${t("changeLanguage")}: ${t("switchTo")}`}
>
🌐 {t("changeLanguage")}
</button>
<button <button
onClick={() => { onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
@@ -78,14 +120,14 @@ export default function TopBar({
}} }}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }} style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
> >
Logout {t("logout")}
</button> </button>
</div> </div>
)} )}
</div> </div>
<button onClick={onOpenNewGame} style={styles.primaryBtn}> <button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game {t("newGame")}
</button> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
/** /**
* Props: * Props:
@@ -7,6 +8,7 @@ import { stylesTokens } from "../styles/theme";
* - winnerEmail: string | null (legacy fallback) * - winnerEmail: string | null (legacy fallback)
*/ */
export default function WinnerBadge({ winner, winnerEmail }) { export default function WinnerBadge({ winner, winnerEmail }) {
const { language } = useLanguage();
const name = const name =
(winner?.display_name || "").trim() || (winner?.display_name || "").trim() ||
(winner?.email || "").trim() || (winner?.email || "").trim() ||
@@ -34,13 +36,13 @@ export default function WinnerBadge({ winner, winnerEmail }) {
<div style={{ fontSize: 18 }}>🏆</div> <div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}> <div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger: {language === "en" ? "Winner:" : "Sieger:"}
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span> <span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div> </div>
</div> </div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
festgelegt {language === "en" ? "selected" : "festgelegt"}
</div> </div>
</div> </div>
); );
+6 -4
View File
@@ -1,6 +1,7 @@
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 { useLanguage } from "../i18n";
export default function WinnerCard({ export default function WinnerCard({
isHost, isHost,
@@ -9,12 +10,13 @@ export default function WinnerCard({
setWinnerUserId, setWinnerUserId,
onSave, onSave,
}) { }) {
const { language, t } = useLanguage();
if (!isHost) return null; if (!isHost) return null;
return ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div> <div style={styles.sectionHeader}>{t("winner")}</div>
<div style={styles.cardBody}> <div style={styles.cardBody}>
<select <select
@@ -22,7 +24,7 @@ export default function WinnerCard({
onChange={(e) => setWinnerUserId(e.target.value || "")} onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }} style={{ ...styles.input, flex: 1 }}
> >
<option value=""> kein Sieger </option> <option value=""> {language === "en" ? "no winner" : "kein Sieger"} </option>
{members.map((m) => { {members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim()); const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return ( return (
@@ -34,12 +36,12 @@ export default function WinnerCard({
</select> </select>
<button onClick={onSave} style={styles.primaryBtn}> <button onClick={onSave} style={styles.primaryBtn}>
Speichern {t("save")}
</button> </button>
</div> </div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}> <div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Nur der Host (Spiel-Ersteller) kann den Sieger setzen. {language === "en" ? "Only the host (game creator) can select the winner." : "Nur der Host (Spiel-Ersteller) kann den Sieger setzen."}
</div> </div>
</div> </div>
</div> </div>
@@ -2,8 +2,10 @@ import React, { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCelebration({ open, winnerName, onClose }) { export default function WinnerCelebration({ open, winnerName, onClose }) {
const { language } = useLanguage();
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -146,15 +148,15 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
lineHeight: 1.25, lineHeight: 1.25,
}} }}
> >
Spieler{" "} {language === "en" ? "Player " : "Spieler "}
<span style={{ color: stylesTokens.textGold }}> <span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"} {winnerName || "Unbekannt"}
</span>{" "} </span>{" "}
hat die richtige Lösung! {language === "en" ? "has the correct solution!" : "hat die richtige Lösung!"}
</div> </div>
<div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}> <div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}>
Fall gelöst. Respekt. {language === "en" ? "Case solved. Respect. ✨" : "Fall gelöst. Respekt. ✨"}
</div> </div>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}> <div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}>
-1
View File
@@ -1,2 +1 @@
export const API_BASE = "/api"; export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
+246
View File
@@ -0,0 +1,246 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
const STORAGE_KEY = "hpLanguage";
const LanguageContext = createContext(null);
const entryLabels = {
"Schlaftrunk": "Sleeping Potion",
"Verschwindekabinett": "Vanishing Cabinet",
"Portschlüssel": "Portkey",
"Impedimenta": "Impedimenta",
"Petrificus Totalus": "Petrificus Totalus",
"Alraune": "Mandrake",
"Große Halle": "Great Hall",
"Krankenflügel": "Hospital Wing",
"Raum der Wünsche": "Room of Requirement",
"Klassenzimmer für Zaubertränke": "Potions Classroom",
"Pokalszimmer": "Trophy Room",
"Klassenzimmer für Wahrsagen": "Divination Classroom",
"Eulerei": "Owlery",
"Bibliothek": "Library",
"Verteidigung gegen die dunklen Künste": "Defence Against the Dark Arts",
};
export function translateEntryLabel(label, language) {
return language === "en" ? (entryLabels[label] || label) : label;
}
export const translations = {
de: {
language: "Deutsch",
changeLanguage: "Sprache ändern",
switchTo: "English",
statistics: "Statistik",
adminDashboard: "Admin Dashboard",
adminSettings: "Admin Settings",
setPassword: "Passwort setzen",
changeDesign: "Design ändern",
logout: "Logout",
newGame: "New Game",
user: "User",
admin: "Admin",
help: "Hilfe",
game: "Spiel",
players: "Spieler",
suspects: "Verdächtige Personen",
items: "Gegenstände",
locations: "Orte",
winner: "Sieger",
started: "Spiel läuft",
finished: "Spiel beendet",
lobby: "Lobby",
startGame: "Spiel starten",
waitingForHost: "Warte, bis der Host das Spiel startet.",
chipsCreated: "Spieler-Chips erstellt",
atLeastTwoPlayers: "Mindestens 2 Spieler werden benötigt.",
chipsOnStart: "Beim Start werden die Spieler-Chips erstellt.",
noPlayersYet: "Noch keine Spieler beigetreten.",
close: "Schließen",
cancel: "Abbrechen",
save: "Speichern",
create: "Erstellen",
edit: "Bearbeiten",
delete: "Löschen",
disable: "Deaktivieren",
active: "aktiv",
disabled: "deaktiviert",
loading: "Laden …",
email: "E-Mail",
password: "Passwort",
confirmPassword: "Passwort bestätigen",
role: "Rolle",
displayName: "Anzeigename",
name: "Name",
createUser: "User anlegen",
editUser: "User bearbeiten",
newUser: "Neuen User anlegen",
existingUsers: "Vorhandene User",
accountActive: "Konto ist aktiv",
optional: "optional",
inviteHint: "Leer lassen = Einladung senden",
inviteNotice: "Ohne Passwort wird automatisch eine einmalige Einladung per SMTP verschickt.",
adminDashboardSubtitle: "Benutzer, Rollen und Zugangsdaten verwalten",
adminSettingsSubtitle: "SMTP und Einladungen konfigurieren",
smtpServer: "SMTP-Server",
port: "Port",
smtpUsername: "SMTP-Benutzername",
senderEmail: "Absender-E-Mail",
senderName: "Absendername",
appUrl: "App-URL für Einladungslinks",
useTls: "STARTTLS verwenden (empfohlen)",
testEmail: "Test-E-Mail",
sendTest: "Test senden",
saveSettings: "Einstellungen speichern",
setupAdmin: "Admin einrichten",
login: "Anmelden",
logout: "Logout",
enterEmail: "E-Mail eingeben",
passwordMin: "Mindestens 8 Zeichen",
passwordAgain: "Passwort wiederholen",
acceptInvite: "Einladung annehmen",
invite: "Deine Einladung",
setPasswordTitle: "Passwort setzen",
design: "Design ändern",
joinGame: "Spiel beitreten",
newGameTitle: "Neues Spiel",
gameCode: "Spiel-Code",
copy: "Kopieren",
copied: "Kopiert",
createGame: "Spiel erstellen",
join: "Beitreten",
chooseAction: "Was möchtest du tun?",
helpTitle: "Hilfe & Spielablauf",
notes: "Notizen",
playerChips: "Spieler-Chips",
whoHasCard: "Wer hat die Karte?",
selectChip: "Chip auswählen",
readOnly: "Spiel beendet Notizzettel ist schreibgeschützt",
cycleStatus: "Klick: Grün → Rot → Grau → Leer",
cycleNote: "— → i → m → s.(Chip) → —",
gameStarted: "Spiel gestartet",
investigationsBegin: "Die Ermittlungen beginnen",
bestDetective: "Möge der beste Detektiv gewinnen.",
},
en: {
language: "English",
changeLanguage: "Change language",
switchTo: "Deutsch",
statistics: "Statistics",
adminDashboard: "Admin Dashboard",
adminSettings: "Admin Settings",
setPassword: "Set password",
changeDesign: "Change design",
logout: "Logout",
newGame: "New Game",
user: "User",
admin: "Admin",
help: "Help",
game: "Game",
players: "Players",
suspects: "Suspects",
items: "Items",
locations: "Locations",
winner: "Winner",
started: "Game in progress",
finished: "Game finished",
lobby: "Lobby",
startGame: "Start game",
waitingForHost: "Wait for the host to start the game.",
chipsCreated: "player chips created",
atLeastTwoPlayers: "At least 2 players are required.",
chipsOnStart: "Player chips will be created when the game starts.",
noPlayersYet: "No players have joined yet.",
close: "Close",
cancel: "Cancel",
save: "Save",
create: "Create",
edit: "Edit",
delete: "Delete",
disable: "Disable",
active: "active",
disabled: "disabled",
loading: "Loading …",
email: "Email",
password: "Password",
confirmPassword: "Confirm password",
role: "Role",
displayName: "Display name",
name: "Name",
createUser: "Create user",
editUser: "Edit user",
newUser: "Create new user",
existingUsers: "Existing users",
accountActive: "Account is active",
optional: "optional",
inviteHint: "Leave blank = send invitation",
inviteNotice: "Without a password, a one-time invitation is sent via SMTP.",
adminDashboardSubtitle: "Manage users, roles and credentials",
adminSettingsSubtitle: "Configure SMTP and invitations",
smtpServer: "SMTP server",
port: "Port",
smtpUsername: "SMTP username",
senderEmail: "Sender email",
senderName: "Sender name",
appUrl: "App URL for invitation links",
useTls: "Use STARTTLS (recommended)",
testEmail: "Test email",
sendTest: "Send test",
saveSettings: "Save settings",
setupAdmin: "Set up admin",
login: "Log in",
enterEmail: "Enter email",
passwordMin: "At least 8 characters",
passwordAgain: "Repeat password",
acceptInvite: "Accept invitation",
invite: "Your invitation",
setPasswordTitle: "Set password",
design: "Change design",
joinGame: "Join game",
newGameTitle: "New game",
gameCode: "Game code",
copy: "Copy",
copied: "Copied",
createGame: "Create game",
join: "Join",
chooseAction: "What would you like to do?",
helpTitle: "Help & game guide",
notes: "Notes",
playerChips: "Player chips",
whoHasCard: "Who has the card?",
selectChip: "Select chip",
readOnly: "Game finished note sheet is read-only",
cycleStatus: "Click: Green → Red → Grey → Empty",
cycleNote: "— → i → m → s.(chip) → —",
gameStarted: "Game started",
investigationsBegin: "The investigation begins",
bestDetective: "May the best detective win.",
},
};
export function LanguageProvider({ children }) {
const [language, setLanguage] = useState(() => {
try { return localStorage.getItem(STORAGE_KEY) === "en" ? "en" : "de"; } catch { return "de"; }
});
useEffect(() => {
try { localStorage.setItem(STORAGE_KEY, language); } catch {}
document.documentElement.lang = language;
}, [language]);
const value = useMemo(() => ({
language,
setLanguage,
toggleLanguage: () => setLanguage((current) => current === "de" ? "en" : "de"),
t: (key, values = {}) => {
let value = translations[language][key] || key;
Object.entries(values).forEach(([name, replacement]) => { value = value.replaceAll(`{${name}}`, replacement); });
return value;
},
}), [language]);
return <LanguageContext.Provider value={value}>{children}</LanguageContext.Provider>;
}
export function useLanguage() {
return useContext(LanguageContext) || { language: "de", toggleLanguage: () => {}, t: (key) => key };
}
+4 -1
View File
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { LanguageProvider } from "./i18n.jsx";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes"; import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
@@ -21,7 +22,9 @@ async function bootstrap() {
} catch {} } catch {}
// App rendern // App rendern
ReactDOM.createRoot(document.getElementById("root")).render(<App />); ReactDOM.createRoot(document.getElementById("root")).render(
<LanguageProvider><App /></LanguageProvider>
);
// Splash mind. 3 Sekunden anzeigen (3000ms) // Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000; const MIN_SPLASH_MS = 3000;
+80 -2
View File
@@ -2,6 +2,8 @@ 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(() => {
@@ -74,10 +76,86 @@ export function useHpGlobalStyles() {
color: ${stylesTokens.textMain}; color: ${stylesTokens.textMain};
} }
body { body {
overflow: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch;
} }
#root { background: transparent; } #root { background: transparent; }
* { -webkit-tap-highlight-color: transparent; } *, *::before, *::after { box-sizing: border-box; -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-settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 10px; }
.hp-settings-test { display: flex; align-items: end; gap: 10px; }
.hp-invite-card { animation: popIn 480ms cubic-bezier(.2,.8,.2,1); box-shadow: 0 24px 80px rgba(0,0,0,.6), 0 0 40px rgba(233,216,166,.08); }
.hp-invite-seal { width: 52px; height: 52px; margin: 0 auto 14px; display: grid; place-items: center; border: 1px solid rgba(233,216,166,.42); border-radius: 50%; color: var(--hp-textGold); font-size: 24px; box-shadow: 0 0 24px rgba(233,216,166,.12); }
.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-settings-grid { grid-template-columns: 1fr; }
.hp-settings-test { align-items: stretch; flex-direction: column; }
.hp-settings-test button { width: 100%; }
.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-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; }
.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);
}, []); }, []);
+81 -65
View File
@@ -10,8 +10,8 @@ export const styles = {
shell: { shell: {
fontFamily: '"IM Fell English", system-ui', fontFamily: '"IM Fell English", system-ui',
padding: 16, padding: "22px 16px 42px",
maxWidth: 680, maxWidth: 760,
margin: "0 auto", margin: "0 auto",
}, },
@@ -22,34 +22,38 @@ export const styles = {
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
padding: 12, padding: "14px 16px",
borderRadius: 16, borderRadius: 18,
background: stylesTokens.panelBg, background: `linear-gradient(135deg, rgba(31, 28, 32, 0.90), ${stylesTokens.panelBg})`,
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid ${stylesTokens.headerBorder}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)", boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.08)",
backdropFilter: "blur(6px)", backdropFilter: "blur(14px) saturate(1.12)",
WebkitBackdropFilter: "blur(14px) saturate(1.12)",
}, },
card: { card: {
borderRadius: 18, borderRadius: 20,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(18, 18, 20, 0.50)", background: `linear-gradient(145deg, rgba(25, 24, 28, 0.88), ${stylesTokens.panelBg})`,
boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "0 20px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.07)",
backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
cardBody: { cardBody: {
padding: 12, padding: "14px 16px",
display: "flex", display: "flex",
gap: 10, gap: 10,
alignItems: "center", alignItems: "center",
}, },
sectionHeader: { sectionHeader: {
padding: "11px 14px", padding: "13px 16px",
fontWeight: 1000, fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui', fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.0, letterSpacing: 1.5,
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
@@ -57,16 +61,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.6)", textShadow: "0 1px 0 rgba(0,0,0,0.72)",
}, },
row: { row: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 54px 68px", gridTemplateColumns: "1fr 54px 68px",
gap: 10, gap: 10,
padding: "12px 14px", padding: "13px 16px",
alignItems: "center", alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.08)", borderBottom: "1px solid rgba(233,216,166,0.10)",
borderLeft: "4px solid rgba(0,0,0,0)", borderLeft: "4px solid rgba(0,0,0,0)",
}, },
@@ -100,9 +104,9 @@ export const styles = {
tagBtn: { tagBtn: {
padding: "8px 0", padding: "8px 0",
fontWeight: 1000, fontWeight: 1000,
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
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)",
@@ -110,9 +114,9 @@ export const styles = {
helpBtn: { helpBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -122,31 +126,31 @@ export const styles = {
input: { input: {
width: "100%", width: "100%",
padding: "10px 12px", padding: "11px 12px",
borderRadius: 14, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(10,10,12,0.55)", background: "rgba(7,7,10,0.64)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 15, fontSize: 15,
}, },
primaryBtn: { primaryBtn: {
padding: "10px 12px", padding: "11px 15px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.28)`, border: `1px solid rgba(233,216,166,0.34)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))", background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)",
}, },
secondaryBtn: { secondaryBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 12, borderRadius: 13,
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.055)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
fontWeight: 900, fontWeight: 900,
cursor: "pointer", cursor: "pointer",
@@ -158,12 +162,13 @@ export const styles = {
position: "relative", position: "relative",
zIndex: 1, zIndex: 1,
marginTop: 14, marginTop: 14,
padding: 12, padding: 14,
borderRadius: 16, borderRadius: 20,
border: `1px solid rgba(233,216,166,0.14)`, border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(18, 18, 20, 0.40)", background: `linear-gradient(145deg, rgba(25, 24, 28, 0.86), ${stylesTokens.panelBg})`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)", boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.06)",
backdropFilter: "blur(6px)", backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
adminTop: { adminTop: {
display: "flex", display: "flex",
@@ -175,13 +180,20 @@ 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.06)", background: "rgba(255,255,255,0.055)",
border: `1px solid rgba(233,216,166,0.10)`, border: `1px solid rgba(233,216,166,0.10)`,
}, },
@@ -203,12 +215,12 @@ export const styles = {
modalCard: { modalCard: {
width: "min(560px, 100%)", width: "min(560px, 100%)",
borderRadius: 18, borderRadius: 20,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(12,12,14,0.96)", background: "linear-gradient(145deg, rgba(27,25,30,0.98), rgba(11,11,14,0.98))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)", boxShadow: "0 24px 70px rgba(0,0,0,0.78), inset 0 1px 0 rgba(255,255,255,0.07)",
padding: 14, padding: 16,
maxHeight: "calc(100vh - 32px)", maxHeight: "calc(100dvh - 32px)",
overflow: "auto", overflow: "auto",
}, },
modalHeader: { modalHeader: {
@@ -305,13 +317,14 @@ export const styles = {
width: "100%", width: "100%",
maxWidth: 420, maxWidth: 420,
padding: 26, padding: 26,
borderRadius: 22, borderRadius: 24,
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid ${stylesTokens.headerBorder}`,
background: "rgba(18, 18, 20, 0.55)", background: `linear-gradient(145deg, rgba(29, 27, 31, 0.92), ${stylesTokens.panelBg})`,
boxShadow: "0 18px 60px rgba(0,0,0,0.70)", boxShadow: "0 24px 80px rgba(0,0,0,0.72), inset 0 1px 0 rgba(255,255,255,0.08)",
backdropFilter: "blur(8px)", backdropFilter: "blur(16px) saturate(1.12)",
WebkitBackdropFilter: "blur(16px) saturate(1.12)",
animation: "popIn 240ms ease-out", animation: "popIn 240ms ease-out",
color: stylesTokens.textMain, color: stylesTokens.textMain,
}, },
@@ -338,24 +351,24 @@ export const styles = {
}, },
loginInput: { loginInput: {
width: "100%", width: "100%",
padding: 10, padding: "11px 12px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(10,10,12,0.60)", background: "rgba(7,7,10,0.64)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 16, fontSize: 16,
}, },
loginBtn: { loginBtn: {
padding: "12px 14px", padding: "12px 15px",
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.28)`, border: `1px solid rgba(233,216,166,0.34)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))", background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
fontSize: 16, fontSize: 16,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)",
}, },
loginHint: { loginHint: {
marginTop: 18, marginTop: 18,
@@ -431,7 +444,10 @@ export const styles = {
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
filter: "saturate(0.9) contrast(1.05) brightness(0.55)", filter: "saturate(0.72) contrast(1.12) brightness(0.42) blur(1.2px)",
transform: "scale(1.015)",
backgroundColor: "rgba(8, 7, 12, 0.34)",
backgroundBlendMode: "multiply",
}, },
chipGrid: { chipGrid: {
@@ -443,9 +459,9 @@ export const styles = {
chipBtn: { chipBtn: {
padding: "10px 14px", padding: "10px 14px",
borderRadius: 12, borderRadius: 13,
border: "1px solid rgba(233,216,166,0.18)", border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -457,7 +473,7 @@ export const styles = {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
padding: "10px 12px", padding: "10px 12px",
borderRadius: 12, borderRadius: 14,
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,
@@ -474,11 +490,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(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))", background: "linear-gradient(145deg, rgba(29,27,33,0.98), rgba(10,10,13,0.96))",
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(8px)", backdropFilter: "blur(14px)",
}, },
userDropdownItem: { userDropdownItem: {