Compare commits

...
6 Commits
Author SHA1 Message Date
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
17 changed files with 580 additions and 143 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]
+216 -1
View File
@@ -1,2 +1,217 @@
# 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
- 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`
- `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()
+4 -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:
@@ -276,23 +276,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 +284,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()
+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;
+55 -2
View File
@@ -30,6 +30,13 @@ export default function App() {
// Auth/Login UI state // Auth/Login UI state
const [me, setMe] = useState(null); const [me, setMe] = useState(null);
const [setupRequired, setSetupRequired] = useState(null);
const [setupEmail, setSetupEmail] = useState("");
const [setupDisplayName, setSetupDisplayName] = useState("");
const [setupPassword, setSetupPassword] = useState("");
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState("");
const [setupError, setSetupError] = useState("");
const [setupSaving, setSetupSaving] = useState(false);
const [loginEmail, setLoginEmail] = useState(""); const [loginEmail, setLoginEmail] = useState("");
const [loginPassword, setLoginPassword] = useState(""); const [loginPassword, setLoginPassword] = useState("");
const [showPw, setShowPw] = useState(false); const [showPw, setShowPw] = useState(false);
@@ -182,8 +189,12 @@ export default function App() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const status = await api("/setup/status");
setSetupRequired(!!status.setup_required);
await load(); await load();
} catch {} } catch {
// The login/setup screen handles unauthenticated sessions.
}
})(); })();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -278,6 +289,36 @@ export default function App() {
await load(); await load();
}; };
const doSetup = async () => {
setSetupError("");
if (setupPassword.length < 8) {
setSetupError("Password must be at least 8 characters long.");
return;
}
if (setupPassword !== setupPasswordConfirm) {
setSetupError("Passwords do not match.");
return;
}
setSetupSaving(true);
try {
await api("/setup/admin", {
method: "POST",
body: JSON.stringify({
email: setupEmail,
display_name: setupDisplayName,
password: setupPassword,
}),
});
setSetupRequired(false);
await load();
} catch (e) {
setSetupError(e?.message || "Setup failed.");
} finally {
setSetupSaving(false);
}
};
const doLogout = async () => { const doLogout = async () => {
await api("/auth/logout", { method: "POST" }); await api("/auth/logout", { method: "POST" });
setMe(null); setMe(null);
@@ -540,6 +581,18 @@ export default function App() {
showPw={showPw} showPw={showPw}
setShowPw={setShowPw} setShowPw={setShowPw}
doLogin={doLogin} doLogin={doLogin}
setupRequired={setupRequired}
setupEmail={setupEmail}
setSetupEmail={setSetupEmail}
setupDisplayName={setupDisplayName}
setSetupDisplayName={setSetupDisplayName}
setupPassword={setupPassword}
setSetupPassword={setSetupPassword}
setupPasswordConfirm={setupPasswordConfirm}
setSetupPasswordConfirm={setSetupPasswordConfirm}
setupError={setupError}
setupSaving={setupSaving}
doSetup={doSetup}
/> />
); );
} }
@@ -567,7 +620,7 @@ export default function App() {
<div style={styles.bgMap} /> <div style={styles.bgMap} />
</div> </div>
<div style={styles.shell}> <div className="hp-shell" style={styles.shell}>
<TopBar <TopBar
me={me} me={me}
userMenuOpen={userMenuOpen} userMenuOpen={userMenuOpen}
+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,
+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>
+42 -1
View File
@@ -81,6 +81,47 @@ export function useHpGlobalStyles() {
} }
#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: flex-start !important; padding: 12px !important; flex-wrap: wrap !important; }
.hp-topbar-actions { width: 100%; margin-left: 0; justify-content: stretch; }
.hp-user-menu-wrap { flex: 1; min-width: 0; }
.hp-topbar-user, .hp-topbar-new { flex: 1; min-height: 44px; }
.hp-topbar-user { width: 100%; }
.hp-topbar-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: 8px !important; }
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
}, []); }, []);
@@ -89,4 +130,4 @@ export function useHpGlobalStyles() {
useEffect(() => { useEffect(() => {
applyTheme("default"); applyTheme("default");
}, []); }, []);
} }
+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)",
}, },
}; };