Compare commits

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

Updated grid settings for better flexibility and spacing across various screen sizes. Adjusted dimensions, padding, and responsive breakpoints to enhance layout appearance and usability, especially for smaller screens. Added minor styling tweaks for improved aesthetics and consistency.
2026-02-07 11:49:27 +01:00
nessi c06ae53b4f Update layout and annotations for player HUD and board tools
Improved clarity of component annotations and adjusted the layout styling in App.jsx and AppLayout.css. Key changes include the introduction of a middle section in the player HUD and refined descriptions for board-related elements. This enhances code readability and structuring consistency.
2026-02-07 11:47:28 +01:00
nessi fd91bbe207 Adjust grid column widths for improved layout consistency
Updated the grid column sizes across the application to ensure a better visual balance and alignment. This includes reducing the minimum and maximum widths for side panels and stacking elements vertically for better usability in the left tools section.
2026-02-07 11:43:00 +01:00
nessi e5f8f00832 Add player rail, dice overlay, and compact card support
This update introduces a player rail to the right of the board, visually representing active and inactive players, and adds a dice overlay beneath the board. Compact variants for placeholder cards were added, with corresponding adjustments to layouts and styles for better responsiveness and a cleaner interface.
2026-02-07 11:38:38 +01:00
nessi e035a99179 Refactor layout for improved responsiveness and scroll control
Updated the app layout to separate fixed and scrollable sections, enabling only the notes panel to scroll. Introduced a new CSS file for a cleaner structure and responsive behavior, ensuring a more consistent user experience across different screen sizes.
2026-02-07 11:31:55 +01:00
nessi 3d7d4c01f7 Update layout and scrolling styles for better responsiveness
Adjusted grid column sizing using "clamp" for improved adaptability to various screen sizes. Minor tweaks to sticky position and maximum height calculation ensure better alignment and consistent scroll behavior.
2026-02-07 11:17:54 +01:00
nessi cf81c25e6e new logic, testing 2026-02-07 11:15:33 +01:00
52 changed files with 1846 additions and 2541 deletions
+11
View File
@@ -0,0 +1,11 @@
POSTGRES_DB=cluedo
POSTGRES_USER=cluedo
POSTGRES_PASSWORD=supersecret
# Backend
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
BACKEND_BASE_URL=http://localhost:8080
# Admin initial user (wird beim Start angelegt, falls nicht existiert)
ADMIN_EMAIL=admin@local
ADMIN_PASSWORD=ChangeMeNow123!
-6
View File
@@ -1,6 +0,0 @@
# Required backend secret. Use a long, random value in your local .env file.
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
# Optional cookie settings for local/internal deployments.
COOKIE_SECURE=false
COOKIE_SAMESITE=Lax
-6
View File
@@ -1,6 +0,0 @@
.env
backend/data/
frontend/node_modules/
frontend/dist/
__pycache__/
*.py[cod]
+1 -221
View File
@@ -1,222 +1,2 @@
# Cluedo HP Webapp
# cluedo-hp-webapp
A small multiplayer web app that acts as a digital note sheet for a Harry Potter-inspired Cluedo game. Several logged-in players can join the same game and manage their personal clues independently.
## Features
- Login with user and admin roles
- Admin-managed user creation and deactivation
- Multiple games per user
- Join games using a short join code
- Host-controlled game start with automatic player chips
- Pre-game lobby with live player list, host status, and start confirmation
- Player chips are generated from the first name initial and first two surname letters, e.g. `SNE` for Sascha Nesterovic
- Automatic player list with host indication
- Personal note sheet for each player and game
- Categories for suspects, items, and locations
- Entry status tracking: empty, ruled out, present, or maybe
- Additional notes using `i`, `m`, and `s.<chip>`
- Winner selection by the game host
- Winner badge and confetti animation for all players
- Live updates for new players and winner changes
- Personal game statistics
- Password change functionality
- Harry Potter house themes: Default, Gryffindor, Slytherin, Ravenclaw, and Hufflepuff
- Installable as a Progressive Web App
## Technology
### Frontend
- React 18
- Vite
- Nginx as the production web server
- `canvas-confetti` for the winner animation
- `vite-plugin-pwa` for PWA support
### Backend
- Python 3.12
- FastAPI
- SQLAlchemy 2
- SQLite 3
- Passlib and bcrypt for password hashing
### Deployment
- Docker Compose
- Frontend on port `8081`
- Backend on port `8080`
- SQLite database stored in a persistent Docker volume
## Quick start with Docker
Requirements:
- Docker
- Docker Compose
1. Copy `.env.example` to `.env` and set a strong secret:
```env
BACKEND_SECRET_KEY=use-a-long-random-secret-value
```
2. Build and start the containers:
```bash
docker compose up --build
```
3. Open the application:
- Frontend: http://localhost:8081
- Backend/API: http://localhost:8080
On the first startup, the application opens a setup screen where you create the first administrator with an email address, display name, and password. The default suspects, items, and locations are seeded automatically as well.
The SQLite database is stored in the Docker volume `cluedo-data` and survives normal container restarts.
Start the containers in the background:
```bash
docker compose up -d --build
```
View logs:
```bash
docker compose logs -f backend
```
Stop the containers:
```bash
docker compose down
```
## Local development without Docker
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=sqlite:///./data/cluedo.db
export SECRET_KEY=use-a-long-random-secret-value
uvicorn app.main:app --reload --port 8080
```
On Windows PowerShell, set the environment variables like this:
```powershell
$env:DATABASE_URL = "sqlite:///./data/cluedo.db"
$env:SECRET_KEY = "use-a-long-random-secret-value"
uvicorn app.main:app --reload --port 8080
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
The frontend is normally available at http://localhost:5173. During development, the API must be reachable under `/api`; the production Docker setup provides this through Nginx.
Create a production build:
```bash
npm run build
```
## Project structure
```text
.
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, startup, seed data
│ │ ├── models.py # SQLAlchemy models
│ │ ├── db.py # SQLite engine and sessions
│ │ ├── security.py # Password and cookie logic
│ │ └── routes/ # Auth, admin, and game APIs
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Main UI and game flow
│ │ ├── api/client.js # API client
│ │ ├── components/ # Pages, cards, and modals
│ │ ├── styles/ # Themes and inline styles
│ │ └── utils/ # Small helpers and storage utilities
│ ├── Dockerfile
│ └── nginx.conf
├── docker-compose.yml
└── .env
```
## Data model
- `users`: users, roles, status, display names, and themes
- `games`: game name, join code, host, and winner
- `game_members`: assignment of users to games
- `entries`: seeded suspects, items, and locations
- `sheet_state`: personal status and notes for an entry
Each player has their own set of `sheet_state` records for every game. A player's notes are therefore not visible to other players.
## API overview
### Authentication
- `POST /auth/login`
- `POST /auth/logout`
- `GET /auth/me`
- `PATCH /auth/password`
- `PATCH /auth/theme`
- `GET /auth/me/stats`
### First-run setup
- `GET /setup/status`
- `POST /setup/admin`
The setup endpoint is available only while no administrator exists. After the first administrator has been created, the setup screen is disabled automatically.
### Administration
- `GET /admin/users`
- `POST /admin/users`
- `DELETE /admin/users/{user_id}` deactivates a user
### Games
- `GET /games`
- `POST /games`
- `POST /games/join`
- `GET /games/{game_id}`
- `GET /games/{game_id}/members`
- `PATCH /games/{game_id}/winner`
- `POST /games/{game_id}/start`
- `GET /games/{game_id}/chips`
- `GET /games/{game_id}/sheet`
- `PATCH /games/{game_id}/sheet/{entry_id}`
## SQLite notes
SQLite is a good fit for this application: the data volume is small, the app is intended for internal use, and write operations are short. The database file is stored inside the container at `/app/data/cluedo.db` and persisted through the `cluedo-data` volume.
For larger deployments with many concurrent write operations, PostgreSQL would still be the more robust choice. For the intended private or small-group use case, SQLite is sufficient.
## Security notes
- The values in `.env` are examples and should be changed before production use.
- When using HTTPS, set `COOKIE_SECURE` to `true`.
- The application is intended for an internal or small user group.
- The automatic database migration is intentionally pragmatic and does not replace a migration system such as Alembic.
-1
View File
@@ -1,7 +1,6 @@
FROM python:3.12-slim
WORKDIR /app
RUN mkdir -p /app/data
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
+2 -17
View File
@@ -1,25 +1,10 @@
import os
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/cluedo.db")
DATABASE_URL = os.environ["DATABASE_URL"]
engine_options = {"pool_pre_ping": True}
if DATABASE_URL.startswith("sqlite"):
# SQLite does not allow connections to be used from another thread by
# default. FastAPI/SQLAlchemy may use a connection across request threads.
engine_options["connect_args"] = {"check_same_thread": False}
# Make the parent directory available for the default local database.
if DATABASE_URL.startswith("sqlite:///"):
sqlite_path = DATABASE_URL.removeprefix("sqlite:///")
if sqlite_path not in (":memory:", ""):
Path(sqlite_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(DATABASE_URL, **engine_options)
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
-63
View File
@@ -1,63 +0,0 @@
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()
with smtplib.SMTP(settings.smtp_host, settings.smtp_port or 587, timeout=20) as server:
server.ehlo()
if settings.smtp_use_tls:
server.starttls(context=context)
server.ehlo()
if settings.smtp_username:
server.login(settings.smtp_username, settings.smtp_password)
server.send_message(message)
def send_user_invite(settings: AppSettings, user: User, invite_url: str):
name = html.escape(user.display_name or user.email)
safe_url = html.escape(invite_url, quote=True)
from_name = html.escape(settings.smtp_from_name or "Cluedo HP")
body_html = f"""
<!doctype html>
<html><body style="margin:0;background:#0b0b0f;color:#f5efdc;font-family:Georgia,serif;">
<div style="padding:36px 16px;background:radial-gradient(circle at top,#312b24 0,#0b0b0f 58%);">
<div style="max-width:580px;margin:0 auto;border:1px solid #6f603f;border-radius:22px;overflow:hidden;background:#17161b;box-shadow:0 18px 55px rgba(0,0,0,.55);">
<div style="padding:28px 30px;text-align:center;background:linear-gradient(135deg,#29231c,#141319);border-bottom:1px solid #6f603f;">
<div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div>
<div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div>
</div>
<div style="padding:30px;line-height:1.55;">
<div style="font-size:20px;color:#e9d8a6;">Hallo {name},</div>
<p>du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
<p>Richte über den folgenden Button dein persönliches Passwort ein:</p>
<p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p>
<p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p>
<p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p>
</div>
<div style="padding:16px 30px;color:#8e8777;font-size:12px;border-top:1px solid #302c2b;text-align:center;">Deine Notizen bleiben privat.</div>
</div>
</div>
</body></html>
"""
body_text = f"Hallo {user.display_name or user.email},\n\nbitte richte dein Passwort ein: {invite_url}\n\nDer Link ist 48 Stunden gültig."
send_html_email(settings, user.email, "Deine Einladung zum Zauber-Detektiv-Notizbogen", body_html, body_text)
+21 -13
View File
@@ -1,3 +1,4 @@
import os
import random
import string
@@ -7,12 +8,11 @@ from sqlalchemy import text
from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal
from .models import User, Entry, Category, Game, GameMember
from .models import User, Entry, Category, Role, Game, GameMember
from .security import hash_password
from .routes.auth import router as auth_router
from .routes.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,8 +31,6 @@ app.add_middleware(
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:
@@ -138,14 +136,6 @@ 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:
@@ -286,6 +276,23 @@ def seed_entries(db: Session):
db.commit()
def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first()
if not u:
db.add(
User(
email=admin_email,
password_hash=hash_password(admin_pw),
role=Role.admin.value,
theme_key="default",
display_name="Admin",
)
)
db.commit()
@app.on_event("startup")
def on_startup():
# create new tables
@@ -294,6 +301,7 @@ def on_startup():
db = SessionLocal()
try:
_auto_migrate(db)
ensure_admin(db)
seed_entries(db)
finally:
db.close()
-36
View File
@@ -41,31 +41,6 @@ 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)
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()))
@@ -79,17 +54,6 @@ 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):
+9 -151
View File
@@ -1,12 +1,7 @@
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 ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, InviteToken, User, Role
from ..models import User, Role
from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"])
@@ -42,47 +37,18 @@ 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 display_name:
raise HTTPException(400, "name/email required")
if not email or not password:
raise HTTPException(400, "email/password required")
if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists")
role = data.get("role")
if not role:
raise HTTPException(400, "role required")
role = data.get("role") or Role.user.value
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.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}
db.add(u); db.commit()
return {"ok": True, "id": u.id}
@router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
@@ -95,119 +61,11 @@ 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_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()
settings.smtp_use_tls = bool(data.get("smtp_use_tls", True))
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}
+2 -85
View File
@@ -1,12 +1,8 @@
import hashlib
import random
import re
import unicodedata
from datetime import datetime, timezone
import hashlib, random
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Game, GameChip, Entry, SheetState, Category, GameMember, User, Role
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"])
@@ -31,25 +27,6 @@ def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
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:
@@ -101,8 +78,6 @@ def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
g = db.query(Game).filter(Game.code == code).first()
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}
@@ -137,7 +112,6 @@ 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
@@ -165,63 +139,9 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"winner_user_id": g.winner_user_id,
"winner_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.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)
@@ -308,9 +228,6 @@ 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
@@ -1,47 +0,0 @@
import hashlib
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import InviteToken, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/auth/invite", tags=["invites"])
def get_invite(token: str, db: Session):
token_hash = hashlib.sha256(token.encode()).hexdigest()
invite = db.query(InviteToken).filter(InviteToken.token_hash == token_hash).first()
expires_at = invite.expires_at if invite else None
if expires_at and expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
if not invite or invite.used_at or expires_at <= datetime.now(timezone.utc):
raise HTTPException(410, "invite is invalid or expired")
user = db.query(User).filter(User.id == invite.user_id, User.disabled == False).first()
if not user:
raise HTTPException(410, "invite is invalid or expired")
return invite, user
@router.get("/{token}")
def invite_info(token: str, db: Session = Depends(get_db)):
_invite, user = get_invite(token, db)
return {"valid": True, "display_name": user.display_name, "email": user.email, "role": user.role}
@router.post("/{token}")
def accept_invite(token: str, data: dict, resp: Response, db: Session = Depends(get_db)):
invite, user = get_invite(token, db)
password = data.get("password") or ""
if len(password) < 8:
raise HTTPException(400, "password too short (min 8)")
user.password_hash = hash_password(password)
invite.used_at = datetime.now(timezone.utc)
db.add(user)
db.add(invite)
db.commit()
set_session(resp, make_session_value(user.id))
return {"ok": True}
-58
View File
@@ -1,58 +0,0 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Role, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/setup", tags=["setup"])
def _has_admin(db: Session) -> bool:
return db.query(User).filter(User.role == Role.admin.value).first() is not None
@router.get("/status")
def setup_status(db: Session = Depends(get_db)):
return {"setup_required": not _has_admin(db)}
@router.post("/admin")
def create_initial_admin(
data: dict,
resp: Response,
db: Session = Depends(get_db),
):
# The setup endpoint is only open until the first admin exists.
if _has_admin(db):
raise HTTPException(status_code=409, detail="setup already completed")
email = (data.get("email") or "").lower().strip()
display_name = (data.get("display_name") or "").strip()
password = data.get("password") or ""
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise HTTPException(status_code=400, detail="valid email required")
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
if not display_name:
display_name = email.split("@", 1)[0]
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=409, detail="email exists")
user = User(
email=email,
password_hash=hash_password(password),
role=Role.admin.value,
display_name=display_name,
)
db.add(user)
db.commit()
db.refresh(user)
# Log the installer in immediately after successful setup.
set_session(resp, make_session_value(user.id))
return {"ok": True, "id": user.id, "email": user.email}
+1
View File
@@ -1,6 +1,7 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
SQLAlchemy==2.0.34
psycopg[binary]==3.2.2
python-multipart==0.0.9
passlib==1.7.4
+21 -4
View File
@@ -1,13 +1,30 @@
services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
backend:
build: ./backend
environment:
DATABASE_URL: sqlite:////app/data/cluedo.db
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SECRET_KEY: ${BACKEND_SECRET_KEY}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true
COOKIE_SAMESITE: "Lax"
volumes:
- cluedo-data:/app/data
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
@@ -19,4 +36,4 @@ services:
- "8081:80"
volumes:
cluedo-data:
pgdata:
-47
View File
@@ -94,36 +94,6 @@
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;
@@ -141,7 +111,6 @@
color: rgba(245, 239, 220, 0.92);
font-size: 18px;
line-height: 1.25;
animation: titleRise 700ms ease-out both;
}
.splash-sub {
@@ -187,21 +156,6 @@
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 -->
@@ -216,7 +170,6 @@
<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>
+1 -6
View File
@@ -7,12 +7,7 @@ server {
}
location /api/ {
# Resolve the Docker service at request time. This prevents Nginx from
# exiting when the Docker DNS entry is not ready during container startup.
resolver 127.0.0.11 ipv6=off valid=10s;
set $backend_upstream http://backend:8080;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass $backend_upstream;
proxy_pass http://backend:8080/;
proxy_http_version 1.1;
proxy_set_header Host $host;
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+613 -624
View File
File diff suppressed because it is too large Load Diff
+379
View File
@@ -0,0 +1,379 @@
/* frontend/src/AppLayout.css */
html, body, #root {
height: 100%;
}
body {
overflow: hidden;
}
.appRoot {
height: 100vh;
display: grid;
/* Links: flexibel | Rechts: Notes clamp */
grid-template-columns: minmax(900px, 1fr) clamp(380px, 32vw, 520px);
gap: 14px;
padding: 14px;
box-sizing: border-box;
overflow: visible;
}
/* LEFT COLUMN */
.leftPane {
overflow: visible;
min-width: 0;
display: grid;
/* Top | Main | HUD */
grid-template-rows: 68px minmax(360px, 1fr) minmax(160px, 190px);
gap: 14px;
}
.topBarRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
min-width: 0;
}
/* MAIN: Tools | Board | PlayerRail */
.mainRow {
min-height: 0;
overflow: visible;
display: grid;
/* Tools bewusst schmaler, Board bekommt Bühne */
grid-template-columns: 260px minmax(560px, 1fr) 92px;
gap: 14px;
min-width: 0;
}
/* Tools left of board */
.leftTools {
min-width: 0;
overflow: hidden;
display: grid;
align-content: start;
}
.leftToolsRow {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
min-width: 0;
}
/* Board */
.boardWrap {
min-height: 0;
overflow: hidden;
border-radius: 22px;
min-width: 0;
position: relative;
}
/* Dice overlay: under board slightly right */
.diceOverlay {
position: absolute;
overflow: visible;
bottom: 14px;
right: 18px;
width: 220px;
pointer-events: auto;
opacity: 0.98;
}
/* Player rail: right of board, before notes (tight) */
.playerRail {
min-height: 0;
border-radius: 22px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.18);
backdrop-filter: blur(10px);
box-shadow: 0 16px 50px rgba(0,0,0,0.35);
padding: 10px 8px;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
gap: 10px;
overflow: visible;
}
.playerRailInner {
width: 100%;
height: 100%;
overflow: visible;
border-radius: 18px;
display: grid;
justify-items: center;
}
.playerRailTitle {
font-weight: 900;
font-size: 12px;
opacity: 0.9;
}
.playerRailList {
min-height: 0;
overflow: visible;
display: grid;
gap: 10px;
align-content: start;
justify-items: center;
padding-top: 4px;
}
/* HUD */
.playerHud {
align-items: stretch;
overflow: visible;
display: grid;
grid-template-columns: 1.1fr 1.7fr 1.1fr;
gap: 14px;
min-width: 0;
}
.playerHudMiddle {
height: 100%;
min-width: 0;
min-height: 0;
overflow: visible;
display: grid;
/* Geheimkarten groß, Hilfkarte etwas kleiner */
grid-template-columns: 1fr 0.78fr;
gap: 14px;
}
/* RIGHT COLUMN */
.notesPane {
overflow: hidden;
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
}
.notesScroll {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.diceOverlay {
position: absolute;
bottom: 14px;
right: 18px;
width: 220px;
pointer-events: auto; /* ✅ damit Hover/Clicks für Würfel gehen */
opacity: 0.98;
}
/* ===== True 3D Cube Dice (transition-based roll) ===== */
.die3d {
transform-style: preserve-3d;
will-change: transform;
}
.die3d:hover:not(:disabled) {
transform: translateY(-3px) rotateX(8deg) rotateY(-10deg);
}
.dieCubeWrap {
width: 44px;
height: 44px;
position: relative;
transform-style: preserve-3d;
}
.dieCube {
width: 44px;
height: 44px;
position: absolute;
inset: 0;
transform-style: preserve-3d;
/* absolute angles (numbers are set in JS) */
transform: rotateX(calc(var(--rx, 0) * 1deg)) rotateY(calc(var(--ry, 0) * 1deg));
/* default: snappy */
transition: transform 260ms cubic-bezier(0.22, 1.0, 0.25, 1);
}
.dieCube.rolling {
/* roll duration */
transition: transform 820ms cubic-bezier(0.18, 0.88, 0.22, 1);
}
/* Faces: remove hard borders / inner rings */
.dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: none; /* ✅ no face border */
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.05), /* very subtle */
inset 0 14px 24px rgba(255,255,255,0.04);
display: grid;
place-items: center;
backface-visibility: hidden;
}
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* Prevent clipping */
.diceRow3d { overflow: visible; }
.dieCube.snap {
transition: none !important;
}
/* one face */
.dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.10);
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.06),
inset 0 12px 20px rgba(255,255,255,0.05);
display: grid;
place-items: center;
backface-visibility: hidden;
}
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
/* special icon */
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* optional: subtle face ring tint via CSS var */
.dieFace {
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.06),
inset 0 0 0 2px var(--ring, rgba(255,255,255,0.00)),
inset 0 12px 20px rgba(255,255,255,0.05);
}
/* Roll animation */
@keyframes dieRoll {
0% {
transform: translateY(0) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}
25% {
transform: translateY(-6px) rotateX(220deg) rotateY(160deg) rotateZ(90deg);
}
55% {
transform: translateY(0px) rotateX(520deg) rotateY(460deg) rotateZ(240deg);
}
100% {
transform: translateY(-2px) rotateX(720deg) rotateY(720deg) rotateZ(360deg);
}
}
.die3dRolling {
animation: dieRoll 820ms cubic-bezier(0.2, 0.9, 0.25, 1) both;
}
/* --- Responsive: shrink gracefully (no scroll outside notes) --- */
@media (max-height: 860px) {
.leftPane {
grid-template-rows: 64px minmax(300px, 1fr) 150px;
}
.diceOverlay {
bottom: 10px;
right: 12px;
width: 200px;
}
}
@media (max-width: 1440px) {
.appRoot {
grid-template-columns: minmax(820px, 1fr) clamp(360px, 34vw, 480px);
}
.mainRow {
grid-template-columns: 240px minmax(520px, 1fr) 88px;
}
}
@media (max-width: 1280px) {
.appRoot {
grid-template-columns: 1fr clamp(340px, 36vw, 460px);
}
.mainRow {
grid-template-columns: 220px minmax(480px, 1fr) 84px;
}
}
@media (max-width: 1180px) {
/* ab hier wirds eng: tools noch schmäler */
.mainRow {
grid-template-columns: 200px minmax(440px, 1fr) 82px;
}
}
-1
View File
@@ -3,7 +3,6 @@ import { API_BASE } from "../constants";
export async function api(path, opts = {}) {
const res = await fetch(API_BASE + path, {
credentials: "include",
cache: "no-store",
headers: { "Content-Type": "application/json" },
...opts,
});
+155 -125
View File
@@ -1,174 +1,204 @@
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";
import { createPortal } from "react-dom";
const emptyForm = { displayName: "", email: "", password: "", role: "user", disabled: false };
export default function AdminPanel({ open: dashboardOpen = false, onClose, currentUserId }) {
const { language, t } = useLanguage();
export default function AdminPanel() {
const [users, setUsers] = useState([]);
const [editorOpen, setEditorOpen] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [form, setForm] = useState(emptyForm);
const [open, setOpen] = useState(false);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [msg, setMsg] = useState("");
const [notice, setNotice] = useState("");
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!dashboardOpen && !editorOpen) return;
const previous = document.body.style.overflow;
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = previous; };
}, [dashboardOpen, editorOpen]);
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
useEffect(() => {
if (!dashboardOpen) {
setEditorOpen(false);
setMsg("");
}
}, [dashboardOpen]);
loadUsers().catch(() => {});
}, []);
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 resetForm = () => {
setDisplayName("");
setEmail("");
setPassword("");
setRole("user");
};
const openEdit = (user) => {
setEditingUser(user);
setForm({
displayName: user.display_name || "",
email: user.email || "",
password: "",
role: user.role || "user",
disabled: !!user.disabled,
});
const createUser = async () => {
setMsg("");
setNotice("");
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
setEditingUser(null);
setMsg("");
};
const saveUser = async () => {
setMsg("");
if (!form.email.trim()) return setMsg(`${t("email")} ${language === "en" ? "is required." : "ist erforderlich."}`);
if (!editingUser && form.password && form.password.length < 8) return setMsg(`${t("password")} ${language === "en" ? "must be at least 8 characters." : "muss mindestens 8 Zeichen haben."}`);
if (editingUser && form.password && form.password.length < 8) return setMsg(`${language === "en" ? "New password must be at least 8 characters." : "Neues Passwort muss mindestens 8 Zeichen haben."}`);
setSaving(true);
try {
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 api("/admin/users", {
method: "POST",
body: JSON.stringify({ display_name: displayName, email, password, role }),
});
setMsg("✅ User erstellt.");
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();
resetForm();
setOpen(false);
} catch (e) {
setMsg("❌ " + (e?.message || (language === "en" ? "Saving failed." : "Speichern fehlgeschlagen.")));
} finally {
setSaving(false);
setMsg("❌ Fehler: " + (e?.message || "unknown"));
}
};
const disableUser = async (user) => {
if (user.id === currentUserId) return;
if (!window.confirm(`User wirklich deaktivieren?\n\n${user.display_name || user.email}`)) return;
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/${user.id}`, { method: "DELETE" });
await api(`/admin/users/${u.id}`, { method: "DELETE" });
await loadUsers();
} catch (e) {
alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
alert("Fehler: " + (e?.message || "unknown"));
}
};
if (!dashboardOpen) return null;
const closeModal = () => {
setOpen(false);
setMsg("");
};
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>
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>
</div>
<div style={{ marginTop: 18, display: "flex", justifyContent: "space-between", alignItems: "center", gap: 10 }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{t("existingUsers")} <span style={{ color: stylesTokens.textDim, fontWeight: 700 }}>({users.length})</span></div>
<button onClick={openCreate} style={styles.primaryBtn}>+ {t("createUser")}</button>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
Vorhandene User
</div>
{notice && <div style={{ marginTop: 10, color: "#baf3c9", fontSize: 13 }}>{notice}</div>}
<div style={{ marginTop: 10, display: "grid", gap: 9 }}>
{users.map((user) => (
<div key={user.id} className="hp-admin-user-row" style={{ ...styles.userRow, alignItems: "center" }}>
<div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>{user.display_name || "—"}</div>
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{user.email}</div>
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>{user.role}</div>
<div className={`hp-admin-status ${user.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85 }}>{user.disabled ? "disabled" : "active"}</div>
<div className="hp-admin-actions">
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></button>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((u) => (
<div
key={u.id}
style={{
...styles.userRow,
gridTemplateColumns: "1fr 1fr 80px 90px 92px",
alignItems: "center",
}}
>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role}
</div>
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"}
</div>
<button
onClick={() => deleteUser(u)}
style={{
...styles.secondaryBtn,
padding: "8px 10px",
borderRadius: 12,
color: "#ffb3b3",
opacity: u.role === "admin" ? 0.4 : 1,
pointerEvents: u.role === "admin" ? "none" : "auto",
}}
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
>
Löschen
</button>
</div>
))}
</div>
</div>
</div>
{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()}>
{open &&
createPortal(
<div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<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 style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Neuen User anlegen
</div>
<button onClick={closeEditor} style={styles.modalCloseBtn} aria-label={t("close")}></button>
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</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
style={{
marginTop: 12,
display: "grid",
gap: 8,
justifyItems: "center", // <<< zentriert alles
}}
>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Name (z.B. Sascha)"
style={styles.input}
autoFocus
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
style={styles.input}
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Initial Passwort"
type="password"
style={styles.input}
/>
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
{msg && <div style={{ opacity: 0.9, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button
onClick={() => {
resetForm();
setMsg("");
}}
style={styles.secondaryBtn}
>
Leeren
</button>
<button onClick={createUser} style={styles.primaryBtn}>
User erstellen
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Name wird in TopBar & Siegeranzeige genutzt.
</div>
</div>
</div>
</div>,
document.body
)}
</div>,
document.body
)
}
</div>
);
}
@@ -1,86 +0,0 @@
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_use_tls: true,
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 className="hp-admin-check"><input type="checkbox" checked={!!form.smtp_use_tls} onChange={(e) => setField("smtp_use_tls", e.target.checked)} /> {t("useTls")}</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
);
}
+9 -16
View File
@@ -1,40 +1,33 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) {
const { language, t } = useLanguage();
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) {
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 }}>{t("whoHasCard")}</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label={t("close")}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Wer hat die Karte?</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>{t("selectChip")}:</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}>
{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}
{CHIP_LIST.map((c) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
{c}
</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 }}>
{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 .</>}
Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .
</div>
</div>
</div>
+3 -5
View File
@@ -2,10 +2,8 @@ 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);
@@ -14,14 +12,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 }}>{t("changeDesign")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Design ändern</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}>
{language === "en" ? "Choose your theme:" : "Wähle dein Theme:"}
Wähle dein Theme:
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
+147
View File
@@ -0,0 +1,147 @@
// frontend/src/components/Dice/Dice3D.jsx
import React from "react";
export const cubeRotationForD6 = (value) => {
switch (value) {
case 1: return { rx: 0, ry: 0 };
case 2: return { rx: -90, ry: 0 };
case 3: return { rx: 0, ry: -90 };
case 4: return { rx: 0, ry: 90 };
case 5: return { rx: 90, ry: 0 };
case 6: return { rx: 0, ry: 180 };
default: return { rx: 0, ry: 0 };
}
};
export const PipFace = ({ value }) => {
const pos = (gx, gy) => ({ left: `${gx * 50}%`, top: `${gy * 50}%` });
const faces = {
1: [[1, 1]],
2: [[0, 0], [2, 2]],
3: [[0, 0], [1, 1], [2, 2]],
4: [[0, 0], [2, 0], [0, 2], [2, 2]],
5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]],
6: [[0, 0], [0, 1], [0, 2], [2, 0], [2, 1], [2, 2]],
};
const arr = faces[value] || faces[1];
return (
<div className="pipGrid">
{arr.map(([x, y], idx) => (
<div
key={idx}
className="pip"
style={{ ...pos(x, y), transform: "translate(-50%, -50%)" }}
/>
))}
</div>
);
};
export const DieShell = ({ children, rolling = false, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className="die3d"
style={{
width: 64,
height: 64,
borderRadius: 18,
border: "none",
background: "transparent",
boxShadow: "none",
position: "relative",
overflow: "visible",
display: "grid",
placeItems: "center",
cursor: rolling ? "default" : "pointer",
transition: "transform 160ms ease",
padding: 0,
outline: "none",
}}
disabled={rolling}
>
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
</button>
);
};
export const DieD6 = ({ rolling, onClick, ax, ay, onDone, snap = false }) => {
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
<div className="dieFace front"><PipFace value={1} /></div>
<div className="dieFace back"><PipFace value={6} /></div>
<div className="dieFace top"><PipFace value={2} /></div>
<div className="dieFace bottom"><PipFace value={5} /></div>
<div className="dieFace right"><PipFace value={3} /></div>
<div className="dieFace left"><PipFace value={4} /></div>
</div>
</div>
</DieShell>
);
};
export const HouseDie = ({ face, rolling, onClick, ax, ay, onDone, snap = false }) => {
const faces = {
gryffindor: { icon: "🦁", color: "#ef4444" },
slytherin: { icon: "🐍", color: "#22c55e" },
ravenclaw: { icon: "🦅", color: "#3b82f6" },
hufflepuff: { icon: "🦡", color: "#facc15" },
help: { icon: "🃏", color: "#f2d27a" },
dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" },
};
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
{order.map((key, i) => {
const item = faces[key];
const faceClass =
i === 0 ? "front" :
i === 1 ? "top" :
i === 2 ? "right" :
i === 3 ? "left" :
i === 4 ? "bottom" :
"back";
return (
<div key={key} className={`dieFace ${faceClass}`}>
<div
className="specialIcon"
style={{
color: item.color,
textShadow: `0 0 18px ${item.color}55, 0 10px 22px rgba(0,0,0,0.55)`,
}}
>
{item.icon}
</div>
</div>
);
})}
</div>
</div>
</DieShell>
);
};
+188
View File
@@ -0,0 +1,188 @@
// frontend/src/components/Dice/DicePanel.jsx
import React, { useEffect, useRef, useState } from "react";
import { stylesTokens } from "../../styles/theme";
import { cubeRotationForD6, DieD6, HouseDie } from "./Dice/Dice3D.jsx";
export default function DicePanel({ onRoll }) {
const LS_KEY = "hp_cluedo_dice_v1";
const [d1, setD1] = useState(4);
const [d2, setD2] = useState(2);
const [special, setSpecial] = useState("gryffindor");
const [a1, setA1] = useState({ x: 0, y: 90 });
const [a2, setA2] = useState({ x: -90, y: 0 });
const [as, setAs] = useState({ x: 0, y: 0 });
const [rolling, setRolling] = useState(false);
const [snap, setSnap] = useState(false);
const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
// roll bookkeeping
const pendingRef = useRef(null);
const rollIdRef = useRef(0);
const doneForRollRef = useRef({ d1: false, d2: false, s: false });
// restore last
useEffect(() => {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed?.d1) setD1(parsed.d1);
if (parsed?.d2) setD2(parsed.d2);
if (parsed?.special) setSpecial(parsed.special);
if (parsed?.d1) {
const r = cubeRotationForD6(parsed.d1);
setA1({ x: r.rx, y: r.ry });
}
if (parsed?.d2) {
const r = cubeRotationForD6(parsed.d2);
setA2({ x: r.rx, y: r.ry });
}
if (parsed?.special) {
const idx = Math.max(0, order.indexOf(parsed.special));
const r = cubeRotationForD6(idx + 1);
setAs({ x: r.rx, y: r.ry });
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
try {
localStorage.setItem(LS_KEY, JSON.stringify({ d1, d2, special }));
} catch {}
}, [d1, d2, special]);
const normalize360 = (n) => ((n % 360) + 360) % 360;
const rollTo = (currentAngles, targetBase) => {
const spinX = 720 + randInt(0, 2) * 360;
const spinY = 720 + randInt(0, 2) * 360;
const currX = normalize360(currentAngles.x);
const currY = normalize360(currentAngles.y);
const dx = targetBase.rx - currX;
const dy = targetBase.ry - currY;
return { x: currentAngles.x + spinX + dx, y: currentAngles.y + spinY + dy };
};
const rollAll = () => {
if (rolling) return;
const nd1 = randInt(1, 6);
const nd2 = randInt(1, 6);
const ns = specialFaces[randInt(0, specialFaces.length - 1)];
const r1 = cubeRotationForD6(nd1);
const r2 = cubeRotationForD6(nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1);
pendingRef.current = { nd1, nd2, ns };
rollIdRef.current += 1;
doneForRollRef.current = { d1: false, d2: false, s: false };
setRolling(true);
setA1((cur) => rollTo(cur, r1));
setA2((cur) => rollTo(cur, r2));
setAs((cur) => rollTo(cur, rs));
};
const maybeCommit = () => {
const flags = doneForRollRef.current;
if (!flags.d1 || !flags.d2 || !flags.s) return;
const p = pendingRef.current;
if (!p) return;
// ✅ rolling beenden
setRolling(false);
// ✅ Ergebnis state setzen
setD1(p.nd1);
setD2(p.nd2);
setSpecial(p.ns);
// ✅ Winkel auf kleine Basis normalisieren (ohne Transition)
const r1 = cubeRotationForD6(p.nd1);
const r2 = cubeRotationForD6(p.nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(p.ns)) + 1);
setSnap(true);
setA1({ x: r1.rx, y: r1.ry });
setA2({ x: r2.rx, y: r2.ry });
setAs({ x: rs.rx, y: rs.ry });
// Snap nur 1 Frame aktiv lassen
requestAnimationFrame(() => {
requestAnimationFrame(() => setSnap(false));
});
pendingRef.current = null;
onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns });
};
const onDoneD1 = () => {
if (!rolling) return;
if (doneForRollRef.current.d1) return;
doneForRollRef.current.d1 = true;
maybeCommit();
};
const onDoneD2 = () => {
if (!rolling) return;
if (doneForRollRef.current.d2) return;
doneForRollRef.current.d2 = true;
maybeCommit();
};
const onDoneS = () => {
if (!rolling) return;
if (doneForRollRef.current.s) return;
doneForRollRef.current.s = true;
maybeCommit();
};
return (
<div style={{ pointerEvents: "auto" }}>
<div style={{ display: "grid", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 13 }}>Würfel</div>
<div style={{ color: stylesTokens.textDim, fontWeight: 900, fontSize: 11.5, letterSpacing: 0.7, opacity: 0.75 }}>
{rolling ? "ROLL…" : "READY"}
</div>
</div>
<div
className="diceRow3d"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
alignItems: "center",
justifyItems: "center",
overflow: "visible",
}}
>
<DieD6 rolling={rolling} onClick={rollAll} ax={a1.x} ay={a1.y} onDone={onDoneD1} />
<DieD6 rolling={rolling} onClick={rollAll} ax={a2.x} ay={a2.y} onDone={onDoneD2} />
<HouseDie face={special} rolling={rolling} onClick={rollAll} ax={as.x} ay={as.y} onDone={onDoneS} />
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}>Klicken zum Rollen</div>
</div>
</div>
);
}
+6 -107
View File
@@ -1,7 +1,6 @@
import React, { useState } from "react";
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function GamePickerCard({
games,
@@ -11,17 +10,8 @@ export default function GamePickerCard({
members = [],
me,
hostUserId,
isHost = false,
started = false,
finished = false,
winnerName = "",
chipCount = 0,
onStartGame,
}) {
const { language, t } = useLanguage();
const cur = games.find((x) => x.id === gameId);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState("");
const renderMemberName = (m) => {
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
@@ -52,25 +42,10 @@ 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);
}
};
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>{t("game")}</div>
<div style={styles.sectionHeader}>Spiel</div>
<div style={styles.cardBody}>
<select
@@ -85,8 +60,8 @@ export default function GamePickerCard({
))}
</select>
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}>
{t("help")}
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe">
Hilfe
</button>
</div>
@@ -110,84 +85,8 @@ 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>
</>
) : (
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
{t("waitingForHost")}
</div>
)}
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
</div>
) : (
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
{finished
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
: `${t("started")} · ${chipCount} ${t("chipsCreated")}`}
</div>
)}
{/* Spieler */}
{started && members?.length > 0 && (
{members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}>
<div
style={{
@@ -200,7 +99,7 @@ export default function GamePickerCard({
gap: 10,
}}
>
<div>{t("players")}</div>
<div>Spieler</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
{members.length}
</div>
@@ -1,47 +0,0 @@
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
);
}
+97 -91
View File
@@ -1,180 +1,186 @@
// 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 }}>{t("helpTitle")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Hilfe</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={styles.helpBody}>
<div style={styles.helpSectionTitle}>1) {en ? "Lobby & starting the game" : "Lobby & Spielstart"}</div>
{/* ===== 0) Spiele & Navigation ===== */}
<div style={styles.helpSectionTitle}>0) Spiele auswählen / Neues Spiel</div>
<div style={styles.helpText}>
{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.</>}
Oben im Bereich <b>Spiel</b> kannst du zwischen bestehenden Spielen wechseln oder ein neues
Spiel erstellen:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b>{en ? "Game selection" : "Spiel-Auswahl"}</b> = {en ? "open existing games" : "vorhandene Spiele öffnen"}
<b>Spiel-Auswahl</b> (Dropdown neben dem Hilfe-Button) = vorhandene / alte Spiele öffnen
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b>New Game</b> = {en ? "create a game or join with a code" : "neues Spiel erstellen oder per Code beitreten"}
<b>Neues Spiel</b> = erstellt ein neues Spiel und öffnet es automatisch
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>3) {en ? "Tap a name status" : "Namen antippen Status"}</div>
{/* ===== 1) Status per Tippen ===== */}
<div style={styles.helpSectionTitle}>1) Namen antippen (Status)</div>
<div style={styles.helpText}>
{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:"}
Tippe auf einen Namen, um den Status zu ändern. Reihenfolge:
</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>{en ? "Green" : "Grün"}</b> = {en ? "confirmed / present" : "bestätigt / vorhanden"}</div>
<span
style={{
...styles.helpBadge,
background: "rgba(0,190,80,0.18)",
color: "#baf3c9",
}}
>
</span>
<div>
<b>Grün</b> = bestätigt / fix richtig
</div>
<div style={styles.helpListRow}>
<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 style={styles.helpListRow}>
<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>
<span
style={{
...styles.helpBadge,
background: "rgba(255,35,35,0.18)",
color: "#ffb3b3",
}}
>
</span>
<div>
<b>Rot</b> = ausgeschlossen / fix falsch
</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>{en ? "Empty" : "Leer"}</b> = {en ? "not evaluated yet" : "noch nicht bewertet"}</div>
<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
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,255,255,0.08)",
color: "rgba(233,216,166,0.75)",
}}
>
</span>
<div>
<b>Leer</b> = noch nicht bewertet
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>4) {en ? "Notes & player chips" : "Notizen & Spieler-Chips"}</div>
{/* ===== 2) i / m / s Notizen ===== */}
<div style={styles.helpSectionTitle}>2) i / m / s Button (Notizen)</div>
<div style={styles.helpText}>
{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:"}
Rechts pro Zeile gibt es einen Button, der durch diese Werte rotiert:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>i</span>
<div><b>i</b> = {en ? "I have this card" : "Ich habe diese Karte"}</div>
<div>
<b>i</b> = Ich habe diese Karte
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>m</span>
<div><b>m</b> = {en ? "card from the middle deck" : "Karte aus dem mittleren Deck"}</div>
<div>
<b>m</b> = Karte aus dem mittleren Deck
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>s</span>
<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>
<b>s</b> = Ein anderer Spieler hat die Karte danach Chip auswählen (z.B. <b>s.AL</b>)
</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 Zusatznotiz</div>
<div>
<b></b> = keine Notiz
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpSectionTitle}>5) {en ? "Game end" : "Spielende"}</div>
{/* ===== 3) User-Menü ===== */}
<div style={styles.helpSectionTitle}>3) User-Menü (Passwort / Logout)</div>
<div style={styles.helpText}>
{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>{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>
Oben rechts im <b>User</b>-Menü findest du persönliche Einstellungen:
</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>
<span style={styles.helpMiniTag}>👤</span>
<div>
<b>User</b> öffnen = zeigt die aktuell verwendete Email-Adresse
</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>{t("setPassword")}</b> = {en ? "change your password" : "eigenes Passwort ändern"}</div>
<div>
<b>Passwort setzen</b> = eigenes Passwort ändern
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div><b>{t("logout")}</b> = {en ? "sign out" : "abmelden"}</div>
<div>
<b>Logout</b> = ausloggen
</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.helpText, marginTop: 16 }}>
{en ? "Your notes remain private. Each player sees only their own note sheet." : "Deine Notizen bleiben privat. Jeder Spieler sieht nur seinen eigenen Notizzettel."}
<div style={styles.helpDivider} />
<div style={styles.helpText}>
Tipp: Jeder Spieler sieht nur seine eigenen Notizen andere Spieler können nicht in deinen
Zettel schauen.
</div>
</div>
</div>
-42
View File
@@ -1,42 +0,0 @@
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>;
}
+4 -6
View File
@@ -2,10 +2,8 @@
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);
@@ -21,13 +19,13 @@ export default function JoinGameModal({ open, onClose, onJoin }) {
const doJoin = async () => {
const c = (code || "").trim();
if (!c) return setMsg(`${language === "en" ? "Please enter a code." : "Bitte Code eingeben."}`);
if (!c) return setMsg("Bitte Code eingeben.");
setBusy(true);
setMsg("");
try {
await onJoin(c);
} catch (e) {
setMsg("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setMsg("❌ Fehler: " + (e?.message || "unknown"));
setBusy(false);
}
};
@@ -36,8 +34,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 }}>{t("joinGame")}</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Spiel beitreten</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
+7 -82
View File
@@ -1,6 +1,5 @@
import React from "react";
import { styles } from "../styles/styles";
import { useLanguage } from "../i18n";
export default function LoginPage({
loginEmail,
@@ -10,22 +9,7 @@ export default function LoginPage({
showPw,
setShowPw,
doLogin,
setupRequired,
setupEmail,
setSetupEmail,
setupDisplayName,
setSetupDisplayName,
setupPassword,
setSetupPassword,
setupPasswordConfirm,
setSetupPasswordConfirm,
setupError,
setupSaving,
doSetup,
}) {
const { language, t } = useLanguage();
const isSetup = setupRequired === true;
return (
<div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true">
@@ -37,72 +21,14 @@ export default function LoginPage({
<div style={styles.loginCard}>
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
<div style={styles.loginSubtitle}>
{setupRequired === null
? (language === "en" ? "Initializing application …" : "Initialisiere Anwendung …")
: isSetup
? (language === "en" ? "Set up the first administrator" : "Richte den ersten Administrator ein")
: (language === "en" ? "Log in to open your Cluedo sheet" : "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen")}
</div>
<div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
{isSetup ? (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder={t("displayName")}
style={styles.loginInput}
autoComplete="name"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder={language === "en" ? "Admin email" : "Admin-E-Mail"}
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder={t("passwordMin")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder={t("confirmPassword")}
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}>
{setupSaving ? (language === "en" ? "Setting up …" : "Einrichtung …") : (language === "en" ? "✦ Create administrator" : "✦ Administrator erstellen")}
</button>
</div>
) : (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder={t("email")}
placeholder="Email"
style={styles.loginInput}
inputMode="email"
autoComplete="username"
@@ -114,7 +40,7 @@ export default function LoginPage({
<input
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder={t("password")}
placeholder="Passwort"
type={showPw ? "text" : "password"}
style={styles.inputInRow}
autoComplete="current-password"
@@ -123,8 +49,8 @@ export default function LoginPage({
type="button"
onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn}
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")}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"}
title={showPw ? "Verstecken" : "Anzeigen"}
>
{showPw ? "🙈" : "👁"}
</button>
@@ -132,13 +58,12 @@ export default function LoginPage({
</div>
<button onClick={doLogin} style={styles.loginBtn}>
{t("login")}
Anmelden
</button>
</div>
)}
<div style={styles.loginHint}>
{language === "en" ? "Your notes remain private every player only sees their own sheet." : "Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Notizzettel."}
Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel.
</div>
</div>
</div>
+22 -24
View File
@@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function NewGameModal({
open,
@@ -14,7 +13,6 @@ 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("");
@@ -55,7 +53,7 @@ export default function NewGameModal({
setCreated({ code: res.code });
setMode("create");
} catch (e) {
setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setErr("❌ Fehler: " + (e?.message || "unknown"));
}
};
@@ -65,16 +63,16 @@ export default function NewGameModal({
await onJoin(joinCode.trim().toUpperCase());
onClose();
} catch (e) {
setErr("❌ " + (language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
setErr("❌ Fehler: " + (e?.message || "unknown"));
}
};
const copyText = async (text, okMsg = language === "en" ? "✅ Code copied" : "✅ Code kopiert") => {
const copyText = async (text, okMsg = "✅ Code kopiert") => {
try {
await navigator.clipboard.writeText(text || "");
showToast(okMsg);
} catch {
showToast(language === "en" ? "❌ Copy failed" : "❌ Copy nicht möglich");
showToast("❌ Copy nicht möglich");
}
};
@@ -87,9 +85,9 @@ export default function NewGameModal({
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
{t("game")}
Spiel
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
@@ -118,7 +116,7 @@ export default function NewGameModal({
{mode === "running" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
{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>:</>}
Das Spiel läuft noch. Hier ist der <b>Join-Code</b>:
</div>
<div
@@ -133,7 +131,7 @@ export default function NewGameModal({
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{t("gameCode")}
Spiel-Code
</div>
<div
@@ -152,19 +150,19 @@ export default function NewGameModal({
onClick={() => copyText(codeToShow)}
style={styles.primaryBtn}
disabled={!codeToShow}
title={!codeToShow ? (language === "en" ? "No code available" : "Kein Code verfügbar") : (language === "en" ? "Copy code" : "Code kopieren")}
title={!codeToShow ? "Kein Code verfügbar" : "Code kopieren"}
>
{t("copy")} {language === "en" ? "code" : "Code"}
Code kopieren
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
{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."}
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}>
{language === "en" ? "Done" : "Fertig"}
Fertig
</button>
</div>
</>
@@ -175,15 +173,15 @@ export default function NewGameModal({
{mode === "choice" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
{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>?</>}
Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>?
</div>
<button onClick={doCreate} style={styles.primaryBtn}>
{t("createGame")}
Spiel erstellen
</button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}>
{t("joinGame")}
Spiel beitreten
</button>
</>
)}
@@ -191,7 +189,7 @@ export default function NewGameModal({
{mode === "join" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
{language === "en" ? <>Enter the <b>code</b>:</> : <>Gib den <b>Code</b> ein:</>}
Gib den <b>Code</b> ein:
</div>
<input
@@ -204,10 +202,10 @@ export default function NewGameModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
{language === "en" ? "Back" : "Zurück"}
Zurück
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
{t("join")}
Beitreten
</button>
</div>
</>
@@ -216,7 +214,7 @@ export default function NewGameModal({
{mode === "create" && created && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
{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:"}
Dein Spiel wurde erstellt. Dieser Code bleibt auch bei Alte Spiele sichtbar:
</div>
<div
@@ -231,7 +229,7 @@ export default function NewGameModal({
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{t("gameCode")}
Spiel-Code
</div>
<div
@@ -247,13 +245,13 @@ export default function NewGameModal({
</div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
{t("copy")} {language === "en" ? "code" : "Code"}
Code kopieren
</button>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
{language === "en" ? "Done" : "Fertig"}
Fertig
</button>
</div>
</>
+7 -9
View File
@@ -1,7 +1,6 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function PasswordModal({
pwOpen,
@@ -14,15 +13,14 @@ 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 }}>{t("setPasswordTitle")}</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label={t("close")}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
@@ -31,7 +29,7 @@ export default function PasswordModal({
<input
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder={language === "en" ? "New password" : "Neues Passwort"}
placeholder="Neues Passwort"
type="password"
style={styles.input}
autoFocus
@@ -39,7 +37,7 @@ export default function PasswordModal({
<input
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder={t("passwordAgain")}
placeholder="Neues Passwort wiederholen"
type="password"
style={styles.input}
onKeyDown={(e) => {
@@ -51,15 +49,15 @@ export default function PasswordModal({
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
{t("cancel")}
Abbrechen
</button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? (language === "en" ? "Saving…" : "Speichern") : t("save")}
{pwSaving ? "Speichern..." : "Speichern"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
{language === "en" ? "Note: At least 8 characters are recommended." : "Hinweis: Mindestens 8 Zeichen empfohlen."}
Hinweis: Mindestens 8 Zeichen empfohlen.
</div>
</div>
</div>
+4 -9
View File
@@ -2,7 +2,6 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function SheetSection({
title,
@@ -11,9 +10,7 @@ export default function SheetSection({
onCycleStatus,
onToggleTag,
displayTag,
readOnly = false,
}) {
const { t } = useLanguage();
const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg;
@@ -73,15 +70,14 @@ export default function SheetSection({
}}
>
<div
onClick={() => !readOnly && onCycleStatus(e)}
onClick={() => 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={readOnly ? t("readOnly") : t("cycleStatus")}
title="Klick: Grün → Rot → Grau → Leer"
>
{e.label}
</div>
@@ -99,10 +95,9 @@ export default function SheetSection({
</div>
<button
onClick={() => !readOnly && onToggleTag(e)}
onClick={() => onToggleTag(e)}
style={styles.tagBtn}
disabled={readOnly}
title={readOnly ? t("readOnly") : t("cycleNote")}
title="— → i → m → s.(Chip) → —"
>
{displayTag(e)}
</button>
+7 -9
View File
@@ -2,7 +2,6 @@ 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 (
@@ -49,7 +48,6 @@ 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) : "";
@@ -59,13 +57,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 }}>{t("statistics")}</div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label={t("close")}>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
@@ -73,7 +71,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 }}>
{language === "en" ? "Loading statistics…" : "Lade Statistik…"}
Lade Statistik
</div>
) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
@@ -85,10 +83,10 @@ export default function StatsModal({ open, onClose, me, stats, loading, error })
gap: 10,
}}
>
<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"} />
<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" />
</div>
)}
</div>
+13 -55
View File
@@ -1,7 +1,6 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function TopBar({
me,
@@ -10,18 +9,13 @@ 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}>
<div style={styles.topBar}>
<div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
@@ -29,21 +23,20 @@ export default function TopBar({
</div>
</div>
<div className="hp-topbar-actions" style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div className="hp-user-menu-wrap" style={{ position: "relative" }}>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}>
<button
className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title={t("user") + " menu"}
title="User Menü"
>
<span style={{ fontSize: 16 }}>{isAdmin ? "🛡️" : "👤"}</span>
<span>{roleLabel}</span>
<span style={{ fontSize: 16 }}>👤</span>
<span>User</span>
<span style={{ opacity: 0.7 }}></span>
</button>
{userMenuOpen && (
<div className="hp-user-dropdown" style={styles.userDropdown}>
<div style={styles.userDropdown}>
<div
style={{
padding: "10px 12px",
@@ -63,56 +56,21 @@ export default function TopBar({
}}
style={styles.userDropdownItem}
>
{t("statistics")}
Statistik
</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}>
{t("setPassword")}
Passwort setzen
</button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
{t("changeDesign")}
Design ändern
</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);
@@ -120,14 +78,14 @@ export default function TopBar({
}}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
>
{t("logout")}
Logout
</button>
</div>
)}
</div>
<button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}>
{t("newGame")}
<button onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game
</button>
</div>
</div>
+2 -4
View File
@@ -1,6 +1,5 @@
import React from "react";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
/**
* Props:
@@ -8,7 +7,6 @@ import { useLanguage } from "../i18n";
* - winnerEmail: string | null (legacy fallback)
*/
export default function WinnerBadge({ winner, winnerEmail }) {
const { language } = useLanguage();
const name =
(winner?.display_name || "").trim() ||
(winner?.email || "").trim() ||
@@ -36,13 +34,13 @@ export default function WinnerBadge({ winner, winnerEmail }) {
<div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{language === "en" ? "Winner:" : "Sieger:"}
Sieger:
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div>
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{language === "en" ? "selected" : "festgelegt"}
festgelegt
</div>
</div>
);
+4 -6
View File
@@ -1,7 +1,6 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function WinnerCard({
isHost,
@@ -10,13 +9,12 @@ 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}>{t("winner")}</div>
<div style={styles.sectionHeader}>Sieger</div>
<div style={styles.cardBody}>
<select
@@ -24,7 +22,7 @@ export default function WinnerCard({
onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }}
>
<option value=""> {language === "en" ? "no winner" : "kein Sieger"} </option>
<option value=""> kein Sieger </option>
{members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return (
@@ -36,12 +34,12 @@ export default function WinnerCard({
</select>
<button onClick={onSave} style={styles.primaryBtn}>
{t("save")}
Speichern
</button>
</div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
{language === "en" ? "Only the host (game creator) can select the winner." : "Nur der Host (Spiel-Ersteller) kann den Sieger setzen."}
Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
</div>
</div>
</div>
@@ -2,10 +2,8 @@ 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;
@@ -148,15 +146,15 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
lineHeight: 1.25,
}}
>
{language === "en" ? "Player " : "Spieler "}
Spieler{" "}
<span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"}
</span>{" "}
{language === "en" ? "has the correct solution!" : "hat die richtige Lösung!"}
hat die richtige Lösung!
</div>
<div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}>
{language === "en" ? "Case solved. Respect. ✨" : "Fall gelöst. Respekt. ✨"}
Fall gelöst. Respekt.
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}>
+1
View File
@@ -1 +1,2 @@
export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
-224
View File
@@ -1,224 +0,0 @@
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
const STORAGE_KEY = "hpLanguage";
const LanguageContext = createContext(null);
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 };
}
+1 -4
View File
@@ -1,7 +1,6 @@
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";
@@ -22,9 +21,7 @@ async function bootstrap() {
} catch {}
// App rendern
ReactDOM.createRoot(document.getElementById("root")).render(
<LanguageProvider><App /></LanguageProvider>
);
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
// Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000;
+2 -80
View File
@@ -2,8 +2,6 @@ import { useEffect } from "react";
import { stylesTokens } from "../theme";
import { applyTheme } from "../themes";
export function useHpGlobalStyles() {
// Google Fonts
useEffect(() => {
@@ -76,86 +74,10 @@ export function useHpGlobalStyles() {
color: ${stylesTokens.textMain};
}
body {
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
overflow: hidden;
}
#root { background: transparent; }
*, *::before, *::after { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
button, input, select { font-family: inherit; }
button { transition: transform 140ms ease, filter 140ms ease, border-color 140ms ease, background 140ms ease; }
button:not(:disabled):hover { filter: brightness(1.12); transform: translateY(-1px); }
button:not(:disabled):active { transform: translateY(0); filter: brightness(0.98); }
button:disabled { cursor: not-allowed; opacity: 0.55; }
input:focus, select:focus { border-color: var(--hp-textGold) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--hp-textGold) 16%, transparent); }
.hp-row { transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease; }
.hp-row:hover { box-shadow: inset 0 0 0 1px rgba(233,216,166,0.12); }
.hp-start-overlay { position: fixed; inset: 0; z-index: 2147483646; display: grid; place-items: center; padding: 20px; background: radial-gradient(circle at center, rgba(233,216,166,0.12), rgba(4,4,7,0.88) 48%, rgba(0,0,0,0.96)); animation: hpStartFade 260ms ease-out; }
.hp-start-card { position: relative; width: min(420px, 100%); padding: 30px 22px 24px; border: 1px solid rgba(233,216,166,0.34); border-radius: 24px; text-align: center; background: linear-gradient(145deg, rgba(34,31,38,0.96), rgba(10,10,14,0.96)); box-shadow: 0 24px 90px rgba(0,0,0,0.72), 0 0 60px rgba(233,216,166,0.12); animation: hpStartCard 520ms cubic-bezier(.2,.8,.2,1); overflow: hidden; }
.hp-start-card::before, .hp-start-card::after { content: ""; position: absolute; left: 12%; right: 12%; height: 1px; background: linear-gradient(90deg, transparent, rgba(233,216,166,0.72), transparent); animation: hpStartLine 1.8s ease-in-out infinite; }
.hp-start-card::before { top: 12px; }
.hp-start-card::after { bottom: 12px; animation-delay: .45s; }
.hp-start-rune { color: var(--hp-textGold); font-size: 32px; line-height: 1; animation: hpStartRune 1.4s ease-in-out infinite; }
.hp-start-kicker { margin-top: 14px; color: var(--hp-textDim); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; }
.hp-start-title { margin-top: 8px; color: var(--hp-textGold); font-family: "Cinzel Decorative", "IM Fell English", system-ui; font-size: 25px; font-weight: 900; }
.hp-start-subtitle { margin-top: 8px; color: var(--hp-textMain); font-size: 15px; }
.hp-start-card button { border: 1px solid rgba(233,216,166,0.26); border-radius: 12px; background: rgba(233,216,166,0.10); padding: 10px 16px; font-weight: 900; cursor: pointer; }
@keyframes hpStartFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes hpStartCard { from { opacity: 0; transform: translateY(18px) scale(.92) rotateX(8deg); } to { opacity: 1; transform: translateY(0) scale(1) rotateX(0); } }
@keyframes hpStartRune { 0%, 100% { transform: rotate(0) scale(1); opacity: .7; } 50% { transform: rotate(180deg) scale(1.22); opacity: 1; } }
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
.hp-topbar-actions { margin-left: auto; }
.hp-user-menu-wrap { min-width: 0; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.45fr) 62px 62px 78px; white-space: nowrap; }
.hp-admin-action { min-width: 0; }
.hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
.hp-admin-icon-action { width: 34px; height: 34px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; }
.hp-admin-name, .hp-admin-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; }
.hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
.hp-admin-editor-card { overflow: hidden; }
.hp-settings-grid { display: grid; grid-template-columns: minmax(0, 1fr) 140px; gap: 10px; }
.hp-settings-test { display: flex; align-items: end; gap: 10px; }
.hp-invite-card { animation: popIn 480ms cubic-bezier(.2,.8,.2,1); box-shadow: 0 24px 80px rgba(0,0,0,.6), 0 0 40px rgba(233,216,166,.08); }
.hp-invite-seal { width: 52px; height: 52px; margin: 0 auto 14px; display: grid; place-items: center; border: 1px solid rgba(233,216,166,.42); border-radius: 50%; color: var(--hp-textGold); font-size: 24px; box-shadow: 0 0 24px rgba(233,216,166,.12); }
.hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; }
.hp-admin-status { color: #baf3c9 !important; font-size: 13px; }
.hp-admin-status--disabled { color: #ffb3b3 !important; }
@media (max-width: 600px) {
.hp-settings-grid { grid-template-columns: 1fr; }
.hp-settings-test { align-items: stretch; flex-direction: column; }
.hp-settings-test button { width: 100%; }
.hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; }
.hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
.hp-topbar > div:first-child { min-width: 0; flex: 1; }
.hp-topbar > div:first-child > div:last-child { max-width: 105px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px !important; }
.hp-topbar-actions { width: auto; flex: 0 0 auto; margin-left: 0; gap: 5px !important; }
.hp-user-menu-wrap { flex: 0 0 auto; min-width: 0; }
.hp-topbar-user, .hp-topbar-new { flex: 0 0 auto; min-height: 40px; width: auto; padding: 8px 9px !important; font-size: 13px; }
.hp-topbar-user { justify-content: center; }
.hp-user-dropdown { left: 50% !important; right: auto !important; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
.hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 11px !important; border-radius: 15px !important; }
.hp-admin-name { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; }
.hp-admin-email { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px !important; }
.hp-admin-role { grid-column: 2; grid-row: 1; }
.hp-admin-status { grid-column: 2; grid-row: 2; }
.hp-admin-actions { grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: flex-end; }
.hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; }
.hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; }
.hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; }
.hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; }
.hp-admin-editor-card button { min-height: 40px; }
.hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
.hp-row button { min-height: 40px; }
button { min-height: 42px; }
input, select { min-height: 44px; box-sizing: border-box; }
.hp-topbar + * { margin-top: 12px !important; }
}
@media (max-width: 360px) {
.hp-row { grid-template-columns: minmax(0, 1fr) 38px 56px !important; font-size: 14px; }
.hp-topbar { gap: 5px !important; }
.hp-topbar-user, .hp-topbar-new { padding-left: 7px !important; padding-right: 7px !important; font-size: 12px; }
}
* { -webkit-tap-highlight-color: transparent; }
`;
document.head.appendChild(style);
}, []);
+65 -81
View File
@@ -10,8 +10,8 @@ export const styles = {
shell: {
fontFamily: '"IM Fell English", system-ui',
padding: "22px 16px 42px",
maxWidth: 760,
padding: 16,
maxWidth: 680,
margin: "0 auto",
},
@@ -22,38 +22,34 @@ export const styles = {
justifyContent: "space-between",
alignItems: "center",
gap: 10,
padding: "14px 16px",
borderRadius: 18,
background: `linear-gradient(135deg, rgba(31, 28, 32, 0.90), ${stylesTokens.panelBg})`,
border: `1px solid ${stylesTokens.headerBorder}`,
boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.08)",
backdropFilter: "blur(14px) saturate(1.12)",
WebkitBackdropFilter: "blur(14px) saturate(1.12)",
padding: 12,
borderRadius: 16,
background: stylesTokens.panelBg,
border: `1px solid ${stylesTokens.panelBorder}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
},
card: {
borderRadius: 20,
borderRadius: 18,
overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`,
background: `linear-gradient(145deg, rgba(25, 24, 28, 0.88), ${stylesTokens.panelBg})`,
boxShadow: "0 20px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.07)",
backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
background: "rgba(18, 18, 20, 0.50)",
boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
},
cardBody: {
padding: "14px 16px",
padding: 12,
display: "flex",
gap: 10,
alignItems: "center",
},
sectionHeader: {
padding: "13px 16px",
padding: "11px 14px",
fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.5,
fontSize: 14,
letterSpacing: 1.0,
color: stylesTokens.textGold,
// WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert
@@ -61,16 +57,16 @@ export const styles = {
borderBottom: `1px solid ${stylesTokens.headerBorder}`,
textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.72)",
textShadow: "0 1px 0 rgba(0,0,0,0.6)",
},
row: {
display: "grid",
gridTemplateColumns: "1fr 54px 68px",
gap: 10,
padding: "13px 16px",
padding: "12px 14px",
alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.10)",
borderBottom: "1px solid rgba(233,216,166,0.08)",
borderLeft: "4px solid rgba(0,0,0,0)",
},
@@ -104,9 +100,9 @@ export const styles = {
tagBtn: {
padding: "8px 0",
fontWeight: 1000,
borderRadius: 13,
border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(255,255,255,0.055)",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
@@ -114,9 +110,9 @@ export const styles = {
helpBtn: {
padding: "10px 12px",
borderRadius: 13,
border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(255,255,255,0.055)",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
@@ -126,31 +122,31 @@ export const styles = {
input: {
width: "100%",
padding: "11px 12px",
borderRadius: 13,
border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(7,7,10,0.64)",
padding: "10px 12px",
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain,
outline: "none",
fontSize: 15,
},
primaryBtn: {
padding: "11px 15px",
borderRadius: 13,
border: `1px solid rgba(233,216,166,0.34)`,
background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))",
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
},
secondaryBtn: {
padding: "10px 12px",
borderRadius: 13,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)",
background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
@@ -162,13 +158,12 @@ export const styles = {
position: "relative",
zIndex: 1,
marginTop: 14,
padding: 14,
borderRadius: 20,
border: `1px solid ${stylesTokens.panelBorder}`,
background: `linear-gradient(145deg, rgba(25, 24, 28, 0.86), ${stylesTokens.panelBg})`,
boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.06)",
backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
padding: 12,
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.14)`,
background: "rgba(18, 18, 20, 0.40)",
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
},
adminTop: {
display: "flex",
@@ -180,20 +175,13 @@ 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,
background: "rgba(255,255,255,0.055)",
background: "rgba(255,255,255,0.06)",
border: `1px solid rgba(233,216,166,0.10)`,
},
@@ -215,12 +203,12 @@ export const styles = {
modalCard: {
width: "min(560px, 100%)",
borderRadius: 20,
borderRadius: 18,
border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(145deg, rgba(27,25,30,0.98), rgba(11,11,14,0.98))",
boxShadow: "0 24px 70px rgba(0,0,0,0.78), inset 0 1px 0 rgba(255,255,255,0.07)",
padding: 16,
maxHeight: "calc(100dvh - 32px)",
background: "rgba(12,12,14,0.96)",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 14,
maxHeight: "calc(100vh - 32px)",
overflow: "auto",
},
modalHeader: {
@@ -317,14 +305,13 @@ export const styles = {
width: "100%",
maxWidth: 420,
padding: 26,
borderRadius: 24,
borderRadius: 22,
position: "relative",
zIndex: 2,
border: `1px solid ${stylesTokens.headerBorder}`,
background: `linear-gradient(145deg, rgba(29, 27, 31, 0.92), ${stylesTokens.panelBg})`,
boxShadow: "0 24px 80px rgba(0,0,0,0.72), inset 0 1px 0 rgba(255,255,255,0.08)",
backdropFilter: "blur(16px) saturate(1.12)",
WebkitBackdropFilter: "blur(16px) saturate(1.12)",
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(18, 18, 20, 0.55)",
boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
backdropFilter: "blur(8px)",
animation: "popIn 240ms ease-out",
color: stylesTokens.textMain,
},
@@ -351,24 +338,24 @@ export const styles = {
},
loginInput: {
width: "100%",
padding: "11px 12px",
borderRadius: 13,
border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(7,7,10,0.64)",
padding: 10,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain,
outline: "none",
fontSize: 16,
},
loginBtn: {
padding: "12px 15px",
padding: "12px 14px",
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.34)`,
background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))",
border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold,
fontWeight: 1000,
fontSize: 16,
cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
},
loginHint: {
marginTop: 18,
@@ -444,10 +431,7 @@ export const styles = {
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
filter: "saturate(0.72) contrast(1.12) brightness(0.42) blur(1.2px)",
transform: "scale(1.015)",
backgroundColor: "rgba(8, 7, 12, 0.34)",
backgroundBlendMode: "multiply",
filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
},
chipGrid: {
@@ -459,9 +443,9 @@ export const styles = {
chipBtn: {
padding: "10px 14px",
borderRadius: 13,
borderRadius: 12,
border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.055)",
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
@@ -473,7 +457,7 @@ export const styles = {
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderRadius: 14,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain,
@@ -490,11 +474,11 @@ export const styles = {
minWidth: 220,
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(145deg, rgba(29,27,33,0.98), rgba(10,10,13,0.96))",
background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden",
zIndex: 99999,
backdropFilter: "blur(14px)",
backdropFilter: "blur(8px)",
},
userDropdownItem: {