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
32 changed files with 1482 additions and 1220 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 -220
View File
@@ -1,221 +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
- Player chips are generated from the first name initial and first two surname letters, e.g. `SNE` for Sascha Nesterovic
- Automatic player list with host indication
- Personal note sheet for each player and game
- Categories for suspects, items, and locations
- Entry status tracking: empty, ruled out, present, or maybe
- Additional notes using `i`, `m`, and `s.<chip>`
- Winner selection by the game host
- Winner badge and confetti animation for all players
- Live updates for new players and winner changes
- Personal game statistics
- Password change functionality
- Harry Potter house themes: Default, Gryffindor, Slytherin, Ravenclaw, and Hufflepuff
- Installable as a Progressive Web App
## Technology
### Frontend
- React 18
- Vite
- Nginx as the production web server
- `canvas-confetti` for the winner animation
- `vite-plugin-pwa` for PWA support
### Backend
- Python 3.12
- FastAPI
- SQLAlchemy 2
- SQLite 3
- Passlib and bcrypt for password hashing
### Deployment
- Docker Compose
- Frontend on port `8081`
- Backend on port `8080`
- SQLite database stored in a persistent Docker volume
## Quick start with Docker
Requirements:
- Docker
- Docker Compose
1. Copy `.env.example` to `.env` and set a strong secret:
```env
BACKEND_SECRET_KEY=use-a-long-random-secret-value
```
2. Build and start the containers:
```bash
docker compose up --build
```
3. Open the application:
- Frontend: http://localhost:8081
- Backend/API: http://localhost:8080
On the first startup, the application opens a setup screen where you create the first administrator with an email address, display name, and password. The default suspects, items, and locations are seeded automatically as well.
The SQLite database is stored in the Docker volume `cluedo-data` and survives normal container restarts.
Start the containers in the background:
```bash
docker compose up -d --build
```
View logs:
```bash
docker compose logs -f backend
```
Stop the containers:
```bash
docker compose down
```
## Local development without Docker
### Backend
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
export DATABASE_URL=sqlite:///./data/cluedo.db
export SECRET_KEY=use-a-long-random-secret-value
uvicorn app.main:app --reload --port 8080
```
On Windows PowerShell, set the environment variables like this:
```powershell
$env:DATABASE_URL = "sqlite:///./data/cluedo.db"
$env:SECRET_KEY = "use-a-long-random-secret-value"
uvicorn app.main:app --reload --port 8080
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
The frontend is normally available at http://localhost:5173. During development, the API must be reachable under `/api`; the production Docker setup provides this through Nginx.
Create a production build:
```bash
npm run build
```
## Project structure
```text
.
├── backend/
│ ├── app/
│ │ ├── main.py # FastAPI app, startup, seed data
│ │ ├── models.py # SQLAlchemy models
│ │ ├── db.py # SQLite engine and sessions
│ │ ├── security.py # Password and cookie logic
│ │ └── routes/ # Auth, admin, and game APIs
│ ├── Dockerfile
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── App.jsx # Main UI and game flow
│ │ ├── api/client.js # API client
│ │ ├── components/ # Pages, cards, and modals
│ │ ├── styles/ # Themes and inline styles
│ │ └── utils/ # Small helpers and storage utilities
│ ├── Dockerfile
│ └── nginx.conf
├── docker-compose.yml
└── .env
```
## Data model
- `users`: users, roles, status, display names, and themes
- `games`: game name, join code, host, and winner
- `game_members`: assignment of users to games
- `entries`: seeded suspects, items, and locations
- `sheet_state`: personal status and notes for an entry
Each player has their own set of `sheet_state` records for every game. A player's notes are therefore not visible to other players.
## API overview
### Authentication
- `POST /auth/login`
- `POST /auth/logout`
- `GET /auth/me`
- `PATCH /auth/password`
- `PATCH /auth/theme`
- `GET /auth/me/stats`
### First-run setup
- `GET /setup/status`
- `POST /setup/admin`
The setup endpoint is available only while no administrator exists. After the first administrator has been created, the setup screen is disabled automatically.
### Administration
- `GET /admin/users`
- `POST /admin/users`
- `DELETE /admin/users/{user_id}` deactivates a user
### Games
- `GET /games`
- `POST /games`
- `POST /games/join`
- `GET /games/{game_id}`
- `GET /games/{game_id}/members`
- `PATCH /games/{game_id}/winner`
- `POST /games/{game_id}/start`
- `GET /games/{game_id}/chips`
- `GET /games/{game_id}/sheet`
- `PATCH /games/{game_id}/sheet/{entry_id}`
## SQLite notes
SQLite is a good fit for this application: the data volume is small, the app is intended for internal use, and write operations are short. The database file is stored inside the container at `/app/data/cluedo.db` and persisted through the `cluedo-data` volume.
For larger deployments with many concurrent write operations, PostgreSQL would still be the more robust choice. For the intended private or small-group use case, SQLite is sufficient.
## Security notes
- The values in `.env` are examples and should be changed before production use.
- When using HTTPS, set `COOKIE_SECURE` to `true`.
- The application is intended for an internal or small user group.
- The automatic database migration is intentionally pragmatic and does not replace a migration system such as Alembic.
-1
View File
@@ -1,7 +1,6 @@
FROM python:3.12-slim FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN mkdir -p /app/data
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
+3 -18
View File
@@ -1,25 +1,10 @@
import os import os
from pathlib import Path
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/cluedo.db") DATABASE_URL = os.environ["DATABASE_URL"]
engine_options = {"pool_pre_ping": True} engine = create_engine(DATABASE_URL, pool_pre_ping=True)
if DATABASE_URL.startswith("sqlite"):
# SQLite does not allow connections to be used from another thread by
# default. FastAPI/SQLAlchemy may use a connection across request threads.
engine_options["connect_args"] = {"check_same_thread": False}
# Make the parent directory available for the default local database.
if DATABASE_URL.startswith("sqlite:///"):
sqlite_path = DATABASE_URL.removeprefix("sqlite:///")
if sqlite_path not in (":memory:", ""):
Path(sqlite_path).expanduser().parent.mkdir(parents=True, exist_ok=True)
engine = create_engine(DATABASE_URL, **engine_options)
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase): class Base(DeclarativeBase):
@@ -31,4 +16,4 @@ def get_db():
yield db yield db
finally: finally:
db.close() db.close()
+22 -12
View File
@@ -1,3 +1,4 @@
import os
import random import random
import string import string
@@ -7,11 +8,11 @@ from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, SessionLocal from .db import Base, engine, SessionLocal
from .models import User, Entry, Category, Game, GameMember from .models import User, Entry, Category, Role, Game, GameMember
from .security import hash_password
from .routes.auth import router as auth_router from .routes.auth import router as auth_router
from .routes.admin import router as admin_router from .routes.admin import router as admin_router
from .routes.games import router as games_router from .routes.games import router as games_router
from .routes.setup import router as setup_router
app = FastAPI(title="Cluedo Sheet") app = FastAPI(title="Cluedo Sheet")
@@ -30,7 +31,6 @@ app.add_middleware(
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(games_router) app.include_router(games_router)
app.include_router(setup_router)
def _rand_join_code(n: int = 6) -> str: def _rand_join_code(n: int = 6) -> str:
@@ -136,14 +136,6 @@ Very small, pragmatic auto-migration (no alembic).
except Exception: except Exception:
db.rollback() db.rollback()
# started_at: games are open for joining until the host starts them
if not _has_column(db, "games", "started_at"):
try:
db.execute(text("ALTER TABLE games ADD COLUMN started_at DATETIME"))
db.commit()
except Exception:
db.rollback()
# host_user_id (nice to have for "only host can set winner") # host_user_id (nice to have for "only host can set winner")
if not _has_column(db, "games", "host_user_id"): if not _has_column(db, "games", "host_user_id"):
try: try:
@@ -284,6 +276,23 @@ def seed_entries(db: Session):
db.commit() db.commit()
def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first()
if not u:
db.add(
User(
email=admin_email,
password_hash=hash_password(admin_pw),
role=Role.admin.value,
theme_key="default",
display_name="Admin",
)
)
db.commit()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
# create new tables # create new tables
@@ -292,7 +301,8 @@ 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()
+1 -12
View File
@@ -54,17 +54,6 @@ class Game(Base):
code: Mapped[str] = mapped_column(String, unique=True, index=True) code: Mapped[str] = mapped_column(String, unique=True, index=True)
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True) winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
started_at: Mapped[str | None] = mapped_column(DateTime(timezone=True), nullable=True)
class GameChip(Base):
__tablename__ = "game_chips"
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_chip_user"),)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
chip: Mapped[str] = mapped_column(String)
class GameMember(Base): class GameMember(Base):
@@ -97,4 +86,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)
+3 -83
View File
@@ -1,12 +1,8 @@
import hashlib import hashlib, random
import random
import re
import unicodedata
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import Game, GameChip, Entry, SheetState, Category, GameMember, User, Role from ..models import Game, Entry, SheetState, Category, GameMember, User, Role
from ..security import get_session_user_id from ..security import get_session_user_id
router = APIRouter(prefix="/games", tags=["games"]) router = APIRouter(prefix="/games", tags=["games"])
@@ -31,25 +27,6 @@ def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n)) return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def make_user_chip(user: User) -> str:
"""Create first-name initial + first two surname letters, e.g. SNE."""
display_name = (user.display_name or "").strip()
if not display_name:
display_name = (user.email or "").split("@", 1)[0].replace(".", " ")
parts = re.split(r"\s+", display_name)
first = parts[0] if parts else "X"
last = parts[-1] if len(parts) > 1 else first
def letters(value: str) -> str:
normalized = unicodedata.normalize("NFKD", value)
return "".join(c for c in normalized if c.isalpha())
first_letters = letters(first).upper() or "X"
last_letters = letters(last).upper() or "X"
return (first_letters[0] + last_letters[:2]).ljust(3, "X")
def ensure_member(db: Session, game_id: str, user_id: str): def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first() ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex: if ex:
@@ -101,8 +78,6 @@ def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
g = db.query(Game).filter(Game.code == code).first() g = db.query(Game).filter(Game.code == code).first()
if not g: if not g:
raise HTTPException(404, "game not found") raise HTTPException(404, "game not found")
if g.started_at:
raise HTTPException(400, "game already started")
ensure_member(db, g.id, uid) ensure_member(db, g.id, uid)
return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id} return {"ok": True, "id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
@@ -137,7 +112,6 @@ def list_games(req: Request, db: Session = Depends(get_db)):
"host_user_id": g.host_user_id, "host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"started": bool(g.started_at),
} }
) )
return out return out
@@ -165,63 +139,9 @@ def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
"winner_user_id": g.winner_user_id, "winner_user_id": g.winner_user_id,
"winner_email": winner_email, "winner_email": winner_email,
"winner_display_name": winner_display_name, "winner_display_name": winner_display_name,
"started": bool(g.started_at),
"started_at": g.started_at,
} }
@router.post("/{game_id}/start")
def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if g.host_user_id != uid:
raise HTTPException(403, "only host can start the game")
if not g.started_at:
members = (
db.query(User)
.join(GameMember, GameMember.user_id == User.id)
.filter(GameMember.game_id == game_id, User.role == Role.user.value, User.disabled == False)
.order_by(User.email.asc())
.all()
)
used = set()
for member in members:
base = make_user_chip(member)
chip = base
suffix = 2
while chip in used:
chip = f"{base[:2]}{suffix}"
suffix += 1
used.add(chip)
db.add(GameChip(game_id=game_id, user_id=member.id, chip=chip))
g.started_at = datetime.now(timezone.utc)
db.add(g)
db.commit()
return {"ok": True, "started": True}
@router.get("/{game_id}/chips")
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if not g.started_at:
return []
chips = (
db.query(GameChip, User)
.join(User, User.id == GameChip.user_id)
.filter(GameChip.game_id == game_id)
.order_by(User.display_name.asc(), User.email.asc())
.all()
)
return [{"user_id": chip.user_id, "chip": chip.chip, "display_name": user.display_name, "email": user.email} for chip, user in chips]
@router.get("/{game_id}/members") @router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)): def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
@@ -357,4 +277,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
@@ -1,58 +0,0 @@
import re
from fastapi import APIRouter, Depends, HTTPException, Response
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import Role, User
from ..security import hash_password, make_session_value, set_session
router = APIRouter(prefix="/setup", tags=["setup"])
def _has_admin(db: Session) -> bool:
return db.query(User).filter(User.role == Role.admin.value).first() is not None
@router.get("/status")
def setup_status(db: Session = Depends(get_db)):
return {"setup_required": not _has_admin(db)}
@router.post("/admin")
def create_initial_admin(
data: dict,
resp: Response,
db: Session = Depends(get_db),
):
# The setup endpoint is only open until the first admin exists.
if _has_admin(db):
raise HTTPException(status_code=409, detail="setup already completed")
email = (data.get("email") or "").lower().strip()
display_name = (data.get("display_name") or "").strip()
password = data.get("password") or ""
if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email):
raise HTTPException(status_code=400, detail="valid email required")
if len(password) < 8:
raise HTTPException(status_code=400, detail="password too short (min 8)")
if not display_name:
display_name = email.split("@", 1)[0]
if db.query(User).filter(User.email == email).first():
raise HTTPException(status_code=409, detail="email exists")
user = User(
email=email,
password_hash=hash_password(password),
role=Role.admin.value,
display_name=display_name,
)
db.add(user)
db.commit()
db.refresh(user)
# Log the installer in immediately after successful setup.
set_session(resp, make_session_value(user.id))
return {"ok": True, "id": user.id, "email": user.email}
+1
View File
@@ -1,6 +1,7 @@
fastapi==0.115.0 fastapi==0.115.0
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
SQLAlchemy==2.0.34 SQLAlchemy==2.0.34
psycopg[binary]==3.2.2
python-multipart==0.0.9 python-multipart==0.0.9
passlib==1.7.4 passlib==1.7.4
+21 -4
View File
@@ -1,13 +1,30 @@
services: services:
db:
image: postgres:16
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
backend: backend:
build: ./backend build: ./backend
environment: environment:
DATABASE_URL: sqlite:////app/data/cluedo.db DATABASE_URL: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
SECRET_KEY: ${BACKEND_SECRET_KEY} SECRET_KEY: ${BACKEND_SECRET_KEY}
ADMIN_EMAIL: ${ADMIN_EMAIL}
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true COOKIE_SECURE: "false" # intern ohne https; wenn du später https machst -> true
COOKIE_SAMESITE: "Lax" COOKIE_SAMESITE: "Lax"
volumes: depends_on:
- cluedo-data:/app/data db:
condition: service_healthy
ports: ports:
- "8080:8080" - "8080:8080"
@@ -19,4 +36,4 @@ services:
- "8081:80" - "8081:80"
volumes: volumes:
cluedo-data: pgdata:
+1 -6
View File
@@ -7,12 +7,7 @@ server {
} }
location /api/ { location /api/ {
# Resolve the Docker service at request time. This prevents Nginx from proxy_pass http://backend:8080/;
# exiting when the Docker DNS entry is not ready during container startup.
resolver 127.0.0.11 ipv6=off valid=10s;
set $backend_upstream http://backend:8080;
rewrite ^/api/(.*)$ /$1 break;
proxy_pass $backend_upstream;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+617 -551
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;
}
}
+5 -6
View File
@@ -89,25 +89,24 @@ 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 className="hp-admin-name" style={{ color: stylesTokens.textMain, fontWeight: 900 }}> <div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"} {u.display_name || "—"}
</div> </div>
<div className="hp-admin-email" style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div> <div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div className="hp-admin-role" style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}> <div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role} {u.role}
</div> </div>
<div className={`hp-admin-status ${u.disabled ? "hp-admin-status--disabled" : ""}`} style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}> <div 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,
+5 -10
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip, chips = [] }) { export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) {
if (!chipOpen) return null; if (!chipOpen) return null;
return ( return (
@@ -18,19 +19,13 @@ export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip,
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div> <div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}> <div style={styles.chipGrid}>
{chips.map((item) => ( {CHIP_LIST.map((c) => (
<button key={item.user_id || item.chip} onClick={() => chooseChip(item.chip)} style={styles.chipBtn} title={item.display_name || item.email}> <button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
{item.chip} {c}
</button> </button>
))} ))}
</div> </div>
{!chips.length && (
<div style={{ marginTop: 12, color: stylesTokens.textDim }}>
Das Spiel wurde noch nicht gestartet oder es sind keine Spieler-Chips vorhanden.
</div>
)}
<div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}> <div style={{ marginTop: 12, fontSize: 12, color: stylesTokens.textDim }}>
Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf . Tipp: Wenn du wieder auf den Notiz-Button klickst, gehts von <b>s</b> zurück auf .
</div> </div>
+147
View File
@@ -0,0 +1,147 @@
// frontend/src/components/Dice/Dice3D.jsx
import React from "react";
export const cubeRotationForD6 = (value) => {
switch (value) {
case 1: return { rx: 0, ry: 0 };
case 2: return { rx: -90, ry: 0 };
case 3: return { rx: 0, ry: -90 };
case 4: return { rx: 0, ry: 90 };
case 5: return { rx: 90, ry: 0 };
case 6: return { rx: 0, ry: 180 };
default: return { rx: 0, ry: 0 };
}
};
export const PipFace = ({ value }) => {
const pos = (gx, gy) => ({ left: `${gx * 50}%`, top: `${gy * 50}%` });
const faces = {
1: [[1, 1]],
2: [[0, 0], [2, 2]],
3: [[0, 0], [1, 1], [2, 2]],
4: [[0, 0], [2, 0], [0, 2], [2, 2]],
5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]],
6: [[0, 0], [0, 1], [0, 2], [2, 0], [2, 1], [2, 2]],
};
const arr = faces[value] || faces[1];
return (
<div className="pipGrid">
{arr.map(([x, y], idx) => (
<div
key={idx}
className="pip"
style={{ ...pos(x, y), transform: "translate(-50%, -50%)" }}
/>
))}
</div>
);
};
export const DieShell = ({ children, rolling = false, onClick }) => {
return (
<button
type="button"
onClick={onClick}
className="die3d"
style={{
width: 64,
height: 64,
borderRadius: 18,
border: "none",
background: "transparent",
boxShadow: "none",
position: "relative",
overflow: "visible",
display: "grid",
placeItems: "center",
cursor: rolling ? "default" : "pointer",
transition: "transform 160ms ease",
padding: 0,
outline: "none",
}}
disabled={rolling}
>
<div style={{ position: "relative", zIndex: 2 }}>{children}</div>
</button>
);
};
export const DieD6 = ({ rolling, onClick, ax, ay, onDone, snap = false }) => {
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
<div className="dieFace front"><PipFace value={1} /></div>
<div className="dieFace back"><PipFace value={6} /></div>
<div className="dieFace top"><PipFace value={2} /></div>
<div className="dieFace bottom"><PipFace value={5} /></div>
<div className="dieFace right"><PipFace value={3} /></div>
<div className="dieFace left"><PipFace value={4} /></div>
</div>
</div>
</DieShell>
);
};
export const HouseDie = ({ face, rolling, onClick, ax, ay, onDone, snap = false }) => {
const faces = {
gryffindor: { icon: "🦁", color: "#ef4444" },
slytherin: { icon: "🐍", color: "#22c55e" },
ravenclaw: { icon: "🦅", color: "#3b82f6" },
hufflepuff: { icon: "🦡", color: "#facc15" },
help: { icon: "🃏", color: "#f2d27a" },
dark: { icon: "🌙", color: "rgba(255,255,255,0.85)" },
};
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
return (
<DieShell rolling={rolling} onClick={onClick}>
<div className="dieCubeWrap">
<div
className={`dieCube ${rolling ? "rolling" : ""}`}
style={{ "--rx": ax, "--ry": ay }}
onTransitionEnd={(e) => {
if (e.propertyName !== "transform") return;
if (rolling) onDone?.();
}}
>
{order.map((key, i) => {
const item = faces[key];
const faceClass =
i === 0 ? "front" :
i === 1 ? "top" :
i === 2 ? "right" :
i === 3 ? "left" :
i === 4 ? "bottom" :
"back";
return (
<div key={key} className={`dieFace ${faceClass}`}>
<div
className="specialIcon"
style={{
color: item.color,
textShadow: `0 0 18px ${item.color}55, 0 10px 22px rgba(0,0,0,0.55)`,
}}
>
{item.icon}
</div>
</div>
);
})}
</div>
</div>
</DieShell>
);
};
+188
View File
@@ -0,0 +1,188 @@
// frontend/src/components/Dice/DicePanel.jsx
import React, { useEffect, useRef, useState } from "react";
import { stylesTokens } from "../../styles/theme";
import { cubeRotationForD6, DieD6, HouseDie } from "./Dice/Dice3D.jsx";
export default function DicePanel({ onRoll }) {
const LS_KEY = "hp_cluedo_dice_v1";
const [d1, setD1] = useState(4);
const [d2, setD2] = useState(2);
const [special, setSpecial] = useState("gryffindor");
const [a1, setA1] = useState({ x: 0, y: 90 });
const [a2, setA2] = useState({ x: -90, y: 0 });
const [as, setAs] = useState({ x: 0, y: 0 });
const [rolling, setRolling] = useState(false);
const [snap, setSnap] = useState(false);
const specialFaces = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const order = ["gryffindor", "slytherin", "ravenclaw", "hufflepuff", "help", "dark"];
const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
// roll bookkeeping
const pendingRef = useRef(null);
const rollIdRef = useRef(0);
const doneForRollRef = useRef({ d1: false, d2: false, s: false });
// restore last
useEffect(() => {
try {
const raw = localStorage.getItem(LS_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed?.d1) setD1(parsed.d1);
if (parsed?.d2) setD2(parsed.d2);
if (parsed?.special) setSpecial(parsed.special);
if (parsed?.d1) {
const r = cubeRotationForD6(parsed.d1);
setA1({ x: r.rx, y: r.ry });
}
if (parsed?.d2) {
const r = cubeRotationForD6(parsed.d2);
setA2({ x: r.rx, y: r.ry });
}
if (parsed?.special) {
const idx = Math.max(0, order.indexOf(parsed.special));
const r = cubeRotationForD6(idx + 1);
setAs({ x: r.rx, y: r.ry });
}
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
try {
localStorage.setItem(LS_KEY, JSON.stringify({ d1, d2, special }));
} catch {}
}, [d1, d2, special]);
const normalize360 = (n) => ((n % 360) + 360) % 360;
const rollTo = (currentAngles, targetBase) => {
const spinX = 720 + randInt(0, 2) * 360;
const spinY = 720 + randInt(0, 2) * 360;
const currX = normalize360(currentAngles.x);
const currY = normalize360(currentAngles.y);
const dx = targetBase.rx - currX;
const dy = targetBase.ry - currY;
return { x: currentAngles.x + spinX + dx, y: currentAngles.y + spinY + dy };
};
const rollAll = () => {
if (rolling) return;
const nd1 = randInt(1, 6);
const nd2 = randInt(1, 6);
const ns = specialFaces[randInt(0, specialFaces.length - 1)];
const r1 = cubeRotationForD6(nd1);
const r2 = cubeRotationForD6(nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(ns)) + 1);
pendingRef.current = { nd1, nd2, ns };
rollIdRef.current += 1;
doneForRollRef.current = { d1: false, d2: false, s: false };
setRolling(true);
setA1((cur) => rollTo(cur, r1));
setA2((cur) => rollTo(cur, r2));
setAs((cur) => rollTo(cur, rs));
};
const maybeCommit = () => {
const flags = doneForRollRef.current;
if (!flags.d1 || !flags.d2 || !flags.s) return;
const p = pendingRef.current;
if (!p) return;
// ✅ rolling beenden
setRolling(false);
// ✅ Ergebnis state setzen
setD1(p.nd1);
setD2(p.nd2);
setSpecial(p.ns);
// ✅ Winkel auf kleine Basis normalisieren (ohne Transition)
const r1 = cubeRotationForD6(p.nd1);
const r2 = cubeRotationForD6(p.nd2);
const rs = cubeRotationForD6(Math.max(0, order.indexOf(p.ns)) + 1);
setSnap(true);
setA1({ x: r1.rx, y: r1.ry });
setA2({ x: r2.rx, y: r2.ry });
setAs({ x: rs.rx, y: rs.ry });
// Snap nur 1 Frame aktiv lassen
requestAnimationFrame(() => {
requestAnimationFrame(() => setSnap(false));
});
pendingRef.current = null;
onRoll?.({ d1: p.nd1, d2: p.nd2, special: p.ns });
};
const onDoneD1 = () => {
if (!rolling) return;
if (doneForRollRef.current.d1) return;
doneForRollRef.current.d1 = true;
maybeCommit();
};
const onDoneD2 = () => {
if (!rolling) return;
if (doneForRollRef.current.d2) return;
doneForRollRef.current.d2 = true;
maybeCommit();
};
const onDoneS = () => {
if (!rolling) return;
if (doneForRollRef.current.s) return;
doneForRollRef.current.s = true;
maybeCommit();
};
return (
<div style={{ pointerEvents: "auto" }}>
<div style={{ display: "grid", gap: 8 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<div style={{ color: stylesTokens.textMain, fontWeight: 900, fontSize: 13 }}>Würfel</div>
<div style={{ color: stylesTokens.textDim, fontWeight: 900, fontSize: 11.5, letterSpacing: 0.7, opacity: 0.75 }}>
{rolling ? "ROLL…" : "READY"}
</div>
</div>
<div
className="diceRow3d"
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 10,
alignItems: "center",
justifyItems: "center",
overflow: "visible",
}}
>
<DieD6 rolling={rolling} onClick={rollAll} ax={a1.x} ay={a1.y} onDone={onDoneD1} />
<DieD6 rolling={rolling} onClick={rollAll} ax={a2.x} ay={a2.y} onDone={onDoneD2} />
<HouseDie face={special} rolling={rolling} onClick={rollAll} ax={as.x} ay={as.y} onDone={onDoneS} />
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 12, opacity: 0.95 }}>Klicken zum Rollen</div>
</div>
</div>
);
}
@@ -10,10 +10,6 @@ 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);
@@ -89,24 +85,6 @@ 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" }}>
+4 -77
View File
@@ -9,21 +9,7 @@ export default function LoginPage({
showPw, showPw,
setShowPw, setShowPw,
doLogin, doLogin,
setupRequired,
setupEmail,
setSetupEmail,
setupDisplayName,
setSetupDisplayName,
setupPassword,
setSetupPassword,
setupPasswordConfirm,
setSetupPasswordConfirm,
setupError,
setupSaving,
doSetup,
}) { }) {
const isSetup = setupRequired === true;
return ( return (
<div style={styles.loginPage}> <div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true"> <div style={styles.bgFixed} aria-hidden="true">
@@ -35,67 +21,9 @@ export default function LoginPage({
<div style={styles.loginCard}> <div style={styles.loginCard}>
<div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div> <div style={styles.loginTitle}>Zauber-Detektiv Notizbogen</div>
<div style={styles.loginSubtitle}> <div style={styles.loginSubtitle}>Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen</div>
{setupRequired === null
? "Initialisiere Anwendung …"
: isSetup
? "Richte den ersten Administrator ein"
: "Melde dich an, um dein Cluedo-Magie-Sheet zu öffnen"}
</div>
{isSetup ? ( <div style={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={{ 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}
@@ -132,11 +60,10 @@ 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}>
Your notes remain private every player only sees their own sheet. Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel.
</div> </div>
</div> </div>
</div> </div>
+5 -6
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 className="hp-topbar" style={styles.topBar}> <div style={styles.topBar}>
<div> <div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div> <div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
@@ -23,10 +23,9 @@ export default function TopBar({
</div> </div>
</div> </div>
<div className="hp-topbar-actions" style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu> <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div className="hp-user-menu-wrap" style={{ position: "relative" }}> <div style={{ position: "relative" }}>
<button <button
className="hp-topbar-user"
onClick={() => setUserMenuOpen((v) => !v)} onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn} style={styles.userBtn}
title="User Menü" title="User Menü"
@@ -37,7 +36,7 @@ export default function TopBar({
</button> </button>
{userMenuOpen && ( {userMenuOpen && (
<div className="hp-user-dropdown" style={styles.userDropdown}> <div style={styles.userDropdown}>
<div <div
style={{ style={{
padding: "10px 12px", padding: "10px 12px",
@@ -85,7 +84,7 @@ export default function TopBar({
)} )}
</div> </div>
<button className="hp-topbar-new" onClick={onOpenNewGame} style={styles.primaryBtn}> <button onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game New Game
</button> </button>
</div> </div>
+1
View File
@@ -1 +1,2 @@
export const API_BASE = "/api"; export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];
+1 -47
View File
@@ -2,8 +2,6 @@ import { useEffect } from "react";
import { stylesTokens } from "../theme"; import { stylesTokens } from "../theme";
import { applyTheme } from "../themes"; import { applyTheme } from "../themes";
export function useHpGlobalStyles() { export function useHpGlobalStyles() {
// Google Fonts // Google Fonts
useEffect(() => { useEffect(() => {
@@ -76,54 +74,10 @@ export function useHpGlobalStyles() {
color: ${stylesTokens.textMain}; color: ${stylesTokens.textMain};
} }
body { body {
overflow-x: hidden; overflow: hidden;
-webkit-overflow-scrolling: touch;
} }
#root { background: transparent; } #root { background: transparent; }
* { -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);
}, []); }, []);
+66 -75
View File
@@ -10,8 +10,8 @@ export const styles = {
shell: { shell: {
fontFamily: '"IM Fell English", system-ui', fontFamily: '"IM Fell English", system-ui',
padding: "22px 16px 42px", padding: 16,
maxWidth: 760, maxWidth: 680,
margin: "0 auto", margin: "0 auto",
}, },
@@ -22,38 +22,34 @@ export const styles = {
justifyContent: "space-between", justifyContent: "space-between",
alignItems: "center", alignItems: "center",
gap: 10, gap: 10,
padding: "14px 16px", padding: 12,
borderRadius: 18, borderRadius: 16,
background: `linear-gradient(135deg, rgba(31, 28, 32, 0.90), ${stylesTokens.panelBg})`, background: stylesTokens.panelBg,
border: `1px solid ${stylesTokens.headerBorder}`, border: `1px solid ${stylesTokens.panelBorder}`,
boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(14px) saturate(1.12)", backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(14px) saturate(1.12)",
}, },
card: { card: {
borderRadius: 20, borderRadius: 18,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid ${stylesTokens.panelBorder}`,
background: `linear-gradient(145deg, rgba(25, 24, 28, 0.88), ${stylesTokens.panelBg})`, background: "rgba(18, 18, 20, 0.50)",
boxShadow: "0 20px 48px rgba(0,0,0,0.46), inset 0 1px 0 rgba(255,255,255,0.07)", boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
backdropFilter: "blur(14px) saturate(1.08)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
cardBody: { cardBody: {
padding: "14px 16px", padding: 12,
display: "flex", display: "flex",
gap: 10, gap: 10,
alignItems: "center", alignItems: "center",
}, },
sectionHeader: { sectionHeader: {
padding: "13px 16px", padding: "11px 14px",
fontWeight: 1000, fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui', fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.5, letterSpacing: 1.0,
fontSize: 14,
color: stylesTokens.textGold, color: stylesTokens.textGold,
// WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert // WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert
@@ -61,16 +57,16 @@ export const styles = {
borderBottom: `1px solid ${stylesTokens.headerBorder}`, borderBottom: `1px solid ${stylesTokens.headerBorder}`,
textTransform: "uppercase", textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.72)", textShadow: "0 1px 0 rgba(0,0,0,0.6)",
}, },
row: { row: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 54px 68px", gridTemplateColumns: "1fr 54px 68px",
gap: 10, gap: 10,
padding: "13px 16px", padding: "12px 14px",
alignItems: "center", alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.10)", borderBottom: "1px solid rgba(233,216,166,0.08)",
borderLeft: "4px solid rgba(0,0,0,0)", borderLeft: "4px solid rgba(0,0,0,0)",
}, },
@@ -104,9 +100,9 @@ export const styles = {
tagBtn: { tagBtn: {
padding: "8px 0", padding: "8px 0",
fontWeight: 1000, fontWeight: 1000,
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
cursor: "pointer", cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
@@ -114,9 +110,9 @@ export const styles = {
helpBtn: { helpBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -126,31 +122,31 @@ export const styles = {
input: { input: {
width: "100%", width: "100%",
padding: "11px 12px", padding: "10px 12px",
borderRadius: 13, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(7,7,10,0.64)", background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 15, fontSize: 15,
}, },
primaryBtn: { primaryBtn: {
padding: "11px 15px", padding: "10px 12px",
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.34)`, border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))", background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
}, },
secondaryBtn: { secondaryBtn: {
padding: "10px 12px", padding: "10px 12px",
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
fontWeight: 900, fontWeight: 900,
cursor: "pointer", cursor: "pointer",
@@ -162,13 +158,12 @@ export const styles = {
position: "relative", position: "relative",
zIndex: 1, zIndex: 1,
marginTop: 14, marginTop: 14,
padding: 14, padding: 12,
borderRadius: 20, borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`, border: `1px solid rgba(233,216,166,0.14)`,
background: `linear-gradient(145deg, rgba(25, 24, 28, 0.86), ${stylesTokens.panelBg})`, background: "rgba(18, 18, 20, 0.40)",
boxShadow: "0 18px 42px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.06)", boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(14px) saturate(1.08)", backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(14px) saturate(1.08)",
}, },
adminTop: { adminTop: {
display: "flex", display: "flex",
@@ -186,7 +181,7 @@ export const styles = {
gap: 8, gap: 8,
padding: 10, padding: 10,
borderRadius: 12, borderRadius: 12,
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
border: `1px solid rgba(233,216,166,0.10)`, border: `1px solid rgba(233,216,166,0.10)`,
}, },
@@ -208,12 +203,12 @@ export const styles = {
modalCard: { modalCard: {
width: "min(560px, 100%)", width: "min(560px, 100%)",
borderRadius: 20, borderRadius: 18,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(145deg, rgba(27,25,30,0.98), rgba(11,11,14,0.98))", background: "rgba(12,12,14,0.96)",
boxShadow: "0 24px 70px rgba(0,0,0,0.78), inset 0 1px 0 rgba(255,255,255,0.07)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 16, padding: 14,
maxHeight: "calc(100dvh - 32px)", maxHeight: "calc(100vh - 32px)",
overflow: "auto", overflow: "auto",
}, },
modalHeader: { modalHeader: {
@@ -310,14 +305,13 @@ export const styles = {
width: "100%", width: "100%",
maxWidth: 420, maxWidth: 420,
padding: 26, padding: 26,
borderRadius: 24, borderRadius: 22,
position: "relative", position: "relative",
zIndex: 2, zIndex: 2,
border: `1px solid ${stylesTokens.headerBorder}`, border: `1px solid rgba(233,216,166,0.18)`,
background: `linear-gradient(145deg, rgba(29, 27, 31, 0.92), ${stylesTokens.panelBg})`, background: "rgba(18, 18, 20, 0.55)",
boxShadow: "0 24px 80px rgba(0,0,0,0.72), inset 0 1px 0 rgba(255,255,255,0.08)", boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
backdropFilter: "blur(16px) saturate(1.12)", backdropFilter: "blur(8px)",
WebkitBackdropFilter: "blur(16px) saturate(1.12)",
animation: "popIn 240ms ease-out", animation: "popIn 240ms ease-out",
color: stylesTokens.textMain, color: stylesTokens.textMain,
}, },
@@ -344,24 +338,24 @@ export const styles = {
}, },
loginInput: { loginInput: {
width: "100%", width: "100%",
padding: "11px 12px", padding: 10,
borderRadius: 13, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.22)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(7,7,10,0.64)", background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
outline: "none", outline: "none",
fontSize: 16, fontSize: 16,
}, },
loginBtn: { loginBtn: {
padding: "12px 15px", padding: "12px 14px",
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.34)`, border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(135deg, rgba(233,216,166,0.28), rgba(120,88,35,0.20))", background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
fontSize: 16, fontSize: 16,
cursor: "pointer", cursor: "pointer",
boxShadow: "0 8px 20px rgba(0,0,0,0.20), inset 0 1px 0 rgba(255,255,255,0.11)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
}, },
loginHint: { loginHint: {
marginTop: 18, marginTop: 18,
@@ -437,10 +431,7 @@ export const styles = {
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundRepeat: "no-repeat", backgroundRepeat: "no-repeat",
filter: "saturate(0.72) contrast(1.12) brightness(0.42) blur(1.2px)", filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
transform: "scale(1.015)",
backgroundColor: "rgba(8, 7, 12, 0.34)",
backgroundBlendMode: "multiply",
}, },
chipGrid: { chipGrid: {
@@ -452,9 +443,9 @@ export const styles = {
chipBtn: { chipBtn: {
padding: "10px 14px", padding: "10px 14px",
borderRadius: 13, borderRadius: 12,
border: "1px solid rgba(233,216,166,0.18)", border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.055)", background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold, color: stylesTokens.textGold,
fontWeight: 1000, fontWeight: 1000,
cursor: "pointer", cursor: "pointer",
@@ -466,7 +457,7 @@ export const styles = {
alignItems: "center", alignItems: "center",
gap: 8, gap: 8,
padding: "10px 12px", padding: "10px 12px",
borderRadius: 14, borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)", background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain, color: stylesTokens.textMain,
@@ -483,11 +474,11 @@ export const styles = {
minWidth: 220, minWidth: 220,
borderRadius: 14, borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`, border: `1px solid rgba(233,216,166,0.18)`,
background: "linear-gradient(145deg, rgba(29,27,33,0.98), rgba(10,10,13,0.96))", background: "linear-gradient(180deg, rgba(20,20,24,0.96), rgba(12,12,14,0.92))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)", boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden", overflow: "hidden",
zIndex: 99999, zIndex: 99999,
backdropFilter: "blur(14px)", backdropFilter: "blur(8px)",
}, },
userDropdownItem: { userDropdownItem: {
@@ -505,4 +496,4 @@ export const styles = {
height: 1, height: 1,
background: "rgba(233,216,166,0.12)", background: "rgba(233,216,166,0.12)",
}, },
}; };