Compare commits

..
24 Commits
Author SHA1 Message Date
nessi 209f9c10dd Reduce winner celebration confetti intensity and duration for subtler animation effect
Simplified confetti animation by removing continuous rain effect and reducing burst parameters. Changed color palette to warmer gold/green tones. Decreased particle count from 170 to 55 per burst, reduced spread from 95° to 72°, lowered velocity from 42 to 28, and adjusted positioning. Added gravity, ticks, and scalar parameters for more controlled particle behavior. Shortened total celebration duration from 5500ms to 4200ms.
2026-08-02 11:47:16 +02:00
nessi d672648a05 Prevent winner selection from being overwritten by polling updates using dirty flag
Added `winnerSelectionDirtyRef` to track when user has manually changed winner selection. Modified `loadGameMeta` to skip updating `winnerUserId` state when dirty flag is set, preventing polling from overwriting user's unsaved selection. Introduced `selectWinner` helper that sets dirty flag on manual selection and clears flag after successful save. Reset dirty flag in all state cleanup handlers (goHome, logout, le
2026-08-02 11:38:08 +02:00
nessi ea08016ea5 Add dedicated home page with game list and welcome screen, replacing auto-selection behavior
Implemented HomePage component with centered welcome message, create/join button, and list of user's games with name/code display. Modified App.jsx to show HomePage when no game is selected instead of auto-selecting first game. Added `goHome` handler to reset all game state and return to home screen. Added home button (🏠) to GamePickerCard header next to help button with bilingual tooltip. Removed automatic
2026-08-02 10:58:38 +02:00
nessi c9eae136fb Allow QR code generation for both created games and joined games using current code
Extended QR code functionality to work with `currentCode` (joined games) in addition to `created.code` (created games). Updated QR generation effect to use fallback value and added dependencies. Moved QR toggle button and display from created-game-only section to shared code display area, enabling QR codes for all game participants.
2026-08-02 10:55:42 +02:00
nessi ee8ae8829e Add QR code generation and scanning for game join codes with camera integration
Implemented QR code generation for created games using qrcode library with custom dark/light color scheme. Added camera-based QR scanner using BarcodeDetector API with environment-facing camera preference, automatic code extraction, and 350ms polling interval. Added toggle buttons for QR display/scanner with bilingual labels, video preview with styled container, and fallback error handling for unsupported browsers or denied
2026-08-02 10:52:37 +02:00
nessi b20056913b Add game cancellation feature with cascade deletion and host-only access control
Implemented DELETE `/games/{game_id}` endpoint allowing hosts to cancel games with cascade deletion of sheet states, chips, and memberships. Added `cancelGame` handler in App.jsx to delete game, refresh game list, reset state, and show confirmation snack. Added cancel button to GamePickerCard lobby section (pre-start) and started section (non-finished games) with bilingual confirmation dialog, loading state, and error handling.
2026-08-02 10:46:38 +02:00
nessi 565d538c35 Add permanent user deletion endpoint with cascade cleanup and trash icon button in admin panel
Implemented DELETE `/admin/users/{user_id}/permanent` endpoint with safeguards preventing self-deletion and blocking deletion of game hosts. Added cascade deletion of user's game memberships, chips, sheet states, and invite tokens, with winner references nullified. Added trash icon button (🗑) to admin user rows with bilingual confirmation dialog and error handling for game ownership conflicts. Adjusted
2026-08-02 10:44:40 +02:00
nessi 45fc8e4c9e Improve email template readability with explicit text colors and dark background for invite body content 2026-08-02 10:42:13 +02:00
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
35 changed files with 1847 additions and 463 deletions
+5
View File
@@ -8,6 +8,9 @@ A small multiplayer web app that acts as a digital note sheet for a Harry Potter
- 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
@@ -200,6 +203,8 @@ The setup endpoint is available only while no administrator exists. After the fi
- `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}`
+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;color:#f5efdc !important;background:#17161b;">
<div style="font-size:20px;color:#e9d8a6 !important;">Hallo {name},</div>
<p style="color:#f5efdc !important;margin:16px 0;">du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
<p style="color:#f5efdc !important;margin:16px 0;">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)
+18
View File
@@ -12,6 +12,7 @@ from .routes.auth import router as auth_router
from .routes.admin import router as admin_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")
@@ -31,6 +32,7 @@ app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(games_router)
app.include_router(setup_router)
app.include_router(invites_router)
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)
"""
# --- 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 ---
if not _has_column(db, "users", "display_name"):
try:
@@ -136,6 +146,14 @@ Very small, pragmatic auto-migration (no alembic).
except Exception:
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")
if not _has_column(db, "games", "host_user_id"):
try:
+37
View File
@@ -41,6 +41,32 @@ class User(Base):
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):
__tablename__ = "games"
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)
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):
+178 -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 sqlalchemy.orm import Session
from ..db import get_db
from ..models import User, Role
from ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, Game, GameChip, GameMember, InviteToken, SheetState, User, Role
from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -37,18 +42,69 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
if not email or not password:
raise HTTPException(400, "email/password required")
if not email or not display_name:
raise HTTPException(400, "name/email required")
if db.query(User).filter(User.email == email).first():
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):
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)
db.add(u); db.commit()
return {"ok": True, "id": u.id}
db.add(u)
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}/permanent")
def permanently_delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
admin = require_admin(req, db)
if admin.id == user_id:
raise HTTPException(400, "cannot delete yourself")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "not found")
if db.query(Game).filter(Game.host_user_id == user_id).first():
raise HTTPException(409, "cannot delete a user who owns games")
db.query(Game).filter(Game.winner_user_id == user_id).update({Game.winner_user_id: None}, synchronize_session=False)
db.query(GameMember).filter(GameMember.user_id == user_id).delete(synchronize_session=False)
db.query(GameChip).filter(GameChip.user_id == user_id).delete(synchronize_session=False)
db.query(SheetState).filter(SheetState.owner_user_id == user_id).delete(synchronize_session=False)
db.query(InviteToken).filter(InviteToken.user_id == user_id).delete(synchronize_session=False)
db.delete(user)
db.commit()
return {"ok": True, "deleted": True}
@router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
@@ -61,11 +117,124 @@ def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
if not u:
raise HTTPException(404, "not found")
if u.role == Role.admin.value:
raise HTTPException(400, "cannot delete admin user")
# soft delete
u.disabled = True
db.add(u)
db.commit()
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}
+101 -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 sqlalchemy.orm import Session
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
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))
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):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
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()
if not g:
raise HTTPException(404, "game not found")
if g.started_at:
raise HTTPException(400, "game already started")
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}
@@ -112,6 +137,7 @@ def list_games(req: Request, db: Session = Depends(get_db)):
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
"started": bool(g.started_at),
}
)
return out
@@ -139,9 +165,79 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
"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.delete("/{game_id}")
def cancel_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 cancel the game")
# Remove dependent game state before deleting the game itself.
db.query(SheetState).filter(SheetState.game_id == game_id).delete(synchronize_session=False)
db.query(GameChip).filter(GameChip.game_id == game_id).delete(synchronize_session=False)
db.query(GameMember).filter(GameMember.game_id == game_id).delete(synchronize_session=False)
db.delete(g)
db.commit()
return {"ok": True, "cancelled": 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")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
@@ -228,6 +324,9 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
uid = require_user(req, db)
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")
note_tag = data.get("note_tag")
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}
+47
View File
@@ -94,6 +94,36 @@
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 {
content: "";
position: absolute;
@@ -111,6 +141,7 @@
color: rgba(245, 239, 220, 0.92);
font-size: 18px;
line-height: 1.25;
animation: titleRise 700ms ease-out both;
}
.splash-sub {
@@ -156,6 +187,21 @@
0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
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>
<!-- Theme-Key sofort setzen -->
@@ -170,6 +216,7 @@
<body>
<div id="app-splash" aria-hidden="true">
<div class="splash-card">
<div class="magic-orbit" aria-hidden="true"></div>
<div class="splash-title">Zauber-Detektiv Notizbogen</div>
<div class="splash-sub">Magie wird vorbereitet…</div>
<div class="loader" aria-label="Laden"></div>
+2 -1
View File
@@ -10,7 +10,8 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"canvas-confetti": "^1.9.3"
"canvas-confetti": "^1.9.3",
"qrcode": "^1.5.4"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
+167 -18
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import WinnerCelebration from "./components/WinnerCelebration";
import GameStartCelebration from "./components/GameStartCelebration";
import { api } from "./api/client";
import { cycleTag } from "./utils/cycleTag";
@@ -24,9 +25,14 @@ import WinnerCard from "./components/WinnerCard";
import WinnerBadge from "./components/WinnerBadge";
import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal";
import AdminSettingsModal from "./components/AdminSettingsModal";
import InvitePage from "./components/InvitePage";
import HomePage from "./components/HomePage";
import { useLanguage } from "./i18n";
export default function App() {
useHpGlobalStyles();
const { language, t } = useLanguage();
// Auth/Login UI state
const [me, setMe] = useState(null);
@@ -50,15 +56,19 @@ export default function App() {
// Game meta
const [gameMeta, setGameMeta] = useState(null); // {code, host_user_id, winner_email, winner_user_id}
const [members, setMembers] = useState([]);
const [gameChips, setGameChips] = useState([]);
// Winner selection (host only)
const [winnerUserId, setWinnerUserId] = useState("");
const winnerSelectionDirtyRef = useRef(false);
// Modals
const [helpOpen, setHelpOpen] = useState(false);
const [chipOpen, setChipOpen] = useState(false);
const [chipEntry, setChipEntry] = useState(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [adminOpen, setAdminOpen] = useState(false);
const [adminSettingsOpen, setAdminSettingsOpen] = useState(false);
const [pwOpen, setPwOpen] = useState(false);
const [pw1, setPw1] = useState("");
@@ -90,10 +100,14 @@ export default function App() {
// ===== Winner Celebration =====
const [celebrateOpen, setCelebrateOpen] = useState(false);
const [celebrateName, setCelebrateName] = useState("");
const [startCelebrateOpen, setStartCelebrateOpen] = useState(false);
const inviteToken = window.location.pathname.startsWith("/invite/") ? decodeURIComponent(window.location.pathname.slice("/invite/".length)) : "";
// baseline per game: beim ersten Meta-Load NICHT feiern
const winnerBaselineRef = useRef(false);
const lastWinnerIdRef = useRef(null);
const gameStartBaselineRef = useRef(false);
const lastGameStartedRef = useRef(false);
const showSnack = (msg) => {
setSnack(msg);
@@ -122,7 +136,7 @@ export default function App() {
const gs = await api("/games");
setGames(gs);
if (gs[0] && !gameId) setGameId(gs[0].id);
// Always open on the start page; the user explicitly chooses a game.
};
const reloadSheet = async () => {
@@ -136,7 +150,12 @@ export default function App() {
const meta = await api(`/games/${gameId}`);
setGameMeta(meta);
if (!winnerSelectionDirtyRef.current) {
setWinnerUserId(meta?.winner_user_id || "");
}
const chips = meta?.started ? await api(`/games/${gameId}/chips`) : [];
setGameChips(chips || []);
const mem = await api(`/games/${gameId}/members`);
setMembers(mem);
@@ -208,8 +227,12 @@ export default function App() {
// reset winner celebration baseline when switching games
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
gameStartBaselineRef.current = false;
lastGameStartedRef.current = false;
winnerSelectionDirtyRef.current = false;
setCelebrateOpen(false);
setCelebrateName("");
setStartCelebrateOpen(false);
(async () => {
if (!gameId) return;
@@ -221,18 +244,23 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gameId]);
// ✅ Live refresh (Members/Meta) damit neue Joiner ohne Reload sichtbar sind
// Für 56 Spieler reicht 2.5s völlig, ist "live genug" und schont Backend.
// ✅ Live refresh (Members/Meta) Lobby schnell, laufendes Spiel genügsamer.
useEffect(() => {
if (!me || !gameId) return;
let alive = true;
let pending = false;
const refreshInterval = gameMeta?.started ? 2500 : 700;
const tick = async () => {
if (pending) return;
pending = true;
try {
await loadGameMeta(); // refresh members + winner meta
} catch {
// ignore
} finally {
pending = false;
}
};
@@ -242,14 +270,14 @@ export default function App() {
const id = setInterval(() => {
if (!alive) return;
tick();
}, 2500);
}, refreshInterval);
return () => {
alive = false;
clearInterval(id);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id, gameId]);
}, [me?.id, gameId, gameMeta?.started]);
useEffect(() => {
// wid kann auch "" sein (kein Sieger)
@@ -279,6 +307,23 @@ export default function App() {
}
}, [gameMeta?.winner_user_id, gameMeta?.winner_display_name, gameMeta?.winner_email]);
useEffect(() => {
if (!gameMeta) return;
const started = !!gameMeta.started;
if (!gameStartBaselineRef.current) {
gameStartBaselineRef.current = true;
lastGameStartedRef.current = started;
return;
}
if (!lastGameStartedRef.current && started) {
setStartCelebrateOpen(true);
vibrate([25, 55, 25]);
}
lastGameStartedRef.current = started;
}, [gameMeta?.started]);
// ===== Auth actions =====
const doLogin = async () => {
@@ -327,13 +372,18 @@ export default function App() {
setSheet(null);
setGameMeta(null);
setMembers([]);
setGameChips([]);
setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
// reset winner celebration on logout
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
gameStartBaselineRef.current = false;
lastGameStartedRef.current = false;
setCelebrateOpen(false);
setCelebrateName("");
setStartCelebrateOpen(false);
};
// ===== Password =====
@@ -355,8 +405,8 @@ export default function App() {
const savePassword = async () => {
setPwMsg("");
if (!pw1 || pw1.length < 8) return setPwMsg("Passwort muss mindestens 8 Zeichen haben.");
if (pw1 !== pw2) return setPwMsg("Passwörter stimmen nicht überein.");
if (!pw1 || pw1.length < 8) return setPwMsg(`${language === "en" ? "Password must be at least 8 characters." : "Passwort muss mindestens 8 Zeichen haben."}`);
if (pw1 !== pw2) return setPwMsg(`${language === "en" ? "Passwords do not match." : "Passwörter stimmen nicht überein."}`);
setPwSaving(true);
try {
@@ -364,10 +414,10 @@ export default function App() {
method: "PATCH",
body: JSON.stringify({ password: pw1 }),
});
setPwMsg("Passwort gespeichert.");
setPwMsg(`${language === "en" ? "Password saved." : "Passwort gespeichert."}`);
setTimeout(() => closePwModal(), 650);
} catch (e) {
setPwMsg("❌ Fehler: " + (e?.message || "unknown"));
setPwMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} finally {
setPwSaving(false);
}
@@ -414,7 +464,7 @@ export default function App() {
setStats(s);
} catch (e) {
setStats(null);
setStatsError("❌ Fehler: " + (e?.message || "unknown"));
setStatsError("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
} finally {
setStatsLoading(false);
}
@@ -431,7 +481,9 @@ export default function App() {
setSheet(null);
setGameMeta(null);
setMembers([]);
setGameChips([]);
setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
setPulseId(null);
// auch Chip-Modal-State resetten
@@ -441,12 +493,15 @@ export default function App() {
// reset winner celebration baseline for the new game
winnerBaselineRef.current = false;
lastWinnerIdRef.current = null;
gameStartBaselineRef.current = false;
lastGameStartedRef.current = false;
setCelebrateOpen(false);
setCelebrateName("");
setStartCelebrateOpen(false);
const g = await api("/games", {
method: "POST",
body: JSON.stringify({ name: "Spiel " + new Date().toLocaleString() }),
body: JSON.stringify({ name: (language === "en" ? "Game " : "Spiel ") + new Date().toLocaleString(language === "en" ? "en-US" : "de-DE") }),
});
const gs = await api("/games");
@@ -469,6 +524,44 @@ export default function App() {
setGameId(res.id);
};
const startGame = async () => {
if (!gameId || !gameMeta?.host_user_id || me?.id !== gameMeta.host_user_id) return;
await api(`/games/${gameId}/start`, { method: "POST" });
await loadGameMeta();
};
const cancelGame = async () => {
if (!gameId || !isHost) return;
await api(`/games/${gameId}`, { method: "DELETE" });
const nextGames = await api("/games");
setGames(nextGames);
setGameId(null);
setSheet(null);
setGameMeta(null);
setMembers([]);
setGameChips([]);
setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
setChipOpen(false);
setChipEntry(null);
showSnack(language === "en" ? "Game cancelled." : "Spiel abgebrochen.");
};
const goHome = () => {
setGameId(null);
setSheet(null);
setGameMeta(null);
setMembers([]);
setGameChips([]);
setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
setChipOpen(false);
setChipEntry(null);
setCelebrateOpen(false);
setCelebrateName("");
setStartCelebrateOpen(false);
};
// ===== Winner =====
const saveWinner = async () => {
if (!gameId) return;
@@ -476,11 +569,18 @@ export default function App() {
method: "PATCH",
body: JSON.stringify({ winner_user_id: winnerUserId || null }),
});
winnerSelectionDirtyRef.current = false;
await loadGameMeta();
};
const selectWinner = (value) => {
winnerSelectionDirtyRef.current = true;
setWinnerUserId(value);
};
// ===== Sheet actions =====
const cycleStatus = async (entry) => {
if (gameMeta?.winner_user_id) return;
let next = 0;
if (entry.status === 0) next = 2;
else if (entry.status === 2) next = 1;
@@ -493,14 +593,20 @@ export default function App() {
});
await reloadSheet();
vibrate(10);
setPulseId(entry.entry_id);
setTimeout(() => setPulseId(null), 220);
};
const toggleTag = async (entry) => {
if (gameMeta?.winner_user_id) return;
const next = cycleTag(entry.note_tag);
if (next === "s") {
if (!gameMeta?.started || gameChips.length === 0) {
showSnack(language === "en" ? "The game must be started first." : "Das Spiel muss zuerst gestartet werden.");
return;
}
setChipEntry(entry);
setChipOpen(true);
return;
@@ -514,10 +620,11 @@ export default function App() {
});
await reloadSheet();
vibrate(10);
};
const chooseChip = async (chip) => {
if (!chipEntry) return;
if (!chipEntry || gameMeta?.winner_user_id) return;
const entry = chipEntry;
setChipOpen(false);
@@ -532,6 +639,7 @@ export default function App() {
});
} finally {
await reloadSheet();
vibrate(12);
}
};
@@ -571,6 +679,10 @@ export default function App() {
};
// ===== Login page =====
if (inviteToken) {
return <InvitePage token={inviteToken} />;
}
if (!me) {
return (
<LoginPage
@@ -599,13 +711,14 @@ export default function App() {
const sections = sheet
? [
{ key: "suspect", title: "VERDÄCHTIGE PERSON", entries: sheet.suspect || [] },
{ key: "item", title: "GEGENSTAND", entries: sheet.item || [] },
{ key: "location", title: "ORT", entries: sheet.location || [] },
{ key: "suspect", title: t("suspects").toUpperCase(), entries: sheet.suspect || [] },
{ key: "item", title: t("items").toUpperCase(), entries: sheet.item || [] },
{ key: "location", title: t("locations").toUpperCase(), entries: sheet.location || [] },
]
: [];
const isHost = !!(me?.id && gameMeta?.host_user_id && me.id === gameMeta.host_user_id);
const gameStarted = !!gameMeta?.started;
return (
<div style={styles.page}>
@@ -615,6 +728,10 @@ export default function App() {
winnerName={celebrateName}
onClose={() => setCelebrateOpen(false)}
/>
<GameStartCelebration
open={startCelebrateOpen}
onClose={() => setStartCelebrateOpen(false)}
/>
<div style={styles.bgFixed} aria-hidden="true">
<div style={styles.bgMap} />
@@ -628,12 +745,28 @@ export default function App() {
openPwModal={openPwModal}
openDesignModal={openDesignModal}
openStatsModal={openStatsModal}
openAdminPanel={() => {
setAdminOpen(true);
setUserMenuOpen(false);
}}
openAdminSettings={() => {
setAdminSettingsOpen(true);
setUserMenuOpen(false);
}}
doLogout={doLogout}
onOpenNewGame={() => setNewGameOpen(true)}
/>
{me.role === "admin" && <AdminPanel />}
{me.role === "admin" && (
<AdminPanel open={adminOpen} onClose={() => setAdminOpen(false)} currentUserId={me.id} />
)}
{me.role === "admin" && (
<AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} />
)}
{!gameId ? (
<HomePage games={games} onOpenNewGame={() => setNewGameOpen(true)} onOpenGame={setGameId} />
) : <>
<GamePickerCard
games={games}
gameId={gameId}
@@ -642,18 +775,29 @@ export default function App() {
members={members}
me={me}
hostUserId={gameMeta?.host_user_id || ""}
isHost={isHost}
started={!!gameMeta?.started}
finished={!!gameMeta?.winner_user_id}
winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""}
chipCount={gameChips.length}
onStartGame={startGame}
onCancelGame={cancelGame}
onGoHome={goHome}
/>
{/* Sieger Badge: zwischen Spiel und Verdächtigte Person */}
{gameStarted && (
<WinnerBadge
winner={{
display_name: gameMeta?.winner_display_name || "",
email: gameMeta?.winner_email || "",
}}
/>
)}
</>}
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
{gameStarted && (
<div style={{ marginTop: 14, display: "grid", gap: 14 }}>
{sections.map((sec) => (
<SheetSection
@@ -664,18 +808,22 @@ export default function App() {
onCycleStatus={cycleStatus}
onToggleTag={toggleTag}
displayTag={displayTag}
readOnly={!!gameMeta?.winner_user_id}
/>
))}
</div>
)}
{/* Host-only Winner Auswahl */}
{gameStarted && (
<WinnerCard
isHost={isHost}
members={members}
winnerUserId={winnerUserId}
setWinnerUserId={setWinnerUserId}
setWinnerUserId={selectWinner}
onSave={saveWinner}
/>
)}
<div style={{ height: 24 }} />
</div>
@@ -717,6 +865,7 @@ export default function App() {
chipOpen={chipOpen}
closeChipModalToDash={closeChipModalToDash}
chooseChip={chooseChip}
chips={gameChips}
/>
<StatsModal
+1
View File
@@ -3,6 +3,7 @@ import { API_BASE } from "../constants";
export async function api(path, opts = {}) {
const res = await fetch(API_BASE + path, {
credentials: "include",
cache: "no-store",
headers: { "Content-Type": "application/json" },
...opts,
});
+148 -165
View File
@@ -1,205 +1,188 @@
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 { 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 [open, setOpen] = useState(false);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [editorOpen, setEditorOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [form, setForm] = useState(emptyForm);
const [msg, setMsg] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
if (!dashboardOpen && !editorOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
return () => { document.body.style.overflow = previous; };
}, [dashboardOpen, editorOpen]);
useEffect(() => {
loadUsers().catch(() => {});
}, []);
if (!dashboardOpen) {
setEditorOpen(false);
setMsg("");
}
}, [dashboardOpen]);
const resetForm = () => {
setDisplayName("");
setEmail("");
setPassword("");
setRole("user");
const loadUsers = async () => setUsers(await api("/admin/users"));
useEffect(() => { loadUsers().catch(() => {}); }, []);
const setField = (key, value) => setForm((current) => ({ ...current, [key]: value }));
const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
setMsg("");
setNotice("");
setEditorOpen(true);
};
const createUser = async () => {
setMsg("");
try {
await api("/admin/users", {
method: "POST",
body: JSON.stringify({ display_name: displayName, email, password, role }),
const openEdit = (user) => {
setEditingUser(user);
setForm({
displayName: user.display_name || "",
email: user.email || "",
password: "",
role: user.role || "user",
disabled: !!user.disabled,
});
setMsg("✅ User erstellt.");
await loadUsers();
resetForm();
setOpen(false);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
}
setMsg("");
setNotice("");
setEditorOpen(true);
};
const deleteUser = async (u) => {
if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return;
try {
await api(`/admin/users/${u.id}`, { method: "DELETE" });
await loadUsers();
} catch (e) {
alert("Fehler: " + (e?.message || "unknown"));
}
};
const closeModal = () => {
setOpen(false);
const closeEditor = () => {
setEditorOpen(false);
setEditingUser(null);
setMsg("");
};
return (
<div style={styles.adminWrap}>
<div style={styles.adminTop}>
<div style={styles.adminTitle}>Admin Dashboard</div>
<button onClick={() => setOpen(true)} style={styles.primaryBtn}>
+ User anlegen
</button>
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 {
const payload = {
display_name: form.displayName,
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),
});
await loadUsers();
setNotice(result.invite_sent ? (language === "en" ? "✅ Invitation sent by email." : "✅ Einladung wurde per E-Mail versendet.") : (language === "en" ? "✅ User saved." : "✅ User gespeichert."));
closeEditor();
} catch (e) {
setMsg("❌ " + (e?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen.")));
} finally {
setSaving(false);
}
};
const disableUser = async (user) => {
if (user.id === currentUserId) return;
if (!window.confirm(`User wirklich deaktivieren?\n\n${user.display_name || user.email}`)) return;
try {
await api(`/admin/users/${user.id}`, { method: "DELETE" });
await loadUsers();
} catch (e) {
alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
}
};
const permanentlyDeleteUser = async (user) => {
if (user.id === currentUserId) return;
const label = user.display_name || user.email;
if (!window.confirm(language === "en" ? `Delete ${label} permanently? This cannot be undone.` : `${label} wirklich dauerhaft löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`)) return;
try {
await api(`/admin/users/${user.id}/permanent`, { method: "DELETE" });
await loadUsers();
setNotice(language === "en" ? "✅ User permanently deleted." : "✅ User dauerhaft gelöscht.");
} catch (e) {
alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
}
};
if (!dashboardOpen) return null;
return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div className="hp-admin-dashboard-card" style={{ ...styles.modalCard, width: "min(780px, 100%)", padding: 0 }} onMouseDown={(e) => e.stopPropagation()}>
<div style={{ padding: "18px 18px 16px" }}>
<div style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 19 }}>{t("adminDashboard")}</div>
<div style={{ marginTop: 3, color: stylesTokens.textDim, fontSize: 12 }}>{t("adminDashboardSubtitle")}</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Dashboard schließen"></button>
</div>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
Vorhandene User
<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: 8, display: "grid", gap: 8 }}>
{users.map((u) => (
<div
key={u.id}
className="hp-admin-user-row"
style={{
...styles.userRow,
alignItems: "center",
}}
>
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
<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>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => permanentlyDeleteUser(user)} disabled={user.id === currentUserId} style={{ ...styles.secondaryBtn, color: "#ff8f9b" }} title={language === "en" ? "Delete permanently" : "Dauerhaft löschen"} aria-label={`${user.display_name || user.email} ${language === "en" ? "delete permanently" : "dauerhaft löschen"}`}>🗑</button>
</div>
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role}
</div>
<div className={`hp-admin-status ${u.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"}
</div>
<button
className="hp-admin-action"
onClick={() => deleteUser(u)}
style={{
...styles.secondaryBtn,
padding: "8px 10px",
borderRadius: 12,
color: "#ffb3b3",
opacity: u.role === "admin" ? 0.4 : 1,
pointerEvents: u.role === "admin" ? "none" : "auto",
}}
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
>
Löschen
</button>
</div>
))}
</div>
</div>
</div>
{open &&
createPortal(
<div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
{editorOpen && createPortal(
<div style={styles.modalOverlay} onMouseDown={closeEditor}>
<div className="hp-admin-editor-card" style={{ ...styles.modalCard, width: "min(470px, 100%)" }} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Neuen User anlegen
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold, fontSize: 18 }}>{editingUser ? t("editUser") : t("newUser")}</div>
<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>
<button onClick={closeEditor} style={styles.modalCloseBtn} aria-label={t("close")}></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 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>
<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>
)}
</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 { styles } from "../styles/styles";
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;
return (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Wer hat die Karte?</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("whoHasCard")}</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</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}>
{CHIP_LIST.map((c) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
{c}
{chips.map((item) => (
<button key={item.user_id || item.chip} onClick={() => chooseChip(item.chip)} style={styles.chipBtn} title={item.display_name || item.email}>
{item.chip}
</button>
))}
</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 }}>
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>
+5 -3
View File
@@ -2,8 +2,10 @@ import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { THEMES } from "../styles/themes";
import { useLanguage } from "../i18n";
export default function DesignModal({ open, onClose, themeKey, onSelect }) {
const { language, t } = useLanguage();
if (!open) return null;
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.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Design ändern</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("changeDesign")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
<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 style={{ marginTop: 12, display: "grid", gap: 10 }}>
+134 -6
View File
@@ -1,6 +1,7 @@
import React from "react";
import React, { useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GamePickerCard({
games,
@@ -10,8 +11,20 @@ export default function GamePickerCard({
members = [],
me,
hostUserId,
isHost = false,
started = false,
finished = false,
winnerName = "",
chipCount = 0,
onStartGame,
onCancelGame,
onGoHome,
}) {
const { language, t } = useLanguage();
const cur = games.find((x) => x.id === gameId);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState("");
const [cancelling, setCancelling] = useState(false);
const renderMemberName = (m) => {
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
@@ -42,10 +55,40 @@ export default function GamePickerCard({
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);
}
};
const handleCancelGame = async () => {
if (!onCancelGame || cancelling) return;
const confirmed = window.confirm(language === "en" ? "Cancel and delete this game? All game data will be removed." : "Dieses Spiel wirklich abbrechen und löschen? Alle Spieldaten werden entfernt.");
if (!confirmed) return;
setCancelling(true);
setStartError("");
try {
await onCancelGame();
} catch (e) {
setStartError(e?.message || (language === "en" ? "The game could not be cancelled." : "Das Spiel konnte nicht abgebrochen werden."));
} finally {
setCancelling(false);
}
};
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Spiel</div>
<div style={styles.sectionHeader}>{t("game")}</div>
<div style={styles.cardBody}>
<select
@@ -60,8 +103,11 @@ export default function GamePickerCard({
))}
</select>
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe">
Hilfe
<button onClick={onGoHome} style={styles.helpBtn} title={language === "en" ? "Home" : "Startseite"} aria-label={language === "en" ? "Home" : "Startseite"}>
🏠
</button>
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")} aria-label={t("help")}>
</button>
</div>
@@ -85,8 +131,90 @@ export default function GamePickerCard({
</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>
<button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, width: "100%", marginTop: 9, color: "#ffb3b3" }} disabled={cancelling}>
{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}
</button>
</>
) : (
<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>
{isHost && !finished && <button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, margin: "0 12px 12px", width: "calc(100% - 24px)", color: "#ffb3b3" }} disabled={cancelling}>{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}</button>}
</>
)}
{/* Spieler */}
{members?.length > 0 && (
{started && members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}>
<div
style={{
@@ -99,7 +227,7 @@ export default function GamePickerCard({
gap: 10,
}}
>
<div>Spieler</div>
<div>{t("players")}</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
{members.length}
</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
);
}
+91 -97
View File
@@ -1,186 +1,180 @@
// src/components/HelpModal.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function HelpModal({ open, onClose }) {
const { language, t } = useLanguage();
if (!open) return null;
const en = language === "en";
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Hilfe</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("helpTitle")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
<div style={styles.helpBody}>
{/* ===== 0) Spiele & Navigation ===== */}
<div style={styles.helpSectionTitle}>0) Spiele auswählen / Neues Spiel</div>
<div style={styles.helpSectionTitle}>1) {en ? "Lobby & starting the game" : "Lobby & Spielstart"}</div>
<div style={styles.helpText}>
Oben im Bereich <b>Spiel</b> kannst du zwischen bestehenden Spielen wechseln oder ein neues
Spiel erstellen:
{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.</>}
</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 style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<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 style={styles.helpDivider} />
{/* ===== 1) Status per Tippen ===== */}
<div style={styles.helpSectionTitle}>1) Namen antippen (Status)</div>
<div style={styles.helpSectionTitle}>3) {en ? "Tap a name status" : "Namen antippen Status"}</div>
<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 style={styles.helpList}>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(0,190,80,0.18)",
color: "#baf3c9",
}}
>
</span>
<div>
<b>Grün</b> = bestätigt / fix richtig
<span style={{ ...styles.helpBadge, background: "rgba(0,190,80,0.18)", color: "#baf3c9" }}></span>
<div><b>{en ? "Green" : "Grün"}</b> = {en ? "confirmed / present" : "bestätigt / vorhanden"}</div>
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,35,35,0.18)",
color: "#ffb3b3",
}}
>
</span>
<div>
<b>Rot</b> = ausgeschlossen / fix falsch
<span style={{ ...styles.helpBadge, background: "rgba(255,35,35,0.18)", color: "#ffb3b3" }}></span>
<div><b>{en ? "Red" : "Rot"}</b> = {en ? "excluded / not present" : "ausgeschlossen / nicht vorhanden"}</div>
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(140,140,140,0.14)",
color: "rgba(233,216,166,0.85)",
}}
>
?
</span>
<div>
<b>Grau</b> = unsicher / vielleicht
<span style={{ ...styles.helpBadge, background: "rgba(140,140,140,0.14)", color: "rgba(233,216,166,0.85)" }}>?</span>
<div><b>{en ? "Grey" : "Grau"}</b> = {en ? "uncertain / maybe" : "unsicher / vielleicht"}</div>
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,255,255,0.08)",
color: "rgba(233,216,166,0.75)",
}}
>
</span>
<div>
<b>Leer</b> = noch nicht bewertet
</div>
<span style={{ ...styles.helpBadge, background: "rgba(255,255,255,0.08)", color: "rgba(233,216,166,0.75)" }}></span>
<div><b>{en ? "Empty" : "Leer"}</b> = {en ? "not evaluated yet" : "noch nicht bewertet"}</div>
</div>
</div>
<div style={styles.helpDivider} />
{/* ===== 2) i / m / s Notizen ===== */}
<div style={styles.helpSectionTitle}>2) i / m / s Button (Notizen)</div>
<div style={styles.helpSectionTitle}>4) {en ? "Notes & player chips" : "Notizen & Spieler-Chips"}</div>
<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 style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>i</span>
<div>
<b>i</b> = Ich habe diese Karte
<div><b>i</b> = {en ? "I have this card" : "Ich habe diese Karte"}</div>
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>m</span>
<div>
<b>m</b> = Karte aus dem mittleren Deck
<div><b>m</b> = {en ? "card from the middle deck" : "Karte aus dem mittleren Deck"}</div>
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>s</span>
<div>
<b>s</b> = Ein anderer Spieler hat die Karte danach Chip auswählen (z.B. <b>s.AL</b>)
<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>
</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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b></b> = keine Notiz
</div>
<div><b></b> = keine Zusatznotiz</div>
</div>
</div>
<div style={styles.helpDivider} />
{/* ===== 3) User-Menü ===== */}
<div style={styles.helpSectionTitle}>3) User-Menü (Passwort / Logout)</div>
<div style={styles.helpSectionTitle}>5) {en ? "Game end" : "Spielende"}</div>
<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 style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>👤</span>
<div>
<b>User</b> öffnen = zeigt die aktuell verwendete Email-Adresse
<span style={styles.helpMiniTag}>🔒</span>
<div>{en ? <>All note sheets become <b>read-only</b>.</> : <>Alle Notizzettel werden <b>schreibgeschützt</b>.</>}</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 style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🔒</span>
<div>
<b>Passwort setzen</b> = eigenes Passwort ändern
</div>
<div><b>{t("setPassword")}</b> = {en ? "change your password" : "eigenes Passwort ändern"}</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b>Logout</b> = ausloggen
<div><b>{t("logout")}</b> = {en ? "sign out" : "abmelden"}</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 style={styles.helpDivider} />
<div style={styles.helpText}>
Tipp: Jeder Spieler sieht nur seine eigenen Notizen andere Spieler können nicht in deinen
Zettel schauen.
<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>
</div>
</div>
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function HomePage({ games = [], onOpenNewGame, onOpenGame }) {
const { language, t } = useLanguage();
return (
<main className="hp-home-page" style={{ marginTop: 14, display: "grid", gap: 14 }}>
<div style={styles.card}>
<div style={{ padding: "26px 20px 22px", textAlign: "center" }}>
<div style={{ fontSize: 34, color: stylesTokens.textGold }}></div>
<h1 style={{ margin: "8px 0 0", color: stylesTokens.textGold, fontSize: 24 }}>{language === "en" ? "Welcome to Notizbogen" : "Willkommen beim Notizbogen"}</h1>
<p style={{ margin: "9px auto 0", maxWidth: 430, color: stylesTokens.textDim, lineHeight: 1.5 }}>{language === "en" ? "Create a new investigation or join an existing game to get started." : "Erstelle eine neue Ermittlung oder tritt einem bestehenden Spiel bei, um zu beginnen."}</p>
<button onClick={onOpenNewGame} style={{ ...styles.primaryBtn, marginTop: 18 }}>{language === "en" ? "✦ Create or join game" : "✦ Spiel erstellen oder beitreten"}</button>
</div>
</div>
{games.length > 0 && <div style={styles.card}>
<div style={styles.sectionHeader}>{language === "en" ? "Your games" : "Deine Spiele"}</div>
<div style={{ display: "grid", gap: 8, padding: 12 }}>
{games.map((game) => <button key={game.id} onClick={() => onOpenGame(game.id)} style={{ ...styles.secondaryBtn, display: "flex", justifyContent: "space-between", alignItems: "center", textAlign: "left" }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{game.name}</span><span style={{ color: stylesTokens.textGold, marginLeft: 10 }}>{game.code}</span>
</button>)}
</div>
</div>}
</main>
);
}
+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 { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function JoinGameModal({ open, onClose, onJoin }) {
const { language, t } = useLanguage();
const [code, setCode] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
@@ -19,13 +21,13 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
const doJoin = async () => {
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);
setMsg("");
try {
await onJoin(c);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
setMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setBusy(false);
}
};
@@ -34,8 +36,8 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Spiel beitreten</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("joinGame")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
+16 -14
View File
@@ -1,5 +1,6 @@
import React from "react";
import { styles } from "../styles/styles";
import { useLanguage } from "../i18n";
export default function LoginPage({
loginEmail,
@@ -22,6 +23,7 @@ export default function LoginPage({
setupSaving,
doSetup,
}) {
const { language, t } = useLanguage();
const isSetup = setupRequired === true;
return (
@@ -37,10 +39,10 @@ export default function LoginPage({
<div style={styles.loginSubtitle}>
{setupRequired === null
? "Initialisiere Anwendung …"
? (language === "en" ? "Initializing application …" : "Initialisiere Anwendung …")
: isSetup
? "Richte den ersten Administrator ein"
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"}
? (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>
{isSetup ? (
@@ -49,7 +51,7 @@ export default function LoginPage({
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder="Display name"
placeholder={t("displayName")}
style={styles.loginInput}
autoComplete="name"
/>
@@ -59,7 +61,7 @@ export default function LoginPage({
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="Admin email"
placeholder={language === "en" ? "Admin email" : "Admin-E-Mail"}
style={styles.loginInput}
inputMode="email"
autoComplete="username"
@@ -70,7 +72,7 @@ export default function LoginPage({
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Password (min. 8 characters)"
placeholder={t("passwordMin")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
@@ -81,7 +83,7 @@ export default function LoginPage({
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Confirm password"
placeholder={t("confirmPassword")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
@@ -91,7 +93,7 @@ export default function LoginPage({
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}>
{setupSaving ? "Setting up …" : "✦ Create administrator"}
{setupSaving ? (language === "en" ? "Setting up …" : "Einrichtung …") : (language === "en" ? "✦ Create administrator" : "✦ Administrator erstellen")}
</button>
</div>
) : (
@@ -100,7 +102,7 @@ export default function LoginPage({
<input
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="Email"
placeholder={t("email")}
style={styles.loginInput}
inputMode="email"
autoComplete="username"
@@ -112,7 +114,7 @@ export default function LoginPage({
<input
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Passwort"
placeholder={t("password")}
type={showPw ? "text" : "password"}
style={styles.inputInRow}
autoComplete="current-password"
@@ -121,8 +123,8 @@ export default function LoginPage({
type="button"
onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"}
title={showPw ? "Verstecken" : "Anzeigen"}
aria-label={showPw ? (language === "en" ? "Hide password" : "Passwort verstecken") : (language === "en" ? "Show password" : "Passwort anzeigen")}
title={showPw ? (language === "en" ? "Hide" : "Verstecken") : (language === "en" ? "Show" : "Anzeigen")}
>
{showPw ? "🙈" : "👁"}
</button>
@@ -130,13 +132,13 @@ export default function LoginPage({
</div>
<button onClick={doLogin} style={styles.loginBtn}>
Anmelden
{t("login")}
</button>
</div>
)}
<div style={styles.loginHint}>
Your notes remain private every player only sees their own sheet.
{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>
+108 -23
View File
@@ -1,6 +1,8 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import QRCode from "qrcode";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function NewGameModal({
open,
@@ -13,12 +15,18 @@ export default function NewGameModal({
gameFinished = false,
hasGame = false,
}) {
const { language, t } = useLanguage();
// modes: running | choice | create | join
const [mode, setMode] = useState("choice");
const [joinCode, setJoinCode] = useState("");
const [err, setErr] = useState("");
const [created, setCreated] = useState(null); // { code }
const [toast, setToast] = useState("");
const [qrOpen, setQrOpen] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState("");
const [scannerOpen, setScannerOpen] = useState(false);
const videoRef = useRef(null);
const streamRef = useRef(null);
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
@@ -30,6 +38,9 @@ export default function NewGameModal({
setToast("");
setJoinCode("");
setCreated(null);
setQrOpen(false);
setQrDataUrl("");
setScannerOpen(false);
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
if (hasGame && !gameFinished) {
@@ -39,6 +50,57 @@ export default function NewGameModal({
}
}, [open, hasGame, gameFinished]);
useEffect(() => {
const qrValue = created?.code || currentCode;
if (!qrOpen || !qrValue) return;
QRCode.toDataURL(qrValue, { width: 280, margin: 2, errorCorrectionLevel: "M", color: { dark: "#17161b", light: "#f5efdc" } })
.then(setQrDataUrl).catch(() => setQrDataUrl(""));
}, [qrOpen, created?.code, currentCode]);
useEffect(() => {
if (!scannerOpen) return undefined;
let alive = true;
let intervalId;
const startScanner = async () => {
if (!("BarcodeDetector" in window) || !navigator.mediaDevices?.getUserMedia) {
setErr(language === "en" ? "QR scanning is not supported here. Enter the code manually." : "QR-Scannen wird hier nicht unterstützt. Bitte Code manuell eingeben.");
setScannerOpen(false);
return;
}
try {
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false });
if (!alive) { stream.getTracks().forEach((track) => track.stop()); return; }
streamRef.current = stream;
videoRef.current.srcObject = stream;
await videoRef.current.play();
intervalId = window.setInterval(async () => {
if (!alive || !videoRef.current) return;
try {
const codes = await detector.detect(videoRef.current);
const raw = codes?.[0]?.rawValue?.trim() || "";
if (raw) {
setJoinCode((raw.match(/[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4,}/i)?.[0] || raw).toUpperCase());
setScannerOpen(false);
setToast(language === "en" ? "✅ QR code recognized" : "✅ QR-Code erkannt");
}
} catch { /* frame not ready */ }
}, 350);
} catch {
setErr(language === "en" ? "Camera access was denied. Enter the code manually." : "Kamerazugriff wurde verweigert. Bitte Code manuell eingeben.");
setScannerOpen(false);
}
};
startScanner();
return () => {
alive = false;
if (intervalId) window.clearInterval(intervalId);
if (streamRef.current) streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
if (videoRef.current) videoRef.current.srcObject = null;
};
}, [scannerOpen, language]);
if (!open) return null;
const showToast = (msg) => {
@@ -53,7 +115,7 @@ export default function NewGameModal({
setCreated({ code: res.code });
setMode("create");
} catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown"));
setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
}
};
@@ -63,16 +125,16 @@ export default function NewGameModal({
await onJoin(joinCode.trim().toUpperCase());
onClose();
} 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 {
await navigator.clipboard.writeText(text || "");
showToast(okMsg);
} catch {
showToast("❌ Copy nicht möglich");
showToast(language === "en" ? "❌ Copy failed" : "❌ Copy nicht möglich");
}
};
@@ -85,9 +147,9 @@ export default function NewGameModal({
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Spiel
{t("game")}
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
@@ -116,7 +178,7 @@ export default function NewGameModal({
{mode === "running" && (
<>
<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
@@ -131,7 +193,7 @@ export default function NewGameModal({
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
{t("gameCode")}
</div>
<div
@@ -150,19 +212,26 @@ export default function NewGameModal({
onClick={() => copyText(codeToShow)}
style={styles.primaryBtn}
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 onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn} disabled={!codeToShow}>
{qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
</button>
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
</div>}
</div>
<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 style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
{language === "en" ? "Done" : "Fertig"}
</button>
</div>
</>
@@ -173,15 +242,15 @@ export default function NewGameModal({
{mode === "choice" && (
<>
<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>
<button onClick={doCreate} style={styles.primaryBtn}>
Spiel erstellen
{t("createGame")}
</button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}>
Spiel beitreten
{t("joinGame")}
</button>
</>
)}
@@ -189,7 +258,7 @@ export default function NewGameModal({
{mode === "join" && (
<>
<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>
<input
@@ -200,12 +269,21 @@ export default function NewGameModal({
autoFocus
/>
<button type="button" onClick={() => { setErr(""); setScannerOpen((value) => !value); }} style={styles.secondaryBtn}>
{scannerOpen ? (language === "en" ? "Close scanner" : "Scanner schließen") : (language === "en" ? "Scan QR code" : "QR-Code scannen")}
</button>
{scannerOpen && <div style={{ display: "grid", gap: 8 }}>
<video ref={videoRef} muted playsInline style={{ width: "100%", maxHeight: 240, objectFit: "cover", borderRadius: 14, background: "#050507", border: `1px solid ${stylesTokens.panelBorder}` }} />
<div style={{ color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>{language === "en" ? "Point your camera at the game QR code." : "Richte die Kamera auf den QR-Code des Spiels."}</div>
</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
Zurück
{language === "en" ? "Back" : "Zurück"}
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
Beitreten
{t("join")}
</button>
</div>
</>
@@ -214,7 +292,7 @@ export default function NewGameModal({
{mode === "create" && created && (
<>
<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
@@ -229,7 +307,7 @@ export default function NewGameModal({
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
{t("gameCode")}
</div>
<div
@@ -245,13 +323,20 @@ export default function NewGameModal({
</div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren
{t("copy")} {language === "en" ? "code" : "Code"}
</button>
<button onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn}>
{qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
</button>
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
</div>}
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
{language === "en" ? "Done" : "Fertig"}
</button>
</div>
</>
+9 -7
View File
@@ -1,6 +1,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function PasswordModal({
pwOpen,
@@ -13,14 +14,15 @@ export default function PasswordModal({
pwSaving,
savePassword,
}) {
const { language, t } = useLanguage();
if (!pwOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>{t("setPasswordTitle")}</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
@@ -29,7 +31,7 @@ export default function PasswordModal({
<input
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort"
placeholder={language === "en" ? "New password" : "Neues Passwort"}
type="password"
style={styles.input}
autoFocus
@@ -37,7 +39,7 @@ export default function PasswordModal({
<input
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen"
placeholder={t("passwordAgain")}
type="password"
style={styles.input}
onKeyDown={(e) => {
@@ -49,15 +51,15 @@ export default function PasswordModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen
{t("cancel")}
</button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"}
{pwSaving ? (language === "en" ? "Saving…" : "Speichern") : t("save")}
</button>
</div>
<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>
+10 -5
View File
@@ -2,6 +2,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { translateEntryLabel, useLanguage } from "../i18n";
export default function SheetSection({
title,
@@ -10,7 +11,9 @@ export default function SheetSection({
onCycleStatus,
onToggleTag,
displayTag,
readOnly = false,
}) {
const { language, t } = useLanguage();
const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg;
@@ -70,16 +73,17 @@ export default function SheetSection({
}}
>
<div
onClick={() => onCycleStatus(e)}
onClick={() => !readOnly && onCycleStatus(e)}
style={{
...styles.name,
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
color: getNameColor(effectiveStatus),
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 style={styles.statusCell}>
@@ -95,9 +99,10 @@ export default function SheetSection({
</div>
<button
onClick={() => onToggleTag(e)}
onClick={() => !readOnly && onToggleTag(e)}
style={styles.tagBtn}
title="— → i → m → s.(Chip) → —"
disabled={readOnly}
title={readOnly ? t("readOnly") : t("cycleNote")}
>
{displayTag(e)}
</button>
+9 -7
View File
@@ -2,6 +2,7 @@ import React from "react";
import { createPortal } from "react-dom";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
function Tile({ label, value, sub }) {
return (
@@ -48,6 +49,7 @@ function Tile({ label, value, sub }) {
}
export default function StatsModal({ open, onClose, me, stats, loading, error }) {
const { language, t } = useLanguage();
if (!open) return null;
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.modalHeader}>
<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 }}>
{displayName}
</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
</button>
</div>
@@ -71,7 +73,7 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
<div style={{ marginTop: 12 }}>
{loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik
{language === "en" ? "Loading statistics…" : "Lade Statistik…"}
</div>
) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
@@ -83,10 +85,10 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
gap: 10,
}}
>
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" />
<Tile label={language === "en" ? "Games played" : "Gespielte Spiele"} value={stats?.played ?? 0} />
<Tile label={language === "en" ? "Wins" : "Siege"} value={stats?.wins ?? 0} />
<Tile label={language === "en" ? "Losses" : "Verluste"} value={stats?.losses ?? 0} />
<Tile label={language === "en" ? "Win rate" : "Siegerate"} value={`${stats?.winrate ?? 0}%`} sub={language === "en" ? "finished games only" : "nur beendete Spiele"} />
</div>
)}
</div>
+49 -8
View File
@@ -1,6 +1,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function TopBar({
me,
@@ -9,10 +10,15 @@ export default function TopBar({
openPwModal,
openDesignModal,
openStatsModal,
openAdminPanel,
openAdminSettings,
doLogout,
onOpenNewGame,
}) {
const { toggleLanguage, t } = useLanguage();
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
const isAdmin = me?.role === "admin";
const roleLabel = isAdmin ? t("admin") : t("user");
return (
<div className="hp-topbar" style={styles.topBar}>
@@ -29,10 +35,10 @@ export default function TopBar({
className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
title={t("user") + " menu"}
>
<span style={{ fontSize: 16 }}>👤</span>
<span>User</span>
<span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span>
<span>{roleLabel}</span>
<span style={{ opacity: 0.7 }}></span>
</button>
@@ -57,21 +63,56 @@ export default function TopBar({
}}
style={styles.userDropdownItem}
>
Statistik
{t("statistics")}
</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} />
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
{t("setPassword")}
</button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern
{t("changeDesign")}
</button>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
toggleLanguage();
setUserMenuOpen(false);
}}
style={styles.userDropdownItem}
title={`${t("changeLanguage")}: ${t("switchTo")}`}
>
🌐 {t("changeLanguage")}
</button>
<button
onClick={() => {
setUserMenuOpen(false);
@@ -79,14 +120,14 @@ export default function TopBar({
}}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
>
Logout
{t("logout")}
</button>
</div>
)}
</div>
<button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game
{t("newGame")}
</button>
</div>
</div>
+4 -2
View File
@@ -1,5 +1,6 @@
import React from "react";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
/**
* Props:
@@ -7,6 +8,7 @@ import { stylesTokens } from "../styles/theme";
* - winnerEmail: string | null (legacy fallback)
*/
export default function WinnerBadge({ winner, winnerEmail }) {
const { language } = useLanguage();
const name =
(winner?.display_name || "").trim() ||
(winner?.email || "").trim() ||
@@ -34,13 +36,13 @@ export default function WinnerBadge({ winner, winnerEmail }) {
<div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger:
{language === "en" ? "Winner:" : "Sieger:"}
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div>
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
festgelegt
{language === "en" ? "selected" : "festgelegt"}
</div>
</div>
);
+6 -4
View File
@@ -1,6 +1,7 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCard({
isHost,
@@ -9,12 +10,13 @@ export default function WinnerCard({
setWinnerUserId,
onSave,
}) {
const { language, t } = useLanguage();
if (!isHost) return null;
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div>
<div style={styles.sectionHeader}>{t("winner")}</div>
<div style={styles.cardBody}>
<select
@@ -22,7 +24,7 @@ export default function WinnerCard({
onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }}
>
<option value=""> kein Sieger </option>
<option value=""> {language === "en" ? "no winner" : "kein Sieger"} </option>
{members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return (
@@ -34,12 +36,12 @@ export default function WinnerCard({
</select>
<button onClick={onSave} style={styles.primaryBtn}>
Speichern
{t("save")}
</button>
</div>
<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>
+21 -28
View File
@@ -2,8 +2,10 @@ 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 WinnerCelebration({ open, winnerName, onClose }) {
const { language } = useLanguage();
useEffect(() => {
if (!open) return;
@@ -16,48 +18,39 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
const end = Date.now() + 4500;
// WICHTIG: über dem Overlay rendern
const TOP_Z = 2147483647;
// hellere Farben damits auch auf dark overlay knallt
const bright = ["#ffffff", "#ffd166", "#06d6a0", "#4cc9f0", "#f72585"];
const bright = ["#ffffff", "#e9d8a6", "#c9aa62", "#7cffa8"];
// 2 große Bursts
confetti({
particleCount: 170,
spread: 95,
startVelocity: 42,
origin: { x: 0.12, y: 0.62 },
particleCount: 55,
spread: 72,
startVelocity: 28,
gravity: 0.85,
ticks: 150,
scalar: 0.82,
origin: { x: 0.22, y: 0.62 },
zIndex: TOP_Z,
colors: bright,
});
confetti({
particleCount: 170,
spread: 95,
startVelocity: 42,
origin: { x: 0.88, y: 0.62 },
particleCount: 55,
spread: 72,
startVelocity: 28,
gravity: 0.85,
ticks: 150,
scalar: 0.82,
origin: { x: 0.78, y: 0.62 },
zIndex: TOP_Z,
colors: bright,
});
// “Rain” über die Zeit
(function frame() {
confetti({
particleCount: 8,
spread: 75,
startVelocity: 34,
origin: { x: Math.random(), y: Math.random() * 0.18 },
scalar: 1.05,
zIndex: TOP_Z,
colors: bright,
});
if (Date.now() < end) requestAnimationFrame(frame);
})();
}
const t = setTimeout(() => onClose?.(), 5500);
const t = setTimeout(() => onClose?.(), 4200);
return () => {
clearTimeout(t);
@@ -146,15 +139,15 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
lineHeight: 1.25,
}}
>
Spieler{" "}
{language === "en" ? "Player " : "Spieler "}
<span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"}
</span>{" "}
hat die richtige Lösung!
{language === "en" ? "has the correct solution!" : "hat die richtige Lösung!"}
</div>
<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 style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}>
-1
View File
@@ -1,2 +1 @@
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 ReactDOM from "react-dom/client";
import App from "./App.jsx";
import { LanguageProvider } from "./i18n.jsx";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { registerSW } from "virtual:pwa-register";
@@ -21,7 +22,9 @@ async function bootstrap() {
} catch {}
// App rendern
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
ReactDOM.createRoot(document.getElementById("root")).render(
<LanguageProvider><App /></LanguageProvider>
);
// Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000;
+47 -15
View File
@@ -80,7 +80,7 @@ export function useHpGlobalStyles() {
-webkit-overflow-scrolling: touch;
}
#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); }
@@ -89,29 +89,60 @@ export function useHpGlobalStyles() {
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.2fr) minmax(0, 1.5fr) 70px 76px 86px; }
.hp-admin-user-row { grid-template-columns: minmax(90px, 1.2fr) minmax(90px, 1.5fr) 58px 58px 100px; white-space: nowrap; min-height: 42px; padding: 6px 8px !important; }
.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: 30px; height: 30px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; }
.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: flex-start !important; padding: 12px !important; flex-wrap: wrap !important; }
.hp-topbar-actions { width: 100%; margin-left: 0; justify-content: stretch; }
.hp-user-menu-wrap { flex: 1; min-width: 0; }
.hp-topbar-user, .hp-topbar-new { flex: 1; min-height: 44px; }
.hp-topbar-user { width: 100%; }
.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: 0 !important; right: auto !important; width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
.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-action { grid-column: 2; grid-row: 3; justify-self: end; width: auto; min-width: 94px; min-height: 38px; padding: 7px 14px !important; }
.hp-admin-user-row { grid-template-columns: minmax(65px, 1.1fr) minmax(0, 1.3fr) 42px 42px 94px; gap: 3px; padding: 7px !important; border-radius: 12px !important; min-height: 42px; }
.hp-admin-name, .hp-admin-email { min-width: 0; font-size: 12px !important; }
.hp-admin-role, .hp-admin-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 11px !important; padding-left: 2px !important; padding-right: 2px !important; }
.hp-admin-actions { width: auto; justify-content: flex-end; gap: 3px; }
.hp-admin-actions .hp-admin-action { width: 29px; min-width: 29px; height: 29px; min-height: 29px; padding: 0 !important; font-size: 14px; }
.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; }
@@ -120,7 +151,8 @@ export function useHpGlobalStyles() {
}
@media (max-width: 360px) {
.hp-row { grid-template-columns: minmax(0, 1fr) 38px 56px !important; font-size: 14px; }
.hp-topbar { gap: 8px !important; }
.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);
+7 -1
View File
@@ -180,9 +180,15 @@ export const styles = {
fontWeight: 1000,
color: stylesTokens.textGold,
},
adminFieldLabel: {
display: "grid",
gap: 5,
color: stylesTokens.textDim,
fontSize: 12,
fontWeight: 800,
},
userRow: {
display: "grid",
gridTemplateColumns: "1fr 80px 90px",
gap: 8,
padding: 10,
borderRadius: 12,