Compare commits

...

103 Commits

Author SHA1 Message Date
3809bd5425 Refractor 3D Dice 2026-02-08 11:47:32 +01:00
85bb30ae73 3d dice 2026-02-08 11:39:25 +01:00
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
cf81c25e6e new logic, testing 2026-02-07 11:15:33 +01:00
97ad77f2a4 Merge pull request 'dev' (#6) from dev into main
Reviewed-on: #6
2026-02-07 10:12:30 +00:00
62439d2d28 Enhance loading screen with animation and splash improvements
Updated the loading screen to include animated gold lines, a loader, and a spark effect. Adjusted splash visibility to ensure a minimum display time of 3 seconds and improved transitions for smoother visuals. Enhanced style and structure for better user experience during app initialization.
2026-02-06 18:52:16 +01:00
57cb9a57ef Replace preload mechanism with splash screen
The preload class was replaced by a more user-friendly splash screen design. This change ensures a smoother transition while loading assets and eliminates black background flashes. The splash overlay is automatically hidden and removed after the app is ready, providing a seamless loading experience.
2026-02-06 18:49:25 +01:00
e975d7aa25 Set up immediate theme application and manual SW updates
Moved theme application logic to occur immediately on app initialization to prevent UI flash. Added a check to wait for all fonts to load before making the app visible and adjusted Service Worker behavior to require manual updates instead of auto-reloading.
2026-02-06 18:47:13 +01:00
7c4754e506 "Enhance loading experience and optimize theme application
Added a fallback background and a preload lock for smoother loading transitions. Improved theme application by applying it prior to React rendering and removed theme flash. Adjusted Service Worker registration for better performance and reliability."
2026-02-06 18:44:44 +01:00
070057afb3 Set and persist theme preference in localStorage
Implemented logic to store and retrieve theme preferences using localStorage for both logged-in users and guests. This ensures the selected theme is applied immediately on load, preventing theme flash issues. Adjusted initialization to apply the correct theme at app startup.
2026-02-06 18:41:45 +01:00
6434256dfb Set dynamic theme color for PWA and Android status bar.
Added logic to update the theme-color meta tag dynamically based on the active theme. This improves the visual consistency of the application, particularly for PWA and Android users. Default theme color has also been updated in the configuration.
2026-02-06 18:39:04 +01:00
bdf18c2aea Merge pull request 'Migrate development brnach into production brunch' (#5) from dev into main
Reviewed-on: #5
2026-02-06 17:30:14 +00:00
770b2cb531 Enhance WinnerCelebration visuals and confetti effects
Updated confetti burst and rain effects with brighter colors, improved motion, and increased visibility on dark overlays. Refined UI styling for better clarity, reduced dimensions for mobile-friendliness, and adjusted overlay opacity for enhanced contrast. Made layout and text updates for improved alignment and readability.
2026-02-06 17:13:42 +01:00
61c7ed6ffe Improve winner celebration logic in game meta updates
Adjusted the logic to ensure celebrations trigger only when the winner ID changes and not on initial meta loads. Also added a reset check to prevent celebrations when the winner ID becomes empty.
2026-02-06 17:09:43 +01:00
3a9da788e5 Add winner celebration feature with confetti effects
This update introduces a winner celebration overlay displayed when a game's winner is announced. It includes confetti animations along with a congratulatory message, enhancing user experience. The feature resets appropriately during game transitions and logout to maintain correct behavior.
2026-02-06 17:05:19 +01:00
56ef076010 Remove star emoji for host users in GamePickerCard.
The star emoji for identifying host users has been removed from the `suffix` logic. This simplifies the component and aligns with updated display requirements.
2026-02-06 16:52:17 +01:00
aefb4234d6 Update host icon in GamePickerCard
Replaced the star () icon with a crown (👑) to represent the host. Adjusted labels, tooltip styles, and descriptive text accordingly for improved clarity and consistency.
2026-02-06 15:01:50 +01:00
83893a0060 Remove unused "teilen" hint from GamePickerCard.
The small "teilen" hint was not being actively used and has been removed for cleaner code. This improves readability and maintains consistency in the component.
2026-02-06 14:59:58 +01:00
f555526e64 Add host and player identification in GamePickerCard
This update introduces visual indicators to identify the host and the current user in the GamePickerCard component. Hosts are marked with a star, and the current user is labeled as "(du)". The design of the member pills has also been enhanced for better clarity and aesthetics.
2026-02-06 14:57:35 +01:00
d4e629b211 Enhance member display in GamePickerCard and remove redundancy
Added functionality to display members in GamePickerCard, replacing the previously redundant implementation in NewGameModal. This change centralizes member display logic, reducing code duplication and improving maintainability.
2026-02-06 14:53:11 +01:00
85805531c2 Add current members list display in NewGameModal
Introduced a UI element in NewGameModal to display the list of current members when available. Also used React Portal to render the bottom snack for joins directly in the document body. These updates enhance UI clarity and user feedback during game interactions.
2026-02-06 14:46:36 +01:00
7b7b23f52d Add join notifications with bottom snack and vibration feedback
Added functionality to detect new members joining games, displaying a snack message and providing optional vibration feedback. Managed state with refs to track members and reset baselines when switching games. Styled a bottom toast notification for better user feedback.
2026-02-06 14:37:36 +01:00
9ff5d0291f Merge pull request 'dev' (#4) from dev into main
Reviewed-on: #4
2026-02-06 13:36:47 +00:00
3cbb4ce89a Enhance new game modal with running game state handling
Introduced functionality to detect and handle ongoing games in the new game modal. The modal now switches between "running" and "choice" modes based on game state, improving clarity for users. Added a dedicated section to display the game code when a game is active.
2026-02-06 14:17:39 +01:00
6b9d4d1295 Add live refresh for game members and metadata
Implemented a useEffect to periodically refresh game member and winner metadata every 2.5 seconds. This ensures new joiners are visible without requiring a page reload, balancing performance and usability.
2026-02-06 14:07:26 +01:00
730b9ed552 Adjust transparency levels for "rowMaybeBg" and "rowEmptyBg".
Reduced the opacity in "rowMaybeBg" and "rowEmptyBg" styles across all themes to improve visual clarity and match the design requirements. These changes ensure a more subtle background appearance in various UI states.
2026-02-06 14:00:32 +01:00
0c983f7e44 Add winner display name support in game metadata
Updated backend to include winner's display name in the game metadata API and frontend to display it alongside the email. This enhances clarity by showing a more user-friendly identifier.
2026-02-06 13:52:19 +01:00
b4b5c7903a Remove redundant email display logic in WinnerBadge component.
Simplified the WinnerBadge component by removing the conditional logic for optionally displaying the email when the display name is available. Updated WinnerCard to use display name as the primary label fallback for members, ensuring cleaner and consistent rendering.
2026-02-06 13:46:02 +01:00
45722e057f Refactor modal overlay styles for consistency and responsiveness
Replaced top/left/right/bottom with a single "inset" property for cleaner and more concise code. Updated padding to account for safe areas dynamically and ensured box-sizing includes padding. Improved responsiveness by using percentages instead of viewport units for width and height.
2026-02-06 13:40:10 +01:00
dc98eeb41c Fix email display in WinnerBadge component
Replaced the incorrect `me.display_name` with `winner.email` to ensure the correct email is shown when `showEmail` is true. This resolves the display issue for winner information.
2026-02-06 13:36:51 +01:00
bdc6824e18 Remove unused displayName variable from WinnerBadge.jsx
The `displayName` variable was declared but not used effectively in the component. This cleanup improves code readability and eliminates unnecessary declarations. Updated the code to directly use `me.display_name`.
2026-02-06 13:35:31 +01:00
1473100498 Add theme_key to user payload in /me endpoint
The /me endpoint now includes the theme_key in the response payload. This ensures the frontend can access the user's theme preference directly.
2026-02-06 13:31:18 +01:00
745b661709 Fix email display logic in WinnerBadge component
Replaced the conditional display of the winner's email with a more robust logic to show either the trimmed display name or the email. This ensures better handling of cases where the display name is unavailable or matches the email.
2026-02-06 13:24:38 +01:00
fa89987f39 Update components to display email instead of display_name
Replaced `display_name` with `email` in `WinnerBadge` and `WinnerCard` components. This ensures email addresses are shown consistently when rendering winner-related information.
2026-02-06 13:22:23 +01:00
59e224b4ca Add user stats feature with API and modal integration
Introduced an endpoint to fetch user stats and integrated it with a new StatsModal component in the frontend. Users can now view game statistics, including played games, wins, losses, and win rates, accessible from the user menu.
2026-02-06 13:14:27 +01:00
bfb1df8e59 Enhance styling consistency and alignment in Admin Panel
Centered elements in the Admin Panel using `justifyItems: "center"`. Adjusted input padding, font size, and primary button styling for improved layout and usability.
2026-02-06 12:56:59 +01:00
b830428251 Refactor AdminPanel: Move function definition above useEffect.
This change reorders the `AdminPanel` function definition to appear before the `useEffect` hook, enhancing readability and maintaining consistent organization. No functionality is altered.
2026-02-06 12:51:20 +01:00
52ace41ac4 Refactor modal logic and implement ModalPortal component
Moved modal rendering logic to a new `ModalPortal` component to improve reusability and separation of concerns. Adjusted styles for better UI consistency, including improved backdrop and modal behavior. Enhanced accessibility by handling escape key events and blocking background scrolling when the modal is open.
2026-02-06 12:43:24 +01:00
556a7a5d81 Update WinnerBadge to display displayName instead of email
Replaced `winner.email` with `winner.displayName` in the WinnerBadge component. This ensures a more user-friendly representation by showing the display name instead of the email.
2026-02-06 12:29:07 +01:00
2cdd4ae17e Adjust AdminPanel spacing and enhance modal UI effects
Reduced grid gap in AdminPanel for better spacing consistency. Improved modal overlay and card design with stronger darkening, added blur, and responsive adjustments to prevent layout issues in smaller viewports.
2026-02-06 12:28:41 +01:00
8a73dd86cf Refactor TopBar displayName initialization logic.
Moved the `displayName` variable initialization inside the `TopBar` component and handled null checks consistently. This improves readability and ensures a safer default value handling for `me`.
2026-02-06 12:22:20 +01:00
9aa3532dd6 Enable immediate PWA updates and cache cleanup.
Added functionality to immediately activate new Service Worker versions and reload the page upon updates. Enhanced caching configuration in Vite to clean outdated caches and ensure clients use the latest version.
2026-02-06 12:20:52 +01:00
3a66c0cf74 Add display_name support for users in backend and frontend
This commit introduces the `display_name` field to the user model. It updates database migrations, API endpoints, and the admin panel to handle this field. Additionally, the `display_name` is now shown in the TopBar and WinnerBadge components, improving user experience.
2026-02-06 12:09:21 +01:00
4a012b7345 Improve database column check and update frontend z-index styling
Enhanced the `_has_column` function to handle database dialects cleanly, reducing unnecessary PostgreSQL logs. Adjusted frontend z-index values to ensure proper element stacking in the UI.
2026-02-06 12:00:14 +01:00
8b10d699ee Fix incorrect column update logic in games migration
Updated the migration logic to ensure `host_user_id` is referenced properly during updates and checks. This resolves potential issues with assigning `host_user_id` and creating corresponding `GameMember` entries correctly.
2026-02-06 11:53:14 +01:00
8e5a2426e7 Enhance schema migration with new column checks and backfills
This commit updates the schema migration logic to include checks for the existence of columns in a database-agnostic manner, supporting both SQLite and Postgres. It introduces new columns, ensures proper synchronization between old and new column names, adds unique indexes, and backfills missing data. This improves database compatibility and ensures data consistency for evolving schemas.
2026-02-06 11:37:50 +01:00
4669d1f8c4 Refactor and enhance game management, user roles, and state handling
This commit introduces significant changes across the backend and frontend to improve game creation, joining, and member management. Key updates include adding a host role, structured handling of winners, and a New Game modal in the frontend. The refactor also simplifies join codes, improves persistence for user themes, and enhances overall user interaction with better UI feedback and logic.
2026-02-06 11:21:43 +01:00
d0f65b856e Integrate join codes, player management, and themes
This update introduces "join codes" for games to simplify game joining. Enhancements include player role and winner management for better organization. Additionally, theme preferences are now user-configurable and persisted server-side.
2026-02-06 11:08:41 +01:00
be0f5e9a9f Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-06 09:43:03 +00:00
6732208792 Refactor WinnerBadge component to simplify implementation
Moved logic for displaying the winner badge directly into the `WinnerBadge` component, removing unused local storage helper functions. Updated styling and streamlined the component for better clarity and maintainability.
2026-02-06 10:05:21 +01:00
74de7bf4dd Enhance winner management with localStorage updates
Refactored winner storage logic by introducing `clearWinnerLS` and replacing outdated functions with `getWinnerLS` and `setWinnerLS`. Added a `WinnerBadge` component to display the winner's status and updated game lifecycle handling to ensure proper winner reset and management.
2026-02-06 10:02:11 +01:00
7024a681da Add themed background images for Hogwarts houses
This commit introduces specific background images for Gryffindor, Slytherin, Ravenclaw, and Hufflepuff houses. Updated the theme configuration to dynamically set these images per house and adjusted the styles to utilize the new theme token for background images.
2026-02-06 09:52:54 +01:00
4295b139b2 Refactor header and row styles to use theme tokens.
Replaced hardcoded header colors with theme-based tokens for better maintainability and consistency across themes. Simplified the structure by consolidating row and header-specific design tokens and removing unused badge tokens.
2026-02-06 09:39:21 +01:00
6e460b69b4 Refactor and expand style tokens for rows and badges.
This commit separates badge and row styles into distinct tokens, improving modularity and theme customization. It also introduces new tokens for section headers and replaces hardcoded values with more consistent styling logic. These changes enhance maintainability and visual consistency across the application.
2026-02-06 09:33:09 +01:00
a9021fb4f1 Refactor status-based styles to use theme tokens.
This change replaces hardcoded status-based styles with theme tokens, improving consistency and maintainability. The update also enables easier customization of styles across different themes by centralizing row and badge colors in theme configurations.
2026-02-06 09:25:43 +01:00
a08b74ff7a Add theme customization and winner management features
Introduced a theme selection feature, allowing users to customize the application's appearance, with themes stored per user. Added functionality to manage and store the game's winner locally. These changes improve user experience and personalization.
2026-02-06 09:15:51 +01:00
1db91c6c88 Merge pull request 'main: merge dev branch into main' (#2) from dev into main
Reviewed-on: #2
2026-02-05 09:13:56 +00:00
4d81a1be03 Merge branch 'main' of https://git.nesterovic.cc/nessi/cluedo-hp-webapp into dev 2026-02-05 10:09:02 +01:00
22e3001a31 Update HelpModal content and structure for clarity
Added new sections for game selection, user menu, and revised existing instructions for better clarity. Included detailed descriptions and clear formatting to improve the help guide’s usability.
2026-02-05 10:08:53 +01:00
fc93c95d4a Merge pull request 'Merge dev branch into main brunch' (#1) from dev into main
Reviewed-on: #1
2026-02-05 09:04:23 +00:00
198175c0fe Rename "Neues Spiel" button text to "New Game"
Updated the button label in the TopBar component to display "New Game" instead of "Neues Spiel." This change aligns the interface language with English for consistency.
2026-02-05 09:15:19 +01:00
1f326c2b46 Update label from "Account" to "User" in TopBar
The label on the user menu button was changed to improve readability and consistency with the UI language. This ensures better comprehension for users interacting with the application.
2026-02-05 09:10:11 +01:00
c6a56b14ff Update TopBar labels for clarity and relevance
Replaced "Zauber-Notizbogen" with "Notizbogen" to simplify terminology. Displayed user email instead of role for a more meaningful and personalized experience.
2026-02-05 09:08:15 +01:00
a3216950c8 Update TopBar UI for improved structure and clarity
Refactored the TopBar component to enhance readability and organization. Added labeled sections for user account, role display, and the "Neues Spiel" button. Adjusted styles and improved spacing for better UI consistency.
2026-02-04 09:06:17 +01:00
e4c1a57f0f Update button text in TopBar component
Revised the button label from "Neues Spiel" to "✦ Neues Spiel" to enhance visual appeal and draw user attention. This minor UI tweak improves the user experience.
2026-02-04 09:02:50 +01:00
3b628b6c57 Refactor App structure and add modular components
Split GamePickerCard, HelpModal, and SheetSection into separate components for better modularity and clarity. Refactored App.jsx to utilize these new components, restructured state variables, and organized functions for improved readability. Enhanced code comments for easier maintenance.
2026-02-04 08:58:00 +01:00
1afb060bbc Refactor app by modularizing components and extracting utilities.
The changes split large features into smaller, reusable components like `AdminPanel`, `LoginPage`, `TopBar`, `PasswordModal`, and `ChipModal`. Utility functions such as `cycleTag` and `chipStorage` were extracted for better organization. This improves the code's readability, maintainability, and scalability.
2026-02-04 08:49:34 +01:00
49 changed files with 4925 additions and 1357 deletions

View File

@@ -1,9 +1,14 @@
import os import os
import random
import string
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
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 from .models import User, Entry, Category, Role, Game, GameMember
from .security import hash_password 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
@@ -14,7 +19,10 @@ app = FastAPI(title="Cluedo Sheet")
# Intern: Frontend läuft auf :8081 # Intern: Frontend läuft auf :8081
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:8081", "http://127.0.0.1:8081"], allow_origins=[
"http://localhost:8081",
"http://127.0.0.1:8081",
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
@@ -24,9 +32,235 @@ 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)
def _rand_join_code(n: int = 6) -> str:
# digits only (kahoot style)
return "".join(random.choice(string.digits) for _ in range(n))
def _has_column(db: Session, table: str, col: str) -> bool:
"""
Postgres + SQLite friendly check without spamming Postgres logs.
- SQLite: PRAGMA table_info
- Postgres: information_schema
"""
dialect = None
try:
dialect = db.get_bind().dialect.name # "postgresql" | "sqlite" | ...
except Exception:
dialect = None
if dialect == "sqlite":
try:
rows = db.execute(text(f"PRAGMA table_info({table})")).all()
return any(r[1] == col for r in rows)
except Exception:
db.rollback()
return False
# default: Postgres (or others) via information_schema
try:
rows = db.execute(
text(
"""
SELECT 1
FROM information_schema.columns
WHERE table_name = :t AND column_name = :c
LIMIT 1
"""
),
{"t": table, "c": col},
).all()
return len(rows) > 0
except Exception:
db.rollback()
return False
def _auto_migrate(db: Session):
"""
Very small, pragmatic auto-migration (no alembic).
- creates missing tables via create_all
- adds missing columns via ALTER TABLE (best effort)
- supports old schema (join_code/chip_code) and new schema (code/chip)
"""
# --- users.display_name ---
if not _has_column(db, "users", "display_name"):
try:
db.execute(text("ALTER TABLE users ADD COLUMN display_name VARCHAR DEFAULT ''"))
db.commit()
except Exception:
db.rollback()
# --- users.theme_key ---
if not _has_column(db, "users", "theme_key"):
try:
db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'"))
db.commit()
except Exception:
db.rollback()
# --- games: code / join_code + winner_user_id + host_user_id (optional) ---
# We support both column names:
# old: join_code
# new: code
has_join_code = _has_column(db, "games", "join_code")
has_code = _has_column(db, "games", "code")
# If neither exists, create "code" (new preferred)
if not has_join_code and not has_code:
try:
db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR"))
db.commit()
has_code = True
except Exception:
db.rollback()
# If only join_code exists but your code now expects "code",
# add "code" too and later mirror values.
if has_join_code and not has_code:
try:
db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR"))
db.commit()
has_code = True
except Exception:
db.rollback()
# winner_user_id
if not _has_column(db, "games", "winner_user_id"):
try:
db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR"))
db.commit()
except Exception:
db.rollback()
# host_user_id (nice to have for "only host can set winner")
if not _has_column(db, "games", "host_user_id"):
try:
db.execute(text("ALTER TABLE games ADD COLUMN host_user_id VARCHAR"))
db.commit()
except Exception:
db.rollback()
# --- sheet_state chip / chip_code ---
has_chip_code = _has_column(db, "sheet_state", "chip_code")
has_chip = _has_column(db, "sheet_state", "chip")
if not has_chip_code and not has_chip:
# prefer "chip"
try:
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR"))
db.commit()
has_chip = True
except Exception:
db.rollback()
# if old chip_code exists but new expects chip -> add chip and mirror later
if has_chip_code and not has_chip:
try:
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR"))
db.commit()
has_chip = True
except Exception:
db.rollback()
# --- indexes for game code ---
# We create unique index for the column(s) that exist.
try:
if has_join_code:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)"))
if has_code:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_code ON games (code)"))
db.commit()
except Exception:
db.rollback()
# --- backfill code values ---
# 1) if join_code exists and code exists, ensure code mirrors join_code where missing
if has_join_code and has_code:
try:
db.execute(text("UPDATE games SET code = join_code WHERE (code IS NULL OR code = '') AND join_code IS NOT NULL AND join_code <> ''"))
db.commit()
except Exception:
db.rollback()
# 2) generate missing codes in whichever column we have
# Prefer writing into "code" (new), but also keep join_code in sync if present.
code_col = "code" if has_code else "join_code" if has_join_code else None
if code_col:
try:
missing = db.execute(
text(f"SELECT id FROM games WHERE {code_col} IS NULL OR {code_col} = ''")
).all()
except Exception:
db.rollback()
missing = []
if missing:
try:
used_rows = db.execute(text(f"SELECT {code_col} FROM games WHERE {code_col} IS NOT NULL")).all()
used = set([r[0] for r in used_rows if r and r[0]])
except Exception:
db.rollback()
used = set()
for (gid,) in missing:
code = _rand_join_code()
while code in used:
code = _rand_join_code()
used.add(code)
try:
# write into main col
db.execute(text(f"UPDATE games SET {code_col} = :c WHERE id = :id"), {"c": code, "id": gid})
# keep both in sync if both exist
if has_join_code and code_col == "code":
db.execute(text("UPDATE games SET join_code = :c WHERE id = :id AND (join_code IS NULL OR join_code = '')"), {"c": code, "id": gid})
if has_code and code_col == "join_code":
db.execute(text("UPDATE games SET code = :c WHERE id = :id AND (code IS NULL OR code = '')"), {"c": code, "id": gid})
db.commit()
except Exception:
db.rollback()
# --- backfill host_user_id: default to owner_user_id ---
try:
if _has_column(db, "games", "host_user_id"):
db.execute(text("UPDATE games SET host_user_id = host_user_id WHERE host_user_id IS NULL OR host_user_id = ''"))
db.commit()
except Exception:
db.rollback()
# --- backfill membership: ensure owner is member ---
# uses ORM; only relies on existing table GameMember (create_all already ran)
try:
all_games = db.query(Game).all()
for g in all_games:
host_id = getattr(g, "host_user_id", None)
if not host_id:
continue
exists = (
db.query(GameMember)
.filter(GameMember.game_id == g.id, GameMember.user_id == host_id)
.first()
)
if not exists:
db.add(GameMember(game_id=g.id, user_id=host_id))
db.commit()
except Exception:
db.rollback()
# --- mirror chip_code -> chip if both exist and chip empty ---
if has_chip_code and has_chip:
try:
db.execute(text("UPDATE sheet_state SET chip = chip_code WHERE (chip IS NULL OR chip = '') AND chip_code IS NOT NULL AND chip_code <> ''"))
db.commit()
except Exception:
db.rollback()
def seed_entries(db: Session): def seed_entries(db: Session):
# Du kannst hier deine HP-Edition Einträge reinschreiben.
# (Für rein private Nutzung ok öffentlich würde ichs generisch machen.)
if db.query(Entry).count() > 0: if db.query(Entry).count() > 0:
return return
suspects = ["Draco Malfoy", "Crabbe & Goyle", "Lucius Malfoy", "Dolores Umbridge", "Peter Pettigrew", "Bellatrix Lestrange"] suspects = ["Draco Malfoy", "Crabbe & Goyle", "Lucius Malfoy", "Dolores Umbridge", "Peter Pettigrew", "Bellatrix Lestrange"]
@@ -41,19 +275,32 @@ def seed_entries(db: Session):
db.add(Entry(category=Category.location.value, label=l)) db.add(Entry(category=Category.location.value, label=l))
db.commit() db.commit()
def ensure_admin(db: Session): def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip() admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!") admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
u = db.query(User).filter(User.email == admin_email).first() u = db.query(User).filter(User.email == admin_email).first()
if not u: if not u:
db.add(User(email=admin_email, password_hash=hash_password(admin_pw), role=Role.admin.value)) db.add(
User(
email=admin_email,
password_hash=hash_password(admin_pw),
role=Role.admin.value,
theme_key="default",
display_name="Admin",
)
)
db.commit() db.commit()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
# create new tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
db = SessionLocal() db = SessionLocal()
try: try:
_auto_migrate(db)
ensure_admin(db) ensure_admin(db)
seed_entries(db) seed_entries(db)
finally: finally:

View File

@@ -1,19 +1,31 @@
# backend/app/models.py
import enum import enum
import uuid import uuid
from sqlalchemy import String, Boolean, DateTime, ForeignKey, Integer, SmallInteger, UniqueConstraint from sqlalchemy import (
from sqlalchemy.orm import Mapped, mapped_column, relationship String,
Boolean,
DateTime,
ForeignKey,
Integer,
SmallInteger,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func from sqlalchemy.sql import func
from .db import Base from .db import Base
class Role(str, enum.Enum): class Role(str, enum.Enum):
admin = "admin" admin = "admin"
user = "user" user = "user"
class Category(str, enum.Enum): class Category(str, enum.Enum):
suspect = "suspect" suspect = "suspect"
item = "item" item = "item"
location = "location" location = "location"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
@@ -23,29 +35,55 @@ class User(Base):
disabled: Mapped[bool] = mapped_column(Boolean, default=False) disabled: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
theme_key: Mapped[str] = mapped_column(String, default="default")
# NEW: schöner Name für UI (TopBar / WinnerBadge)
display_name: Mapped[str] = mapped_column(String, default="")
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
host_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
name: Mapped[str] = mapped_column(String) name: Mapped[str] = mapped_column(String)
seed: Mapped[int] = mapped_column(Integer) seed: Mapped[int] = mapped_column(Integer)
created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
code: Mapped[str] = mapped_column(String, unique=True, index=True)
winner_user_id: Mapped[str | None] = mapped_column(String, ForeignKey("users.id"), nullable=True)
class GameMember(Base):
__tablename__ = "game_members"
__table_args__ = (UniqueConstraint("game_id", "user_id", name="uq_game_member"),)
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)
joined_at: Mapped[str] = mapped_column(DateTime(timezone=True), server_default=func.now())
class Entry(Base): class Entry(Base):
__tablename__ = "entries" __tablename__ = "entries"
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
category: Mapped[str] = mapped_column(String, index=True) category: Mapped[str] = mapped_column(String, index=True)
label: Mapped[str] = mapped_column(String) label: Mapped[str] = mapped_column(String)
class SheetState(Base): class SheetState(Base):
__tablename__ = "sheet_state" __tablename__ = "sheet_state"
__table_args__ = ( __table_args__ = (UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),)
UniqueConstraint("game_id", "owner_user_id", "entry_id", name="uq_sheet"),
)
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4())) 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) game_id: Mapped[str] = mapped_column(String, ForeignKey("games.id"), index=True)
owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True) owner_user_id: Mapped[str] = mapped_column(String, ForeignKey("users.id"), index=True)
entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True) entry_id: Mapped[str] = mapped_column(String, ForeignKey("entries.id"), index=True)
status: Mapped[int] = mapped_column(SmallInteger, default=0) # 0 unknown, 1 crossed, 2 confirmed
note_tag: Mapped[str | None] = mapped_column(String, nullable=True) # null | 'i' | 'm' | 's' status: Mapped[int] = mapped_column(SmallInteger, default=0)
note_tag: Mapped[str | None] = mapped_column(String, nullable=True)
chip: Mapped[str | None] = mapped_column(String, nullable=True)

View File

@@ -19,21 +19,53 @@ def require_admin(req: Request, db: Session) -> User:
def list_users(req: Request, db: Session = Depends(get_db)): def list_users(req: Request, db: Session = Depends(get_db)):
require_admin(req, db) require_admin(req, db)
users = db.query(User).order_by(User.created_at.desc()).all() users = db.query(User).order_by(User.created_at.desc()).all()
return [{"id": u.id, "email": u.email, "role": u.role, "disabled": u.disabled} for u in users] return [
{
"id": u.id,
"email": u.email,
"display_name": u.display_name,
"role": u.role,
"disabled": u.disabled,
}
for u in users
]
@router.post("/users") @router.post("/users")
def create_user(req: Request, data: dict, db: Session = Depends(get_db)): def create_user(req: Request, data: dict, db: Session = Depends(get_db)):
require_admin(req, db) require_admin(req, db)
email = (data.get("email") or "").lower().strip() email = (data.get("email") or "").lower().strip()
password = data.get("password") or "" password = data.get("password") or ""
display_name = (data.get("display_name") or "").strip()
if not email or not password: if not email or not password:
raise HTTPException(400, "email/password required") raise HTTPException(400, "email/password required")
if db.query(User).filter(User.email == email).first(): if db.query(User).filter(User.email == email).first():
raise HTTPException(409, "email exists") raise HTTPException(409, "email exists")
role = data.get("role") or Role.user.value role = data.get("role") or Role.user.value
if role not in (Role.admin.value, Role.user.value): if role not in (Role.admin.value, Role.user.value):
raise HTTPException(400, "invalid role") raise HTTPException(400, "invalid role")
u = User(email=email, password_hash=hash_password(password), role=role)
u = User(email=email, password_hash=hash_password(password), role=role, display_name=display_name)
db.add(u); db.commit() db.add(u); db.commit()
return {"ok": True, "id": u.id} return {"ok": True, "id": u.id}
@router.delete("/users/{user_id}")
def 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")
u = db.query(User).filter(User.id == user_id).first()
if not u:
raise HTTPException(404, "not found")
if u.role == Role.admin.value:
raise HTTPException(400, "cannot delete admin user")
# soft delete
u.disabled = True
db.add(u)
db.commit()
return {"ok": True}

View File

@@ -2,10 +2,18 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import User from ..models import User
from ..security import verify_password, make_session_value, set_session, clear_session, get_session_user_id, hash_password from ..security import (
verify_password,
make_session_value,
set_session,
clear_session,
get_session_user_id,
hash_password,
)
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login") @router.post("/login")
def login(data: dict, resp: Response, db: Session = Depends(get_db)): def login(data: dict, resp: Response, db: Session = Depends(get_db)):
email = (data.get("email") or "").lower().strip() email = (data.get("email") or "").lower().strip()
@@ -13,14 +21,17 @@ def login(data: dict, resp: Response, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == email, User.disabled == False).first() user = db.query(User).filter(User.email == email, User.disabled == False).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
raise HTTPException(status_code=401, detail="invalid credentials") raise HTTPException(status_code=401, detail="invalid credentials")
set_session(resp, make_session_value(user.id)) set_session(resp, make_session_value(user.id))
return {"ok": True, "role": user.role, "email": user.email} return {"ok": True, "role": user.role, "email": user.email, "theme_key": user.theme_key}
@router.post("/logout") @router.post("/logout")
def logout(resp: Response): def logout(resp: Response):
clear_session(resp) clear_session(resp)
return {"ok": True} return {"ok": True}
@router.get("/me") @router.get("/me")
def me(req: Request, db: Session = Depends(get_db)): def me(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req) uid = get_session_user_id(req)
@@ -29,7 +40,44 @@ def me(req: Request, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == uid).first() user = db.query(User).filter(User.id == uid).first()
if not user: if not user:
raise HTTPException(status_code=401, detail="not logged in") raise HTTPException(status_code=401, detail="not logged in")
return {"id": user.id, "email": user.email, "role": user.role} return {"id": user.id, "email": user.email, "role": user.role, "display_name": user.display_name, "theme_key": user.theme_key}
@router.get("/me/stats")
def my_stats(req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
# "played" = games where user is member AND winner is set (finished games)
from sqlalchemy import func
from ..models import Game, GameMember
played = (
db.query(func.count(Game.id))
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid, Game.winner_user_id != None)
.scalar()
or 0
)
wins = (
db.query(func.count(Game.id))
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid, Game.winner_user_id == uid)
.scalar()
or 0
)
losses = max(int(played) - int(wins), 0)
winrate = (float(wins) / float(played) * 100.0) if played else 0.0
return {
"played": int(played),
"wins": int(wins),
"losses": int(losses),
"winrate": round(winrate, 1),
}
@router.patch("/password") @router.patch("/password")
def set_password(data: dict, req: Request, db: Session = Depends(get_db)): def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
@@ -48,5 +96,26 @@ def set_password(data: dict, req: Request, db: Session = Depends(get_db)):
user.password_hash = hash_password(password) user.password_hash = hash_password(password)
db.add(user) db.add(user)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@router.patch("/theme")
def set_theme(data: dict, req: Request, db: Session = Depends(get_db)):
uid = get_session_user_id(req)
if not uid:
raise HTTPException(status_code=401, detail="not logged in")
theme_key = (data.get("theme_key") or "").strip()
if not theme_key:
raise HTTPException(status_code=400, detail="theme_key required")
user = db.query(User).filter(User.id == uid, User.disabled == False).first()
if not user:
raise HTTPException(status_code=401, detail="not logged in")
user.theme_key = theme_key
db.add(user)
db.commit()
return {"ok": True, "theme_key": user.theme_key}

View File

@@ -2,45 +2,204 @@ import hashlib, random
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import Game, Entry, SheetState, Category 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"])
def require_user(req: Request, db: Session): def require_user(req: Request, db: Session):
uid = get_session_user_id(req) uid = get_session_user_id(req)
if not uid: if not uid:
raise HTTPException(status_code=401, detail="not logged in") raise HTTPException(status_code=401, detail="not logged in")
return uid return uid
def stable_order(seed: int, user_id: str, entry_id: str) -> str: def stable_order(seed: int, user_id: str, entry_id: str) -> str:
s = f"{seed}:{user_id}:{entry_id}".encode() s = f"{seed}:{user_id}:{entry_id}".encode()
return hashlib.sha256(s).hexdigest() return hashlib.sha256(s).hexdigest()
CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
def gen_code(n=6) -> str:
return "".join(random.choice(CODE_ALPHABET) for _ in range(n))
def ensure_member(db: Session, game_id: str, user_id: str):
ex = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if ex:
return
db.add(GameMember(game_id=game_id, user_id=user_id))
db.commit()
def require_game_member(db: Session, game_id: str, user_id: str) -> Game:
g = db.query(Game).filter(Game.id == game_id).first()
if not g:
raise HTTPException(404, "game not found")
mem = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == user_id).first()
if not mem:
raise HTTPException(403, "not a member of this game")
return g
@router.post("") @router.post("")
def create_game(req: Request, data: dict, db: Session = Depends(get_db)): def create_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
name = data.get("name") or "Neues Spiel" name = data.get("name") or "Neues Spiel"
seed = random.randint(1, 2_000_000_000) seed = random.randint(1, 2_000_000_000)
g = Game(owner_user_id=uid, name=name, seed=seed)
db.add(g); db.commit() # unique code
return {"id": g.id, "name": g.name} code = gen_code()
while db.query(Game).filter(Game.code == code).first():
code = gen_code()
g = Game(host_user_id=uid, name=name, seed=seed, code=code, winner_user_id=None)
db.add(g)
db.commit()
# creator joins automatically
ensure_member(db, g.id, uid)
return {"id": g.id, "name": g.name, "code": g.code, "host_user_id": g.host_user_id}
@router.post("/join")
def join_game(req: Request, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
code = (data.get("code") or "").strip().upper()
if not code:
raise HTTPException(400, "code required")
g = db.query(Game).filter(Game.code == code).first()
if not g:
raise HTTPException(404, "game not found")
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}
@router.get("") @router.get("")
def list_games(req: Request, db: Session = Depends(get_db)): def list_games(req: Request, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
games = db.query(Game).filter(Game.owner_user_id == uid).order_by(Game.created_at.desc()).all()
return [{"id": g.id, "name": g.name, "seed": g.seed} for g in games] # list games where user is member
q = (
db.query(Game)
.join(GameMember, GameMember.game_id == Game.id)
.filter(GameMember.user_id == uid)
.order_by(Game.created_at.desc())
)
games = q.all()
# winner email (optional)
out = []
for g in games:
winner_email = None
if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first()
winner_email = wu.email if wu else None
out.append(
{
"id": g.id,
"name": g.name,
"seed": g.seed,
"code": g.code,
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
}
)
return out
@router.get("/{game_id}")
def get_game_meta(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
winner_email = None
winner_display_name = None
if g.winner_user_id:
wu = db.query(User).filter(User.id == g.winner_user_id).first()
if wu:
winner_email = wu.email
winner_display_name = wu.display_name
return {
"id": g.id,
"name": g.name,
"code": g.code,
"host_user_id": g.host_user_id,
"winner_user_id": g.winner_user_id,
"winner_email": winner_email,
"winner_display_name": winner_display_name,
}
@router.get("/{game_id}/members")
def list_members(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db)
_g = require_game_member(db, game_id, uid)
# return only "user" role (admin excluded)
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()
)
return [{"id": u.id, "email": u.email, "display_name": u.display_name} for u in members]
@router.patch("/{game_id}/winner")
def set_winner(req: Request, game_id: str, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db)
g = require_game_member(db, game_id, uid)
# only host can set winner
if g.host_user_id != uid:
raise HTTPException(403, "only host can set winner")
winner_user_id = data.get("winner_user_id")
if winner_user_id is None:
g.winner_user_id = None
db.add(g)
db.commit()
return {"ok": True, "winner_user_id": None}
# must be a member AND role=user
member = db.query(GameMember).filter(GameMember.game_id == game_id, GameMember.user_id == winner_user_id).first()
if not member:
raise HTTPException(400, "winner must be a member of the game")
u = db.query(User).filter(User.id == winner_user_id).first()
if not u or u.role != Role.user.value or u.disabled:
raise HTTPException(400, "invalid winner")
g.winner_user_id = winner_user_id
db.add(g)
db.commit()
return {"ok": True, "winner_user_id": g.winner_user_id}
@router.get("/{game_id}/sheet") @router.get("/{game_id}/sheet")
def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)): def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() g = require_game_member(db, game_id, uid)
if not g:
raise HTTPException(404, "game not found")
entries = db.query(Entry).all() entries = db.query(Entry).all()
states = db.query(SheetState).filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid).all() states = (
db.query(SheetState)
.filter(SheetState.game_id == g.id, SheetState.owner_user_id == uid)
.all()
)
state_map = {st.entry_id: st for st in states} state_map = {st.entry_id: st for st in states}
out = {"suspect": [], "item": [], "location": []} out = {"suspect": [], "item": [], "location": []}
@@ -51,11 +210,11 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
"label": e.label, "label": e.label,
"status": st.status if st else 0, "status": st.status if st else 0,
"note_tag": st.note_tag if st else None, "note_tag": st.note_tag if st else None,
"chip": st.chip if st else None, # NEW
"order": stable_order(g.seed, uid, e.id), "order": stable_order(g.seed, uid, e.id),
} }
out[e.category].append(item) out[e.category].append(item)
# sort within category
for k in out: for k in out:
out[k].sort(key=lambda x: x["order"]) out[k].sort(key=lambda x: x["order"])
for i in out[k]: for i in out[k]:
@@ -63,29 +222,41 @@ def get_sheet(req: Request, game_id: str, db: Session = Depends(get_db)):
return out return out
@router.patch("/{game_id}/sheet/{entry_id}") @router.patch("/{game_id}/sheet/{entry_id}")
def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)): def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Session = Depends(get_db)):
uid = require_user(req, db) uid = require_user(req, db)
g = db.query(Game).filter(Game.id == game_id, Game.owner_user_id == uid).first() g = require_game_member(db, game_id, uid)
if not g:
raise HTTPException(404, "game not found")
status = data.get("status") status = data.get("status")
note_tag = data.get("note_tag") note_tag = data.get("note_tag")
chip = data.get("chip")
if note_tag not in (None, "i", "m", "s"): if note_tag not in (None, "i", "m", "s"):
raise HTTPException(400, "invalid note_tag") raise HTTPException(400, "invalid note_tag")
if status is not None and status not in (0, 1, 2, 3): if status is not None and status not in (0, 1, 2, 3):
raise HTTPException(400, "invalid status") raise HTTPException(400, "invalid status")
st = db.query(SheetState).filter( if chip is not None:
chip = (chip or "").strip().upper()
if chip == "":
chip = None
if chip is not None:
if len(chip) > 8:
raise HTTPException(400, "invalid chip")
st = (
db.query(SheetState)
.filter(
SheetState.game_id == g.id, SheetState.game_id == g.id,
SheetState.owner_user_id == uid, SheetState.owner_user_id == uid,
SheetState.entry_id == entry_id SheetState.entry_id == entry_id,
).first() )
.first()
)
if not st: if not st:
st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None) st = SheetState(game_id=g.id, owner_user_id=uid, entry_id=entry_id, status=0, note_tag=None, chip=None)
db.add(st) db.add(st)
if status is not None: if status is not None:
@@ -93,6 +264,17 @@ def patch_sheet(req: Request, game_id: str, entry_id: str, data: dict, db: Sessi
if "note_tag" in data: if "note_tag" in data:
st.note_tag = note_tag st.note_tag = note_tag
# wenn note_tag zurück auf null -> chip auch löschen
if note_tag is None:
st.chip = None
# chip nur speichern wenn note_tag "s" ist (ansonsten löschen wir es)
if "chip" in data:
if st.note_tag == "s":
st.chip = chip
else:
st.chip = None
db.commit() db.commit()
return {"ok": True} return {"ok": True}

View File

@@ -3,12 +3,180 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<title>Cluedo Sheet</title> <title>Cluedo Sheet</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <!-- Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=IM+Fell+English:ital@0;1&display=swap" rel="stylesheet"> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@400;700&family=IM+Fell+English:ital@0;1&display=swap"
rel="stylesheet"
/>
<style>
html,
body {
margin: 0;
height: 100%;
background: radial-gradient(
ellipse at top,
rgba(30, 30, 30, 0.95),
#000
);
}
/* Splash Overlay */
#app-splash {
position: fixed;
inset: 0;
z-index: 2147483647;
display: grid;
place-items: center;
background: radial-gradient(
ellipse at top,
rgba(30, 30, 30, 0.95),
#000
);
overflow: hidden;
opacity: 1;
transition: opacity 260ms ease;
}
#app-splash.hide {
opacity: 0;
pointer-events: none;
}
/* gold animated lines */
#app-splash::before,
#app-splash::after {
content: "";
position: absolute;
inset: -40%;
background:
linear-gradient(
90deg,
transparent,
rgba(233, 216, 166, 0.18),
transparent
);
transform: rotate(12deg);
animation: goldSweep 1.6s linear infinite;
opacity: 0.55;
filter: blur(0.2px);
pointer-events: none;
}
#app-splash::after {
transform: rotate(-12deg);
animation-duration: 2.2s;
opacity: 0.35;
}
@keyframes goldSweep {
0% { transform: translateX(-25%) rotate(12deg); }
100% { transform: translateX(25%) rotate(12deg); }
}
/* glassy card */
.splash-card {
position: relative;
width: min(520px, 90vw);
border-radius: 22px;
padding: 18px 16px;
background: rgba(20, 20, 22, 0.55);
border: 1px solid rgba(233, 216, 166, 0.16);
box-shadow: 0 18px 70px rgba(0,0,0,0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
text-align: center;
overflow: hidden;
}
.splash-card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(233,216,166,0.16), transparent);
opacity: 0.45;
pointer-events: none;
}
.splash-title {
position: relative;
font-family: "Cinzel Decorative", serif;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(245, 239, 220, 0.92);
font-size: 18px;
line-height: 1.25;
}
.splash-sub {
position: relative;
margin-top: 6px;
font-family: "IM Fell English", serif;
font-style: italic;
color: rgba(233, 216, 166, 0.78);
font-size: 13px;
}
/* Loader */
.loader {
position: relative;
margin: 14px auto 0;
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid rgba(233, 216, 166, 0.18);
border-top-color: rgba(233, 216, 166, 0.92);
animation: spin 0.85s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* tiny spark at bottom */
.spark {
position: relative;
margin: 14px auto 0;
width: 160px;
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, transparent, rgba(233,216,166,0.65), transparent);
opacity: 0.6;
filter: blur(0.2px);
animation: pulse 1.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.35; transform: scaleX(0.92); }
50% { opacity: 0.85; transform: scaleX(1.02); }
}
</style>
<!-- Theme-Key sofort setzen -->
<script>
try {
const k = localStorage.getItem("hpTheme:guest") || "default";
document.documentElement.setAttribute("data-theme", k);
} catch {}
</script>
</head> </head>
<body> <body>
<div id="app-splash" aria-hidden="true">
<div class="splash-card">
<div class="splash-title">Zauber-Detektiv Notizbogen</div>
<div class="splash-sub">Magie wird vorbereitet…</div>
<div class="loader" aria-label="Laden"></div>
<div class="spark"></div>
</div>
</div>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.jsx"></script> <script type="module" src="/src/main.jsx"></script>
</body> </body>

View File

@@ -9,7 +9,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"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.3.1", "@vitejs/plugin-react": "^4.3.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1022 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

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

File diff suppressed because it is too large Load Diff

379
frontend/src/AppLayout.css Normal file
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;
}
}

View File

@@ -0,0 +1,11 @@
import { API_BASE } from "../constants";
export async function api(path, opts = {}) {
const res = await fetch(API_BASE + path, {
credentials: "include",
headers: { "Content-Type": "application/json" },
...opts,
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useState } from "react";
import { api } from "../api/client";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { createPortal } from "react-dom";
export default function AdminPanel() {
const [users, setUsers] = useState([]);
const [open, setOpen] = useState(false);
const [displayName, setDisplayName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("user");
const [msg, setMsg] = useState("");
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, [open]);
const loadUsers = async () => {
const u = await api("/admin/users");
setUsers(u);
};
useEffect(() => {
loadUsers().catch(() => {});
}, []);
const resetForm = () => {
setDisplayName("");
setEmail("");
setPassword("");
setRole("user");
};
const createUser = async () => {
setMsg("");
try {
await api("/admin/users", {
method: "POST",
body: JSON.stringify({ display_name: displayName, email, password, role }),
});
setMsg("✅ User erstellt.");
await loadUsers();
resetForm();
setOpen(false);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
}
};
const deleteUser = async (u) => {
if (!window.confirm(`User wirklich löschen (deaktivieren)?\n\n${u.display_name || u.email}`)) return;
try {
await api(`/admin/users/${u.id}`, { method: "DELETE" });
await loadUsers();
} catch (e) {
alert("Fehler: " + (e?.message || "unknown"));
}
};
const closeModal = () => {
setOpen(false);
setMsg("");
};
return (
<div style={styles.adminWrap}>
<div style={styles.adminTop}>
<div style={styles.adminTitle}>Admin Dashboard</div>
<button onClick={() => setOpen(true)} style={styles.primaryBtn}>
+ User anlegen
</button>
</div>
<div style={{ marginTop: 12, fontWeight: 900, color: stylesTokens.textGold }}>
Vorhandene User
</div>
<div style={{ marginTop: 8, display: "grid", gap: 8 }}>
{users.map((u) => (
<div
key={u.id}
style={{
...styles.userRow,
gridTemplateColumns: "1fr 1fr 80px 90px 92px",
alignItems: "center",
}}
>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
{u.display_name || "—"}
</div>
<div style={{ color: stylesTokens.textDim, fontSize: 13 }}>{u.email}</div>
<div style={{ textAlign: "center", fontWeight: 900, color: stylesTokens.textGold }}>
{u.role}
</div>
<div style={{ textAlign: "center", opacity: 0.85, color: stylesTokens.textMain }}>
{u.disabled ? "disabled" : "active"}
</div>
<button
onClick={() => deleteUser(u)}
style={{
...styles.secondaryBtn,
padding: "8px 10px",
borderRadius: 12,
color: "#ffb3b3",
opacity: u.role === "admin" ? 0.4 : 1,
pointerEvents: u.role === "admin" ? "none" : "auto",
}}
title={u.role === "admin" ? "Admin kann nicht gelöscht werden" : "User löschen (deaktivieren)"}
>
Löschen
</button>
</div>
))}
</div>
{open &&
createPortal(
<div style={styles.modalOverlay} onMouseDown={closeModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Neuen User anlegen
</div>
<button onClick={closeModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div
style={{
marginTop: 12,
display: "grid",
gap: 8,
justifyItems: "center", // <<< zentriert alles
}}
>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Name (z.B. Sascha)"
style={styles.input}
autoFocus
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
style={styles.input}
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Initial Passwort"
type="password"
style={styles.input}
/>
<select value={role} onChange={(e) => setRole(e.target.value)} style={styles.input}>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
{msg && <div style={{ opacity: 0.9, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button
onClick={() => {
resetForm();
setMsg("");
}}
style={styles.secondaryBtn}
>
Leeren
</button>
<button onClick={createUser} style={styles.primaryBtn}>
User erstellen
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Name wird in TopBar & Siegeranzeige genutzt.
</div>
</div>
</div>
</div>,
document.body
)
}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { CHIP_LIST } from "../constants";
export default function ChipModal({ chipOpen, closeChipModalToDash, chooseChip }) {
if (!chipOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closeChipModalToDash}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Wer hat die Karte?</div>
<button onClick={closeChipModalToDash} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain }}>Chip auswählen:</div>
<div style={styles.chipGrid}>
{CHIP_LIST.map((c) => (
<button key={c} onClick={() => chooseChip(c)} style={styles.chipBtn}>
{c}
</button>
))}
</div>
<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 .
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
import { THEMES } from "../styles/themes";
export default function DesignModal({ open, onClose, themeKey, onSelect }) {
if (!open) return null;
const themeEntries = Object.entries(THEMES);
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Design ändern</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, color: stylesTokens.textMain, opacity: 0.92 }}>
Wähle dein Theme:
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
{themeEntries.map(([key, t]) => {
const active = key === themeKey;
return (
<button
key={key}
onClick={() => onSelect(key)}
style={{
...styles.secondaryBtn,
textAlign: "left",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
border: active
? `1px solid rgba(233,216,166,0.40)`
: `1px solid rgba(233,216,166,0.18)`,
}}
>
<span style={{ fontWeight: 1000 }}>{t.label}</span>
<span style={{ opacity: 0.75 }}>{active ? "✓ aktiv" : ""}</span>
</button>
);
})}
</div>
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Das Design wird pro User gespeichert (Email).
</div>
</div>
</div>
);
}

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>
);
};

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>
);
}

View File

@@ -0,0 +1,142 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function GamePickerCard({
games,
gameId,
setGameId,
onOpenHelp,
members = [],
me,
hostUserId,
}) {
const cur = games.find((x) => x.id === gameId);
const renderMemberName = (m) => {
const base = ((m.display_name || "").trim() || (m.email || "").trim() || "—");
const isMe = !!(me?.id && String(me.id) === String(m.id));
const isHost = !!(hostUserId && String(hostUserId) === String(m.id));
const suffix = `${isHost ? " " : ""}${isMe ? " (du)" : ""}`;
return base + suffix;
};
const pillStyle = (isHost, isMe) => ({
padding: "7px 10px",
borderRadius: 999,
border: `1px solid ${
isHost ? "rgba(233,216,166,0.35)" : "rgba(233,216,166,0.16)"
}`,
background: isHost
? "linear-gradient(180deg, rgba(233,216,166,0.14), rgba(10,10,12,0.35))"
: "rgba(10,10,12,0.30)",
color: stylesTokens.textMain,
fontSize: 13,
fontWeight: 950,
boxShadow: isHost ? "0 8px 18px rgba(0,0,0,0.25)" : "none",
opacity: isMe ? 1 : 0.95,
display: "inline-flex",
alignItems: "center",
gap: 6,
whiteSpace: "nowrap",
});
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Spiel</div>
<div style={styles.cardBody}>
<select
value={gameId || ""}
onChange={(e) => setGameId(e.target.value)}
style={{ ...styles.input, flex: 1 }}
>
{games.map((g) => (
<option key={g.id} value={g.id}>
{g.name} {g.code ? `${g.code}` : ""}
</option>
))}
</select>
<button onClick={onOpenHelp} style={styles.helpBtn} title="Hilfe">
Hilfe
</button>
</div>
{/* Code Zeile */}
{cur?.code && (
<div
style={{
padding: "0 12px 10px",
fontSize: 12,
color: stylesTokens.textDim,
opacity: 0.92,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<div>
Code: <b style={{ color: stylesTokens.textGold }}>{cur.code}</b>
</div>
</div>
)}
{/* Spieler */}
{members?.length > 0 && (
<div style={{ padding: "0 12px 12px" }}>
<div
style={{
fontSize: 12,
opacity: 0.85,
color: stylesTokens.textDim,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<div>Spieler</div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>
{members.length}
</div>
</div>
<div
style={{
marginTop: 8,
padding: 10,
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.10)`,
background: "rgba(10,10,12,0.18)",
display: "flex",
flexWrap: "wrap",
gap: 8,
}}
>
{members.map((m) => {
const isMe = !!(me?.id && String(me.id) === String(m.id));
const isHost = !!(hostUserId && String(hostUserId) === String(m.id));
const label = renderMemberName(m);
return (
<div key={m.id} style={pillStyle(isHost, isMe)} title={label}>
{isHost && <span style={{ color: stylesTokens.textGold }}>👑</span>}
<span>{label.replace(" 👑", "")}</span>
</div>
);
})}
</div>
<div style={{ marginTop: 6, fontSize: 11, opacity: 0.7, color: stylesTokens.textDim }}>
👑 = Host
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
// src/components/HelpModal.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function HelpModal({ open, onClose }) {
if (!open) return null;
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Hilfe</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={styles.helpBody}>
{/* ===== 0) Spiele & Navigation ===== */}
<div style={styles.helpSectionTitle}>0) Spiele auswählen / Neues Spiel</div>
<div style={styles.helpText}>
Oben im Bereich <b>Spiel</b> kannst du zwischen bestehenden Spielen wechseln oder ein neues
Spiel erstellen:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b>Spiel-Auswahl</b> (Dropdown neben dem Hilfe-Button) = vorhandene / alte Spiele öffnen
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b>Neues Spiel</b> = erstellt ein neues Spiel und öffnet es automatisch
</div>
</div>
</div>
<div style={styles.helpDivider} />
{/* ===== 1) Status per Tippen ===== */}
<div style={styles.helpSectionTitle}>1) Namen antippen (Status)</div>
<div style={styles.helpText}>
Tippe auf einen Namen, um den Status zu ändern. Reihenfolge:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(0,190,80,0.18)",
color: "#baf3c9",
}}
>
</span>
<div>
<b>Grün</b> = bestätigt / fix richtig
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,35,35,0.18)",
color: "#ffb3b3",
}}
>
</span>
<div>
<b>Rot</b> = ausgeschlossen / fix falsch
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(140,140,140,0.14)",
color: "rgba(233,216,166,0.85)",
}}
>
?
</span>
<div>
<b>Grau</b> = unsicher / vielleicht
</div>
</div>
<div style={styles.helpListRow}>
<span
style={{
...styles.helpBadge,
background: "rgba(255,255,255,0.08)",
color: "rgba(233,216,166,0.75)",
}}
>
</span>
<div>
<b>Leer</b> = noch nicht bewertet
</div>
</div>
</div>
<div style={styles.helpDivider} />
{/* ===== 2) i / m / s Notizen ===== */}
<div style={styles.helpSectionTitle}>2) i / m / s Button (Notizen)</div>
<div style={styles.helpText}>
Rechts pro Zeile gibt es einen Button, der durch diese Werte rotiert:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>i</span>
<div>
<b>i</b> = Ich habe diese Karte
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>m</span>
<div>
<b>m</b> = Karte aus dem mittleren Deck
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>s</span>
<div>
<b>s</b> = Ein anderer Spieler hat die Karte danach Chip auswählen (z.B. <b>s.AL</b>)
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b></b> = keine Notiz
</div>
</div>
</div>
<div style={styles.helpDivider} />
{/* ===== 3) User-Menü ===== */}
<div style={styles.helpSectionTitle}>3) User-Menü (Passwort / Logout)</div>
<div style={styles.helpText}>
Oben rechts im <b>User</b>-Menü findest du persönliche Einstellungen:
</div>
<div style={styles.helpList}>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>👤</span>
<div>
<b>User</b> öffnen = zeigt die aktuell verwendete Email-Adresse
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}>🔒</span>
<div>
<b>Passwort setzen</b> = eigenes Passwort ändern
</div>
</div>
<div style={styles.helpListRow}>
<span style={styles.helpMiniTag}></span>
<div>
<b>Logout</b> = ausloggen
</div>
</div>
</div>
<div style={styles.helpDivider} />
<div style={styles.helpText}>
Tipp: Jeder Spieler sieht nur seine eigenen Notizen andere Spieler können nicht in deinen
Zettel schauen.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,74 @@
// src/components/JoinGameModal.jsx
import React, { useEffect, useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function JoinGameModal({ open, onClose, onJoin }) {
const [code, setCode] = useState("");
const [msg, setMsg] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
setCode("");
setMsg("");
setBusy(false);
}, [open]);
if (!open) return null;
const doJoin = async () => {
const c = (code || "").trim();
if (!c) return setMsg("❌ Bitte Code eingeben.");
setBusy(true);
setMsg("");
try {
await onJoin(c);
} catch (e) {
setMsg("❌ Fehler: " + (e?.message || "unknown"));
setBusy(false);
}
};
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Spiel beitreten</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="z.B. 123456"
style={styles.input}
inputMode="numeric"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") doJoin();
}}
/>
{msg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{msg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={onClose} style={styles.secondaryBtn} disabled={busy}>
Abbrechen
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={busy}>
{busy ? "Beitreten..." : "Beitreten"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Tipp: Der Spiel-Code steht beim Host unter dem Spiel-Dropdown.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import { styles } from "../styles/styles";
export default function LoginPage({
loginEmail,
setLoginEmail,
loginPassword,
setLoginPassword,
showPw,
setShowPw,
doLogin,
}) {
return (
<div style={styles.loginPage}>
<div style={styles.bgFixed} aria-hidden="true">
<div style={styles.bgMap} />
</div>
<div style={styles.candleGlowLayer} aria-hidden="true" />
<div style={styles.loginCard}>
<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={{ marginTop: 18, display: "grid", gap: 12 }}>
<div style={styles.loginFieldWrap}>
<input
value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)}
placeholder="Email"
style={styles.loginInput}
inputMode="email"
autoComplete="username"
/>
</div>
<div style={styles.loginFieldWrap}>
<div style={styles.inputRow}>
<input
value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)}
placeholder="Passwort"
type={showPw ? "text" : "password"}
style={styles.inputInRow}
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPw((v) => !v)}
style={styles.pwToggleBtn}
aria-label={showPw ? "Passwort verstecken" : "Passwort anzeigen"}
title={showPw ? "Verstecken" : "Anzeigen"}
>
{showPw ? "🙈" : "👁"}
</button>
</div>
</div>
<button onClick={doLogin} style={styles.loginBtn}>
Anmelden
</button>
</div>
<div style={styles.loginHint}>
Deine Notizen bleiben privat jeder Spieler sieht nur seinen eigenen Zettel.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,40 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import { styles } from "../styles/styles";
export default function ModalPortal({ open, onClose, children }) {
useEffect(() => {
if (!open) return;
const onKeyDown = (e) => {
if (e.key === "Escape") onClose?.();
};
// Scroll der Seite sperren
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
style={styles.modalOverlay}
onMouseDown={(e) => {
// Klick außerhalb schließt
if (e.target === e.currentTarget) onClose?.();
}}
>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
{children}
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,265 @@
import React, { useEffect, useMemo, useState } from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function NewGameModal({
open,
onClose,
onCreate,
onJoin,
// ✅ neu:
currentCode = "",
gameFinished = false,
hasGame = false,
}) {
// modes: running | choice | create | join
const [mode, setMode] = useState("choice");
const [joinCode, setJoinCode] = useState("");
const [err, setErr] = useState("");
const [created, setCreated] = useState(null); // { code }
const [toast, setToast] = useState("");
const canJoin = useMemo(() => joinCode.trim().length >= 4, [joinCode]);
// ✅ wichtig: beim Öffnen entscheidet der Modus anhand "läuft vs beendet"
useEffect(() => {
if (!open) return;
setErr("");
setToast("");
setJoinCode("");
setCreated(null);
// Wenn ein Spiel läuft (und nicht finished) -> nur Code anzeigen
if (hasGame && !gameFinished) {
setMode("running");
} else {
setMode("choice");
}
}, [open, hasGame, gameFinished]);
if (!open) return null;
const showToast = (msg) => {
setToast(msg);
setTimeout(() => setToast(""), 1100);
};
const doCreate = async () => {
setErr("");
try {
const res = await onCreate();
setCreated({ code: res.code });
setMode("create");
} catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown"));
}
};
const doJoin = async () => {
setErr("");
try {
await onJoin(joinCode.trim().toUpperCase());
onClose();
} catch (e) {
setErr("❌ Fehler: " + (e?.message || "unknown"));
}
};
const copyText = async (text, okMsg = "✅ Code kopiert") => {
try {
await navigator.clipboard.writeText(text || "");
showToast(okMsg);
} catch {
showToast("❌ Copy nicht möglich");
}
};
const codeToShow =
(created?.code || "").trim() ||
(currentCode || "").trim();
return (
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>
Spiel
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
{/* Toast */}
{toast && (
<div
style={{
marginTop: 10,
padding: "10px 12px",
borderRadius: 12,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
color: stylesTokens.textMain,
fontWeight: 900,
textAlign: "center",
animation: "fadeIn 120ms ease-out",
}}
>
{toast}
</div>
)}
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
{/* ✅ RUNNING: Nur Code anzeigen, keine Choice */}
{mode === "running" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Das Spiel läuft noch. Hier ist der <b>Join-Code</b>:
</div>
<div
style={{
display: "grid",
gap: 8,
padding: 12,
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
textAlign: "center",
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
</div>
<div
style={{
fontSize: 28,
fontWeight: 1100,
letterSpacing: 2,
color: stylesTokens.textGold,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
}}
>
{codeToShow || "—"}
</div>
<button
onClick={() => copyText(codeToShow)}
style={styles.primaryBtn}
disabled={!codeToShow}
title={!codeToShow ? "Kein Code verfügbar" : "Code kopieren"}
>
Code kopieren
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Sobald ein Sieger gesetzt wurde, kannst du hier ein neues Spiel erstellen oder beitreten.
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
</button>
</div>
</>
)}
{/* ✅ CHOICE: nur wenn Spiel beendet oder kein Spiel selected */}
{mode === "choice" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Willst du ein Spiel <b>erstellen</b> oder einem Spiel <b>beitreten</b>?
</div>
<button onClick={doCreate} style={styles.primaryBtn}>
Spiel erstellen
</button>
<button onClick={() => setMode("join")} style={styles.secondaryBtn}>
Spiel beitreten
</button>
</>
)}
{mode === "join" && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Gib den <b>Code</b> ein:
</div>
<input
value={joinCode}
onChange={(e) => setJoinCode(e.target.value.toUpperCase())}
placeholder="z.B. 8K3MZQ"
style={styles.input}
autoFocus
/>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={() => setMode("choice")} style={styles.secondaryBtn}>
Zurück
</button>
<button onClick={doJoin} style={styles.primaryBtn} disabled={!canJoin}>
Beitreten
</button>
</div>
</>
)}
{mode === "create" && created && (
<>
<div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>
Dein Spiel wurde erstellt. Dieser Code bleibt auch bei Alte Spiele sichtbar:
</div>
<div
style={{
display: "grid",
gap: 8,
padding: 12,
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
textAlign: "center",
}}
>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
Spiel-Code
</div>
<div
style={{
fontSize: 28,
fontWeight: 1100,
letterSpacing: 2,
color: stylesTokens.textGold,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
}}
>
{created.code}
</div>
<button onClick={() => copyText(created?.code || "")} style={styles.primaryBtn}>
Code kopieren
</button>
</div>
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
<button onClick={onClose} style={styles.primaryBtn}>
Fertig
</button>
</div>
</>
)}
{err && <div style={{ color: stylesTokens.textMain, opacity: 0.92 }}>{err}</div>}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function PasswordModal({
pwOpen,
closePwModal,
pw1,
setPw1,
pw2,
setPw2,
pwMsg,
pwSaving,
savePassword,
}) {
if (!pwOpen) return null;
return (
<div style={styles.modalOverlay} onMouseDown={closePwModal}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Passwort setzen</div>
<button onClick={closePwModal} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12, display: "grid", gap: 10 }}>
<input
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Neues Passwort"
type="password"
style={styles.input}
autoFocus
/>
<input
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Neues Passwort wiederholen"
type="password"
style={styles.input}
onKeyDown={(e) => {
if (e.key === "Enter") savePassword();
}}
/>
{pwMsg && <div style={{ opacity: 0.92, color: stylesTokens.textMain }}>{pwMsg}</div>}
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end", marginTop: 4 }}>
<button onClick={closePwModal} style={styles.secondaryBtn} disabled={pwSaving}>
Abbrechen
</button>
<button onClick={savePassword} style={styles.primaryBtn} disabled={pwSaving}>
{pwSaving ? "Speichern..." : "Speichern"}
</button>
</div>
<div style={{ fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Mindestens 8 Zeichen empfohlen.
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
// src/components/SheetSection.jsx
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function SheetSection({
title,
entries,
pulseId,
onCycleStatus,
onToggleTag,
displayTag,
}) {
const getRowBg = (status) => {
if (status === 1) return stylesTokens.rowNoBg;
if (status === 2) return stylesTokens.rowOkBg;
if (status === 3) return stylesTokens.rowMaybeBg;
if (status === 0) return stylesTokens.rowEmptyBg;
return stylesTokens.rowDefaultBg;
};
const getNameColor = (status) => {
if (status === 1) return stylesTokens.rowNoText;
if (status === 2) return stylesTokens.rowOkText;
if (status === 3) return stylesTokens.rowMaybeText;
return stylesTokens.textMain;
};
const getStatusSymbol = (status) => {
if (status === 2) return "✓";
if (status === 1) return "✕";
if (status === 3) return "?";
return "";
};
const getStatusBadge = (status) => {
if (status === 2) return { color: stylesTokens.badgeOkText, background: stylesTokens.badgeOkBg };
if (status === 1) return { color: stylesTokens.badgeNoText, background: stylesTokens.badgeNoBg };
if (status === 3) return { color: stylesTokens.badgeMaybeText, background: stylesTokens.badgeMaybeBg };
return { color: stylesTokens.badgeEmptyText, background: stylesTokens.badgeEmptyBg };
};
const getBorderLeft = (status) => {
if (status === 2) return `4px solid ${stylesTokens.rowOkBorder}`;
if (status === 1) return `4px solid ${stylesTokens.rowNoBorder}`;
if (status === 3) return `4px solid ${stylesTokens.rowMaybeBorder}`;
return `4px solid ${stylesTokens.rowEmptyBorder}`;
};
return (
<div style={styles.card}>
<div style={styles.sectionHeader}>{title}</div>
<div style={{ display: "grid" }}>
{entries.map((e) => {
const isIorMorS = e.note_tag === "i" || e.note_tag === "m" || e.note_tag === "s";
const effectiveStatus = e.status === 0 && isIorMorS ? 1 : e.status;
const badge = getStatusBadge(effectiveStatus);
return (
<div
key={e.entry_id}
className="hp-row"
style={{
...styles.row,
background: getRowBg(effectiveStatus),
animation: pulseId === e.entry_id ? "rowPulse 220ms ease-out" : "none",
borderLeft: getBorderLeft(effectiveStatus),
}}
>
<div
onClick={() => onCycleStatus(e)}
style={{
...styles.name,
textDecoration: effectiveStatus === 1 ? "line-through" : "none",
color: getNameColor(effectiveStatus),
opacity: effectiveStatus === 1 ? 0.8 : 1,
}}
title="Klick: Grün → Rot → Grau → Leer"
>
{e.label}
</div>
<div style={styles.statusCell}>
<span
style={{
...styles.statusBadge,
color: badge.color,
background: badge.background,
}}
>
{getStatusSymbol(effectiveStatus)}
</span>
</div>
<button
onClick={() => onToggleTag(e)}
style={styles.tagBtn}
title="— → i → m → s.(Chip) → —"
>
{displayTag(e)}
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import React from "react";
import { createPortal } from "react-dom";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
function Tile({ label, value, sub }) {
return (
<div
style={{
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.16)`,
background: "rgba(10,10,12,0.55)",
padding: 12,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
}}
>
<div
style={{
fontSize: 11,
opacity: 0.8,
color: stylesTokens.textDim,
letterSpacing: 0.6,
textTransform: "uppercase",
}}
>
{label}
</div>
<div
style={{
marginTop: 6,
fontWeight: 1000,
fontSize: 26,
lineHeight: "30px",
color: stylesTokens.textGold,
}}
>
{value}
</div>
{sub ? (
<div style={{ marginTop: 2, fontSize: 12, opacity: 0.85, color: stylesTokens.textDim }}>
{sub}
</div>
) : null}
</div>
);
}
export default function StatsModal({ open, onClose, me, stats, loading, error }) {
if (!open) return null;
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
return createPortal(
<div style={styles.modalOverlay} onMouseDown={onClose}>
<div style={styles.modalCard} onMouseDown={(e) => e.stopPropagation()}>
<div style={styles.modalHeader}>
<div>
<div style={{ fontWeight: 1000, color: stylesTokens.textGold }}>Statistik</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
</div>
<button onClick={onClose} style={styles.modalCloseBtn} aria-label="Schließen">
</button>
</div>
<div style={{ marginTop: 12 }}>
{loading ? (
<div style={{ padding: 10, color: stylesTokens.textDim, opacity: 0.9 }}>
Lade Statistik
</div>
) : error ? (
<div style={{ padding: 10, color: "#ffb3b3" }}>{error}</div>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 10,
}}
>
<Tile label="Gespielte Spiele" value={stats?.played ?? 0} />
<Tile label="Siege" value={stats?.wins ?? 0} />
<Tile label="Verluste" value={stats?.losses ?? 0} />
<Tile label="Siegerate" value={`${stats?.winrate ?? 0}%`} sub="nur beendete Spiele" />
</div>
)}
</div>
<div style={{ marginTop: 12, fontSize: 12, opacity: 0.75, color: stylesTokens.textDim }}>
Hinweis: Gespielt zählt nur Spiele mit gesetztem Sieger.
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,93 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function TopBar({
me,
userMenuOpen,
setUserMenuOpen,
openPwModal,
openDesignModal,
openStatsModal,
doLogout,
onOpenNewGame,
}) {
const displayName = me ? ((me.display_name || "").trim() || me.email) : "";
return (
<div style={styles.topBar}>
<div>
<div style={{ fontWeight: 900, color: stylesTokens.textGold }}>Notizbogen</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
{displayName}
</div>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "nowrap" }} data-user-menu>
<div style={{ position: "relative" }}>
<button
onClick={() => setUserMenuOpen((v) => !v)}
style={styles.userBtn}
title="User Menü"
>
<span style={{ fontSize: 16 }}>👤</span>
<span>User</span>
<span style={{ opacity: 0.7 }}></span>
</button>
{userMenuOpen && (
<div style={styles.userDropdown}>
<div
style={{
padding: "10px 12px",
fontSize: 13,
opacity: 0.85,
color: stylesTokens.textDim,
borderBottom: "1px solid rgba(233,216,166,0.12)",
}}
>
{me?.email || ""}
</div>
<button
onClick={() => {
setUserMenuOpen(false);
openStatsModal?.();
}}
style={styles.userDropdownItem}
>
Statistik
</button>
<div style={styles.userDropdownDivider} />
<button onClick={openPwModal} style={styles.userDropdownItem}>
Passwort setzen
</button>
<button onClick={openDesignModal} style={styles.userDropdownItem}>
Design ändern
</button>
<div style={styles.userDropdownDivider} />
<button
onClick={() => {
setUserMenuOpen(false);
doLogout();
}}
style={{ ...styles.userDropdownItem, color: "#ffb3b3" }}
>
Logout
</button>
</div>
)}
</div>
<button onClick={onOpenNewGame} style={styles.primaryBtn}>
New Game
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
import { stylesTokens } from "../styles/theme";
/**
* Props:
* - winner: { display_name?: string, email?: string } | null
* - winnerEmail: string | null (legacy fallback)
*/
export default function WinnerBadge({ winner, winnerEmail }) {
const name =
(winner?.display_name || "").trim() ||
(winner?.email || "").trim() ||
(winnerEmail || "").trim();
if (!name) return null;
return (
<div
style={{
marginTop: 14,
padding: "10px 12px",
borderRadius: 16,
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 12px 30px rgba(0,0,0,0.35)",
backdropFilter: "blur(6px)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ fontSize: 18 }}>🏆</div>
<div style={{ color: stylesTokens.textMain, fontWeight: 900 }}>
Sieger:
<span style={{ color: stylesTokens.textGold }}>{" "}{name}</span>
</div>
</div>
<div style={{ fontSize: 12, opacity: 0.8, color: stylesTokens.textDim }}>
festgelegt
</div>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import React from "react";
import { styles } from "../styles/styles";
import { stylesTokens } from "../styles/theme";
export default function WinnerCard({
isHost,
members,
winnerUserId,
setWinnerUserId,
onSave,
}) {
if (!isHost) return null;
return (
<div style={{ marginTop: 14 }}>
<div style={styles.card}>
<div style={styles.sectionHeader}>Sieger</div>
<div style={styles.cardBody}>
<select
value={winnerUserId || ""}
onChange={(e) => setWinnerUserId(e.target.value || "")}
style={{ ...styles.input, flex: 1 }}
>
<option value=""> kein Sieger </option>
{members.map((m) => {
const dn = ((m.display_name || "").trim() || (m.email || "").trim());
return (
<option key={m.id} value={m.id}>
{dn}
</option>
);
})}
</select>
<button onClick={onSave} style={styles.primaryBtn}>
Speichern
</button>
</div>
<div style={{ padding: "0 12px 12px", fontSize: 12, color: stylesTokens.textDim, opacity: 0.9 }}>
Nur der Host (Spiel-Ersteller) kann den Sieger setzen.
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import React, { useEffect } from "react";
import { createPortal } from "react-dom";
import confetti from "canvas-confetti";
import { stylesTokens } from "../styles/theme";
export default function WinnerCelebration({ open, winnerName, onClose }) {
useEffect(() => {
if (!open) return;
// Scroll lock
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const reduceMotion =
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduceMotion) {
const end = Date.now() + 4500;
// WICHTIG: über dem Overlay rendern
const TOP_Z = 2147483647;
// hellere Farben damits auch auf dark overlay knallt
const bright = ["#ffffff", "#ffd166", "#06d6a0", "#4cc9f0", "#f72585"];
// 2 große Bursts
confetti({
particleCount: 170,
spread: 95,
startVelocity: 42,
origin: { x: 0.12, y: 0.62 },
zIndex: TOP_Z,
colors: bright,
});
confetti({
particleCount: 170,
spread: 95,
startVelocity: 42,
origin: { x: 0.88, y: 0.62 },
zIndex: TOP_Z,
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);
return () => {
clearTimeout(t);
document.body.style.overflow = prevOverflow;
};
}, [open, onClose]);
useEffect(() => {
if (!open) return;
const onKey = (e) => e.key === "Escape" && onClose?.();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
const node = (
<div
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
zIndex: 2147483646,
display: "flex",
alignItems: "center",
justifyContent: "center",
// weniger dunkel -> Confetti wirkt heller
background: "rgba(0,0,0,0.42)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
padding: 14,
}}
onMouseDown={onClose}
>
<div
onMouseDown={(e) => e.stopPropagation()}
style={{
// kleiner + mobile friendly
width: "min(420px, 90vw)",
borderRadius: 18,
padding: "14px 14px",
border: `1px solid ${stylesTokens.panelBorder}`,
background: stylesTokens.panelBg,
boxShadow: "0 18px 70px rgba(0,0,0,0.55)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
position: "relative",
overflow: "hidden",
}}
>
{/* dezente “Gold Line” */}
<div
style={{
position: "absolute",
inset: 0,
background: `linear-gradient(90deg, transparent, ${stylesTokens.goldLine}, transparent)`,
opacity: 0.32,
pointerEvents: "none",
}}
/>
{/* shine */}
<div
style={{
position: "absolute",
top: -70,
right: -120,
width: 240,
height: 200,
background: `radial-gradient(circle at 30% 30%, ${stylesTokens.goldLine}, transparent 60%)`,
opacity: 0.12,
transform: "rotate(12deg)",
pointerEvents: "none",
}}
/>
<div style={{ position: "relative", display: "grid", gap: 8 }}>
<div style={{ fontSize: 30, lineHeight: 1 }}>🏆</div>
<div
style={{
fontSize: 16,
fontWeight: 900,
color: stylesTokens.textMain,
lineHeight: 1.25,
}}
>
Spieler{" "}
<span style={{ color: stylesTokens.textGold }}>
{winnerName || "Unbekannt"}
</span>{" "}
hat die richtige Lösung!
</div>
<div style={{ color: stylesTokens.textDim, opacity: 0.95, fontSize: 13 }}>
Fall gelöst. Respekt.
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 6 }}>
<button
onClick={onClose}
style={{
padding: "9px 11px",
borderRadius: 12,
border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
}}
>
OK
</button>
</div>
</div>
</div>
</div>
);
return createPortal(node, document.body);
}

View File

@@ -0,0 +1,2 @@
export const API_BASE = "/api";
export const CHIP_LIST = ["AL", "JG", "JN", "SN", "TL"];

View File

@@ -1,7 +1,51 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { applyTheme, DEFAULT_THEME_KEY } from "./styles/themes";
import { registerSW } from "virtual:pwa-register"; import { registerSW } from "virtual:pwa-register";
createRoot(document.getElementById("root")).render(<App />); async function bootstrap() {
registerSW({ immediate: true }); // Theme sofort setzen
try {
const key = localStorage.getItem("hpTheme:guest") || DEFAULT_THEME_KEY;
applyTheme(key);
} catch {
applyTheme(DEFAULT_THEME_KEY);
}
// Fonts abwarten (verhindert Layout-Sprung)
try {
if (document.fonts?.ready) {
await document.fonts.ready;
}
} catch {}
// App rendern
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
// Splash mind. 3 Sekunden anzeigen (3000ms)
const MIN_SPLASH_MS = 3000;
const tStart = performance.now();
const hideSplash = () => {
const splash = document.getElementById("app-splash");
if (!splash) return;
splash.classList.add("hide");
setTimeout(() => splash.remove(), 320);
};
const elapsed = performance.now() - tStart;
const remaining = Math.max(0, MIN_SPLASH_MS - elapsed);
setTimeout(hideSplash, remaining);
// Service Worker ohne Auto-Reload-Flash
registerSW({
immediate: true,
onNeedRefresh() {
console.info("Neue Version verfügbar");
// optional: später Toast "Update verfügbar"
},
});
}
bootstrap();

View File

@@ -0,0 +1,89 @@
import { useEffect } from "react";
import { stylesTokens } from "../theme";
import { applyTheme } from "../themes";
export function useHpGlobalStyles() {
// Google Fonts
useEffect(() => {
if (document.getElementById("hp-fonts")) return;
const pre1 = document.createElement("link");
pre1.id = "hp-fonts-pre1";
pre1.rel = "preconnect";
pre1.href = "https://fonts.googleapis.com";
const pre2 = document.createElement("link");
pre2.id = "hp-fonts-pre2";
pre2.rel = "preconnect";
pre2.href = "https://fonts.gstatic.com";
pre2.crossOrigin = "anonymous";
const link = document.createElement("link");
link.id = "hp-fonts";
link.rel = "stylesheet";
link.href =
"https://fonts.googleapis.com/css2?family=Cinzel+Decorative:wght@700;900&family=IM+Fell+English:ital,wght@0,400;0,700;1,400&display=swap";
document.head.appendChild(pre1);
document.head.appendChild(pre2);
document.head.appendChild(link);
}, []);
// Keyframes
useEffect(() => {
if (document.getElementById("hp-anim-style")) return;
const style = document.createElement("style");
style.id = "hp-anim-style";
style.innerHTML = `
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn { from { opacity: 0; transform: translateY(8px) scale(0.985); } to { opacity: 1; transform: translateY(0) scale(1); } }
@keyframes rowPulse { 0%{ transform: scale(1); } 50%{ transform: scale(1.01); } 100%{ transform: scale(1); } }
@keyframes candleGlow {
0% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
35% { opacity: .85; transform: translateY(-2px) scale(1.02); filter: blur(18px); }
70% { opacity: .62; transform: translateY(1px) scale(1.01); filter: blur(17px); }
100% { opacity: .55; transform: translateY(0px) scale(1); filter: blur(16px); }
}
`;
document.head.appendChild(style);
}, []);
// html/body reset
useEffect(() => {
document.documentElement.style.height = "100%";
document.body.style.height = "100%";
document.documentElement.style.margin = "0";
document.body.style.margin = "0";
document.documentElement.style.padding = "0";
document.body.style.padding = "0";
}, []);
// Global CSS
useEffect(() => {
if (document.getElementById("hp-global-style")) return;
const style = document.createElement("style");
style.id = "hp-global-style";
style.innerHTML = `
html, body {
overscroll-behavior-y: none;
-webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: ${stylesTokens.pageBg};
color: ${stylesTokens.textMain};
}
body {
overflow: hidden;
}
#root { background: transparent; }
* { -webkit-tap-highlight-color: transparent; }
`;
document.head.appendChild(style);
}, []);
// Ensure a theme is applied once (fallback)
useEffect(() => {
applyTheme("default");
}, []);
}

View File

@@ -0,0 +1,499 @@
import { stylesTokens } from "./theme";
export const styles = {
page: {
minHeight: "100dvh",
background: "transparent",
position: "relative",
zIndex: 1,
},
shell: {
fontFamily: '"IM Fell English", system-ui',
padding: 16,
maxWidth: 680,
margin: "0 auto",
},
topBar: {
position: "relative",
zIndex: 50,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
padding: 12,
borderRadius: 16,
background: stylesTokens.panelBg,
border: `1px solid ${stylesTokens.panelBorder}`,
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
},
card: {
borderRadius: 18,
overflow: "hidden",
border: `1px solid ${stylesTokens.panelBorder}`,
background: "rgba(18, 18, 20, 0.50)",
boxShadow: "0 18px 40px rgba(0,0,0,0.50), inset 0 1px 0 rgba(255,255,255,0.06)",
},
cardBody: {
padding: 12,
display: "flex",
gap: 10,
alignItems: "center",
},
sectionHeader: {
padding: "11px 14px",
fontWeight: 1000,
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
letterSpacing: 1.0,
color: stylesTokens.textGold,
// WICHTIG: Header-Farben aus Theme-Tokens, nicht hart codiert
background: `linear-gradient(180deg, ${stylesTokens.headerBgTop}, ${stylesTokens.headerBgBottom})`,
borderBottom: `1px solid ${stylesTokens.headerBorder}`,
textTransform: "uppercase",
textShadow: "0 1px 0 rgba(0,0,0,0.6)",
},
row: {
display: "grid",
gridTemplateColumns: "1fr 54px 68px",
gap: 10,
padding: "12px 14px",
alignItems: "center",
borderBottom: "1px solid rgba(233,216,166,0.08)",
borderLeft: "4px solid rgba(0,0,0,0)",
},
name: {
cursor: "pointer",
userSelect: "none",
fontWeight: 800,
letterSpacing: 0.2,
color: stylesTokens.textMain,
},
statusCell: {
display: "flex",
justifyContent: "center",
alignItems: "center",
},
statusBadge: {
width: 34,
height: 34,
display: "inline-flex",
justifyContent: "center",
alignItems: "center",
borderRadius: 999,
border: `1px solid rgba(233,216,166,0.18)`,
fontWeight: 1100,
fontSize: 16,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
tagBtn: {
padding: "8px 0",
fontWeight: 1000,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
helpBtn: {
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
whiteSpace: "nowrap",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
input: {
width: "100%",
padding: "10px 12px",
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.55)",
color: stylesTokens.textMain,
outline: "none",
fontSize: 15,
},
primaryBtn: {
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
},
secondaryBtn: {
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
},
// Admin
adminWrap: {
position: "relative",
zIndex: 1,
marginTop: 14,
padding: 12,
borderRadius: 16,
border: `1px solid rgba(233,216,166,0.14)`,
background: "rgba(18, 18, 20, 0.40)",
boxShadow: "0 12px 30px rgba(0,0,0,0.45)",
backdropFilter: "blur(6px)",
},
adminTop: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
},
adminTitle: {
fontWeight: 1000,
color: stylesTokens.textGold,
},
userRow: {
display: "grid",
gridTemplateColumns: "1fr 80px 90px",
gap: 8,
padding: 10,
borderRadius: 12,
background: "rgba(255,255,255,0.06)",
border: `1px solid rgba(233,216,166,0.10)`,
},
// Modal
modalOverlay: {
position: "fixed",
inset: 0, // statt top/left/right/bottom
width: "100%", // ✅ NICHT 100vw
height: "100%", // ✅ NICHT 100vh
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))",
boxSizing: "border-box", // wichtig bei padding
zIndex: 2147483647,
background: "rgba(0,0,0,0.72)",
overflowY: "auto",
},
modalCard: {
width: "min(560px, 100%)",
borderRadius: 18,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(12,12,14,0.96)",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
padding: 14,
maxHeight: "calc(100vh - 32px)",
overflow: "auto",
},
modalHeader: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 10,
},
modalCloseBtn: {
width: 38,
height: 38,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
lineHeight: "38px",
textAlign: "center",
},
// Help
helpBody: {
marginTop: 10,
paddingTop: 4,
maxHeight: "70vh",
overflow: "auto",
},
helpSectionTitle: {
fontWeight: 1000,
color: stylesTokens.textGold,
marginTop: 10,
marginBottom: 6,
},
helpText: {
color: stylesTokens.textMain,
opacity: 0.92,
lineHeight: 1.35,
},
helpList: {
marginTop: 10,
display: "grid",
gap: 8,
},
helpListRow: {
display: "grid",
gridTemplateColumns: "42px 1fr",
gap: 10,
alignItems: "center",
color: stylesTokens.textMain,
},
helpBadge: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 38,
height: 38,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
fontWeight: 1100,
fontSize: 18,
},
helpMiniTag: {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 38,
height: 38,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1100,
},
helpDivider: {
margin: "14px 0",
height: 1,
background: "rgba(233,216,166,0.12)",
},
// Login
loginPage: {
minHeight: "100dvh",
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
overflow: "hidden",
padding: 20,
background: "transparent",
zIndex: 1,
},
loginCard: {
width: "100%",
maxWidth: 420,
padding: 26,
borderRadius: 22,
position: "relative",
zIndex: 2,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(18, 18, 20, 0.55)",
boxShadow: "0 18px 60px rgba(0,0,0,0.70)",
backdropFilter: "blur(8px)",
animation: "popIn 240ms ease-out",
color: stylesTokens.textMain,
},
loginTitle: {
fontFamily: '"Cinzel Decorative", "IM Fell English", system-ui',
fontWeight: 1000,
fontSize: 26,
color: stylesTokens.textGold,
textAlign: "center",
letterSpacing: 0.6,
},
loginSubtitle: {
marginTop: 6,
textAlign: "center",
color: stylesTokens.textMain,
opacity: 0.9,
fontSize: 15,
lineHeight: 1.4,
},
loginFieldWrap: {
width: "100%",
display: "flex",
justifyContent: "center",
},
loginInput: {
width: "100%",
padding: 10,
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain,
outline: "none",
fontSize: 16,
},
loginBtn: {
padding: "12px 14px",
borderRadius: 14,
border: `1px solid rgba(233,216,166,0.28)`,
background: "linear-gradient(180deg, rgba(233,216,166,0.24), rgba(233,216,166,0.10))",
color: stylesTokens.textGold,
fontWeight: 1000,
fontSize: 16,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
},
loginHint: {
marginTop: 18,
fontSize: 13,
opacity: 0.78,
textAlign: "center",
color: stylesTokens.textDim,
lineHeight: 1.35,
},
candleGlowLayer: {
position: "absolute",
inset: 0,
pointerEvents: "none",
background: `
radial-gradient(circle at 20% 25%, rgba(255, 200, 120, 0.16), rgba(0,0,0,0) 40%),
radial-gradient(circle at 80% 30%, rgba(255, 210, 140, 0.12), rgba(0,0,0,0) 42%),
radial-gradient(circle at 55% 75%, rgba(255, 180, 100, 0.08), rgba(0,0,0,0) 45%)
`,
animation: "candleGlow 3.8s ease-in-out infinite",
mixBlendMode: "multiply",
},
inputRow: {
display: "flex",
alignItems: "stretch",
width: "100%",
},
inputInRow: {
flex: 1,
padding: 10,
borderRadius: "12px 0 0 12px",
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(10,10,12,0.60)",
color: stylesTokens.textMain,
outline: "none",
minWidth: 0,
fontSize: 16,
},
pwToggleBtn: {
width: 48,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0 12px 12px 0",
border: `1px solid rgba(233,216,166,0.18)`,
borderLeft: "none",
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
cursor: "pointer",
fontWeight: 900,
padding: 0,
},
// Background
bgFixed: {
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100dvh",
zIndex: -1,
pointerEvents: "none",
transform: "translateZ(0)",
backfaceVisibility: "hidden",
willChange: "transform",
},
bgMap: {
position: "absolute",
inset: 0,
backgroundImage: stylesTokens.bgImage,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
filter: "saturate(0.9) contrast(1.05) brightness(0.55)",
},
chipGrid: {
marginTop: 12,
display: "grid",
gridTemplateColumns: "repeat(5, minmax(0, 1fr))",
gap: 8,
},
chipBtn: {
padding: "10px 14px",
borderRadius: 12,
border: "1px solid rgba(233,216,166,0.18)",
background: "rgba(255,255,255,0.06)",
color: stylesTokens.textGold,
fontWeight: 1000,
cursor: "pointer",
minWidth: 64,
},
userBtn: {
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 12px",
borderRadius: 12,
border: `1px solid rgba(233,216,166,0.18)`,
background: "rgba(255,255,255,0.05)",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.06)",
maxWidth: 180,
},
userDropdown: {
position: "absolute",
right: 0,
top: "calc(100% + 8px)",
minWidth: 220,
borderRadius: 14,
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))",
boxShadow: "0 18px 55px rgba(0,0,0,0.70)",
overflow: "hidden",
zIndex: 99999,
backdropFilter: "blur(8px)",
},
userDropdownItem: {
width: "100%",
textAlign: "left",
padding: "10px 12px",
border: "none",
background: "transparent",
color: stylesTokens.textMain,
fontWeight: 900,
cursor: "pointer",
},
userDropdownDivider: {
height: 1,
background: "rgba(233,216,166,0.12)",
},
};

View File

@@ -0,0 +1,36 @@
export const stylesTokens = {
pageBg: "var(--hp-pageBg)",
panelBg: "var(--hp-panelBg)",
panelBorder: "var(--hp-panelBorder)",
textMain: "var(--hp-textMain)",
textDim: "var(--hp-textDim)",
textGold: "var(--hp-textGold)",
goldLine: "var(--hp-goldLine)",
// Header
headerBgTop: "var(--hp-headerBgTop)",
headerBgBottom: "var(--hp-headerBgBottom)",
headerBorder: "var(--hp-headerBorder)",
// Rows
rowNoBg: "var(--hp-rowNoBg)",
rowNoText: "var(--hp-rowNoText)",
rowNoBorder: "var(--hp-rowNoBorder)",
rowOkBg: "var(--hp-rowOkBg)",
rowOkText: "var(--hp-rowOkText)",
rowOkBorder: "var(--hp-rowOkBorder)",
rowMaybeBg: "var(--hp-rowMaybeBg)",
rowMaybeText: "var(--hp-rowMaybeText)",
rowMaybeBorder: "var(--hp-rowMaybeBorder)",
rowEmptyBg: "var(--hp-rowEmptyBg)",
rowEmptyText: "var(--hp-rowEmptyText)",
rowEmptyBorder: "var(--hp-rowEmptyBorder)",
// Background
bgImage: "var(--hp-bgImage)",
};

View File

@@ -0,0 +1,253 @@
// frontend/src/styles/themes.js
export const THEMES = {
default: {
label: "Standard",
tokens: {
pageBg: "#0b0b0c",
panelBg: "rgba(20, 20, 22, 0.55)",
panelBorder: "rgba(233, 216, 166, 0.14)",
textMain: "rgba(245, 239, 220, 0.92)",
textDim: "rgba(233, 216, 166, 0.70)",
textGold: "#e9d8a6",
goldLine: "rgba(233, 216, 166, 0.18)",
// Section header (wie TopBar, aber leicht “tiefer”)
headerBgTop: "rgba(32,32,36,0.92)",
headerBgBottom: "rgba(14,14,16,0.92)",
headerBorder: "rgba(233, 216, 166, 0.18)",
// Row colors (fix falsch bleibt rot in ALLEN themes)
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(0, 190, 80, 0.16)",
rowOkText: "#baf3c9",
rowOkBorder: "rgba(0,190,80,0.55)",
rowMaybeBg: "rgba(140, 140, 140, 0.12)",
rowMaybeText: "rgba(233,216,166,0.85)",
rowMaybeBorder: "rgba(233,216,166,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(233,216,166,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/marauders-map-blur.jpg')",
},
},
gryffindor: {
label: "Gryffindor",
tokens: {
pageBg: "#0b0b0c",
panelBg: "rgba(20, 14, 14, 0.58)",
panelBorder: "rgba(255, 190, 120, 0.16)",
textMain: "rgba(245, 239, 220, 0.92)",
textDim: "rgba(255, 210, 170, 0.70)",
textGold: "#ffb86b",
goldLine: "rgba(255, 184, 107, 0.18)",
headerBgTop: "rgba(42,18,18,0.92)",
headerBgBottom: "rgba(18,10,10,0.92)",
headerBorder: "rgba(255, 184, 107, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(255, 184, 107, 0.16)",
rowOkText: "#ffd2a8",
rowOkBorder: "rgba(255,184,107,0.55)",
rowMaybeBg: "rgba(140, 140, 140, 0.04)",
rowMaybeText: "rgba(255,210,170,0.85)",
rowMaybeBorder: "rgba(255,184,107,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(255,210,170,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/gryffindor.png')",
},
},
slytherin: {
label: "Slytherin",
tokens: {
pageBg: "#070a09",
panelBg: "rgba(12, 20, 16, 0.58)",
panelBorder: "rgba(120, 255, 190, 0.12)",
textMain: "rgba(235, 245, 240, 0.92)",
textDim: "rgba(175, 240, 210, 0.70)",
textGold: "#7CFFB6",
goldLine: "rgba(124, 255, 182, 0.18)",
headerBgTop: "rgba(14,28,22,0.92)",
headerBgBottom: "rgba(10,14,12,0.92)",
headerBorder: "rgba(124, 255, 182, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(124, 255, 182, 0.16)",
rowOkText: "rgba(190,255,220,0.92)",
rowOkBorder: "rgba(124,255,182,0.55)",
rowMaybeBg: "rgba(120, 255, 190, 0.02)",
rowMaybeText: "rgba(175,240,210,0.85)",
rowMaybeBorder: "rgba(120,255,190,0.22)",
rowEmptyBg: "rgba(255,255,255,0.04)",
rowEmptyText: "rgba(175,240,210,0.75)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/slytherin.png')",
},
},
ravenclaw: {
label: "Ravenclaw",
tokens: {
pageBg: "#07080c",
panelBg: "rgba(14, 16, 24, 0.60)",
panelBorder: "rgba(140, 180, 255, 0.14)",
textMain: "rgba(235, 240, 250, 0.92)",
textDim: "rgba(180, 205, 255, 0.72)",
textGold: "#8FB6FF",
goldLine: "rgba(143, 182, 255, 0.18)",
headerBgTop: "rgba(18,22,40,0.92)",
headerBgBottom: "rgba(10,12,20,0.92)",
headerBorder: "rgba(143, 182, 255, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(143, 182, 255, 0.16)",
rowOkText: "rgba(210,230,255,0.92)",
rowOkBorder: "rgba(143,182,255,0.55)",
rowMaybeBg: "rgba(140, 180, 255, 0.04)",
rowMaybeText: "rgba(180,205,255,0.85)",
rowMaybeBorder: "rgba(143,182,255,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(180,205,255,0.78)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/ravenclaw.png')",
},
},
hufflepuff: {
label: "Hufflepuff",
tokens: {
pageBg: "#0b0b0c",
panelBg: "rgba(18, 18, 14, 0.60)",
panelBorder: "rgba(255, 230, 120, 0.16)",
textMain: "rgba(245, 239, 220, 0.92)",
textDim: "rgba(255, 240, 180, 0.70)",
textGold: "#FFE27A",
goldLine: "rgba(255, 226, 122, 0.18)",
headerBgTop: "rgba(34,30,14,0.92)",
headerBgBottom: "rgba(16,14,8,0.92)",
headerBorder: "rgba(255, 226, 122, 0.22)",
rowNoBg: "rgba(255, 35, 35, 0.16)",
rowNoText: "#ffb3b3",
rowNoBorder: "rgba(255,35,35,0.55)",
rowOkBg: "rgba(255, 226, 122, 0.16)",
rowOkText: "rgba(255,240,190,0.92)",
rowOkBorder: "rgba(255,226,122,0.55)",
rowMaybeBg: "rgba(255, 226, 122, 0.04)",
rowMaybeText: "rgba(255,240,180,0.85)",
rowMaybeBorder: "rgba(255,226,122,0.22)",
rowEmptyBg: "rgba(255,255,255,0.06)",
rowEmptyText: "rgba(255,240,180,0.78)",
rowEmptyBorder: "rgba(0,0,0,0)",
// Background
bgImage: "url('/bg/hufflepuff.png')",
},
},
};
export const DEFAULT_THEME_KEY = "default";
export function setThemeColorMeta(color) {
try {
const safe = typeof color === "string" ? color.trim() : "";
if (!safe) return;
// only allow solid colors (hex, rgb, hsl); ignore urls/gradients/rgba overlays
const looksSolid =
safe.startsWith("#") ||
safe.startsWith("rgb(") ||
safe.startsWith("hsl(") ||
safe.startsWith("oklch(");
if (!looksSolid) return;
let meta = document.querySelector('meta[name="theme-color"]');
if (!meta) {
meta = document.createElement("meta");
meta.setAttribute("name", "theme-color");
document.head.appendChild(meta);
}
meta.setAttribute("content", safe);
} catch {
// ignore
}
}
export function applyTheme(themeKey) {
const t = THEMES[themeKey] || THEMES[DEFAULT_THEME_KEY];
const root = document.documentElement;
for (const [k, v] of Object.entries(t.tokens)) {
root.style.setProperty(`--hp-${k}`, v);
}
// ✅ PWA/Android Statusbar dynamisch an Theme anpassen
// Nimmt (falls vorhanden) statusBarColor, sonst pageBg
setThemeColorMeta(t.tokens.statusBarColor || t.tokens.pageBg || "#000000");
}
export function themeStorageKey(email) {
return `hpTheme:${(email || "guest").toLowerCase()}`;
}
export function loadThemeKey(email) {
try {
return localStorage.getItem(themeStorageKey(email)) || DEFAULT_THEME_KEY;
} catch {
return DEFAULT_THEME_KEY;
}
}
export function saveThemeKey(email, key) {
try {
localStorage.setItem(themeStorageKey(email), key);
} catch {}
}

View File

@@ -0,0 +1,23 @@
function chipStorageKey(gameId, entryId) {
return `chip:${gameId}:${entryId}`;
}
export function getChipLS(gameId, entryId) {
try {
return localStorage.getItem(chipStorageKey(gameId, entryId));
} catch {
return null;
}
}
export function setChipLS(gameId, entryId, chip) {
try {
localStorage.setItem(chipStorageKey(gameId, entryId), chip);
} catch {}
}
export function clearChipLS(gameId, entryId) {
try {
localStorage.removeItem(chipStorageKey(gameId, entryId));
} catch {}
}

View File

@@ -0,0 +1,11 @@
/**
* Backend erlaubt: null | "i" | "m" | "s"
* Rotation:
* null -> i -> m -> s (Popup) -> null
*/
export function cycleTag(tag) {
if (!tag) return "i";
if (tag === "i") return "m";
if (tag === "m") return "s";
return null;
}

View File

@@ -0,0 +1,28 @@
// frontend/src/utils/winnerStorage.js
function winnerKey(gameId) {
return `winner:${gameId}`;
}
export function getWinnerLS(gameId) {
if (!gameId) return "";
try {
return localStorage.getItem(winnerKey(gameId)) || "";
} catch {
return "";
}
}
export function setWinnerLS(gameId, name) {
if (!gameId) return;
try {
localStorage.setItem(winnerKey(gameId), (name || "").trim());
} catch {}
}
export function clearWinnerLS(gameId) {
if (!gameId) return;
try {
localStorage.removeItem(winnerKey(gameId));
} catch {}
}

View File

@@ -20,7 +20,7 @@ export default defineConfig({
scope: "/", scope: "/",
display: "standalone", display: "standalone",
background_color: "#1c140d", background_color: "#1c140d",
theme_color: "#caa45a", theme_color: "#000000",
icons: [ icons: [
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" } { src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" }
] ]
@@ -28,6 +28,9 @@ export default defineConfig({
workbox: { workbox: {
// Caching-Default: die App-Shell wird offline verfügbar // Caching-Default: die App-Shell wird offline verfügbar
globPatterns: ["**/*.{js,css,html,ico,png,jpg,jpeg,svg,webp}"], globPatterns: ["**/*.{js,css,html,ico,png,jpg,jpeg,svg,webp}"],
cleanupOutdatedCaches: true,
skipWaiting: true,
clientsClaim: true,
} }
}) })
] ]