Compare commits

..
8 Commits
Author SHA1 Message Date
nessi e479e5b2a8 Refine TopBar mobile layout with improved spacing and text truncation
Adjusted TopBar mobile styling to prevent wrapping and optimize space usage on narrow screens. Updated flex properties to prevent button stretching, reduced padding and gaps for tighter layout, and added text truncation for game title with 105px max-width. Decreased minimum button height from 44px to 40px and refined font sizes for better fit on small devices.
2026-08-01 18:29:56 +02:00
nessi a7ac55c598 Add game start functionality with automatic player chip generation
Implemented host-controlled game start mechanism that generates unique player chips from user names. Added `started_at` timestamp to games and new `game_chips` table to store player identifiers. Chips are created using first name initial plus first two surname letters (e.g., SNE for Sascha Nesterovic) with automatic conflict resolution. Updated frontend to display start button for hosts, show game status, and populate chip selection modal
2026-08-01 18:29:02 +02:00
nessi 3904ba403a Add conditional styling for disabled user status in AdminPanel
Added CSS class to distinguish disabled users with red text color while active users display in green. Enhanced admin user row styling with pill-shaped role badges, improved mobile layout with adjusted grid positioning, and refined typography sizing for better visual hierarchy on smaller screens.
2026-08-01 18:22:37 +02:00
nessi b0263437e7 Add responsive user menu styling and improve mobile overflow handling
Enhanced TopBar user menu with CSS classes for targeted styling. Implemented responsive dropdown positioning and sizing for mobile devices with proper viewport constraints. Added text overflow handling for long usernames and emails, improved flex layout behavior, and ensured touch-friendly button sizing on narrow screens.
2026-08-01 18:18:34 +02:00
nessi 39245b7ee7 Add responsive mobile layout and improve viewport handling
Introduced CSS classes for targeted styling and implemented responsive breakpoints for mobile devices. Enhanced TopBar and AdminPanel layouts to adapt to smaller screens with reorganized grid structures and improved touch targets. Updated shell container to use dynamic viewport height (dvh) and added safe area insets for better mobile browser compatibility. Adjusted button sizes, spacing, and element wrapping for improved usability on narrow
2026-08-01 18:15:03 +02:00
nessi c176ccc904 Refine UI styling with enhanced visual polish and consistency
Updated global styles with improved button interactions, focus states, and hover effects. Enhanced component styling across cards, headers, inputs, and buttons with refined gradients, shadows, border radii, and backdrop filters. Adjusted spacing, padding, and typography for better visual hierarchy. Improved background image treatment with enhanced filters and blend modes for better contrast and readability.
2026-08-01 18:13:28 +02:00
nessi f43ab711fd Fix Nginx backend proxy resolution and prevent startup failures
Added dynamic DNS resolution for the backend service to prevent Nginx from failing when the backend container is not yet available during startup. Implemented Docker's internal resolver with a 10-second validity period and moved the backend URL to a variable for runtime resolution. Replaced direct proxy_pass with rewrite rule for proper path handling.
2026-08-01 18:08:27 +02:00
nessi 873627c757 Remove PostgreSQL and move to SQLite3 2026-08-01 18:02:37 +02:00
32 changed files with 1223 additions and 1485 deletions
-11
View File
@@ -1,11 +0,0 @@
POSTGRES_DB=cluedo
POSTGRES_USER=cluedo
POSTGRES_PASSWORD=supersecret
# Backend
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
BACKEND_BASE_URL=http://localhost:8080
# Admin initial user (wird beim Start angelegt, falls nicht existiert)
ADMIN_EMAIL=admin@local
ADMIN_PASSWORD=ChangeMeNow123!
+6
View File
@@ -0,0 +1,6 @@
# Required backend secret. Use a long, random value in your local .env file.
BACKEND_SECRET_KEY=please_change_me_to_a_long_random_string
# Optional cookie settings for local/internal deployments.
COOKIE_SECURE=false
COOKIE_SAMESITE=Lax
+6
View File
@@ -0,0 +1,6 @@
.env
backend/data/
frontend/node_modules/
frontend/dist/
__pycache__/
*.py[cod]
+220 -1
View File
@@ -1,2 +1,221 @@
# 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
- Player chips are generated from the first name initial and first two surname letters, e.g. `SNE` for Sascha Nesterovic
- Automatic player list with host indication
- Personal note sheet for each player and game
- Categories for suspects, items, and locations
- Entry status tracking: empty, ruled out, present, or maybe
- Additional notes using `i`, `m`, and `s.<chip>`
- Winner selection by the game host
- Winner badge and confetti animation for all players
- Live updates for new players and winner changes
- Personal game statistics
- Password change functionality
- Harry Potter house themes: Default, Gryffindor, Slytherin, Ravenclaw, and Hufflepuff
- Installable as a Progressive Web App
## Technology
### Frontend
- React 18
- Vite
- Nginx as the production web server
- `canvas-confetti` for the winner animation
- `vite-plugin-pwa` for PWA support
### Backend
- Python 3.12
- FastAPI
- SQLAlchemy 2
- SQLite 3
- Passlib and bcrypt for password hashing
### Deployment
- Docker Compose
- Frontend on port `8081`
- Backend on port `8080`
- SQLite database stored in a persistent Docker volume
## Quick start with Docker
Requirements:
- Docker
- Docker Compose
1. Copy `.env.example` to `.env` and set a strong secret:
```env
BACKEND_SECRET_KEY=use-a-long-random-secret-value
```
2. Build and start the containers:
```bash
docker compose up --build
```
3. Open the application:
- Frontend: http://localhost:8081
- Backend/API: http://localhost:8080
On the first startup, the application opens a setup screen where you create the first administrator with an email address, display name, and password. The default suspects, items, and locations are seeded automatically as well.
The SQLite database is stored in the Docker volume `cluedo-data` and survives normal container restarts.
Start the containers in the background:
```bash
docker compose up -d --build
```
View logs:
```bash
docker compose logs -f backend
```
Stop the containers:
```bash
docker compose down
```
## Local development without Docker
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=sqlite:///./data/cluedo.db
export SECRET_KEY=use-a-long-random-secret-value
uvicorn app.main:app --reload --port 8080
```
On Windows PowerShell, set the environment variables like this:
```powershell
$env:DATABASE_URL = "sqlite:///./data/cluedo.db"
$env:SECRET_KEY = "use-a-long-random-secret-value"
uvicorn app.main:app --reload --port 8080
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
The frontend is normally available at http://localhost:5173. During development, the API must be reachable under `/api`; the production Docker setup provides this through Nginx.
Create a production build:
```bash
npm run build
```
## Project structure
```text
.
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, startup, seed data
│ │ ├── models.py # SQLAlchemy models
│ │ ├── db.py # SQLite engine and sessions
│ │ ├── security.py # Password and cookie logic
│ │ └── routes/ # Auth, admin, and game APIs
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Main UI and game flow
│ │ ├── api/client.js # API client
│ │ ├── components/ # Pages, cards, and modals
│ │ ├── styles/ # Themes and inline styles
│ │ └── utils/ # Small helpers and storage utilities
│ ├── Dockerfile
│ └── nginx.conf
├── docker-compose.yml
└── .env
```
## Data model
- `users`: users, roles, status, display names, and themes
- `games`: game name, join code, host, and winner
- `game_members`: assignment of users to games
- `entries`: seeded suspects, items, and locations
- `sheet_state`: personal status and notes for an entry
Each player has their own set of `sheet_state` records for every game. A player's notes are therefore not visible to other players.
## API overview
### Authentication
- `POST /auth/login`
- `POST /auth/logout`
- `GET /auth/me`
- `PATCH /auth/password`
- `PATCH /auth/theme`
- `GET /auth/me/stats`
### First-run setup
- `GET /setup/status`
- `POST /setup/admin`
The setup endpoint is available only while no administrator exists. After the first administrator has been created, the setup screen is disabled automatically.
### Administration
- `GET /admin/users`
- `POST /admin/users`
- `DELETE /admin/users/{user_id}` deactivates a user
### Games
- `GET /games`
- `POST /games`
- `POST /games/join`
- `GET /games/{game_id}`
- `GET /games/{game_id}/members`
- `PATCH /games/{game_id}/winner`
- `POST /games/{game_id}/start`
- `GET /games/{game_id}/chips`
- `GET /games/{game_id}/sheet`
- `PATCH /games/{game_id}/sheet/{entry_id}`
## SQLite notes
SQLite is a good fit for this application: the data volume is small, the app is intended for internal use, and write operations are short. The database file is stored inside the container at `/app/data/cluedo.db` and persisted through the `cluedo-data` volume.
For larger deployments with many concurrent write operations, PostgreSQL would still be the more robust choice. For the intended private or small-group use case, SQLite is sufficient.
## Security notes
- The values in `.env` are examples and should be changed before production use.
- When using HTTPS, set `COOKIE_SECURE` to `true`.
- The application is intended for an internal or small user group.
- The automatic database migration is intentionally pragmatic and does not replace a migration system such as Alembic.
+1
View File
@@ -1,6 +1,7 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN mkdir -p /app/data
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+18 -3
View File
@@ -1,10 +1,25 @@
import os import os
from pathlib import Path
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.environ["DATABASE_URL"] DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/cluedo.db")
engine = create_engine(DATABASE_URL, pool_pre_ping=True) engine_options = {"pool_pre_ping": True}
if DATABASE_URL.startswith("sqlite"):
# SQLite does not allow connections to be used from another thread by
# default. FastAPI/SQLAlchemy may use a connection across request threads.
engine_options["connect_args"] = {"check_same_thread": False}
# Make the parent directory available for the default local database.
if DATABASE_URL.startswith("sqlite:///"):
sqlite_path = DATABASE_URL.removeprefix("sqlite:///")
if sqlite_path not in (":memory:", ""):
Path(sqlite_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(DATABASE_URL, **engine_options)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase): class Base(DeclarativeBase):
@@ -16,4 +31,4 @@ def get_db():
yield db yield db
finally: finally:
db.close() db.close()
+12 -22
View File
@@ -1,4 +1,3 @@
import os
import random import random
import string import string
@@ -8,11 +7,11 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .models import User, Entry, Category, Role, Game, GameMember from .models import User, Entry, Category, Game, GameMember
from .security import hash_password
from .routes.auth import router as auth_router from .routes.auth import router as auth_router
from .routes.admin import router as admin_router from .routes.admin import router as admin_router
from .routes.games import router as games_router from .routes.games import router as games_router
from .routes.setup import router as setup_router
app = FastAPI(title="Cluedo Sheet") app = FastAPI(title="Cluedo Sheet")
@@ -31,6 +30,7 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(games_router) app.include_router(games_router)
app.include_router(setup_router)
def _rand_join_code(n: int = 6) -> str: def _rand_join_code(n: int = 6) -> str:
@@ -136,6 +136,14 @@ Very small, pragmatic auto-migration (no alembic).
except Exception: except Exception:
db.rollback() db.rollback()
# started_at: games are open for joining until the host starts them
if not _has_column(db, "games", "started_at"):
try:
db.execute(text("ALTER TABLE games ADD COLUMN started_at DATETIME"))
db.commit()
except Exception:
db.rollback()
# host_user_id (nice to have for "only host can set winner") # host_user_id (nice to have for "only host can set winner")
if not _has_column(db, "games", "host_user_id"): if not _has_column(db, "games", "host_user_id"):
try: try:
@@ -276,23 +284,6 @@ def seed_entries(db: Session):
db.commit() db.commit()
def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first()
if not u:
db.add(
User(
email=admin_email,
password_hash=hash_password(admin_pw),
role=Role.admin.value,
theme_key="default",
display_name="Admin",
)
)
db.commit()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
# create new tables # create new tables
@@ -301,8 +292,7 @@ def on_startup():
db = SessionLocal() db = SessionLocal()
try: try:
_auto_migrate(db) _auto_migrate(db)
ensure_admin(db)
seed_entries(db) seed_entries(db)
finally: finally:
db.close() db.close()
+12 -1
View File
@@ -54,6 +54,17 @@ class Game(Base):
code: Mapped[str] = mapped_column(String, unique=True, index=True) code: Mapped[str] = mapped_column(String, unique=True, index=True)
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
started_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class GameChip(Base):
__tablename__ = "game_chips"
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_chip_user"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
chip: Mapped[str] = mapped_column(String)
class GameMember(Base): class GameMember(Base):
@@ -86,4 +97,4 @@ class SheetState(Base):
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) note_tag: Mapped[str | None] = mapped_column(String, nullable=True)
chip: Mapped[str | None] = mapped_column(String, nullable=True) chip: Mapped[str | None] = mapped_column(String, nullable=True)
+83 -3
View File
@@ -1,8 +1,12 @@
import hashlib, random import hashlib
import random
import re
import unicodedata
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import Game, Entry, SheetState, Category, GameMember, User, Role from ..models import Game, GameChip, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"]) router = APIRouter(prefix="/games", tags=["games"])
@@ -27,6 +31,25 @@ def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def make_user_chip(user: User) -> str:
"""Create first-name initial + first two surname letters, e.g. SNE."""
display_name = (user.display_name or "").strip()
if not display_name:
display_name = (user.email or "").split("@", 1)[0].replace(".", " ")
parts = re.split(r"\s+", display_name)
first = parts[0] if parts else "X"
last = parts[-1] if len(parts) > 1 else first
def letters(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
return "".join(c for c in normalized if c.isalpha())
first_letters = letters(first).upper() or "X"
last_letters = letters(last).upper() or "X"
return (first_letters[0] + last_letters[:2]).ljust(3, "X")
def ensure_member(db: Session, game_id: str, user_id: str): def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex: if ex:
@@ -78,6 +101,8 @@ def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
g = db.query(Game).filter(Game.code == code).first() g = db.query(Game).filter(Game.code == code).first()
if not g: if not g:
raise HTTPException(404, "game not found") raise HTTPException(404, "game not found")
if g.started_at:
raise HTTPException(400, "game already started")
ensure_member(db, g.id, uid) ensure_member(db, g.id, uid)
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
@@ -112,6 +137,7 @@ def list_games(req: Request, db: Session = Depends(get_db)):
"host_user_id": g.host_user_id, "host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"started": bool(g.started_at),
} }
) )
return out return out
@@ -139,9 +165,63 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"winner_display_name": winner_display_name, "winner_display_name": winner_display_name,
"started": bool(g.started_at),
"started_at": g.started_at,
} }
@router.post("/{game_id}/start")
def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if g.host_user_id != uid:
raise HTTPException(403, "only host can start the game")
if not g.started_at:
members = (
db.query(User)
.join(GameMember, GameMember.user_id == User.id)
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
.order_by(User.email.asc())
.all()
)
used = set()
for member in members:
base = make_user_chip(member)
chip = base
suffix = 2
while chip in used:
chip = f"{base[:2]}{suffix}"
suffix += 1
used.add(chip)
db.add(GameChip(game_id=game_id, user_id=member.id, chip=chip))
g.started_at = datetime.now(timezone.utc)
db.add(g)
db.commit()
return {"ok": True, "started": True}
@router.get("/{game_id}/chips")
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if not g.started_at:
return []
chips = (
db.query(GameChip, User)
.join(User, User.id == GameChip.user_id)
.filter(GameChip.game_id == game_id)
.order_by(User.display_name.asc(), User.email.asc())
.all()
)
return [{"user_id": chip.user_id, "chip": chip.chip, "display_name": user.display_name, "email": user.email} for chip, user in chips]
@router.get("/{game_id}/members") @router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
@@ -277,4 +357,4 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
db.commit() db.commit()
return {"ok": True} return {"ok": True}
+58
View File
@@ -0,0 +1,58 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Role, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/setup", tags=["setup"])
def _has_admin(db: Session) -> bool:
return db.query(User).filter(User.role == Role.admin.value).first() is not None
@router.get("/status")
def setup_status(db: Session = Depends(get_db)):
return {"setup_required": not _has_admin(db)}
@router.post("/admin")
def create_initial_admin(
data: dict,
resp: Response,
db: Session = Depends(get_db),
):
# The setup endpoint is only open until the first admin exists.
if _has_admin(db):
raise HTTPException(status_code=409, detail="setup already completed")
email = (data.get("email") or "").lower().strip()
display_name = (data.get("display_name") or "").strip()
password = data.get("password") or ""
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise HTTPException(status_code=400, detail="valid email required")
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
if not display_name:
display_name = email.split("@", 1)[0]
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=409, detail="email exists")
user = User(
email=email,
password_hash=hash_password(password),
role=Role.admin.value,
display_name=display_name,
)
db.add(user)
db.commit()
db.refresh(user)
# Log the installer in immediately after successful setup.
set_session(resp, make_session_value(user.id))
return {"ok": True, "id": user.id, "email": user.email}
-1
View File
@@ -1,7 +1,6 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
SQLAlchemy==2.0.34 SQLAlchemy==2.0.34
psycopg[binary]==3.2.2
python-multipart==0.0.9 python-multipart==0.0.9
passlib==1.7.4 passlib==1.7.4
+4 -21
View File
@@ -1,30 +1,13 @@
services: services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
backend: backend:
build: ./backend build: ./backend
environment: environment:
DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} DATABASE_URL: sqlite:////app/data/cluedo.db
SECRET_KEY: ${BACKEND_SECRET_KEY} SECRET_KEY: ${BACKEND_SECRET_KEY}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true
COOKIE_SAMESITE: "Lax" COOKIE_SAMESITE: "Lax"
depends_on: volumes:
db: - cluedo-data:/app/data
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
@@ -36,4 +19,4 @@ services:
- "8081:80" - "8081:80"
volumes: volumes:
pgdata: cluedo-data:
+6 -1
View File
@@ -7,7 +7,12 @@ server {
} }
location /api/ { location /api/ {
proxy_pass http://backend:8080/; # Resolve the Docker service at request time. This prevents Nginx from
# exiting when the Docker DNS entry is not ready during container startup.
resolver 127.0.0.11 ipv6=off valid=10s;
set $backend_upstream http://backend:8080;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass $backend_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+554 -620
View File
File diff suppressed because it is too large Load Diff
-379
View File
@@ -1,379 +0,0 @@
/* frontend/src/AppLayout.css */
html, body, #root {
height: 100%;
}
body {
overflow: hidden;
}
.appRoot {
height: 100vh;
display: grid;
/* Links: flexibel | Rechts: Notes clamp */
grid-template-columns: minmax(900px, 1fr) clamp(380px, 32vw, 520px);
gap: 14px;
padding: 14px;
box-sizing: border-box;
overflow: visible;
}
/* LEFT COLUMN */
.leftPane {
overflow: visible;
min-width: 0;
display: grid;
/* Top | Main | HUD */
grid-template-rows: 68px minmax(360px, 1fr) minmax(160px, 190px);
gap: 14px;
}
.topBarRow {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
min-width: 0;
}
/* MAIN: Tools | Board | PlayerRail */
.mainRow {
min-height: 0;
overflow: visible;
display: grid;
/* Tools bewusst schmaler, Board bekommt Bühne */
grid-template-columns: 260px minmax(560px, 1fr) 92px;
gap: 14px;
min-width: 0;
}
/* Tools left of board */
.leftTools {
min-width: 0;
overflow: hidden;
display: grid;
align-content: start;
}
.leftToolsRow {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
min-width: 0;
}
/* Board */
.boardWrap {
min-height: 0;
overflow: hidden;
border-radius: 22px;
min-width: 0;
position: relative;
}
/* Dice overlay: under board slightly right */
.diceOverlay {
position: absolute;
overflow: visible;
bottom: 14px;
right: 18px;
width: 220px;
pointer-events: auto;
opacity: 0.98;
}
/* Player rail: right of board, before notes (tight) */
.playerRail {
min-height: 0;
border-radius: 22px;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.18);
backdrop-filter: blur(10px);
box-shadow: 0 16px 50px rgba(0,0,0,0.35);
padding: 10px 8px;
display: grid;
grid-template-rows: auto 1fr;
justify-items: center;
gap: 10px;
overflow: visible;
}
.playerRailInner {
width: 100%;
height: 100%;
overflow: visible;
border-radius: 18px;
display: grid;
justify-items: center;
}
.playerRailTitle {
font-weight: 900;
font-size: 12px;
opacity: 0.9;
}
.playerRailList {
min-height: 0;
overflow: visible;
display: grid;
gap: 10px;
align-content: start;
justify-items: center;
padding-top: 4px;
}
/* HUD */
.playerHud {
align-items: stretch;
overflow: visible;
display: grid;
grid-template-columns: 1.1fr 1.7fr 1.1fr;
gap: 14px;
min-width: 0;
}
.playerHudMiddle {
height: 100%;
min-width: 0;
min-height: 0;
overflow: visible;
display: grid;
/* Geheimkarten groß, Hilfkarte etwas kleiner */
grid-template-columns: 1fr 0.78fr;
gap: 14px;
}
/* RIGHT COLUMN */
.notesPane {
overflow: hidden;
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
}
.notesScroll {
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding-right: 4px;
}
.diceOverlay {
position: absolute;
bottom: 14px;
right: 18px;
width: 220px;
pointer-events: auto; /* ✅ damit Hover/Clicks für Würfel gehen */
opacity: 0.98;
}
/* ===== True 3D Cube Dice (transition-based roll) ===== */
.die3d {
transform-style: preserve-3d;
will-change: transform;
}
.die3d:hover:not(:disabled) {
transform: translateY(-3px) rotateX(8deg) rotateY(-10deg);
}
.dieCubeWrap {
width: 44px;
height: 44px;
position: relative;
transform-style: preserve-3d;
}
.dieCube {
width: 44px;
height: 44px;
position: absolute;
inset: 0;
transform-style: preserve-3d;
/* absolute angles (numbers are set in JS) */
transform: rotateX(calc(var(--rx, 0) * 1deg)) rotateY(calc(var(--ry, 0) * 1deg));
/* default: snappy */
transition: transform 260ms cubic-bezier(0.22, 1.0, 0.25, 1);
}
.dieCube.rolling {
/* roll duration */
transition: transform 820ms cubic-bezier(0.18, 0.88, 0.22, 1);
}
/* Faces: remove hard borders / inner rings */
.dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: none; /* ✅ no face border */
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.05), /* very subtle */
inset 0 14px 24px rgba(255,255,255,0.04);
display: grid;
place-items: center;
backface-visibility: hidden;
}
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* Prevent clipping */
.diceRow3d { overflow: visible; }
.dieCube.snap {
transition: none !important;
}
/* one face */
.dieFace {
position: absolute;
inset: 0;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.10);
background:
radial-gradient(120% 120% at 25% 18%, rgba(255,255,255,0.14), rgba(0,0,0,0.40) 60%, rgba(0,0,0,0.72));
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.06),
inset 0 12px 20px rgba(255,255,255,0.05);
display: grid;
place-items: center;
backface-visibility: hidden;
}
/* face positions */
.dieFace.front { transform: rotateY( 0deg) translateZ(22px); }
.dieFace.back { transform: rotateY(180deg) translateZ(22px); }
.dieFace.right { transform: rotateY( 90deg) translateZ(22px); }
.dieFace.left { transform: rotateY(-90deg) translateZ(22px); }
.dieFace.top { transform: rotateX( 90deg) translateZ(22px); }
.dieFace.bottom { transform: rotateX(-90deg) translateZ(22px); }
/* pips */
.pipGrid {
width: 70%;
height: 70%;
position: relative;
}
.pip {
position: absolute;
width: 7px;
height: 7px;
border-radius: 999px;
background: rgba(245,245,245,0.92);
box-shadow: 0 3px 10px rgba(0,0,0,0.35), inset 0 1px 2px rgba(0,0,0,0.20);
}
/* special icon */
.specialIcon {
font-size: 20px;
line-height: 1;
filter: drop-shadow(0 10px 18px rgba(0,0,0,0.55));
}
/* optional: subtle face ring tint via CSS var */
.dieFace {
box-shadow:
inset 0 0 0 1px rgba(255,255,255,0.06),
inset 0 0 0 2px var(--ring, rgba(255,255,255,0.00)),
inset 0 12px 20px rgba(255,255,255,0.05);
}
/* Roll animation */
@keyframes dieRoll {
0% {
transform: translateY(0) rotateX(0deg) rotateY(0deg) rotateZ(0deg);
}
25% {
transform: translateY(-6px) rotateX(220deg) rotateY(160deg) rotateZ(90deg);
}
55% {
transform: translateY(0px) rotateX(520deg) rotateY(460deg) rotateZ(240deg);
}
100% {
transform: translateY(-2px) rotateX(720deg) rotateY(720deg) rotateZ(360deg);
}
}
.die3dRolling {
animation: dieRoll 820ms cubic-bezier(0.2, 0.9, 0.25, 1) both;
}
/* --- Responsive: shrink gracefully (no scroll outside notes) --- */
@media (max-height: 860px) {
.leftPane {
grid-template-rows: 64px minmax(300px, 1fr) 150px;
}
.diceOverlay {
bottom: 10px;
right: 12px;
width: 200px;
}
}
@media (max-width: 1440px) {
.appRoot {
grid-template-columns: minmax(820px, 1fr) clamp(360px, 34vw, 480px);
}
.mainRow {
grid-template-columns: 240px minmax(520px, 1fr) 88px;
}
}
@media (max-width: 1280px) {
.appRoot {
grid-template-columns: 1fr clamp(340px, 36vw, 460px);
}
.mainRow {
grid-template-columns: 220px minmax(480px, 1fr) 84px;
}
}
@media (max-width: 1180px) {
/* ab hier wirds eng: tools noch schmäler */
.mainRow {
grid-template-columns: 200px minmax(440px, 1fr) 82px;
}
}
+6 -5
View File
@@ -89,24 +89,25 @@ export default function AdminPanel() {
{users.map((u) => ( {users.map((u) => (
<div <div
key={u.id} key={u.id}
className="hp-admin-user-row"
style={{ style={{
...styles.userRow, ...styles.userRow,
gridTemplateColumns: "1fr 1fr 80px 90px 92px",
alignItems: "center", alignItems: "center",
}} }}
> >
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}> <div className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"} {u.display_name || "—"}
</div> </div>
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div> <div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}> <div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role} {u.role}
</div> </div>
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}> <div className={`hp-admin-status ${u.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"} {u.disabled ? "disabled" : "active"}
</div> </div>
<button <button
className="hp-admin-action"
onClick={() => deleteUser(u)} onClick={() => deleteUser(u)}
style={{ style={{
...styles.secondaryBtn, ...styles.secondaryBtn,
+10 -5
View File
@@ -1,9 +1,8 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) { export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) {
if (!chipOpen) return null; if (!chipOpen) return null;
return ( return (
@@ -19,13 +18,19 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div> <div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}> <div style={styles.chipGrid}>
{CHIP_LIST.map((c) => ( {chips.map((item) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}> <button key={item.user_id || item.chip} onClick={() => chooseChip(item.chip)} style={styles.chipBtn} title={item.display_name || item.email}>
{c} {item.chip}
</button> </button>
))} ))}
</div> </div>
{!chips.length && (
<div style={{ marginTop: 12, color: stylesTokens.textDim }}>
Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden.
</div>
)}
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}> <div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf . Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .
</div> </div>
-147
View File
@@ -1,147 +0,0 @@
// frontend/src/components/Dice/Dice3D.jsx
import React from "react";
export const cubeRotationForD6 = (value) => {
switch (value) {
case 1: return { rx: 0, ry: 0 };
case 2: return { rx: -90, ry: 0 };
case 3: return { rx: 0, ry: -90 };
case 4: return { rx: 0, ry: 90 };
case 5: return { rx: 90, ry: 0 };
case 6: return { rx: 0, ry: 180 };
default: return { rx: 0, ry: 0 };
}
};
export const PipFace = ({ value }) => {
const pos = (gx, gy) => ({ left: `${gx * 50}%`, top: `${gy * 50}%` });
const faces = {
1: [[1, 1]],
2: [[0, 0], [2, 2]],
3: [[0, 0], [1, 1], [2, 2]],
4: [[0, 0], [2, 0], [0, 2], [2, 2]],
5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]],
6: [[0, 0], [0, 1], [0, 2], [2, 0], [2, 1], [2, 2]],
};
const arr = faces[value] || faces[1];
return (
<div className="pipGrid">
{arr.map(([x, y], idx) => (
<div
key={idx}
className="pip"
style={{ ...pos(x, y), transform: "translate(-50%, -50%)" }}
/>
))}
</div>
);
};
export const DieShell = ({ children, rolling = false, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className="die3d"
style={{
width: 64,
height: 64,
borderRadius: 18,
border: "none",
background: "transparent",
boxShadow: "none",
position: "relative",
overflow: "visible",
display: "grid",
placeItems: "center",
cursor: rolling ? "default" : "pointer",
transition: "transform 160ms ease",
padding: 0,
outline: "none",
}}
disabled={rolling}
>
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
</button>
);
};
export const DieD6 = ({ rolling, onClick, ax, ay, onDone, snap = false }) => {
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
<div className="dieFace front"><PipFace value={1} /></div>
<div className="dieFace back"><PipFace value={6} /></div>
<div className="dieFace top"><PipFace value={2} /></div>
<div className="dieFace bottom"><PipFace value={5} /></div>
<div className="dieFace right"><PipFace value={3} /></div>
<div className="dieFace left"><PipFace value={4} /></div>
</div>
</div>
</DieShell>
);
};
export const HouseDie = ({ face, rolling, onClick, ax, ay, onDone, snap = false }) => {
const faces = {
gryffindor: { icon: "🦁", color: "#ef4444" },
slytherin: { icon: "🐍", color: "#22c55e" },
ravenclaw: { icon: "🦅", color: "#3b82f6" },
hufflepuff: { icon: "🦡", color: "#facc15" },
help: { icon: "🃏", color: "#f2d27a" },
dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" },
};
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
{order.map((key, i) => {
const item = faces[key];
const faceClass =
i === 0 ? "front" :
i === 1 ? "top" :
i === 2 ? "right" :
i === 3 ? "left" :
i === 4 ? "bottom" :
"back";
return (
<div key={key} className={`dieFace ${faceClass}`}>
<div
className="specialIcon"
style={{
color: item.color,
textShadow: `0 0 18px ${item.color}55, 0 10px 22px rgba(0,0,0,0.55)`,
}}
>
{item.icon}
</div>
</div>
);
})}
</div>
</div>
</DieShell>
);
};
-188
View File
@@ -1,188 +0,0 @@
// frontend/src/components/Dice/DicePanel.jsx
import React, { useEffect, useRef, useState } from "react";
import { stylesTokens } from "../../styles/theme";
import { cubeRotationForD6, DieD6, HouseDie } from "./Dice/Dice3D.jsx";
export default function DicePanel({ onRoll }) {
const LS_KEY = "hp_cluedo_dice_v1";
const [d1, setD1] = useState(4);
const [d2, setD2] = useState(2);
const [special, setSpecial] = useState("gryffindor");
const [a1, setA1] = useState({ x: 0, y: 90 });
const [a2, setA2] = useState({ x: -90, y: 0 });
const [as, setAs] = useState({ x: 0, y: 0 });
const [rolling, setRolling] = useState(false);
const [snap, setSnap] = useState(false);
const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
// roll bookkeeping
const pendingRef = useRef(null);
const rollIdRef = useRef(0);
const doneForRollRef = useRef({ d1: false, d2: false, s: false });
// restore last
useEffect(() => {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed?.d1) setD1(parsed.d1);
if (parsed?.d2) setD2(parsed.d2);
if (parsed?.special) setSpecial(parsed.special);
if (parsed?.d1) {
const r = cubeRotationForD6(parsed.d1);
setA1({ x: r.rx, y: r.ry });
}
if (parsed?.d2) {
const r = cubeRotationForD6(parsed.d2);
setA2({ x: r.rx, y: r.ry });
}
if (parsed?.special) {
const idx = Math.max(0, order.indexOf(parsed.special));
const r = cubeRotationForD6(idx + 1);
setAs({ x: r.rx, y: r.ry });
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
try {
localStorage.setItem(LS_KEY, JSON.stringify({ d1, d2, special }));
} catch {}
}, [d1, d2, special]);
const normalize360 = (n) => ((n % 360) + 360) % 360;
const rollTo = (currentAngles, targetBase) => {
const spinX = 720 + randInt(0, 2) * 360;
const spinY = 720 + randInt(0, 2) * 360;
const currX = normalize360(currentAngles.x);
const currY = normalize360(currentAngles.y);
const dx = targetBase.rx - currX;
const dy = targetBase.ry - currY;
return { x: currentAngles.x + spinX + dx, y: currentAngles.y + spinY + dy };
};
const rollAll = () => {
if (rolling) return;
const nd1 = randInt(1, 6);
const nd2 = randInt(1, 6);
const ns = specialFaces[randInt(0, specialFaces.length - 1)];
const r1 = cubeRotationForD6(nd1);
const r2 = cubeRotationForD6(nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1);
pendingRef.current = { nd1, nd2, ns };
rollIdRef.current += 1;
doneForRollRef.current = { d1: false, d2: false, s: false };
setRolling(true);
setA1((cur) => rollTo(cur, r1));
setA2((cur) => rollTo(cur, r2));
setAs((cur) => rollTo(cur, rs));
};
const maybeCommit = () => {
const flags = doneForRollRef.current;
if (!flags.d1 || !flags.d2 || !flags.s) return;
const p = pendingRef.current;
if (!p) return;
// ✅ rolling beenden
setRolling(false);
// ✅ Ergebnis state setzen
setD1(p.nd1);
setD2(p.nd2);
setSpecial(p.ns);
// ✅ Winkel auf kleine Basis normalisieren (ohne Transition)
const r1 = cubeRotationForD6(p.nd1);
const r2 = cubeRotationForD6(p.nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(p.ns)) + 1);
setSnap(true);
setA1({ x: r1.rx, y: r1.ry });
setA2({ x: r2.rx, y: r2.ry });
setAs({ x: rs.rx, y: rs.ry });
// Snap nur 1 Frame aktiv lassen
requestAnimationFrame(() => {
requestAnimationFrame(() => setSnap(false));
});
pendingRef.current = null;
onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns });
};
const onDoneD1 = () => {
if (!rolling) return;
if (doneForRollRef.current.d1) return;
doneForRollRef.current.d1 = true;
maybeCommit();
};
const onDoneD2 = () => {
if (!rolling) return;
if (doneForRollRef.current.d2) return;
doneForRollRef.current.d2 = true;
maybeCommit();
};
const onDoneS = () => {
if (!rolling) return;
if (doneForRollRef.current.s) return;
doneForRollRef.current.s = true;
maybeCommit();
};
return (
<div style={{ pointerEvents: "auto" }}>
<div style={{ display: "grid", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 13 }}>Würfel</div>
<div style={{ color: stylesTokens.textDim, fontWeight: 900, fontSize: 11.5, letterSpacing: 0.7, opacity: 0.75 }}>
{rolling ? "ROLL…" : "READY"}
</div>
</div>
<div
className="diceRow3d"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
alignItems: "center",
justifyItems: "center",
overflow: "visible",
}}
>
<DieD6 rolling={rolling} onClick={rollAll} ax={a1.x} ay={a1.y} onDone={onDoneD1} />
<DieD6 rolling={rolling} onClick={rollAll} ax={a2.x} ay={a2.y} onDone={onDoneD2} />
<HouseDie face={special} rolling={rolling} onClick={rollAll} ax={as.x} ay={as.y} onDone={onDoneS} />
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}>Klicken zum Rollen</div>
</div>
</div>
);
}
@@ -10,6 +10,10 @@ export default function GamePickerCard({
members = [], members = [],
me, me,
hostUserId, hostUserId,
isHost = false,
started = false,
chipCount = 0,
onStartGame,
}) { }) {
const cur = games.find((x) => x.id === gameId); const cur = games.find((x) => x.id === gameId);
@@ -85,6 +89,24 @@ export default function GamePickerCard({
</div> </div>
)} )}
<div style={{ padding: "0 12px 12px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10, flexWrap: "wrap" }}>
{!started ? (
isHost ? (
<button onClick={onStartGame} style={styles.primaryBtn} disabled={!members.length}>
Spiel starten
</button>
) : (
<div style={{ fontSize: 12, color: stylesTokens.textDim }}>
Warte auf den Host, der das Spiel startet.
</div>
)
) : (
<div style={{ fontSize: 12, color: stylesTokens.textDim }}>
Spiel läuft · {chipCount} Spieler-Chips erstellt
</div>
)}
</div>
{/* Spieler */} {/* Spieler */}
{members?.length > 0 && ( {members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}> <div style={{ padding: "0 12px 12px" }}>
+77 -4
View File
@@ -9,7 +9,21 @@ export default function LoginPage({
showPw, showPw,
setShowPw, setShowPw,
doLogin, doLogin,
setupRequired,
setupEmail,
setSetupEmail,
setupDisplayName,
setSetupDisplayName,
setupPassword,
setSetupPassword,
setupPasswordConfirm,
setSetupPasswordConfirm,
setupError,
setupSaving,
doSetup,
}) { }) {
const isSetup = setupRequired === true;
return ( return (
<div style={styles.loginPage}> <div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true"> <div style={styles.bgFixed} aria-hidden="true">
@@ -21,9 +35,67 @@ export default function LoginPage({
<div style={styles.loginCard}> <div style={styles.loginCard}>
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div> <div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
<div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div> <div style={styles.loginSubtitle}>
{setupRequired === null
? "Initialisiere Anwendung …"
: isSetup
? "Richte den ersten Administrator ein"
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"}
</div>
<div style={{ marginTop: 18, display: "grid", gap: 12 }}> {isSetup ? (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={setupDisplayName}
onChange={(e) => setSetupDisplayName(e.target.value)}
placeholder="Display name"
style={styles.loginInput}
autoComplete="name"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="Admin email"
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Password (min. 8 characters)"
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
<div style={styles.loginFieldWrap}>
<input
value={setupPasswordConfirm}
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
placeholder="Confirm password"
type="password"
style={styles.loginInput}
autoComplete="new-password"
/>
</div>
{setupError && <div style={{ color: "#ffb3b3", fontSize: 13 }}>{setupError}</div>}
<button onClick={doSetup} style={styles.loginBtn} disabled={setupSaving}>
{setupSaving ? "Setting up …" : "✦ Create administrator"}
</button>
</div>
) : (
<div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}> <div style={styles.loginFieldWrap}>
<input <input
value={loginEmail} value={loginEmail}
@@ -60,10 +132,11 @@ export default function LoginPage({
<button onClick={doLogin} style={styles.loginBtn}> <button onClick={doLogin} style={styles.loginBtn}>
Anmelden Anmelden
</button> </button>
</div> </div>
)}
<div style={styles.loginHint}> <div style={styles.loginHint}>
Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel. Your notes remain private every player only sees their own sheet.
</div> </div>
</div> </div>
</div> </div>
+6 -5
View File
@@ -15,7 +15,7 @@ export default function TopBar({
const displayName = me ? ((me.display_name || "").trim() || me.email) : ""; const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
return ( return (
<div style={styles.topBar}> <div className="hp-topbar" style={styles.topBar}>
<div> <div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
@@ -23,9 +23,10 @@ export default function TopBar({
</div> </div>
</div> </div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu> <div className="hp-topbar-actions" style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}> <div className="hp-user-menu-wrap" style={{ position: "relative" }}>
<button <button
className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)} onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn} style={styles.userBtn}
title="User Menü" title="User Menü"
@@ -36,7 +37,7 @@ export default function TopBar({
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div style={styles.userDropdown}> <div className="hp-user-dropdown" style={styles.userDropdown}>
<div <div
style={{ style={{
padding: "10px 12px", padding: "10px 12px",
@@ -84,7 +85,7 @@ export default function TopBar({
)} )}
</div> </div>
<button onClick={onOpenNewGame} style={styles.primaryBtn}> <button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game New Game
</button> </button>
</div> </div>
-1
View File
@@ -1,2 +1 @@
export const API_BASE = "/api"; export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
+47 -1
View File
@@ -2,6 +2,8 @@ import { useEffect } from "react";
import { stylesTokens } from "../theme"; import { stylesTokens } from "../theme";
import { applyTheme } from "../themes"; import { applyTheme } from "../themes";
export function useHpGlobalStyles() { export function useHpGlobalStyles() {
// Google Fonts // Google Fonts
useEffect(() => { useEffect(() => {
@@ -74,10 +76,54 @@ export function useHpGlobalStyles() {
color: ${stylesTokens.textMain}; color: ${stylesTokens.textMain};
} }
body { body {
overflow: hidden; overflow-x: hidden;
-webkit-overflow-scrolling: touch;
} }
#root { background: transparent; } #root { background: transparent; }
* { -webkit-tap-highlight-color: transparent; } * { -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-topbar-actions { margin-left: auto; }
.hp-user-menu-wrap { min-width: 0; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1.2fr) minmax(0, 1.5fr) 70px 76px 86px; }
.hp-admin-action { min-width: 0; }
.hp-admin-role { padding: 4px 8px; border-radius: 999px; background: rgba(233,216,166,0.10); border: 1px solid rgba(233,216,166,0.16); font-size: 12px; }
.hp-admin-status { color: #baf3c9 !important; font-size: 13px; }
.hp-admin-status--disabled { color: #ffb3b3 !important; }
@media (max-width: 600px) {
.hp-shell { padding: calc(12px + env(safe-area-inset-top)) 10px calc(28px + env(safe-area-inset-bottom)) !important; }
.hp-topbar { align-items: center !important; padding: 10px !important; gap: 6px !important; flex-wrap: nowrap !important; }
.hp-topbar > div:first-child { min-width: 0; flex: 1; }
.hp-topbar > div:first-child > div:last-child { max-width: 105px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px !important; }
.hp-topbar-actions { width: auto; flex: 0 0 auto; margin-left: 0; gap: 5px !important; }
.hp-user-menu-wrap { flex: 0 0 auto; min-width: 0; }
.hp-topbar-user, .hp-topbar-new { flex: 0 0 auto; min-height: 40px; width: auto; padding: 8px 9px !important; font-size: 13px; }
.hp-topbar-user { justify-content: center; }
.hp-user-dropdown { left: 0 !important; right: auto !important; width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
.hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 11px !important; border-radius: 15px !important; }
.hp-admin-name { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; }
.hp-admin-email { grid-column: 1; grid-row: 2; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px !important; }
.hp-admin-role { grid-column: 2; grid-row: 1; }
.hp-admin-status { grid-column: 2; grid-row: 2; }
.hp-admin-action { grid-column: 2; grid-row: 3; justify-self: end; width: auto; min-width: 94px; min-height: 38px; padding: 7px 14px !important; }
.hp-row { grid-template-columns: minmax(0, 1fr) 42px 62px !important; gap: 7px !important; padding: 12px 11px !important; }
.hp-row button { min-height: 40px; }
button { min-height: 42px; }
input, select { min-height: 44px; box-sizing: border-box; }
.hp-topbar + * { margin-top: 12px !important; }
}
@media (max-width: 360px) {
.hp-row { grid-template-columns: minmax(0, 1fr) 38px 56px !important; font-size: 14px; }
.hp-topbar { gap: 5px !important; }
.hp-topbar-user, .hp-topbar-new { padding-left: 7px !important; padding-right: 7px !important; font-size: 12px; }
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
}, []); }, []);
+75 -66
View File
@@ -10,8 +10,8 @@ export const styles = {
shell: { shell: {
fontFamily: '"IM Fell English", system-ui', fontFamily: '"IM Fell English", system-ui',
padding: 16, padding: "22px 16px 42px",
maxWidth: 680, maxWidth: 760,
margin: "0 auto", margin: "0 auto",
}, },
@@ -22,34 +22,38 @@ export const styles = {
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
padding: 12, padding: "14px 16px",
borderRadius: 16, borderRadius: 18,
background: stylesTokens.panelBg, background: `linear-gradient(135deg, rgba(31, 28, 32, 0.90), ${stylesTokens.panelBg})`,
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid ${stylesTokens.headerBorder}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)", boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.08)",
backdropFilter: "blur(6px)", backdropFilter: "blur(14px) saturate(1.12)",
WebkitBackdropFilter: "blur(14px) saturate(1.12)",
}, },
card: { card: {
borderRadius: 18, borderRadius: 20,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(18, 18, 20, 0.50)", background: `linear-gradient(145deg, rgba(25, 24, 28, 0.88), ${stylesTokens.panelBg})`,
boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "0 20px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.07)",
backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
cardBody: { cardBody: {
padding: 12, padding: "14px 16px",
display: "flex", display: "flex",
gap: 10, gap: 10,
alignItems: "center", alignItems: "center",
}, },
sectionHeader: { sectionHeader: {
padding: "11px 14px", padding: "13px 16px",
fontWeight: 1000, fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui', fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.0, letterSpacing: 1.5,
fontSize: 14,
color: stylesTokens.textGold, color: stylesTokens.textGold,
// WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert // WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert
@@ -57,16 +61,16 @@ export const styles = {
borderBottom: `1px solid ${stylesTokens.headerBorder}`, borderBottom: `1px solid ${stylesTokens.headerBorder}`,
textTransform: "uppercase", textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.6)", textShadow: "0 1px 0 rgba(0,0,0,0.72)",
}, },
row: { row: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 54px 68px", gridTemplateColumns: "1fr 54px 68px",
gap: 10, gap: 10,
padding: "12px 14px", padding: "13px 16px",
alignItems: "center", alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.08)", borderBottom: "1px solid rgba(233,216,166,0.10)",
borderLeft: "4px solid rgba(0,0,0,0)", borderLeft: "4px solid rgba(0,0,0,0)",
}, },
@@ -100,9 +104,9 @@ export const styles = {
tagBtn: { tagBtn: {
padding: "8px 0", padding: "8px 0",
fontWeight: 1000, fontWeight: 1000,
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
@@ -110,9 +114,9 @@ export const styles = {
helpBtn: { helpBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -122,31 +126,31 @@ export const styles = {
input: { input: {
width: "100%", width: "100%",
padding: "10px 12px", padding: "11px 12px",
borderRadius: 14, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(10,10,12,0.55)", background: "rgba(7,7,10,0.64)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 15, fontSize: 15,
}, },
primaryBtn: { primaryBtn: {
padding: "10px 12px", padding: "11px 15px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.28)`, border: `1px solid rgba(233,216,166,0.34)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))", background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)",
}, },
secondaryBtn: { secondaryBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)", background: "rgba(255,255,255,0.055)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
fontWeight: 900, fontWeight: 900,
cursor: "pointer", cursor: "pointer",
@@ -158,12 +162,13 @@ export const styles = {
position: "relative", position: "relative",
zIndex: 1, zIndex: 1,
marginTop: 14, marginTop: 14,
padding: 12, padding: 14,
borderRadius: 16, borderRadius: 20,
border: `1px solid rgba(233,216,166,0.14)`, border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(18, 18, 20, 0.40)", background: `linear-gradient(145deg, rgba(25, 24, 28, 0.86), ${stylesTokens.panelBg})`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)", boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.06)",
backdropFilter: "blur(6px)", backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
adminTop: { adminTop: {
display: "flex", display: "flex",
@@ -181,7 +186,7 @@ export const styles = {
gap: 8, gap: 8,
padding: 10, padding: 10,
borderRadius: 12, borderRadius: 12,
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
border: `1px solid rgba(233,216,166,0.10)`, border: `1px solid rgba(233,216,166,0.10)`,
}, },
@@ -203,12 +208,12 @@ export const styles = {
modalCard: { modalCard: {
width: "min(560px, 100%)", width: "min(560px, 100%)",
borderRadius: 18, borderRadius: 20,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(12,12,14,0.96)", background: "linear-gradient(145deg, rgba(27,25,30,0.98), rgba(11,11,14,0.98))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)", boxShadow: "0 24px 70px rgba(0,0,0,0.78), inset 0 1px 0 rgba(255,255,255,0.07)",
padding: 14, padding: 16,
maxHeight: "calc(100vh - 32px)", maxHeight: "calc(100dvh - 32px)",
overflow: "auto", overflow: "auto",
}, },
modalHeader: { modalHeader: {
@@ -305,13 +310,14 @@ export const styles = {
width: "100%", width: "100%",
maxWidth: 420, maxWidth: 420,
padding: 26, padding: 26,
borderRadius: 22, borderRadius: 24,
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid ${stylesTokens.headerBorder}`,
background: "rgba(18, 18, 20, 0.55)", background: `linear-gradient(145deg, rgba(29, 27, 31, 0.92), ${stylesTokens.panelBg})`,
boxShadow: "0 18px 60px rgba(0,0,0,0.70)", boxShadow: "0 24px 80px rgba(0,0,0,0.72), inset 0 1px 0 rgba(255,255,255,0.08)",
backdropFilter: "blur(8px)", backdropFilter: "blur(16px) saturate(1.12)",
WebkitBackdropFilter: "blur(16px) saturate(1.12)",
animation: "popIn 240ms ease-out", animation: "popIn 240ms ease-out",
color: stylesTokens.textMain, color: stylesTokens.textMain,
}, },
@@ -338,24 +344,24 @@ export const styles = {
}, },
loginInput: { loginInput: {
width: "100%", width: "100%",
padding: 10, padding: "11px 12px",
borderRadius: 12, borderRadius: 13,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.22)`,
background: "rgba(10,10,12,0.60)", background: "rgba(7,7,10,0.64)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 16, fontSize: 16,
}, },
loginBtn: { loginBtn: {
padding: "12px 14px", padding: "12px 15px",
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.28)`, border: `1px solid rgba(233,216,166,0.34)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))", background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
fontSize: 16, fontSize: 16,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)",
}, },
loginHint: { loginHint: {
marginTop: 18, marginTop: 18,
@@ -431,7 +437,10 @@ export const styles = {
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
filter: "saturate(0.9) contrast(1.05) brightness(0.55)", filter: "saturate(0.72) contrast(1.12) brightness(0.42) blur(1.2px)",
transform: "scale(1.015)",
backgroundColor: "rgba(8, 7, 12, 0.34)",
backgroundBlendMode: "multiply",
}, },
chipGrid: { chipGrid: {
@@ -443,9 +452,9 @@ export const styles = {
chipBtn: { chipBtn: {
padding: "10px 14px", padding: "10px 14px",
borderRadius: 12, borderRadius: 13,
border: "1px solid rgba(233,216,166,0.18)", border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.06)", background: "rgba(255,255,255,0.055)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -457,7 +466,7 @@ export const styles = {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
padding: "10px 12px", padding: "10px 12px",
borderRadius: 12, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)", background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
@@ -474,11 +483,11 @@ export const styles = {
minWidth: 220, minWidth: 220,
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))", background: "linear-gradient(145deg, rgba(29,27,33,0.98), rgba(10,10,13,0.96))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden", overflow: "hidden",
zIndex: 99999, zIndex: 99999,
backdropFilter: "blur(8px)", backdropFilter: "blur(14px)",
}, },
userDropdownItem: { userDropdownItem: {
@@ -496,4 +505,4 @@ export const styles = {
height: 1, height: 1,
background: "rgba(233,216,166,0.12)", background: "rgba(233,216,166,0.12)",
}, },
}; };