Compare commits

..
10 Commits
Author SHA1 Message Date
nessi 209f9c10dd Reduce winner celebration confetti intensity and duration for subtler animation effect
Simplified confetti animation by removing continuous rain effect and reducing burst parameters. Changed color palette to warmer gold/green tones. Decreased particle count from 170 to 55 per burst, reduced spread from 95° to 72°, lowered velocity from 42 to 28, and adjusted positioning. Added gravity, ticks, and scalar parameters for more controlled particle behavior. Shortened total celebration duration from 5500ms to 4200ms.
2026-08-02 11:47:16 +02:00
nessi d672648a05 Prevent winner selection from being overwritten by polling updates using dirty flag
Added `winnerSelectionDirtyRef` to track when user has manually changed winner selection. Modified `loadGameMeta` to skip updating `winnerUserId` state when dirty flag is set, preventing polling from overwriting user's unsaved selection. Introduced `selectWinner` helper that sets dirty flag on manual selection and clears flag after successful save. Reset dirty flag in all state cleanup handlers (goHome, logout, le
2026-08-02 11:38:08 +02:00
nessi ea08016ea5 Add dedicated home page with game list and welcome screen, replacing auto-selection behavior
Implemented HomePage component with centered welcome message, create/join button, and list of user's games with name/code display. Modified App.jsx to show HomePage when no game is selected instead of auto-selecting first game. Added `goHome` handler to reset all game state and return to home screen. Added home button (🏠) to GamePickerCard header next to help button with bilingual tooltip. Removed automatic
2026-08-02 10:58:38 +02:00
nessi c9eae136fb Allow QR code generation for both created games and joined games using current code
Extended QR code functionality to work with `currentCode` (joined games) in addition to `created.code` (created games). Updated QR generation effect to use fallback value and added dependencies. Moved QR toggle button and display from created-game-only section to shared code display area, enabling QR codes for all game participants.
2026-08-02 10:55:42 +02:00
nessi ee8ae8829e Add QR code generation and scanning for game join codes with camera integration
Implemented QR code generation for created games using qrcode library with custom dark/light color scheme. Added camera-based QR scanner using BarcodeDetector API with environment-facing camera preference, automatic code extraction, and 350ms polling interval. Added toggle buttons for QR display/scanner with bilingual labels, video preview with styled container, and fallback error handling for unsupported browsers or denied
2026-08-02 10:52:37 +02:00
nessi b20056913b Add game cancellation feature with cascade deletion and host-only access control
Implemented DELETE `/games/{game_id}` endpoint allowing hosts to cancel games with cascade deletion of sheet states, chips, and memberships. Added `cancelGame` handler in App.jsx to delete game, refresh game list, reset state, and show confirmation snack. Added cancel button to GamePickerCard lobby section (pre-start) and started section (non-finished games) with bilingual confirmation dialog, loading state, and error handling.
2026-08-02 10:46:38 +02:00
nessi 565d538c35 Add permanent user deletion endpoint with cascade cleanup and trash icon button in admin panel
Implemented DELETE `/admin/users/{user_id}/permanent` endpoint with safeguards preventing self-deletion and blocking deletion of game hosts. Added cascade deletion of user's game memberships, chips, sheet states, and invite tokens, with winner references nullified. Added trash icon button (🗑) to admin user rows with bilingual confirmation dialog and error handling for game ownership conflicts. Adjusted
2026-08-02 10:44:40 +02:00
nessi 45fc8e4c9e Improve email template readability with explicit text colors and dark background for invite body content 2026-08-02 10:42:13 +02:00
nessi e7d288ff12 Add SMTP security mode selector with SSL/STARTTLS/none options and automatic port switching
Replaced boolean `smtp_use_tls` with explicit `smtp_security` enum field supporting "none", "starttls", and "ssl" modes. Added database migration to create `smtp_security` column with "starttls" default. Updated mailer to use SMTP_SSL client for direct SSL connections on port 465 and SMTP with STARTTLS for port 587. Modified admin settings UI to show dropdown selector with encryption options and auto-adjust
2026-08-02 10:38:50 +02:00
nessi 33caa8f792 Add entry label translation system for German-to-English game sheet items
Implemented `translateEntryLabel` helper function with mapping dictionary for translating German game entry labels (spells, potions, locations) to English equivalents. Added language parameter extraction in SheetSection component and applied translation to entry labels based on current language setting. Maintains German as source labels with English translations provided via lookup table.
2026-08-02 10:36:44 +02:00
17 changed files with 335 additions and 60 deletions
+10 -6
View File
@@ -23,9 +23,13 @@ def send_html_email(settings: AppSettings, recipient: str, subject: str, body_ht
message.add_alternative(body_html, subtype="html") message.add_alternative(body_html, subtype="html")
context = ssl.create_default_context() context = ssl.create_default_context()
with smtplib.SMTP(settings.smtp_host, settings.smtp_port or 587, timeout=20) as server: security = getattr(settings, "smtp_security", "") or ("starttls" if settings.smtp_use_tls else "none")
port = settings.smtp_port or (465 if security == "ssl" else 587)
smtp_client = smtplib.SMTP_SSL if security == "ssl" else smtplib.SMTP
connection = smtp_client(settings.smtp_host, port, timeout=20, context=context) if security == "ssl" else smtp_client(settings.smtp_host, port, timeout=20)
with connection as server:
server.ehlo() server.ehlo()
if settings.smtp_use_tls: if security == "starttls":
server.starttls(context=context) server.starttls(context=context)
server.ehlo() server.ehlo()
if settings.smtp_username: if settings.smtp_username:
@@ -46,10 +50,10 @@ def send_user_invite(settings: AppSettings, user: User, invite_url: str):
<div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div> <div style="font-size:13px;letter-spacing:3px;text-transform:uppercase;color:#cbb982;">{from_name}</div>
<div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div> <div style="margin-top:10px;font-size:28px;font-weight:bold;color:#e9d8a6;">Eine Einladung wartet</div>
</div> </div>
<div style="padding:30px;line-height:1.55;"> <div style="padding:30px;line-height:1.55;color:#f5efdc !important;background:#17161b;">
<div style="font-size:20px;color:#e9d8a6;">Hallo {name},</div> <div style="font-size:20px;color:#e9d8a6 !important;">Hallo {name},</div>
<p>du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p> <p style="color:#f5efdc !important;margin:16px 0;">du wurdest eingeladen, dem digitalen Zauber-Detektiv-Notizbogen beizutreten.</p>
<p>Richte über den folgenden Button dein persönliches Passwort ein:</p> <p style="color:#f5efdc !important;margin:16px 0;">Richte über den folgenden Button dein persönliches Passwort ein:</p>
<p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p> <p style="text-align:center;margin:28px 0;"><a href="{safe_url}" style="display:inline-block;padding:14px 24px;border-radius:12px;background:#b69a5c;color:#171319;text-decoration:none;font-weight:bold;">Einladung annehmen</a></p>
<p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p> <p style="font-size:13px;color:#b8ae98;">Der Link ist 48 Stunden gültig und kann nur einmal verwendet werden.</p>
<p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p> <p style="font-size:12px;color:#8e8777;word-break:break-all;">{safe_url}</p>
+8
View File
@@ -88,6 +88,14 @@ Very small, pragmatic auto-migration (no alembic).
- supports old schema (join_code/chip_code) and new schema (code/chip) - supports old schema (join_code/chip_code) and new schema (code/chip)
""" """
# --- app settings: explicit SMTP security mode (none/starttls/ssl) ---
if not _has_column(db, "app_settings", "smtp_security"):
try:
db.execute(text("ALTER TABLE app_settings ADD COLUMN smtp_security VARCHAR DEFAULT 'starttls'"))
db.commit()
except Exception:
db.rollback()
# --- users.display_name --- # --- users.display_name ---
if not _has_column(db, "users", "display_name"): if not _has_column(db, "users", "display_name"):
try: try:
+1
View File
@@ -52,6 +52,7 @@ class AppSettings(Base):
smtp_from_email: Mapped[str] = mapped_column(String, default="") smtp_from_email: Mapped[str] = mapped_column(String, default="")
smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP") smtp_from_name: Mapped[str] = mapped_column(String, default="Cluedo HP")
smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True) smtp_use_tls: Mapped[bool] = mapped_column(Boolean, default=True)
smtp_security: Mapped[str] = mapped_column(String, default="starttls")
app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081") app_base_url: Mapped[str] = mapped_column(String, default="http://localhost:8081")
+29 -2
View File
@@ -6,7 +6,7 @@ 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 ..mailer import send_html_email, send_user_invite from ..mailer import send_html_email, send_user_invite
from ..models import AppSettings, InviteToken, User, Role from ..models import AppSettings, Game, GameChip, GameMember, InviteToken, SheetState, User, Role
from ..security import hash_password, get_session_user_id from ..security import hash_password, get_session_user_id
router = APIRouter(prefix="/admin", tags=["admin"]) router = APIRouter(prefix="/admin", tags=["admin"])
@@ -84,6 +84,28 @@ def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
db.commit() db.commit()
return {"ok": True, "id": u.id, "invite_sent": invite_sent} return {"ok": True, "id": u.id, "invite_sent": invite_sent}
@router.delete("/users/{user_id}/permanent")
def permanently_delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
admin = require_admin(req, db)
if admin.id == user_id:
raise HTTPException(400, "cannot delete yourself")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(404, "not found")
if db.query(Game).filter(Game.host_user_id == user_id).first():
raise HTTPException(409, "cannot delete a user who owns games")
db.query(Game).filter(Game.winner_user_id == user_id).update({Game.winner_user_id: None}, synchronize_session=False)
db.query(GameMember).filter(GameMember.user_id == user_id).delete(synchronize_session=False)
db.query(GameChip).filter(GameChip.user_id == user_id).delete(synchronize_session=False)
db.query(SheetState).filter(SheetState.owner_user_id == user_id).delete(synchronize_session=False)
db.query(InviteToken).filter(InviteToken.user_id == user_id).delete(synchronize_session=False)
db.delete(user)
db.commit()
return {"ok": True, "deleted": True}
@router.delete("/users/{user_id}") @router.delete("/users/{user_id}")
def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)): def delete_user(req: Request, user_id: str, db: Session = Depends(get_db)):
admin = require_admin(req, db) admin = require_admin(req, db)
@@ -163,6 +185,7 @@ def read_smtp_settings(req: Request, db: Session = Depends(get_db)):
"smtp_password_configured": bool(settings.smtp_password), "smtp_password_configured": bool(settings.smtp_password),
"smtp_from_email": settings.smtp_from_email, "smtp_from_email": settings.smtp_from_email,
"smtp_from_name": settings.smtp_from_name, "smtp_from_name": settings.smtp_from_name,
"smtp_security": getattr(settings, "smtp_security", "starttls") or ("starttls" if settings.smtp_use_tls else "none"),
"smtp_use_tls": settings.smtp_use_tls, "smtp_use_tls": settings.smtp_use_tls,
"app_base_url": settings.app_base_url, "app_base_url": settings.app_base_url,
} }
@@ -181,7 +204,11 @@ def update_smtp_settings(req: Request, data: dict, db: Session = Depends(get_db)
settings.smtp_username = (data.get("smtp_username") or "").strip() settings.smtp_username = (data.get("smtp_username") or "").strip()
settings.smtp_from_email = (data.get("smtp_from_email") or "").strip() settings.smtp_from_email = (data.get("smtp_from_email") or "").strip()
settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip() settings.smtp_from_name = (data.get("smtp_from_name") or "Cluedo HP").strip()
settings.smtp_use_tls = bool(data.get("smtp_use_tls", True)) security = data.get("smtp_security") or ("starttls" if data.get("smtp_use_tls", True) else "none")
if security not in ("none", "starttls", "ssl"):
raise HTTPException(400, "invalid SMTP security mode")
settings.smtp_security = security
settings.smtp_use_tls = security == "starttls"
settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/") settings.app_base_url = (data.get("app_base_url") or "http://localhost:8081").strip().rstrip("/")
if "smtp_password" in data and data.get("smtp_password"): if "smtp_password" in data and data.get("smtp_password"):
settings.smtp_password = data["smtp_password"] settings.smtp_password = data["smtp_password"]
+16
View File
@@ -205,6 +205,22 @@ def start_game(req: Request, game_id: str, db: Session = Depends(get_db)):
return {"ok": True, "started": True} return {"ok": True, "started": True}
@router.delete("/{game_id}")
def cancel_game(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
if g.host_user_id != uid:
raise HTTPException(403, "only host can cancel the game")
# Remove dependent game state before deleting the game itself.
db.query(SheetState).filter(SheetState.game_id == game_id).delete(synchronize_session=False)
db.query(GameChip).filter(GameChip.game_id == game_id).delete(synchronize_session=False)
db.query(GameMember).filter(GameMember.game_id == game_id).delete(synchronize_session=False)
db.delete(g)
db.commit()
return {"ok": True, "cancelled": True}
@router.get("/{game_id}/chips") @router.get("/{game_id}/chips")
def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)): def list_game_chips(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
+2 -1
View File
@@ -10,7 +10,8 @@
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"canvas-confetti": "^1.9.3" "canvas-confetti": "^1.9.3",
"qrcode": "^1.5.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",
+53 -2
View File
@@ -27,6 +27,7 @@ import NewGameModal from "./components/NewGameModal";
import StatsModal from "./components/StatsModal"; import StatsModal from "./components/StatsModal";
import AdminSettingsModal from "./components/AdminSettingsModal"; import AdminSettingsModal from "./components/AdminSettingsModal";
import InvitePage from "./components/InvitePage"; import InvitePage from "./components/InvitePage";
import HomePage from "./components/HomePage";
import { useLanguage } from "./i18n"; import { useLanguage } from "./i18n";
export default function App() { export default function App() {
@@ -59,6 +60,7 @@ export default function App() {
// Winner selection (host only) // Winner selection (host only)
const [winnerUserId, setWinnerUserId] = useState(""); const [winnerUserId, setWinnerUserId] = useState("");
const winnerSelectionDirtyRef = useRef(false);
// Modals // Modals
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
@@ -134,7 +136,7 @@ export default function App() {
const gs = await api("/games"); const gs = await api("/games");
setGames(gs); setGames(gs);
if (gs[0] && !gameId) setGameId(gs[0].id); // Always open on the start page; the user explicitly chooses a game.
}; };
const reloadSheet = async () => { const reloadSheet = async () => {
@@ -148,7 +150,9 @@ export default function App() {
const meta = await api(`/games/${gameId}`); const meta = await api(`/games/${gameId}`);
setGameMeta(meta); setGameMeta(meta);
if (!winnerSelectionDirtyRef.current) {
setWinnerUserId(meta?.winner_user_id || ""); setWinnerUserId(meta?.winner_user_id || "");
}
const chips = meta?.started ? await api(`/games/${gameId}/chips`) : []; const chips = meta?.started ? await api(`/games/${gameId}/chips`) : [];
setGameChips(chips || []); setGameChips(chips || []);
@@ -225,6 +229,7 @@ export default function App() {
lastWinnerIdRef.current = null; lastWinnerIdRef.current = null;
gameStartBaselineRef.current = false; gameStartBaselineRef.current = false;
lastGameStartedRef.current = false; lastGameStartedRef.current = false;
winnerSelectionDirtyRef.current = false;
setCelebrateOpen(false); setCelebrateOpen(false);
setCelebrateName(""); setCelebrateName("");
setStartCelebrateOpen(false); setStartCelebrateOpen(false);
@@ -369,6 +374,7 @@ export default function App() {
setMembers([]); setMembers([]);
setGameChips([]); setGameChips([]);
setWinnerUserId(""); setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
// reset winner celebration on logout // reset winner celebration on logout
winnerBaselineRef.current = false; winnerBaselineRef.current = false;
@@ -477,6 +483,7 @@ export default function App() {
setMembers([]); setMembers([]);
setGameChips([]); setGameChips([]);
setWinnerUserId(""); setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
setPulseId(null); setPulseId(null);
// auch Chip-Modal-State resetten // auch Chip-Modal-State resetten
@@ -523,6 +530,38 @@ export default function App() {
await loadGameMeta(); await loadGameMeta();
}; };
const cancelGame = async () => {
if (!gameId || !isHost) return;
await api(`/games/${gameId}`, { method: "DELETE" });
const nextGames = await api("/games");
setGames(nextGames);
setGameId(null);
setSheet(null);
setGameMeta(null);
setMembers([]);
setGameChips([]);
setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
setChipOpen(false);
setChipEntry(null);
showSnack(language === "en" ? "Game cancelled." : "Spiel abgebrochen.");
};
const goHome = () => {
setGameId(null);
setSheet(null);
setGameMeta(null);
setMembers([]);
setGameChips([]);
setWinnerUserId("");
winnerSelectionDirtyRef.current = false;
setChipOpen(false);
setChipEntry(null);
setCelebrateOpen(false);
setCelebrateName("");
setStartCelebrateOpen(false);
};
// ===== Winner ===== // ===== Winner =====
const saveWinner = async () => { const saveWinner = async () => {
if (!gameId) return; if (!gameId) return;
@@ -530,9 +569,15 @@ export default function App() {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ winner_user_id: winnerUserId || null }), body: JSON.stringify({ winner_user_id: winnerUserId || null }),
}); });
winnerSelectionDirtyRef.current = false;
await loadGameMeta(); await loadGameMeta();
}; };
const selectWinner = (value) => {
winnerSelectionDirtyRef.current = true;
setWinnerUserId(value);
};
// ===== Sheet actions ===== // ===== Sheet actions =====
const cycleStatus = async (entry) => { const cycleStatus = async (entry) => {
if (gameMeta?.winner_user_id) return; if (gameMeta?.winner_user_id) return;
@@ -719,6 +764,9 @@ export default function App() {
<AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} /> <AdminSettingsModal open={adminSettingsOpen} onClose={() => setAdminSettingsOpen(false)} />
)} )}
{!gameId ? (
<HomePage games={games} onOpenNewGame={() => setNewGameOpen(true)} onOpenGame={setGameId} />
) : <>
<GamePickerCard <GamePickerCard
games={games} games={games}
gameId={gameId} gameId={gameId}
@@ -733,6 +781,8 @@ export default function App() {
winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""} winnerName={gameMeta?.winner_display_name || gameMeta?.winner_email || ""}
chipCount={gameChips.length} chipCount={gameChips.length}
onStartGame={startGame} onStartGame={startGame}
onCancelGame={cancelGame}
onGoHome={goHome}
/> />
{gameStarted && ( {gameStarted && (
@@ -743,6 +793,7 @@ export default function App() {
}} }}
/> />
)} )}
</>}
<HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} /> <HelpModal open={helpOpen} onClose={() => setHelpOpen(false)} />
@@ -769,7 +820,7 @@ export default function App() {
isHost={isHost} isHost={isHost}
members={members} members={members}
winnerUserId={winnerUserId} winnerUserId={winnerUserId}
setWinnerUserId={setWinnerUserId} setWinnerUserId={selectWinner}
onSave={saveWinner} onSave={saveWinner}
/> />
)} )}
+14
View File
@@ -106,6 +106,19 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
} }
}; };
const permanentlyDeleteUser = async (user) => {
if (user.id === currentUserId) return;
const label = user.display_name || user.email;
if (!window.confirm(language === "en" ? `Delete ${label} permanently? This cannot be undone.` : `${label} wirklich dauerhaft löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.`)) return;
try {
await api(`/admin/users/${user.id}/permanent`, { method: "DELETE" });
await loadUsers();
setNotice(language === "en" ? "✅ User permanently deleted." : "✅ User dauerhaft gelöscht.");
} catch (e) {
alert((language === "en" ? "Error: " : "Fehler: ") + (e?.message || "unknown"));
}
};
if (!dashboardOpen) return null; if (!dashboardOpen) return null;
return createPortal( return createPortal(
@@ -136,6 +149,7 @@ export default function AdminPanel({ open: dashboardOpen = false, onClose, curre
<div className="hp-admin-actions"> <div className="hp-admin-actions">
<button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></button> <button className="hp-admin-action hp-admin-icon-action" onClick={() => openEdit(user)} style={styles.secondaryBtn} title={t("edit")} aria-label={`${user.display_name || user.email} ${t("edit")}`}></button>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button> <button className="hp-admin-action hp-admin-icon-action" onClick={() => disableUser(user)} disabled={user.id === currentUserId || user.disabled} style={{ ...styles.secondaryBtn, color: "#ffb3b3" }} title={user.disabled ? t("disabled") : t("disable")} aria-label={`${user.display_name || user.email} ${t("disable")}`}></button>
<button className="hp-admin-action hp-admin-icon-action" onClick={() => permanentlyDeleteUser(user)} disabled={user.id === currentUserId} style={{ ...styles.secondaryBtn, color: "#ff8f9b" }} title={language === "en" ? "Delete permanently" : "Dauerhaft löschen"} aria-label={`${user.display_name || user.email} ${language === "en" ? "delete permanently" : "dauerhaft löschen"}`}>🗑</button>
</div> </div>
</div> </div>
))} ))}
@@ -7,7 +7,7 @@ import { useLanguage } from "../i18n";
const initial = { const initial = {
smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password: "", smtp_host: "", smtp_port: 587, smtp_username: "", smtp_password: "",
smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_use_tls: true, smtp_from_email: "", smtp_from_name: "Cluedo HP", smtp_security: "starttls",
app_base_url: window.location.origin, app_base_url: window.location.origin,
}; };
@@ -72,7 +72,11 @@ export default function AdminSettingsModal({ open, onClose }) {
<label style={styles.adminFieldLabel}>{t("senderName")}<input value={form.smtp_from_name} onChange={(e) => setField("smtp_from_name", e.target.value)} style={styles.input} /></label> <label style={styles.adminFieldLabel}>{t("senderName")}<input value={form.smtp_from_name} onChange={(e) => setField("smtp_from_name", e.target.value)} style={styles.input} /></label>
</div> </div>
<label style={styles.adminFieldLabel}>{t("appUrl")}<input value={form.app_base_url} onChange={(e) => setField("app_base_url", e.target.value)} placeholder="https://notizbogen.example.com" style={styles.input} /></label> <label style={styles.adminFieldLabel}>{t("appUrl")}<input value={form.app_base_url} onChange={(e) => setField("app_base_url", e.target.value)} placeholder="https://notizbogen.example.com" style={styles.input} /></label>
<label className="hp-admin-check"><input type="checkbox" checked={!!form.smtp_use_tls} onChange={(e) => setField("smtp_use_tls", e.target.checked)} /> {t("useTls")}</label> <label style={styles.adminFieldLabel}>{language === "en" ? "SMTP encryption" : "SMTP-Verschlüsselung"}<select value={form.smtp_security || "starttls"} onChange={(e) => { const security = e.target.value; setField("smtp_security", security); if (security === "ssl" && String(form.smtp_port) === "587") setField("smtp_port", 465); if (security === "starttls" && String(form.smtp_port) === "465") setField("smtp_port", 587); }} style={styles.input}>
<option value="none">{language === "en" ? "None" : "Keine Verschlüsselung"}</option>
<option value="starttls">STARTTLS</option>
<option value="ssl">{language === "en" ? "TLS/SSL (direct, port 465)" : "TLS/SSL (direkt, Port 465)"}</option>
</select></label>
<div className="hp-settings-test"> <div className="hp-settings-test">
<label style={{ ...styles.adminFieldLabel, flex: 1 }}>{t("testEmail")}<input type="email" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="you@example.com" style={styles.input} /></label> <label style={{ ...styles.adminFieldLabel, flex: 1 }}>{t("testEmail")}<input type="email" value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="you@example.com" style={styles.input} /></label>
<button onClick={test} style={styles.secondaryBtn} disabled={testing}>{testing ? (language === "en" ? "Sending …" : "Sende …") : t("sendTest")}</button> <button onClick={test} style={styles.secondaryBtn} disabled={testing}>{testing ? (language === "en" ? "Sending …" : "Sende …") : t("sendTest")}</button>
+29 -2
View File
@@ -17,11 +17,14 @@ export default function GamePickerCard({
winnerName = "", winnerName = "",
chipCount = 0, chipCount = 0,
onStartGame, onStartGame,
onCancelGame,
onGoHome,
}) { }) {
const { language, t } = useLanguage(); const { language, t } = useLanguage();
const cur = games.find((x) => x.id === gameId); const cur = games.find((x) => x.id === gameId);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState(""); const [startError, setStartError] = useState("");
const [cancelling, setCancelling] = useState(false);
const renderMemberName = (m) => { const renderMemberName = (m) => {
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—"); const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
@@ -67,6 +70,21 @@ export default function GamePickerCard({
} }
}; };
const handleCancelGame = async () => {
if (!onCancelGame || cancelling) return;
const confirmed = window.confirm(language === "en" ? "Cancel and delete this game? All game data will be removed." : "Dieses Spiel wirklich abbrechen und löschen? Alle Spieldaten werden entfernt.");
if (!confirmed) return;
setCancelling(true);
setStartError("");
try {
await onCancelGame();
} catch (e) {
setStartError(e?.message || (language === "en" ? "The game could not be cancelled." : "Das Spiel konnte nicht abgebrochen werden."));
} finally {
setCancelling(false);
}
};
return ( return (
<div style={{ marginTop: 14 }}> <div style={{ marginTop: 14 }}>
<div style={styles.card}> <div style={styles.card}>
@@ -85,8 +103,11 @@ export default function GamePickerCard({
))} ))}
</select> </select>
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")}> <button onClick={onGoHome} style={styles.helpBtn} title={language === "en" ? "Home" : "Startseite"} aria-label={language === "en" ? "Home" : "Startseite"}>
{t("help")} 🏠
</button>
<button onClick={onOpenHelp} style={styles.helpBtn} title={t("help")} aria-label={t("help")}>
</button> </button>
</div> </div>
@@ -169,6 +190,9 @@ export default function GamePickerCard({
<div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}> <div style={{ marginTop: 7, textAlign: "center", color: stylesTokens.textDim, fontSize: 11 }}>
{members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")} {members.length < 2 ? t("atLeastTwoPlayers") : t("chipsOnStart")}
</div> </div>
<button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, width: "100%", marginTop: 9, color: "#ffb3b3" }} disabled={cancelling}>
{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}
</button>
</> </>
) : ( ) : (
<div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}> <div style={{ marginTop: 11, padding: "9px 10px", borderRadius: 11, background: "rgba(233,216,166,0.07)", color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>
@@ -179,11 +203,14 @@ export default function GamePickerCard({
{startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>} {startError && <div style={{ marginTop: 8, color: "#ffb3b3", fontSize: 12 }}>{startError}</div>}
</div> </div>
) : ( ) : (
<>
<div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}> <div style={{ margin: "0 12px 12px", padding: "10px 12px", borderRadius: 13, border: `1px solid ${stylesTokens.panelBorder}`, background: finished ? "rgba(233,216,166,0.09)" : "rgba(124,255,182,0.07)", color: stylesTokens.textDim, fontSize: 12 }}>
{finished {finished
? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}` ? `🏆 ${t("finished")}${winnerName ? ` · ${t("winner")}: ${winnerName}` : ""}`
: `${t("started")} · ${chipCount} ${t("chipsCreated")}`} : `${t("started")} · ${chipCount} ${t("chipsCreated")}`}
</div> </div>
{isHost && !finished && <button onClick={handleCancelGame} style={{ ...styles.secondaryBtn, margin: "0 12px 12px", width: "calc(100% - 24px)", color: "#ffb3b3" }} disabled={cancelling}>{cancelling ? (language === "en" ? "Cancelling …" : "Wird abgebrochen …") : (language === "en" ? "✕ Cancel game" : "✕ Spiel abbrechen")}</button>}
</>
)} )}
{/* Spieler */} {/* Spieler */}
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n";
export default function HomePage({ games = [], onOpenNewGame, onOpenGame }) {
const { language, t } = useLanguage();
return (
<main className="hp-home-page" style={{ marginTop: 14, display: "grid", gap: 14 }}>
<div style={styles.card}>
<div style={{ padding: "26px 20px 22px", textAlign: "center" }}>
<div style={{ fontSize: 34, color: stylesTokens.textGold }}></div>
<h1 style={{ margin: "8px 0 0", color: stylesTokens.textGold, fontSize: 24 }}>{language === "en" ? "Welcome to Notizbogen" : "Willkommen beim Notizbogen"}</h1>
<p style={{ margin: "9px auto 0", maxWidth: 430, color: stylesTokens.textDim, lineHeight: 1.5 }}>{language === "en" ? "Create a new investigation or join an existing game to get started." : "Erstelle eine neue Ermittlung oder tritt einem bestehenden Spiel bei, um zu beginnen."}</p>
<button onClick={onOpenNewGame} style={{ ...styles.primaryBtn, marginTop: 18 }}>{language === "en" ? "✦ Create or join game" : "✦ Spiel erstellen oder beitreten"}</button>
</div>
</div>
{games.length > 0 && <div style={styles.card}>
<div style={styles.sectionHeader}>{language === "en" ? "Your games" : "Deine Spiele"}</div>
<div style={{ display: "grid", gap: 8, padding: 12 }}>
{games.map((game) => <button key={game.id} onClick={() => onOpenGame(game.id)} style={{ ...styles.secondaryBtn, display: "flex", justifyContent: "space-between", alignItems: "center", textAlign: "left" }}>
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{game.name}</span><span style={{ color: stylesTokens.textGold, marginLeft: 10 }}>{game.code}</span>
</button>)}
</div>
</div>}
</main>
);
}
+84 -1
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import QRCode from "qrcode";
import { styles } from "../styles/styles"; import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme"; import { stylesTokens } from "../styles/theme";
import { useLanguage } from "../i18n"; import { useLanguage } from "../i18n";
@@ -21,6 +22,11 @@ export default function NewGameModal({
const [err, setErr] = useState(""); const [err, setErr] = useState("");
const [created, setCreated] = useState(null); // { code } const [created, setCreated] = useState(null); // { code }
const [toast, setToast] = useState(""); const [toast, setToast] = useState("");
const [qrOpen, setQrOpen] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState("");
const [scannerOpen, setScannerOpen] = useState(false);
const videoRef = useRef(null);
const streamRef = useRef(null);
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]); const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
@@ -32,6 +38,9 @@ export default function NewGameModal({
setToast(""); setToast("");
setJoinCode(""); setJoinCode("");
setCreated(null); setCreated(null);
setQrOpen(false);
setQrDataUrl("");
setScannerOpen(false);
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen // Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
if (hasGame && !gameFinished) { if (hasGame && !gameFinished) {
@@ -41,6 +50,57 @@ export default function NewGameModal({
} }
}, [open, hasGame, gameFinished]); }, [open, hasGame, gameFinished]);
useEffect(() => {
const qrValue = created?.code || currentCode;
if (!qrOpen || !qrValue) return;
QRCode.toDataURL(qrValue, { width: 280, margin: 2, errorCorrectionLevel: "M", color: { dark: "#17161b", light: "#f5efdc" } })
.then(setQrDataUrl).catch(() => setQrDataUrl(""));
}, [qrOpen, created?.code, currentCode]);
useEffect(() => {
if (!scannerOpen) return undefined;
let alive = true;
let intervalId;
const startScanner = async () => {
if (!("BarcodeDetector" in window) || !navigator.mediaDevices?.getUserMedia) {
setErr(language === "en" ? "QR scanning is not supported here. Enter the code manually." : "QR-Scannen wird hier nicht unterstützt. Bitte Code manuell eingeben.");
setScannerOpen(false);
return;
}
try {
const detector = new window.BarcodeDetector({ formats: ["qr_code"] });
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false });
if (!alive) { stream.getTracks().forEach((track) => track.stop()); return; }
streamRef.current = stream;
videoRef.current.srcObject = stream;
await videoRef.current.play();
intervalId = window.setInterval(async () => {
if (!alive || !videoRef.current) return;
try {
const codes = await detector.detect(videoRef.current);
const raw = codes?.[0]?.rawValue?.trim() || "";
if (raw) {
setJoinCode((raw.match(/[23456789ABCDEFGHJKMNPQRSTUVWXYZ]{4,}/i)?.[0] || raw).toUpperCase());
setScannerOpen(false);
setToast(language === "en" ? "✅ QR code recognized" : "✅ QR-Code erkannt");
}
} catch { /* frame not ready */ }
}, 350);
} catch {
setErr(language === "en" ? "Camera access was denied. Enter the code manually." : "Kamerazugriff wurde verweigert. Bitte Code manuell eingeben.");
setScannerOpen(false);
}
};
startScanner();
return () => {
alive = false;
if (intervalId) window.clearInterval(intervalId);
if (streamRef.current) streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
if (videoRef.current) videoRef.current.srcObject = null;
};
}, [scannerOpen, language]);
if (!open) return null; if (!open) return null;
const showToast = (msg) => { const showToast = (msg) => {
@@ -156,6 +216,13 @@ export default function NewGameModal({
> >
{t("copy")} {language === "en" ? "code" : "Code"} {t("copy")} {language === "en" ? "code" : "Code"}
</button> </button>
<button onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn} disabled={!codeToShow}>
{qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
</button>
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
</div>}
</div> </div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}> <div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
@@ -202,6 +269,15 @@ export default function NewGameModal({
autoFocus autoFocus
/> />
<button type="button" onClick={() => { setErr(""); setScannerOpen((value) => !value); }} style={styles.secondaryBtn}>
{scannerOpen ? (language === "en" ? "Close scanner" : "Scanner schließen") : (language === "en" ? "Scan QR code" : "QR-Code scannen")}
</button>
{scannerOpen && <div style={{ display: "grid", gap: 8 }}>
<video ref={videoRef} muted playsInline style={{ width: "100%", maxHeight: 240, objectFit: "cover", borderRadius: 14, background: "#050507", border: `1px solid ${stylesTokens.panelBorder}` }} />
<div style={{ color: stylesTokens.textDim, fontSize: 12, textAlign: "center" }}>{language === "en" ? "Point your camera at the game QR code." : "Richte die Kamera auf den QR-Code des Spiels."}</div>
</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}> <button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
{language === "en" ? "Back" : "Zurück"} {language === "en" ? "Back" : "Zurück"}
@@ -249,6 +325,13 @@ export default function NewGameModal({
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}> <button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
{t("copy")} {language === "en" ? "code" : "Code"} {t("copy")} {language === "en" ? "code" : "Code"}
</button> </button>
<button onClick={() => setQrOpen((value) => !value)} style={styles.secondaryBtn}>
{qrOpen ? (language === "en" ? "Hide QR code" : "QR-Code ausblenden") : (language === "en" ? "Create QR code" : "QR-Code erstellen")}
</button>
{qrOpen && qrDataUrl && <div style={{ marginTop: 4, display: "grid", justifyItems: "center", gap: 7 }}>
<img src={qrDataUrl} alt={language === "en" ? "Game QR code" : "Spiel-QR-Code"} style={{ width: 220, height: 220, borderRadius: 10, padding: 8, background: "#f5efdc" }} />
<div style={{ color: stylesTokens.textDim, fontSize: 12 }}>{language === "en" ? "Scan this code to join the game." : "Scanne diesen Code, um dem Spiel beizutreten."}</div>
</div>}
</div> </div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
+3 -3
View File
@@ -2,7 +2,7 @@
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 { useLanguage } from "../i18n"; import { translateEntryLabel, useLanguage } from "../i18n";
export default function SheetSection({ export default function SheetSection({
title, title,
@@ -13,7 +13,7 @@ export default function SheetSection({
displayTag, displayTag,
readOnly = false, readOnly = false,
}) { }) {
const { t } = useLanguage(); const { language, t } = useLanguage();
const getRowBg = (status) => { const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg; if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg; if (status === 2) return stylesTokens.rowOkBg;
@@ -83,7 +83,7 @@ export default function SheetSection({
}} }}
title={readOnly ? t("readOnly") : t("cycleStatus")} title={readOnly ? t("readOnly") : t("cycleStatus")}
> >
{e.label} {translateEntryLabel(e.label, language)}
</div> </div>
<div style={styles.statusCell}> <div style={styles.statusCell}>
+16 -25
View File
@@ -18,48 +18,39 @@ export default function WinnerCelebration({ open, winnerName, onClose }) {
window.matchMedia("(prefers-reduced-motion: reduce)").matches; window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) { if (!reduceMotion) {
const end = Date.now() + 4500;
// WICHTIG: über dem Overlay rendern // WICHTIG: über dem Overlay rendern
const TOP_Z = 2147483647; const TOP_Z = 2147483647;
// hellere Farben damits auch auf dark overlay knallt // hellere Farben damits auch auf dark overlay knallt
const bright = ["#ffffff", "#ffd166", "#06d6a0", "#4cc9f0", "#f72585"]; const bright = ["#ffffff", "#e9d8a6", "#c9aa62", "#7cffa8"];
// 2 große Bursts // 2 große Bursts
confetti({ confetti({
particleCount: 170, particleCount: 55,
spread: 95, spread: 72,
startVelocity: 42, startVelocity: 28,
origin: { x: 0.12, y: 0.62 }, gravity: 0.85,
ticks: 150,
scalar: 0.82,
origin: { x: 0.22, y: 0.62 },
zIndex: TOP_Z, zIndex: TOP_Z,
colors: bright, colors: bright,
}); });
confetti({ confetti({
particleCount: 170, particleCount: 55,
spread: 95, spread: 72,
startVelocity: 42, startVelocity: 28,
origin: { x: 0.88, y: 0.62 }, gravity: 0.85,
ticks: 150,
scalar: 0.82,
origin: { x: 0.78, y: 0.62 },
zIndex: TOP_Z, zIndex: TOP_Z,
colors: bright, colors: bright,
}); });
// “Rain” über die Zeit
(function frame() {
confetti({
particleCount: 8,
spread: 75,
startVelocity: 34,
origin: { x: Math.random(), y: Math.random() * 0.18 },
scalar: 1.05,
zIndex: TOP_Z,
colors: bright,
});
if (Date.now() < end) requestAnimationFrame(frame);
})();
} }
const t = setTimeout(() => onClose?.(), 5500); const t = setTimeout(() => onClose?.(), 4200);
return () => { return () => {
clearTimeout(t); clearTimeout(t);
+22
View File
@@ -3,6 +3,28 @@ import React, { createContext, useContext, useEffect, useMemo, useState } from "
const STORAGE_KEY = "hpLanguage"; const STORAGE_KEY = "hpLanguage";
const LanguageContext = createContext(null); const LanguageContext = createContext(null);
const entryLabels = {
"Schlaftrunk": "Sleeping Potion",
"Verschwindekabinett": "Vanishing Cabinet",
"Portschlüssel": "Portkey",
"Impedimenta": "Impedimenta",
"Petrificus Totalus": "Petrificus Totalus",
"Alraune": "Mandrake",
"Große Halle": "Great Hall",
"Krankenflügel": "Hospital Wing",
"Raum der Wünsche": "Room of Requirement",
"Klassenzimmer für Zaubertränke": "Potions Classroom",
"Pokalszimmer": "Trophy Room",
"Klassenzimmer für Wahrsagen": "Divination Classroom",
"Eulerei": "Owlery",
"Bibliothek": "Library",
"Verteidigung gegen die dunklen Künste": "Defence Against the Dark Arts",
};
export function translateEntryLabel(label, language) {
return language === "en" ? (entryLabels[label] || label) : label;
}
export const translations = { export const translations = {
de: { de: {
language: "Deutsch", language: "Deutsch",
@@ -105,10 +105,10 @@ export function useHpGlobalStyles() {
@keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } } @keyframes hpStartLine { 0%, 100% { opacity: .3; transform: scaleX(.7); } 50% { opacity: 1; transform: scaleX(1); } }
.hp-topbar-actions { margin-left: auto; } .hp-topbar-actions { margin-left: auto; }
.hp-user-menu-wrap { min-width: 0; } .hp-user-menu-wrap { min-width: 0; }
.hp-admin-user-row { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1.45fr) 62px 62px 78px; white-space: nowrap; } .hp-admin-user-row { grid-template-columns: minmax(90px, 1.2fr) minmax(90px, 1.5fr) 58px 58px 100px; white-space: nowrap; min-height: 42px; padding: 6px 8px !important; }
.hp-admin-action { min-width: 0; } .hp-admin-action { min-width: 0; }
.hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; } .hp-admin-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; min-width: 0; }
.hp-admin-icon-action { width: 34px; height: 34px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 17px; } .hp-admin-icon-action { width: 30px; height: 30px; padding: 0 !important; display: inline-flex; align-items: center; justify-content: center; font-size: 15px; }
.hp-admin-name, .hp-admin-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hp-admin-name, .hp-admin-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; } .hp-admin-check { display: flex; align-items: center; gap: 8px; color: var(--hp-textDim); font-size: 13px; }
.hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); } .hp-admin-check input { width: 18px; height: 18px; accent-color: var(--hp-textGold); }
@@ -134,13 +134,11 @@ export function useHpGlobalStyles() {
.hp-topbar-user { justify-content: center; } .hp-topbar-user { justify-content: center; }
.hp-user-dropdown { left: 50% !important; right: auto !important; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; } .hp-user-dropdown { left: 50% !important; right: auto !important; transform: translateX(-50%); width: max-content; max-width: calc(100vw - 20px); min-width: min(220px, calc(100vw - 20px)) !important; }
.hp-user-dropdown > div:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .hp-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-user-row { grid-template-columns: minmax(65px, 1.1fr) minmax(0, 1.3fr) 42px 42px 94px; gap: 3px; padding: 7px !important; border-radius: 12px !important; min-height: 42px; }
.hp-admin-name { grid-column: 1; grid-row: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 16px; } .hp-admin-name, .hp-admin-email { min-width: 0; font-size: 12px !important; }
.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, .hp-admin-status { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-size: 11px !important; padding-left: 2px !important; padding-right: 2px !important; }
.hp-admin-role { grid-column: 2; grid-row: 1; } .hp-admin-actions { width: auto; justify-content: flex-end; gap: 3px; }
.hp-admin-status { grid-column: 2; grid-row: 2; } .hp-admin-actions .hp-admin-action { width: 29px; min-width: 29px; height: 29px; min-height: 29px; padding: 0 !important; font-size: 14px; }
.hp-admin-actions { grid-column: 1 / -1; grid-row: 3; width: 100%; justify-content: flex-end; }
.hp-admin-actions .hp-admin-action { width: 36px; min-width: 36px; min-height: 36px; padding: 0 !important; }
.hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; } .hp-admin-editor-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; }
.hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; } .hp-admin-editor-card input, .hp-admin-editor-card select { min-height: 40px; padding: 8px 10px !important; }
.hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; } .hp-admin-settings-card { padding: 14px !important; max-height: calc(100dvh - 20px) !important; overflow: auto; }
-1
View File
@@ -189,7 +189,6 @@ export const styles = {
}, },
userRow: { userRow: {
display: "grid", display: "grid",
gridTemplateColumns: "1fr 80px 90px",
gap: 8, gap: 8,
padding: 10, padding: 10,
borderRadius: 12, borderRadius: 12,