Compare commits

..

1 Commits

Author SHA1 Message Date
cpu
774dd7ecb8 working 2025-05-07 15:50:01 +02:00
44 changed files with 1615 additions and 4703 deletions

View File

@@ -1,38 +0,0 @@
# Git files
.git
.gitignore
# Node modules - these are installed within the Docker build context
node_modules
# Docker specific files (if any, other than Dockerfile itself)
# .dockerignore (to avoid including itself if context changes)
# Local development environment files
.env
.env*.local
# Logs and temporary files
logs
*.log
npm-debug.log*
yarn-debug.log*
pnpm-debug.log*
# OS-specific files
.DS_Store
Thumbs.db
# IDE configuration
.idea/
.vscode/
*.sublime-workspace
*.sublime-project
# Build output (if you ever build locally before Docker)
dist/
# If your build output is different, change the line above
# Coverage reports
coverage/
.nyc_output/

144
.gitignore vendored
View File

@@ -1,144 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarnclean
# dotenv environment variables file
.env
.env*.local
.env.development.local
.env.test.local
.env.production.local
# parcel-bundler cache files
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build output
.nuxt
dist
# Svelte Sapper build output
__sapper__
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Vite local development server cache
.vite
# Vite build output directory
/dist
# If your build output is different, change the line above to /your-build-output-dir
# Mac OS system files
.DS_Store
Thumbs.db
# IDE specific
.idea/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sublime-workspace
*.sublime-project
# Editor directories and files
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace

View File

@@ -1,15 +0,0 @@
# Stage 1: Build the Vue.js application
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve the application with Nginx
FROM nginx:stable-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

170
README.md
View File

@@ -1,43 +1,29 @@
# Nexus Timer 🕰️✨ # Nexus Timer 🕰️✨
Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion. It provides a clear visual focus on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who is next. This document serves as a detailed specification for a Progressive Web App (PWA) prototype aimed at game enthusiasts. Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion. It provides a clear visual focus on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who is next.
## Core Concept ## Core Concept
Nexus Timer visualizes players in a circular sequence. The **Current Player** is prominently displayed in the top half of the screen, and the **Next Player** is in the bottom half. This clear visual pairing indicates the flow of turns, making it perfect for board games, role-playing games, timed presentations, or any scenario needing structured turn management with individual countdowns. Nexus Timer visualizes players in a circular sequence. The **Current Player** is prominently displayed in the top half of the screen, and the **Next Player** (their immediate successor) is in the bottom half. This clear visual pairing indicates the flow of turns, making it perfect for board games, round-robin discussions, timed presentations, or any scenario needing structured turn management with individual countdowns.
## Target Audience
Game enthusiasts who play turn-based games (board games, tabletop RPGs, card games) and need a visually clear and customizable timer solution.
## Tech Stack
* **HTML5:** For structuring the user interface.
* **CSS3:** For styling and visual presentation, including animations. Consider a CSS framework like Tailwind CSS for rapid prototyping.
* **JavaScript:** For application logic, timer functionality, and event handling.
* **Web Audio API:** For audio feedback (ticking sounds, alerts).
* **Browser API:** For capturing Players' photo.
* **Local Storage/IndexedDB:** For persistent storage of player data, timer states, and settings.
* **Service Worker:** Essential for PWA functionality (offline access, push notifications - potential future feature).
* **Manifest File:** Defines the PWA's metadata (name, icons, theme color).
* **Web Framework:** Use Vue.js
* **(Optional) Tailwind CSS:** Utility-first CSS framework for rapid UI development.
## Hardware Recommendations (Optional Enhancement) ## Hardware Recommendations (Optional Enhancement)
For an enhanced tactile experience, Nexus Timer supports Smart Buttons based on Bluetooth-connected microcontroller (e.g., XIAO nRF52840) implementing HID (Human Interface Device) protocol. For an enhanced tactile experience, Nexus Timer supports a Bluetooth-connected microcontroller (e.g., XIAO nRF52840) implementing HID (Human Interface Device) protocol.
* **Buttons:** Connect 3 physical buttons, potentially extended (e.g., via 1.5m wires) for easy player access. * **Buttons:** Connect 3 physical buttons, potentially extended (e.g., via 1.5m wires) for easy player access.
* **Configuration:** * **Configuration (Example):**
* **Player 1's Button:** Single Click: Emulates a key press (e.g., 'a'). Configure this as Player 1's "Pass Turn / My Pause" hotkey in the app. * **Player 1's Button:**
* **Player 2's Button:** Single Click: Emulates a key press (e.g., 'b'). Configure as Player 2's "Pass Turn / My Pause" hotkey. * Single Click: Emulates a key press (e.g., 'a'). Configure this as Player 1's "Pass Turn / My Pause" hotkey in the app.
* **Player 3's Button:** Single Click: Emulates a key press (e.g., 'c'). Configure as Player 3's "Pass Turn / My Pause" hotkey. Long Press (if Player 3 is Game Admin): Emulates a key press (e.g., 's'). Configure as the "Global Stop/Pause All" hotkey in the app. * **Player 2's Button:**
* Single Click: Emulates a key press (e.g., 'b'). Configure as Player 2's "Pass Turn / My Pause" hotkey.
* **Player 3's Button:**
* Single Click: Emulates a key press (e.g., 'c'). Configure as Player 3's "Pass Turn / My Pause" hotkey.
* Long Press (if Player 3 is Game Admin): Emulates a key press (e.g., 's'). Configure as the "Global Stop/Pause All" hotkey in the app.
## Key Features ## Key Features
* **Circular Player Display:** * **Circular Player Display:**
1. **Normal Mode (Default):** Central focus on the **Current Player** (top) and **Next Player** (bottom). * Central focus on the **Current Player** (top) and **Next Player** (bottom).
2. **All Timers Running Mode:** List of players with running timers.
* **Individual Player Timers:** * **Individual Player Timers:**
* Customizable countdown timer (MM:SS) for each player. * Customizable countdown timer (MM:SS) for each player.
* Timers continue into **negative time** (e.g., -MM:SS) up to a limit (e.g., -59:59). * Timers continue into **negative time** (e.g., -MM:SS) up to a limit (e.g., -59:59).
@@ -46,81 +32,95 @@ For an enhanced tactile experience, Nexus Timer supports Smart Buttons based on
* **Two Game Modes:** * **Two Game Modes:**
1. **Normal Mode (Default):** 1. **Normal Mode (Default):**
* Only the **Current Player's** timer runs. * Only the **Current Player's** timer runs.
* Pass the turn via **Swipe Up** on the Next Player's area or the Current Player's "Pass Turn / My Pause" hotkey. * Pass the turn via **Swipe Up** or the Current Player's "Pass Turn / My Pause" hotkey.
* 3-seconds ticking sound when the timer starts to alert players about the "Pass Turn".
* Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn. * Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn.
* To pass the turn: **Swipe Up** on the Next Player's area or have the Current Player press their "Pass Turn / My Pause" hotkey. The current timer pauses, the next player becomes Current, and their timer starts.
* If the current player's timer is paused, and then the turn is passed (via swipe up or hotkey), the next player should become the current player, but their timer should not automatically start. It should remain paused.
2. **All Timers Running Mode:** 2. **All Timers Running Mode:**
* All active player timers run simultaneously. * All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers). * Enter by clicking "All Timers Mode" (starts all timers).
* Continuous ticking sound when active. * App-wide visual pulsing and continuous ticking sound when active.
* Initially, all players are shown in a list with their photo, name and timer value. * **Swipe Up** to change which active player is "in focus" in the Current Player display.
* Tapping on a player in the list pauses its timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer. * Focused player can tap their area to pause their *own* timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.
* Only players with a running timer are shown in the list.
* If all players pause their timers, automatically reverts to Normal Mode. * If all players pause their timers, automatically reverts to Normal Mode.
* Main button: "Stop All Timers" (pauses all, returns to Normal Mode) or "Start All Timers" (resumes all in this mode). Can also be triggered by "Global Stop/Pause All" hotkey. * Main button: "Stop All Timers" (pauses all, returns to Normal Mode) or "Start All Timers" (resumes all in this mode). Can also be triggered by "Global Stop/Pause All" hotkey.
* **Player Management:** * **Player Management:**
* Add, edit, and delete players (2-7 players). * Add, edit, and delete players (2-10 players).
* Use device camera (access via browser API) or default avatars for the player's picture. * Upload photos, use device camera, or default avatars.
* Set initial timer values per player (Default: 60:00). * Set initial timer values per player (Default: 60:00).
* Assign unique "Pass Turn / My Pause" hotkeys (single keypresses). E.g.: Use the Player's 1 "single click" action to insert the key. * Assign unique "Pass Turn / My Pause" hotkeys.
* Assign the "Global Stop/Pause All" hotkey (single keypresses). E.g.: Use the Player's 3 "long press" action to insert the key. * Optionally designate one player as "Game Admin" for special hotkey functions.
* Re-order players (drag-and-drop planned), reverse, shuffle. * Easily re-order (drag-and-drop planned), reverse, or shuffle player order.
* **Intuitive Controls:** * **Intuitive Controls:**
* **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal Mode). * **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal Mode) or changing focus (All Timers Mode).
* **On-Screen Taps:** (On the Current Player's area). For pausing/resuming timers contextually. * **On-Screen Taps:** (On the Current Player's area). For pausing/resuming timers contextually.
* **Audio Feedback:** * **Audio Feedback:**
* Continuous ticking in "All Timers Running Mode" when active. * Continuous ticking in "All Timers Running Mode" when active.
* Brief 3-second tick when a timer starts in Normal Mode. Cancel the sound when the timer pauses. * Brief 3-second tick when a timer starts in Normal Mode. Cancel the sound when the timer pauses.
* Global mute option. * Global mute option.
* **Visuals:** * **Persistence:** Player setups, timer states, and settings are saved locally.
* Designed for mobile phone screens (portrait orientation).
* Light/Dark theme options.
* **Persistence:** Player setups, timer states, and settings are saved locally using browser Local Storage.
* **Global Reset:** "Reset Game" button restores all timers to initial values and resets game state. * **Global Reset:** "Reset Game" button restores all timers to initial values and resets game state.
## UI/UX Considerations (For AI Generation) ## Tech Stack (Planned/Example)
* **Minimalist Design:** Focus on clarity and ease of use. Avoid clutter. * Progressive Web App (PWA) for smartphone screens.
* **Large, Clear Timers:** Timers should be easily readable at a glance. * Modular codebase.
* **Color Coding:** Use color to indicate timer state (e.g., green for running, red for negative time, grey for skipped). * Simple sounds using pure Web Audio API.
* **Responsive Layout:** The UI should adapt to different (mobile phone) screen sizes. * CSS for styling, animations, and placeholder avatars.
* **Touch-Friendly:** Buttons and interactive elements should be large enough for easy tapping. * Local Storage/IndexedDB for persistence.
## Data Model (For AI Generation) ## Getting Started
```json **(Details to be added once development begins)**
{
"players": [
{
"id": "1",
"name": "Alice",
"avatar": "image",
"initialTimer": "60:00",
"currentTimer": "60:00",
"hotkey": "a",
"isSkipped": false
},
{
"id": "2",
"name": "Bob",
"avatar": "image",
"initialTimer": "60:00",
"currentTimer": "60:00",
"hotkey": "b",
"isSkipped": false
}
],
"globalHotkey": "s",
"currentPlayerIndex": 0,
"gameMode": "normal", // "normal" or "allTimers"
"isMuted": false
}
```
## Installation
Build the app with docker
```bash 1. **Prerequisites:**
docker build -t nexus-timer . * A modern web browser on a smartphone or tablet.
``` * (Optional) Bluetooth-enabled microcontroller for hardware buttons.
2. **Installation:**
```bash
# Placeholder for PWA installation instructions or link
```
3. **Running the Application:**
```bash
# Placeholder for how to access/run the app
```
## Usage Guide
1. **Manage Players:**
* Tap "Manage Players."
* Add players: Enter name, set initial timer. Optionally, add a photo, assign a "Pass Turn / My Pause" hotkey, and designate an admin.
* Edit existing players or change their order.
* Save changes.
2. **Main Screen:**
* The **Current Player** appears in the top half, **Next Player** in the bottom. Effective use of the phone's screen. No additional elements like header or footer.
3. **Normal Mode (Default):**
* Tap the Current Player's area to start/pause their timer.
* To pass the turn: **Swipe Up** on the Next Player's area or have the Current Player press their "Pass Turn / My Pause" hotkey. The current timer pauses, the next player becomes Current, and their timer starts.
* Click the "All Timers Mode" button to switch modes (this will also start all timers).
4. **All Timers Running Mode:**
* All active player timers run. The app background pulses, and a ticking sound plays (if unmuted).
* The "Current Player" area shows one of the players with an active timer. **Swipe Up** to cycle focus to other players with active timers.
* A player can pause their *own* timer by:
* Tapping their display area (if they are the focused Current Player).
* Pressing their "Pass Turn / My Pause" hotkey.
* If all players pause their timers, the app reverts to Normal Mode.
* The main control button will say "Stop All Timers." Clicking it (or using the "Global Stop/Pause All" hotkey) pauses all timers and returns to Normal Mode. If all timers are already paused in this mode, it says "Start All Timers."
5. **Reset Game:**
* Tap "Reset Game" and confirm to restore all timers to their initial values.
## Configuration
* **Player Hotkeys (in "Manage Players"):**
* **"Pass Turn / My Pause" Key:**
* Normal Mode: If pressed by Current Player, passes the turn.
* All Timers Mode: Pauses/resumes the respective player's own timer.
* **Global Hotkeys (configured in settings or player management for admin):**
* **"Global Stop/Pause All" Hotkey:**
* Normal Mode: Pauses the Current Player's timer.
* All Timers Mode (timers running): Pauses all timers and returns to Normal Mode.
* All Timers Mode (all timers paused): Resumes all timers in All Timers Mode.
* **Audio Mute:** Look for a mute/unmute icon or setting.
## Future Enhancements 🚀
* Light/Dark theme options.
* Game statistics (e.g., average turn time).

126
README.md.orig Normal file
View File

@@ -0,0 +1,126 @@
# Nexus Timer 🕰️✨
Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion. It provides a clear visual focus on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who is next.
## Core Concept
Nexus Timer visualizes players in a circular sequence. The **Current Player** is prominently displayed in the top half of the screen, and the **Next Player** (their immediate successor) is in the bottom half. This clear visual pairing indicates the flow of turns, making it perfect for board games, round-robin discussions, timed presentations, or any scenario needing structured turn management with individual countdowns.
## Hardware Recommendations (Optional Enhancement)
For an enhanced tactile experience, Nexus Timer supports a Bluetooth-connected microcontroller (e.g., XIAO nRF52840) implementing HID (Human Interface Device) protocol.
* **Buttons:** Connect 3 physical buttons, potentially extended (e.g., via 1.5m wires) for easy player access.
* **Configuration (Example):**
* **Player 1's Button:**
* Single Click: Emulates a key press (e.g., 'a'). Configure this as Player 1's "Pass Turn / My Pause" hotkey in the app.
* **Player 2's Button:**
* Single Click: Emulates a key press (e.g., 'b'). Configure as Player 2's "Pass Turn / My Pause" hotkey.
* **Player 3's Button:**
* Single Click: Emulates a key press (e.g., 'c'). Configure as Player 3's "Pass Turn / My Pause" hotkey.
* Long Press (if Player 3 is Game Admin): Emulates a key press (e.g., 's'). Configure as the "Global Stop/Pause All" hotkey in the app.
## Key Features ✨
* **Circular Player Display:**
* Central focus on the **Current Player** (top) and **Next Player** (bottom).
* **Individual Player Timers:**
* Customizable countdown timer (MM:SS) for each player.
* Timers continue into **negative time** (e.g., -MM:SS) up to a limit (e.g., -59:59).
* Players reaching max negative time are **skipped** until reset or timer edit.
* Visual feedback: Pulsating effect for active timers (background for positive, text for negative). Skipped players are visually distinct (e.g., greyed out).
* **Two Game Modes:**
1. **Normal Mode (Default):**
* Only the **Current Player's** timer runs.
* Pass the turn via **Swipe Up** or the Current Player's "Pass Turn / My Pause" hotkey.
* Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn.
2. **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
* App-wide visual pulsing and continuous ticking sound when active.
* **Swipe Up** to change which active player is "in focus" in the Current Player display.
* Focused player can tap their area to pause their *own* timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.
* If all players pause their timers, automatically reverts to Normal Mode.
* Main button: "Stop All Timers" (pauses all, returns to Normal Mode) or "Start All Timers" (resumes all in this mode). Can also be triggered by "Global Stop/Pause All" hotkey.
* **Player Management:**
* Add, edit, and delete players (2-10 players).
* Upload photos, use device camera, or default avatars.
* Set initial timer values per player (Default: 60:00).
* Assign unique "Pass Turn / My Pause" hotkeys.
* Optionally designate one player as "Game Admin" for special hotkey functions.
* Easily re-order (drag-and-drop planned), reverse, or shuffle player order.
* **Intuitive Controls:**
* **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal Mode) or changing focus (All Timers Mode).
* **On-Screen Taps:** (On the Current Player's area). For pausing/resuming timers contextually.
* **Audio Feedback:**
* Continuous ticking in "All Timers Running Mode" when active.
* Brief 3-second tick when a timer starts in Normal Mode. Cancel the sound when the timer pauses.
* Global mute option.
* **Persistence:** Player setups, timer states, and settings are saved locally.
* **Global Reset:** "Reset Game" button restores all timers to initial values and resets game state.
## Tech Stack (Planned/Example)
* Progressive Web App (PWA) for smartphone screens.
* Modular codebase.
* Simple sounds using pure Web Audio API.
* CSS for styling, animations, and placeholder avatars.
* Local Storage/IndexedDB for persistence.
## Getting Started
**(Details to be added once development begins)**
1. **Prerequisites:**
* A modern web browser on a smartphone or tablet.
* (Optional) Bluetooth-enabled microcontroller for hardware buttons.
2. **Installation:**
```bash
# Placeholder for PWA installation instructions or link
```
3. **Running the Application:**
```bash
# Placeholder for how to access/run the app
```
## Usage Guide
1. **Manage Players:**
* Tap "Manage Players."
* Add players: Enter name, set initial timer. Optionally, add a photo, assign a "Pass Turn / My Pause" hotkey, and designate an admin.
* Edit existing players or change their order.
* Save changes.
2. **Main Screen:**
* The **Current Player** appears in the top half, **Next Player** in the bottom. Effective use of the phone's screen. No additional elements like header or footer.
3. **Normal Mode (Default):**
* Tap the Current Player's area to start/pause their timer.
* To pass the turn: **Swipe Up** on the Next Player's area or have the Current Player press their "Pass Turn / My Pause" hotkey. The current timer pauses, the next player becomes Current, and their timer starts.
* Click the "All Timers Mode" button to switch modes (this will also start all timers).
4. **All Timers Running Mode:**
* All active player timers run. The app background pulses, and a ticking sound plays (if unmuted).
* The "Current Player" area shows one of the players with an active timer. **Swipe Up** to cycle focus to other players with active timers.
* A player can pause their *own* timer by:
* Tapping their display area (if they are the focused Current Player).
* Pressing their "Pass Turn / My Pause" hotkey.
* If all players pause their timers, the app reverts to Normal Mode.
* The main control button will say "Stop All Timers." Clicking it (or using the "Global Stop/Pause All" hotkey) pauses all timers and returns to Normal Mode. If all timers are already paused in this mode, it says "Start All Timers."
5. **Reset Game:**
* Tap "Reset Game" and confirm to restore all timers to their initial values.
## Configuration
* **Player Hotkeys (in "Manage Players"):**
* **"Pass Turn / My Pause" Key:**
* Normal Mode: If pressed by Current Player, passes the turn.
* All Timers Mode: Pauses/resumes the respective player's own timer.
* **Global Hotkeys (configured in settings or player management for admin):**
* **"Global Stop/Pause All" Hotkey:**
* Normal Mode: Pauses the Current Player's timer.
* All Timers Mode (timers running): Pauses all timers and returns to Normal Mode.
* All Timers Mode (all timers paused): Resumes all timers in All Timers Mode.
* **Audio Mute:** Look for a mute/unmute icon or setting.
## Future Enhancements 🚀
* Light/Dark theme options.
* Game statistics (e.g., average turn time).

13
README.md.rej Normal file
View File

@@ -0,0 +1,13 @@
--- README.md
+++ README.md
@@ -104,6 +104,10 @@
* **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
+ * **All Timers Player List:** A list of all players with running timers is displayed in the "Next Player" area. Players are removed from the list as their timers pause.
+ * The focused player on the top is a first player with a running timer.
+ * The list is updated dynamically as timers start and stop.
+
* Tap Current Player's area to pause/resume their *own* timer.
* Swipe Up to change which active player is "in focus" in the Current Player display.

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="4" stroke="#000000" stroke-width="2"/>
<path d="M4 21C4 17.134 7.58172 14 12 14C16.4183 14 20 17.134 20 21" stroke="#000000" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -1,30 +1,87 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-T">
<link rel="icon" href="/favicon.ico"> <!-- Will be served from public/favicon.ico -> dist/favicon.ico -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Nexus Timer</title> <title>Nexus Timer</title>
<link rel="manifest" href="/manifest.json"> <!-- Will be served from public/manifest.json -> dist/manifest.json --> <link rel="stylesheet" href="style.css">
<meta name="theme-color" content="#000000"> <link rel="manifest" href="manifest.json">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png"> <!-- Will be served from public/icons/ -> dist/icons/ --> <meta name="theme-color" content="#2A2A3E">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app-container">
<script type="module" src="/src/main.js"></script> <div id="current-player-area" class="player-area">
<script> <div class="player-photo-container">
if ('serviceWorker' in navigator) { <img src="assets/default-avatar.svg" alt="Current Player Photo" id="current-player-photo" class="player-photo">
window.addEventListener('load', () => { </div>
// service-worker.js from public dir will be copied to dist root <div class="player-info">
navigator.serviceWorker.register('/service-worker.js') <h2 id="current-player-name">Current Player</h2>
.then(registration => { <div id="current-player-timer" class="timer-display">00:00</div>
console.log('ServiceWorker registration successful with scope: ', registration.scope); </div>
}) </div>
.catch(error => {
console.log('ServiceWorker registration failed: ', error); <div id="next-player-area" class="player-area">
}); <div class="player-info">
}); <h3 id="next-player-name">Next Player</h3>
} <div id="next-player-timer" class="timer-display small-timer">00:00</div>
</script> </div>
</body> <div class="player-photo-container">
<img src="assets/default-avatar.svg" alt="Next Player Photo" id="next-player-photo" class="player-photo">
</div>
</div>
<div id="controls">
<button id="manage-players-btn" class="control-button">Manage Players</button>
<button id="game-mode-btn" class="control-button">All Timers Mode</button>
<button id="reset-game-btn" class="control-button">Reset Game</button>
<button id="mute-btn" class="control-button">🔇 Mute</button>
</div>
</div>
<div id="manage-players-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-modal-btn">×</span>
<h2>Manage Players (2-10)</h2>
<div id="player-list-editor">
<!-- Player entries will be dynamically added here -->
</div>
<div id="all-timers-player-list"></div>
<div class="player-form-buttons">
<button id="add-player-form-btn">Add New Player</button>
<button id="shuffle-players-btn">Shuffle Order</button>
<button id="reverse-players-btn">Reverse Order</button>
</div>
<div id="add-edit-player-form" style="display: none;">
<h3 id="player-form-title">Add Player</h3>
<input type="hidden" id="edit-player-id">
<label for="player-name-input">Name:</label>
<input type="text" id="player-name-input" placeholder="Player Name" required>
<label for="player-time-input">Initial Time (MM:SS):</label>
<input type="text" id="player-time-input" placeholder="60:00" value="60:00" pattern="\d{1,2}:\d{2}">
<label for="player-photo-capture-input">Player Photo (Tap to use camera):</label>
<input type="file" id="player-photo-capture-input" accept="image/*" capture="user">
<img id="player-photo-preview" src="assets/default-avatar.svg" alt="Photo Preview" class="photo-preview-modal">
<button id="remove-photo-btn" type="button" style="display:none;">Remove Photo</button>
<label for="player-hotkey-input">"Pass/My Pause" Hotkey (single char):</label>
<input type="text" id="player-hotkey-input" placeholder="e.g., a" maxlength="1">
<label>
<input type="checkbox" id="player-admin-input"> Game Admin (for global hotkeys)
</label>
<div class="player-form-actions">
<button id="save-player-btn">Save Player</button>
<button id="cancel-edit-player-btn" type="button">Cancel</button>
</div>
</div>
<button id="save-player-management-btn" class="modal-main-action">Done</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html> </html>

86
index.html.orig Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-T">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Nexus Timer</title>
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#2A2A3E">
</head>
<body>
<div id="app-container">
<div id="current-player-area" class="player-area">
<div class="player-photo-container">
<img src="assets/default-avatar.svg" alt="Current Player Photo" id="current-player-photo" class="player-photo">
</div>
<div class="player-info">
<h2 id="current-player-name">Current Player</h2>
<div id="current-player-timer" class="timer-display">00:00</div>
</div>
</div>
<div id="next-player-area" class="player-area">
<div class="player-info">
<h3 id="next-player-name">Next Player</h3>
<div id="next-player-timer" class="timer-display small-timer">00:00</div>
</div>
<div class="player-photo-container">
<img src="assets/default-avatar.svg" alt="Next Player Photo" id="next-player-photo" class="player-photo">
</div>
</div>
<div id="controls">
<button id="manage-players-btn" class="control-button">Manage Players</button>
<button id="game-mode-btn" class="control-button">All Timers Mode</button>
<button id="reset-game-btn" class="control-button">Reset Game</button>
<button id="mute-btn" class="control-button">🔇 Mute</button>
</div>
</div>
<div id="manage-players-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-modal-btn">×</span>
<h2>Manage Players (2-10)</h2>
<div id="player-list-editor">
<!-- Player entries will be dynamically added here -->
</div>
<div class="player-form-buttons">
<button id="add-player-form-btn">Add New Player</button>
<button id="shuffle-players-btn">Shuffle Order</button>
<button id="reverse-players-btn">Reverse Order</button>
</div>
<div id="add-edit-player-form" style="display: none;">
<h3 id="player-form-title">Add Player</h3>
<input type="hidden" id="edit-player-id">
<label for="player-name-input">Name:</label>
<input type="text" id="player-name-input" placeholder="Player Name" required>
<label for="player-time-input">Initial Time (MM:SS):</label>
<input type="text" id="player-time-input" placeholder="60:00" value="60:00" pattern="\d{1,2}:\d{2}">
<label for="player-photo-capture-input">Player Photo (Tap to use camera):</label>
<input type="file" id="player-photo-capture-input" accept="image/*" capture="user">
<img id="player-photo-preview" src="assets/default-avatar.svg" alt="Photo Preview" class="photo-preview-modal">
<button id="remove-photo-btn" type="button" style="display:none;">Remove Photo</button>
<label for="player-hotkey-input">"Pass/My Pause" Hotkey (single char):</label>
<input type="text" id="player-hotkey-input" placeholder="e.g., a" maxlength="1">
<label>
<input type="checkbox" id="player-admin-input"> Game Admin (for global hotkeys)
</label>
<div class="player-form-actions">
<button id="save-player-btn">Save Player</button>
<button id="cancel-edit-player-btn" type="button">Cancel</button>
</div>
</div>
<button id="save-player-management-btn" class="modal-main-action">Done</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

65
index.html.rej Normal file
View File

@@ -0,0 +1,65 @@
--- index.html
+++ index.html
@@ -262,6 +263,7 @@
const playerListEditor = document.getElementById('player-list-editor');
const addPlayerFormBtn = document.getElementById('add-player-form-btn'); // <<<< ENSURED DEFINITION
const shufflePlayersBtn = document.getElementById('shuffle-players-btn');
+ const allTimersPlayerListEl = document.getElementById('all-timers-player-list');
const reversePlayersBtn = document.getElementById('reverse-players-btn');
const addEditPlayerForm = document.getElementById('add-edit-player-form');
const playerFormTitle = document.getElementById('player-form-title');
@@ -670,6 +671,11 @@
function updateGameModeUI() {
if (gameMode === 'allTimersRunning') {
gameModeBtn.textContent = 'Stop All Timers';
+ // Update the all timers player list
+ renderAllTimersPlayerList();
+ allTimersPlayerListEl.style.display = 'block';
+ } else {
+ allTimersPlayerListEl.style.display = 'none';
let anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) {
appContainer.classList.add('pulsating-background');
@@ -702,6 +708,26 @@
}
// --- Player Management ---
+ function renderAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ const activePlayers = players.filter(p => !p.isSkipped && playerTimers[p.id] !== null);
+ activePlayers.forEach(player => {
+ const entry = document.createElement('div');
+ entry.className = 'all-timers-player-entry';
+ entry.textContent = `${player.name} (${formatTime(player.currentTime)})`;
+ allTimersPlayerListEl.appendChild(entry);
+ });
+ }
+
+ function updateAllTimersPlayerList() {
+ renderAllTimersPlayerList();
+ }
+
+ function clearAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ }
+
+
function renderPlayerManagementList() {
playerListEditor.innerHTML = '';
if (players.length === 0) {
@@ -1040,6 +1066,7 @@
}
// --- Initialization ---
+
function init() {
initAudio();
loadState();
@@ -1048,6 +1075,7 @@
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW registration failed:', err));
+
}
}
init();

24
manifest.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "Nexus Timer",
"short_name": "NexusTimer",
"description": "Dynamic multi-player timer for games and workshops.",
"start_url": "index.html",
"display": "standalone",
"background_color": "#1E1E2F",
"theme_color": "#2A2A3E",
"orientation": "portrait",
"icons": [
{
"src": "assets/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "assets/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -1,16 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
# Optional: Add headers for PWA, security, etc.
# location ~* \.(?:manifest\.json)$ {
# add_header Cache-Control "no-cache";
# }
}

2281
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +0,0 @@
{
"name": "nexus-timer",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.4",
"vuex": "^4.0.2"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.16",
"@vitejs/plugin-vue": "^4.2.3",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3",
"vite": "^4.4.5"
}
}

86
patch.diff Normal file
View File

@@ -0,0 +1,86 @@
--- a/index.html
+++ b/index.html
@@ -144,6 +144,7 @@
<div id="player-list-editor">
<!-- Player entries will be dynamically added here -->
</div>
+ <div id="all-timers-player-list"></div>
<div class="player-form-buttons">
<button id="add-player-form-btn">Add New Player</button>
<button id="shuffle-players-btn">Shuffle Order</button>
@@ -261,6 +262,7 @@
const playerListEditor = document.getElementById('player-list-editor');
const addPlayerFormBtn = document.getElementById('add-player-form-btn'); // <<<< ENSURED DEFINITION
const shufflePlayersBtn = document.getElementById('shuffle-players-btn');
+ const allTimersPlayerListEl = document.getElementById('all-timers-player-list');
const reversePlayersBtn = document.getElementById('reverse-players-btn');
const addEditPlayerForm = document.getElementById('add-edit-player-form');
const playerFormTitle = document.getElementById('player-form-title');
@@ -669,6 +670,11 @@
function updateGameModeUI() {
if (gameMode === 'allTimersRunning') {
gameModeBtn.textContent = 'Stop All Timers';
+ // Update the all timers player list
+ renderAllTimersPlayerList();
+ allTimersPlayerListEl.style.display = 'block';
+ } else {
+ allTimersPlayerListEl.style.display = 'none';
let anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) {
appContainer.classList.add('pulsating-background');
@@ -701,6 +707,26 @@
}
// --- Player Management ---
+ function renderAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ const activePlayers = players.filter(p => !p.isSkipped && playerTimers[p.id] !== null);
+ activePlayers.forEach(player => {
+ const entry = document.createElement('div');
+ entry.className = 'all-timers-player-entry';
+ entry.textContent = `${player.name} (${formatTime(player.currentTime)})`;
+ allTimersPlayerListEl.appendChild(entry);
+ });
+ }
+
+ function updateAllTimersPlayerList() {
+ renderAllTimersPlayerList();
+ }
+
+ function clearAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ }
+
+
function renderPlayerManagementList() {
playerListEditor.innerHTML = '';
if (players.length === 0) {
@@ -1039,6 +1065,7 @@
}
// --- Initialization ---
+
function init() {
initAudio();
loadState();
@@ -1047,6 +1074,7 @@
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW registration failed:', err));
+
}
}
init();
--- a/README.md
+++ b/README.md
@@ -104,6 +104,10 @@
* **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
+ * **All Timers Player List:** A list of all players with running timers is displayed in the "Next Player" area. Players are removed from the list as their timers pause.
+ * The focused player on the top is a first player with a running timer.
+ * The list is updated dynamically as timers start and stop.
+
* Tap Current Player's area to pause/resume their *own* timer.
* Swipe Up to change which active player is "in focus" in the Current Player display.
* Focused player can tap their area to pause their *own* timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,21 +0,0 @@
{
"name": "Nexus Timer",
"short_name": "NexusTimer",
"description": "A dynamic multi-player timer for games and workshops.",
"start_url": "/",
"display": "standalone",
"background_color": "#1f2937",
"theme_color": "#3b82f6",
"icons": [
{
"src": "/icons/icon-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -1,49 +0,0 @@
const CACHE_NAME = 'nexus-timer-cache-v2';
const urlsToCache = [
'/',
'/index.html',
'/manifest.json',
'/favicon.ico',
'/icons/icon-192x192.png',
'/icons/icon-512x512.png',
// Add other static assets here, like JS/CSS bundles if not dynamically named
// Vite typically names bundles with hashes, so caching them directly might be tricky
// For a PWA, focus on caching the app shell and key static assets
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response; // Serve from cache
}
return fetch(event.request); // Fetch from network
})
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
return caches.delete(cacheName);
}
})
);
})
);
});

649
script.js Normal file
View File

@@ -0,0 +1,649 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const currentPlayerArea = document.getElementById('current-player-area');
const currentPlayerNameEl = document.getElementById('current-player-name');
const currentPlayerTimerEl = document.getElementById('current-player-timer');
const currentPlayerPhotoEl = document.getElementById('current-player-photo');
const nextPlayerArea = document.getElementById('next-player-area');
const nextPlayerNameEl = document.getElementById('next-player-name');
const nextPlayerTimerEl = document.getElementById('next-player-timer');
const nextPlayerPhotoEl = document.getElementById('next-player-photo');
const managePlayersBtn = document.getElementById('manage-players-btn');
const gameModeBtn = document.getElementById('game-mode-btn');
const resetGameBtn = document.getElementById('reset-game-btn');
const muteBtn = document.getElementById('mute-btn');
const managePlayersModal = document.getElementById('manage-players-modal');
const closeModalBtn = document.querySelector('.close-modal-btn');
const playerListEditor = document.getElementById('player-list-editor');
const addPlayerFormBtn = document.getElementById('add-player-form-btn'); // <<<< ENSURED DEFINITION
const shufflePlayersBtn = document.getElementById('shuffle-players-btn');
const reversePlayersBtn = document.getElementById('reverse-players-btn');
const addEditPlayerForm = document.getElementById('add-edit-player-form');
const playerFormTitle = document.getElementById('player-form-title');
const editPlayerIdInput = document.getElementById('edit-player-id');
const playerNameInput = document.getElementById('player-name-input');
const playerTimeInput = document.getElementById('player-time-input');
// playerPhotoInput (for URL) is removed, new elements for camera:
const playerPhotoCaptureInput = document.getElementById('player-photo-capture-input');
const playerPhotoPreviewEl = document.getElementById('player-photo-preview');
const removePhotoBtn = document.getElementById('remove-photo-btn');
const playerHotkeyInput = document.getElementById('player-hotkey-input');
const playerAdminInput = document.getElementById('player-admin-input');
const savePlayerBtn = document.getElementById('save-player-btn');
const cancelEditPlayerBtn = document.getElementById('cancel-edit-player-btn');
const savePlayerManagementBtn = document.getElementById('save-player-management-btn');
const appContainer = document.getElementById('app-container');
// Web Audio API
let audioContext;
let continuousTickIntervalId = null;
let shortTickIntervalId = null;
let shortTickTimeoutId = null;
// Game State
let players = [];
let currentPlayerIndex = 0;
let gameMode = 'normal';
let isMuted = false;
let playerTimers = {};
let focusedPlayerIndexInAllTimersMode = 0;
let currentPhotoDataUrl = null; // Temp store for new photo in form
const DEFAULT_PHOTO_URL = 'assets/default-avatar.svg';
const MAX_NEGATIVE_TIME_SECONDS = -3599;
const TICK_FREQUENCY_HZ = 1200;
const TICK_DURATION_S = 0.05;
const CONTINUOUS_TICK_INTERVAL_MS = 750;
const SHORT_TICK_DURATION_MS = 3000;
// --- Web Audio API Initialization ---
function initAudio() {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error("Web Audio API is not supported in this browser", e);
isMuted = true;
updateMuteButton();
}
}
function resumeAudioContext() {
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().then(() => {
// console.log("AudioContext resumed successfully"); // Kept for debugging if needed
}).catch(e => console.error("Error resuming AudioContext:", e));
}
}
// --- Persistence ---
function saveState() {
const state = {
players: players.map(p => ({ ...p, timerInstance: undefined })),
currentPlayerIndex,
gameMode,
isMuted,
focusedPlayerIndexInAllTimersMode
};
localStorage.setItem('nexusTimerState', JSON.stringify(state));
}
function loadState() {
const savedState = localStorage.getItem('nexusTimerState');
if (savedState) {
const state = JSON.parse(savedState);
players = state.players.map(p => ({
...p,
currentTime: parseInt(p.currentTime, 10),
initialTime: parseInt(p.initialTime, 10),
isSkipped: p.isSkipped || false,
}));
currentPlayerIndex = state.currentPlayerIndex || 0;
gameMode = state.gameMode || 'normal';
isMuted = state.isMuted || false;
focusedPlayerIndexInAllTimersMode = state.focusedPlayerIndexInAllTimersMode || 0;
if (players.length === 0) setupDefaultPlayers();
} else {
setupDefaultPlayers();
}
renderPlayerManagementList();
updateDisplay();
updateGameModeUI();
}
function setupDefaultPlayers() {
players = [
{ id: Date.now(), name: 'Player 1', initialTime: 3600, currentTime: 3600, photo: DEFAULT_PHOTO_URL, hotkey: 'a', isAdmin: true, isSkipped: false },
{ id: Date.now() + 1, name: 'Player 2', initialTime: 3600, currentTime: 3600, photo: DEFAULT_PHOTO_URL, hotkey: 'b', isAdmin: false, isSkipped: false },
];
currentPlayerIndex = 0;
}
// --- Time Formatting ---
function formatTime(totalSeconds) {
const isNegative = totalSeconds < 0;
if (isNegative) totalSeconds = -totalSeconds;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const sign = isNegative ? '-' : '';
return `${sign}${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
function parseTimeInput(timeStr) {
const parts = timeStr.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
if (!isNaN(minutes) && !isNaN(seconds) && seconds < 60 && seconds >= 0 && minutes >= 0) {
return (minutes * 60) + seconds;
}
}
return 3600;
}
// --- UI Updates ---
function updatePlayerDisplay(player, nameEl, timerEl, photoEl, isSmallTimer = false) {
if (player) {
nameEl.textContent = player.name;
timerEl.textContent = formatTime(player.currentTime);
timerEl.className = `timer-display ${isSmallTimer ? 'small-timer' : ''} ${player.currentTime < 0 ? 'negative' : ''}`;
photoEl.src = player.photo || DEFAULT_PHOTO_URL; // player.photo can be data URL
photoEl.alt = `${player.name}'s Photo`;
const parentArea = nameEl.closest('.player-area');
if (parentArea) {
parentArea.classList.toggle('player-skipped', !!player.isSkipped);
parentArea.classList.remove('pulsating-background', 'pulsating-text', 'pulsating-negative-text');
if (playerTimers[player.id] && !player.isSkipped) {
if (player.currentTime >= 0) parentArea.classList.add('pulsating-background');
else parentArea.classList.add('pulsating-negative-text');
}
}
} else {
nameEl.textContent = '---';
timerEl.textContent = '00:00';
timerEl.className = `timer-display ${isSmallTimer ? 'small-timer' : ''}`;
photoEl.src = DEFAULT_PHOTO_URL;
photoEl.alt = 'Player Photo';
const parentArea = nameEl.closest('.player-area');
if (parentArea) parentArea.classList.remove('player-skipped', 'pulsating-background', 'pulsating-text', 'pulsating-negative-text');
}
}
function updateDisplay() {
if (players.length === 0) {
updatePlayerDisplay(null, currentPlayerNameEl, currentPlayerTimerEl, currentPlayerPhotoEl);
updatePlayerDisplay(null, nextPlayerNameEl, nextPlayerTimerEl, nextPlayerPhotoEl, true);
return;
}
let currentP, nextP;
if (gameMode === 'allTimersRunning') {
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length > 0) {
focusedPlayerIndexInAllTimersMode = focusedPlayerIndexInAllTimersMode % activePlayers.length;
currentP = activePlayers[focusedPlayerIndexInAllTimersMode];
nextP = activePlayers.length > 1 ? activePlayers[(focusedPlayerIndexInAllTimersMode + 1) % activePlayers.length] : null;
} else {
currentP = players[0]; nextP = players.length > 1 ? players[1] : null;
}
} else {
currentP = players[currentPlayerIndex];
nextP = players.length > 1 ? players[(currentPlayerIndex + 1) % players.length] : null;
}
updatePlayerDisplay(currentP, currentPlayerNameEl, currentPlayerTimerEl, currentPlayerPhotoEl);
updatePlayerDisplay(nextP, nextPlayerNameEl, nextPlayerTimerEl, nextPlayerPhotoEl, true);
saveState();
}
function updateGameModeUI() {
if (gameMode === 'allTimersRunning') {
gameModeBtn.textContent = 'Stop All Timers';
let anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) {
appContainer.classList.add('pulsating-background');
if (!isMuted) playContinuousTick(true);
} else {
gameModeBtn.textContent = 'Start All Timers';
appContainer.classList.remove('pulsating-background');
playContinuousTick(false);
}
} else {
gameModeBtn.textContent = 'All Timers Mode';
appContainer.classList.remove('pulsating-background');
playContinuousTick(false);
}
}
// --- Timer Logic ---
function startTimer(playerId) {
const player = players.find(p => p.id === playerId);
if (!player || player.isSkipped || playerTimers[playerId]) return;
if (gameMode === 'normal' && !isMuted) playShortTick();
playerTimers[playerId] = setInterval(() => {
player.currentTime--;
if (player.currentTime < MAX_NEGATIVE_TIME_SECONDS) {
player.currentTime = MAX_NEGATIVE_TIME_SECONDS;
player.isSkipped = true;
pauseTimer(playerId, false);
if (gameMode === 'normal' && players[currentPlayerIndex].id === playerId) passTurn();
}
updateDisplay();
}, 1000);
updateDisplay();
}
function pauseTimer(playerId, checkAllTimersModeRevert = true) {
if (playerTimers[playerId]) {
clearInterval(playerTimers[playerId]);
playerTimers[playerId] = null;
}
if (gameMode === 'normal' && players[currentPlayerIndex]?.id === playerId) stopShortTick();
updateDisplay();
if (gameMode === 'allTimersRunning' && checkAllTimersModeRevert) {
const allPaused = players.every(p => p.isSkipped || !playerTimers[p.id]);
if (allPaused) switchToNormalMode();
else updateGameModeUI();
}
}
function resetPlayerTimer(player) {
player.currentTime = player.initialTime;
player.isSkipped = false;
if (playerTimers[player.id]) pauseTimer(player.id);
}
// --- Game Flow & Modes ---
function passTurn() {
if (players.length < 1 || gameMode !== 'normal') return;
const currentP = players[currentPlayerIndex];
const currentTimerWasActive = !!playerTimers[currentP.id];
pauseTimer(currentP.id);
let nextIndex = (currentPlayerIndex + 1) % players.length;
let attempts = 0;
while (players[nextIndex].isSkipped && attempts < players.length) {
nextIndex = (nextIndex + 1) % players.length;
attempts++;
}
if (players[nextIndex].isSkipped && attempts >= players.length) {
currentPlayerIndex = nextIndex;
console.log("All subsequent players are skipped. Turn passed to a skipped player.");
} else {
currentPlayerIndex = nextIndex;
const nextP = players[currentPlayerIndex];
if (currentTimerWasActive) {
startTimer(nextP.id);
}
}
updateDisplay();
}
function switchToNormalMode() {
gameMode = 'normal';
players.forEach(p => pauseTimer(p.id, false));
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length > 0 && focusedPlayerIndexInAllTimersMode < activePlayers.length) {
const focusedPlayer = activePlayers[focusedPlayerIndexInAllTimersMode];
const newCurrentIndex = players.findIndex(p => p.id === focusedPlayer.id);
if (newCurrentIndex !== -1) currentPlayerIndex = newCurrentIndex;
} else if (activePlayers.length > 0) {
currentPlayerIndex = players.findIndex(p => p.id === activePlayers[0].id);
}
updateGameModeUI(); updateDisplay();
}
function switchToAllTimersMode(startTimers = true) {
gameMode = 'allTimersRunning';
if (startTimers) {
players.forEach(p => { if (!p.isSkipped) startTimer(p.id); });
const currentActualPlayer = players[currentPlayerIndex];
const activePlayers = players.filter(p => !p.isSkipped);
const focusIdx = activePlayers.findIndex(p => p.id === currentActualPlayer.id);
focusedPlayerIndexInAllTimersMode = (focusIdx !== -1) ? focusIdx : 0;
}
updateGameModeUI(); updateDisplay();
}
function changeFocusInAllTimersMode() {
if (gameMode !== 'allTimersRunning' || players.length === 0) return;
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length <= 1) return;
focusedPlayerIndexInAllTimersMode = (focusedPlayerIndexInAllTimersMode + 1) % activePlayers.length;
updateDisplay();
}
function resetGame() {
if (!confirm("Reset game? All timers will be restored to initial values.")) return;
players.forEach(resetPlayerTimer);
currentPlayerIndex = 0; focusedPlayerIndexInAllTimersMode = 0;
if (gameMode === 'allTimersRunning') switchToNormalMode();
updateDisplay(); saveState();
}
// --- Player Management ---
function renderPlayerManagementList() {
playerListEditor.innerHTML = '';
if (players.length === 0) {
playerListEditor.innerHTML = '<p>No players yet. Add some!</p>';
}
players.forEach((player, index) => {
const entry = document.createElement('div');
entry.className = 'player-editor-entry';
const photoSrc = (player.photo && player.photo.startsWith('data:image')) ? player.photo : DEFAULT_PHOTO_URL;
entry.innerHTML = `
<img src="${photoSrc}" alt="P" style="width:20px; height:20px; border-radius:50%; margin-right: 5px; object-fit:cover;">
<span>${index + 1}. ${player.name} (${formatTime(player.initialTime)}) ${player.isAdmin ? '(Admin)' : ''} ${player.hotkey ? `[${player.hotkey}]`: ''}</span>
<div>
<button class="edit-player-btn" data-id="${player.id}">Edit</button>
<button class="delete-player-btn" data-id="${player.id}">Del</button>
${index > 0 ? `<button class="move-player-up-btn" data-index="${index}">↑</button>` : ''}
${index < players.length - 1 ? `<button class="move-player-down-btn" data-index="${index}">↓</button>` : ''}
</div>
`;
playerListEditor.appendChild(entry);
});
document.querySelectorAll('.edit-player-btn').forEach(btn => btn.addEventListener('click', handleEditPlayerForm));
document.querySelectorAll('.delete-player-btn').forEach(btn => btn.addEventListener('click', handleDeletePlayer));
document.querySelectorAll('.move-player-up-btn').forEach(btn => btn.addEventListener('click', handleMovePlayerUp));
document.querySelectorAll('.move-player-down-btn').forEach(btn => btn.addEventListener('click', handleMovePlayerDown));
}
function openPlayerForm(playerToEdit = null) {
addEditPlayerForm.style.display = 'block';
currentPhotoDataUrl = null;
playerPhotoCaptureInput.value = '';
if (playerToEdit) {
playerFormTitle.textContent = 'Edit Player';
editPlayerIdInput.value = playerToEdit.id;
playerNameInput.value = playerToEdit.name;
playerTimeInput.value = formatTime(playerToEdit.initialTime).replace('-', '');
playerPhotoPreviewEl.src = playerToEdit.photo || DEFAULT_PHOTO_URL;
currentPhotoDataUrl = (playerToEdit.photo && playerToEdit.photo.startsWith('data:image')) ? playerToEdit.photo : null;
playerHotkeyInput.value = playerToEdit.hotkey || '';
playerAdminInput.checked = playerToEdit.isAdmin || false;
} else {
playerFormTitle.textContent = 'Add Player';
editPlayerIdInput.value = '';
playerNameInput.value = '';
playerTimeInput.value = '60:00';
playerPhotoPreviewEl.src = DEFAULT_PHOTO_URL;
playerHotkeyInput.value = '';
playerAdminInput.checked = false;
}
removePhotoBtn.style.display = (playerPhotoPreviewEl.src !== DEFAULT_PHOTO_URL && playerPhotoPreviewEl.src !== '') ? 'block' : 'none';
playerNameInput.focus();
}
playerPhotoCaptureInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
currentPhotoDataUrl = e.target.result;
playerPhotoPreviewEl.src = currentPhotoDataUrl;
removePhotoBtn.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
removePhotoBtn.addEventListener('click', () => {
currentPhotoDataUrl = null;
playerPhotoPreviewEl.src = DEFAULT_PHOTO_URL;
playerPhotoCaptureInput.value = '';
removePhotoBtn.style.display = 'none';
});
addPlayerFormBtn.addEventListener('click', () => { // Event listener for addPlayerFormBtn
if (players.length >= 10) { alert("Max 10 players."); return; }
openPlayerForm();
});
cancelEditPlayerBtn.addEventListener('click', () => {
addEditPlayerForm.style.display = 'none';
});
savePlayerBtn.addEventListener('click', () => {
const id = editPlayerIdInput.value;
const name = playerNameInput.value.trim();
const initialTimeSeconds = parseTimeInput(playerTimeInput.value);
let photoToSave;
if (currentPhotoDataUrl) {
photoToSave = currentPhotoDataUrl;
} else if (id) {
const existingPlayer = players.find(p => p.id === parseInt(id));
if (playerPhotoPreviewEl.src === DEFAULT_PHOTO_URL && !currentPhotoDataUrl) {
photoToSave = DEFAULT_PHOTO_URL;
} else {
photoToSave = existingPlayer.photo;
}
} else {
photoToSave = DEFAULT_PHOTO_URL;
}
const hotkey = playerHotkeyInput.value.trim().toLowerCase();
const isAdmin = playerAdminInput.checked;
if (!name) { alert('Name empty.'); return; }
if (hotkey.length > 1) { alert('Hotkey single char.'); return; }
if (hotkey && players.some(p => p.hotkey === hotkey && p.id !== (id ? parseInt(id) : null))) { alert(`Hotkey '${hotkey}' taken.`); return; }
if (isAdmin) { players.forEach(p => { if (p.id !== (id ? parseInt(id) : null)) p.isAdmin = false; }); }
if (id) {
const player = players.find(p => p.id === parseInt(id));
if (player) {
player.name = name;
player.initialTime = initialTimeSeconds;
if (player.initialTime !== initialTimeSeconds && !playerTimers[player.id]) player.currentTime = initialTimeSeconds;
player.photo = photoToSave;
player.hotkey = hotkey;
player.isAdmin = isAdmin;
}
} else {
if (players.length >= 10) { alert("Max 10 players."); return; }
players.push({ id: Date.now(), name, initialTime: initialTimeSeconds, currentTime: initialTimeSeconds, photo: photoToSave, hotkey, isAdmin, isSkipped: false });
}
addEditPlayerForm.style.display = 'none';
renderPlayerManagementList();
updateDisplay();
saveState();
});
function handleEditPlayerForm(event) { openPlayerForm(players.find(p => p.id === parseInt(event.target.dataset.id))); }
function handleDeletePlayer(event) {
const playerId = parseInt(event.target.dataset.id);
if (players.length <= 2) { alert("Min 2 players."); return; }
if (confirm("Delete player?")) {
const playerIndex = players.findIndex(p => p.id === playerId);
if (playerIndex > -1) {
if (playerIndex === currentPlayerIndex) {
if (gameMode === 'normal') pauseTimer(players[currentPlayerIndex].id);
} else if (playerIndex < currentPlayerIndex) {
currentPlayerIndex--;
}
if (playerTimers[playerId]) { pauseTimer(playerId, false); delete playerTimers[playerId]; }
players.splice(playerIndex, 1);
if (players.length > 0) {
currentPlayerIndex = Math.max(0, Math.min(currentPlayerIndex, players.length - 1));
} else {
currentPlayerIndex = 0;
}
focusedPlayerIndexInAllTimersMode = Math.min(focusedPlayerIndexInAllTimersMode, players.filter(p => !p.isSkipped).length -1);
if (focusedPlayerIndexInAllTimersMode < 0) focusedPlayerIndexInAllTimersMode = 0;
renderPlayerManagementList(); updateDisplay(); saveState();
}
}
}
function handleMovePlayerUp(event) {
const index = parseInt(event.target.dataset.index);
if (index > 0) {
[players[index-1], players[index]] = [players[index], players[index-1]];
if (currentPlayerIndex === index) currentPlayerIndex = index - 1; else if (currentPlayerIndex === index - 1) currentPlayerIndex = index;
renderPlayerManagementList(); updateDisplay(); saveState();
}
}
function handleMovePlayerDown(event) {
const index = parseInt(event.target.dataset.index);
if (index < players.length - 1) {
[players[index+1], players[index]] = [players[index], players[index+1]];
if (currentPlayerIndex === index) currentPlayerIndex = index + 1; else if (currentPlayerIndex === index + 1) currentPlayerIndex = index;
renderPlayerManagementList(); updateDisplay(); saveState();
}
}
shufflePlayersBtn.addEventListener('click', () => {
for (let i = players.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [players[i], players[j]] = [players[j], players[i]]; }
currentPlayerIndex = 0; focusedPlayerIndexInAllTimersMode = 0; renderPlayerManagementList(); updateDisplay(); saveState();
});
reversePlayersBtn.addEventListener('click', () => {
players.reverse(); if (players.length > 0) currentPlayerIndex = (players.length - 1) - currentPlayerIndex;
focusedPlayerIndexInAllTimersMode = 0; renderPlayerManagementList(); updateDisplay(); saveState();
});
// --- Audio (Synthesized Ticks) ---
function playSingleTick() {
if (!audioContext || isMuted) return;
resumeAudioContext();
const time = audioContext.currentTime;
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(TICK_FREQUENCY_HZ, time);
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(0.3, time + TICK_DURATION_S / 2);
gain.gain.linearRampToValueAtTime(0, time + TICK_DURATION_S);
osc.connect(gain);
gain.connect(audioContext.destination);
osc.start(time);
osc.stop(time + TICK_DURATION_S);
}
function playContinuousTick(play) {
if (!audioContext) return;
resumeAudioContext();
if (continuousTickIntervalId) { clearInterval(continuousTickIntervalId); continuousTickIntervalId = null; }
if (play && !isMuted) {
playSingleTick();
continuousTickIntervalId = setInterval(playSingleTick, CONTINUOUS_TICK_INTERVAL_MS);
}
}
function playShortTick() {
if (!audioContext || isMuted) return;
resumeAudioContext();
stopShortTick();
playSingleTick();
shortTickIntervalId = setInterval(playSingleTick, CONTINUOUS_TICK_INTERVAL_MS);
shortTickTimeoutId = setTimeout(stopShortTick, SHORT_TICK_DURATION_MS);
}
function stopShortTick() {
if (shortTickIntervalId) { clearInterval(shortTickIntervalId); shortTickIntervalId = null; }
if (shortTickTimeoutId) { clearTimeout(shortTickTimeoutId); shortTickTimeoutId = null; }
}
function updateMuteButton() {
muteBtn.textContent = isMuted ? '🔊 Unmute' : '🔇 Mute';
if (isMuted) {
playContinuousTick(false);
stopShortTick();
} else {
if (gameMode === 'allTimersRunning' && Object.values(playerTimers).some(id => id !== null)) {
playContinuousTick(true);
}
}
}
// --- Event Listeners ---
function handleUserInteractionForAudio() {
resumeAudioContext();
}
document.addEventListener('click', handleUserInteractionForAudio, { once: true });
document.addEventListener('touchstart', handleUserInteractionForAudio, { once: true });
document.addEventListener('keydown', handleUserInteractionForAudio, { once: true });
managePlayersBtn.addEventListener('click', () => {
managePlayersModal.style.display = 'block'; renderPlayerManagementList(); addEditPlayerForm.style.display = 'none';
});
closeModalBtn.addEventListener('click', () => managePlayersModal.style.display = 'none');
savePlayerManagementBtn.addEventListener('click', () => {
if (players.length < 2) { alert("Min 2 players."); return; }
managePlayersModal.style.display = 'none'; updateDisplay(); saveState();
});
window.addEventListener('click', (event) => { if (event.target === managePlayersModal) managePlayersModal.style.display = 'none'; });
gameModeBtn.addEventListener('click', () => {
if (players.length < 2) { alert("Min 2 players."); return; }
if (gameMode === 'normal') switchToAllTimersMode(true);
else {
const anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) switchToNormalMode(); else switchToAllTimersMode(true);
}
});
resetGameBtn.addEventListener('click', resetGame);
muteBtn.addEventListener('click', () => { isMuted = !isMuted; updateMuteButton(); saveState(); });
currentPlayerArea.addEventListener('click', () => {
if (players.length === 0) return;
if (gameMode === 'normal') {
const currentP = players[currentPlayerIndex];
if (playerTimers[currentP.id]) pauseTimer(currentP.id); else if (!currentP.isSkipped) startTimer(currentP.id);
} else {
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length > 0) {
const focusedPlayer = activePlayers[focusedPlayerIndexInAllTimersMode];
if (playerTimers[focusedPlayer.id]) pauseTimer(focusedPlayer.id); else if (!focusedPlayer.isSkipped) startTimer(focusedPlayer.id);
}
}
});
let touchStartY = 0;
nextPlayerArea.addEventListener('touchstart', (e) => { if (e.touches.length === 1) touchStartY = e.touches[0].clientY; }, { passive: true });
nextPlayerArea.addEventListener('touchend', (e) => {
if (players.length === 0 || touchStartY === 0 || e.changedTouches.length === 0) return;
if ((touchStartY - e.changedTouches[0].clientY) > 50) { // Swipe Up
if (gameMode === 'normal') passTurn(); else changeFocusInAllTimersMode();
}
touchStartY = 0;
});
document.addEventListener('keydown', (event) => {
const key = event.key.toLowerCase();
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
const triggeredPlayer = players.find(p => p.hotkey === key);
if (triggeredPlayer) {
event.preventDefault();
if (gameMode === 'normal') { if (players[currentPlayerIndex].id === triggeredPlayer.id) passTurn(); }
else { if (playerTimers[triggeredPlayer.id]) pauseTimer(triggeredPlayer.id); else if (!triggeredPlayer.isSkipped) startTimer(triggeredPlayer.id); }
return;
}
const adminPlayer = players.find(p => p.isAdmin);
if (adminPlayer && key === 's') {
event.preventDefault();
if (gameMode === 'normal') {
const currentP = players[currentPlayerIndex];
if (playerTimers[currentP.id]) pauseTimer(currentP.id); else if (!currentP.isSkipped) startTimer(currentP.id);
} else {
const anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) switchToNormalMode(); else switchToAllTimersMode(true);
}
}
});
// --- Initialization ---
function init() {
initAudio();
loadState();
updateMuteButton();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW registration failed:', err));
}
}
init();
});

View File

@@ -1,104 +0,0 @@
<template>
<div :class="[theme, 'min-h-screen flex flex-col no-select']"> <!-- min-h-screen and flex flex-col are good -->
<router-view class="flex-grow" /> <!-- Add flex-grow to router-view if its direct child needs to expand -->
</div>
</template>
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useStore } from 'vuex';
import { AudioService } from './services/AudioService';
const store = useStore();
const theme = computed(() => store.getters.theme);
onMounted(() => {
store.dispatch('loadState').then(() => {
console.log("App.vue: Store has finished loading state.");
applyTheme();
}).catch(error => {
console.error("App.vue: Error during store.dispatch('loadState'):", error);
});
document.addEventListener('keydown', handleGlobalKeyDown);
const resumeAudio = () => {
AudioService.resumeContext();
document.body.removeEventListener('click', resumeAudio);
document.body.removeEventListener('touchstart', resumeAudio);
};
document.body.addEventListener('click', resumeAudio, { once: true });
document.body.addEventListener('touchstart', resumeAudio, { once: true });
});
watch(theme, () => {
applyTheme();
});
watch(() => store.state.isMuted, (newMutedState) => {
AudioService.setMuted(newMutedState);
});
const applyTheme = () => {
if (store.getters.theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
const handleGlobalKeyDown = (event) => {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.isContentEditable) {
return;
}
const keyPressed = event.key.toLowerCase();
if (keyPressed === store.getters.globalHotkeyStopPause && store.getters.globalHotkeyStopPause) {
event.preventDefault();
store.dispatch('globalStopPauseAll');
return;
}
const currentPlayerInStore = store.getters.currentPlayer;
const gameModeInStore = store.getters.gameMode;
if (gameModeInStore === 'normal' && currentPlayerInStore && keyPressed === currentPlayerInStore.hotkey) {
event.preventDefault();
const wasRunning = currentPlayerInStore.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer;
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
return;
}
if (gameModeInStore === 'allTimers') {
const playerToToggle = store.state.players.find(p => p.hotkey === keyPressed && !p.isSkipped);
if (playerToToggle) {
event.preventDefault();
const playerIndex = store.state.players.indexOf(playerToToggle);
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
}
}
};
</script>
<style>
html, body, #app { /* These styles are critical */
height: 100%;
margin: 0; /* Ensure no default margin */
padding: 0; /* Ensure no default padding */
overscroll-behavior: none;
}
/* Ensure #app's direct child (from router-view) can also flex expand if needed */
/* This might not be necessary if GameView itself handles its height with flex */
/* #app > div {
display: flex;
flex-direction: column;
flex-grow: 1;
} */
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 458 B

View File

@@ -1,43 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 antialiased select-none;
overscroll-behavior-y: contain; /* Prevents pull-to-refresh on mobile */
}
/* Basic button styling */
.btn {
@apply px-4 py-2 rounded font-semibold focus:outline-none focus:ring-2 focus:ring-opacity-50;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-400;
}
.btn-secondary {
@apply bg-gray-500 hover:bg-gray-600 text-white focus:ring-gray-400;
}
.btn-danger {
@apply bg-red-500 hover:bg-red-600 text-white focus:ring-red-400;
}
.btn-warning {
@apply bg-yellow-500 hover:bg-yellow-600 text-black focus:ring-yellow-400;
}
.btn-icon {
@apply p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700;
}
/* Input styling */
.input-base {
@apply mt-1 block w-full px-3 py-2 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm;
}
/* For preventing text selection during swipes/taps */
.no-select {
-webkit-touch-callout: none; /* iOS Safari */
-webkit-user-select: none; /* Safari */
-khtml-user-select: none; /* Konqueror HTML */
-moz-user-select: none; /* Old versions of Firefox */
-ms-user-select: none; /* Internet Explorer/Edge */
user-select: none; /* Non-prefixed version, currently supported by Chrome, Edge, Opera and Firefox */
}

View File

@@ -1,9 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
</svg>
</template>
<script setup>
// No script needed for a simple SVG icon component
</script>

View File

@@ -1,158 +0,0 @@
<template>
<div
:class="['player-area p-2 md:p-4 flex flex-col items-center justify-center text-center relative no-select',
areaClass,
{ 'opacity-50': player.isSkipped,
'bg-green-100 dark:bg-green-800 animate-pulsePositive': player.isTimerRunning && player.currentTimerSec >=0 && !isNextPlayerArea,
'bg-red-100 dark:bg-red-900': player.isTimerRunning && player.currentTimerSec < 0 && !isNextPlayerArea,
}]"
@click="handleTap"
v-touch:swipe.up="handleSwipeUp"
>
<!-- Avatar -->
<div
class="mb-6 md:mb-4 relative"
:style="{
width: avatarSize.width,
height: avatarSize.height,
}"
>
<img
v-if="player.avatar"
:src="player.avatar"
alt="Player Avatar"
class="rounded-full object-cover border-2 md:border-4 shadow-lg w-full h-full"
:class="player.isSkipped ? 'border-gray-500 filter grayscale' : 'border-blue-500 dark:border-blue-400'"
/>
<DefaultAvatarIcon
v-else
class="rounded-full object-cover border-2 md:border-4 shadow-lg text-gray-400 dark:text-gray-500 bg-gray-200 dark:bg-gray-700 p-1 md:p-2 w-full h-full"
:class="player.isSkipped ? 'border-gray-500 filter grayscale !text-gray-300 dark:!text-gray-600' : 'border-blue-500 dark:border-blue-400'"
/>
</div>
<!-- Player Name -->
<h2 class="font-semibold mb-6 md:mb-6 text-5xl sm:text-2xl md:text-3xl lg:text-5xl">
{{ player.name }}
</h2>
<!-- Timer Display -->
<TimerDisplay
:seconds="player.currentTimerSec"
:is-negative="player.currentTimerSec < 0"
:is-pulsating="player.isTimerRunning"
class="text-4xl sm:text-5xl md:text-6xl lg:text-6xl xl:text-5xl"
/>
<p v-if="player.isSkipped" class="text-red-500 dark:text-red-400 mt-1 md:mt-2 font-semibold text-sm md:text-base lg:text-lg">SKIPPED</p>
<p v-if="isNextPlayerArea && !player.isSkipped" class="mt-1 md:mt-2 text-xs md:text-sm text-gray-600 dark:text-gray-400">(Swipe up to pass turn)</p>
</div>
</template>
<script setup>
import { computed, ref, onMounted, onUnmounted } from 'vue';
import TimerDisplay from './TimerDisplay.vue';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
const vTouch = {
mounted: (el, binding) => {
if (binding.arg === 'swipe' && binding.modifiers.up) {
let touchstartX = 0;
let touchstartY = 0;
let touchendX = 0;
let touchendY = 0;
const swipeThreshold = 50;
el.addEventListener('touchstart', function(event) {
touchstartX = event.changedTouches[0].screenX;
touchstartY = event.changedTouches[0].screenY;
}, { passive: true });
el.addEventListener('touchend', function(event) {
touchendX = event.changedTouches[0].screenX;
touchendY = event.changedTouches[0].screenY;
handleGesture();
}, { passive: true });
function handleGesture() {
const deltaY = touchstartY - touchendY;
const deltaX = Math.abs(touchendX - touchstartX);
if (deltaY > swipeThreshold && deltaY > deltaX) {
if (typeof binding.value === 'function') {
binding.value();
}
}
}
}
}
};
const props = defineProps({
player: {
type: Object,
required: true
},
isCurrentPlayerArea: Boolean,
isNextPlayerArea: Boolean,
areaClass: String,
});
const emit = defineEmits(['tapped', 'swiped-up']);
// --- Responsive Avatar Size Logic ---
const avatarSize = ref({ width: '120px', height: '120px' }); // Default mobile size
const calculateAvatarSize = () => {
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
const isNormalModeContext = document.querySelector('.player-area')?.parentElement?.classList.contains('flex-col'); // Heuristic
if (isNormalModeContext) { // Larger sizes for Normal Mode split screen
// Use vmin for responsiveness but cap it for desktop
// Each player display gets roughly half the screen height in Normal mode.
// Avatar should be a significant portion of that, but not overwhelming on desktop.
const availableHeight = screenHeight / 2; // Approximate height for one player display
let size = Math.min(availableHeight * 0.5, screenWidth * 0.4, 140); // Cap at 220px for desktop
if (screenWidth < 768) { // Mobile
size = Math.min(availableHeight * 0.6, screenWidth * 0.5, 210); // Smaller cap for mobile
} else if (screenWidth < 1024) { // Tablet / Small Desktop
size = Math.min(availableHeight * 0.55, screenWidth * 0.45, 180);
}
// Ensure a minimum size
size = Math.max(size, 100);
avatarSize.value = { width: `${size}px`, height: `${size}px` };
} else {
// Fallback or different logic for other contexts if PlayerDisplay is reused
avatarSize.value = { width: '120px', height: '120px' };
}
};
onMounted(() => {
calculateAvatarSize();
window.addEventListener('resize', calculateAvatarSize);
});
onUnmounted(() => {
window.removeEventListener('resize', calculateAvatarSize);
});
// --- End Responsive Avatar Size Logic ---
const handleTap = () => {
if (props.isCurrentPlayerArea && !props.player.isSkipped) {
emit('tapped');
}
};
const handleSwipeUp = () => {
if (props.isNextPlayerArea && !props.player.isSkipped) {
emit('swiped-up');
}
};
</script>

View File

@@ -1,219 +0,0 @@
<template>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex justify-center items-center p-4" @click.self="closeModal">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-md">
<h2 class="text-2xl font-semibold mb-4">{{ isEditing ? 'Edit Player' : 'Add New Player' }}</h2>
<form @submit.prevent="submitForm">
<div class="mb-4">
<label for="playerName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input type="text" id="playerName" v-model="editablePlayer.name" required class="input-base">
</div>
<div class="mb-4">
<!-- Changed label and v-model -->
<label for="remainingTime" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Remaining Time (MM:SS or -MM:SS)</label>
<input type="text" id="remainingTime" v-model="currentTimeFormatted" @blur="validateCurrentTimeFormat" placeholder="e.g., 55:30 or -02:15" required class="input-base">
<p v-if="currentTimeFormatError" class="text-red-500 text-xs mt-1">{{ currentTimeFormatError }}</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar</label>
<div class="mt-1 flex items-center">
<!-- Conditional rendering for avatar -->
<img
v-if="editablePlayer.avatar"
:src="editablePlayer.avatar"
alt="Avatar"
class="w-16 h-16 rounded-full object-cover mr-4 border"
/>
<DefaultAvatarIcon
v-else
class="w-16 h-16 rounded-full object-cover mr-4 border text-gray-400 bg-gray-200 p-1"
/>
<button type="button" @click="capturePhoto" class="btn btn-secondary text-sm mr-2">Take Photo</button>
<button type="button" @click="useDefaultAvatar" class="btn btn-secondary text-sm">Default</button>
</div>
<p v-if="cameraError" class="text-red-500 text-xs mt-1">{{ cameraError }}</p>
</div>
<div class="mb-4">
<label for="playerHotkey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">"Pass Turn / My Pause" Hotkey</label>
<input
type="text"
id="playerHotkey"
v-model="editablePlayer.hotkey"
@keydown.prevent="captureHotkey($event, 'player')"
placeholder="Press a key"
class="input-base"
readonly
>
<button type="button" @click="clearHotkey('player')" class="text-xs text-blue-500 hover:underline mt-1">Clear Hotkey</button>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="closeModal" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!!currentTimeFormatError">{{ isEditing ? 'Save Changes' : 'Add Player' }}</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted, computed } from 'vue';
import { useStore } from 'vuex';
import { CameraService } from '../services/CameraService';
import { formatTime, parseTime } from '../utils/timeFormatter';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue'; // Import SVG component
import { AudioService } from '../services/AudioService';
const props = defineProps({
player: Object,
});
const emit = defineEmits(['close', 'save']);
const store = useStore();
const DEFAULT_AVATAR_MARKER = null; // Align with store
const isEditing = computed(() => !!props.player);
const editablePlayer = reactive({
id: null,
name: '',
avatar: DEFAULT_AVATAR_MARKER,
initialTimerSec: 3600, // Still keep initial for reference or new players
currentTimerSec: 3600, // This will be edited
hotkey: '',
});
const currentTimeFormatted = ref('60:00'); // For "Remaining Time"
const currentTimeFormatError = ref('');
const cameraError = ref('');
const maxNegativeSeconds = computed(() => store.getters.maxNegativeTimeReached);
onMounted(() => {
if (isEditing.value && props.player) {
editablePlayer.id = props.player.id;
editablePlayer.name = props.player.name;
editablePlayer.avatar = props.player.avatar; // Already null or data URI from store
editablePlayer.initialTimerSec = props.player.initialTimerSec; // Keep for reference
editablePlayer.currentTimerSec = props.player.currentTimerSec; // Key field for edit
editablePlayer.hotkey = props.player.hotkey || '';
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
} else { // Adding new player
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
editablePlayer.initialTimerSec = 3600; // Default initial for new player
editablePlayer.currentTimerSec = 3600; // Default current for new player
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
}
});
watch(currentTimeFormatted, (newTime) => {
validateCurrentTimeFormat();
if (!currentTimeFormatError.value) {
editablePlayer.currentTimerSec = parseTime(newTime);
// If editing, currentTimerSec might differ from initialTimerSec
// If adding new, initialTimerSec should also be set to this value.
if (!isEditing.value) {
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
}
}
});
function validateCurrentTimeFormat() {
const time = currentTimeFormatted.value;
const isNegativeInput = time.startsWith('-');
// Regex for MM:SS or -MM:SS
// Allows more than 59 minutes for setup (e.g. 70:00 or -70:00 is technically allowed by parseTime)
// But usually for remaining time, it reflects the actual state.
if (!/^-?(?:[0-9]+:[0-5]\d)$/.test(time)) {
currentTimeFormatError.value = 'Invalid format. Use MM:SS or -MM:SS (e.g., 05:30, -02:15).';
} else {
const parsedSeconds = parseTime(time);
if (isNegativeInput && parsedSeconds > 0) { // Should be negative or zero
currentTimeFormatError.value = 'Negative time should parse to negative seconds.';
} else if (parsedSeconds < maxNegativeSeconds.value) {
currentTimeFormatError.value = `Time cannot be less than ${formatTime(maxNegativeSeconds.value)}.`;
}
else {
currentTimeFormatError.value = '';
}
}
}
async function capturePhoto() {
cameraError.value = '';
try {
AudioService.resumeContext();
const photoDataUrl = await CameraService.getPhoto();
editablePlayer.avatar = photoDataUrl;
} catch (error) {
console.error('Failed to capture photo:', error);
cameraError.value = error.message || 'Could not capture photo.';
}
}
function useDefaultAvatar() {
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
}
function captureHotkey(event, type) {
event.preventDefault();
const key = event.key.toLowerCase();
if (key && !['control', 'alt', 'shift', 'meta'].includes(key)) {
if (type === 'player') {
const existingPlayerHotkey = store.state.players.some(p => p.hotkey === key && p.id !== editablePlayer.id);
const globalHotkeyInUse = store.state.globalHotkeyStopPause === key;
if (existingPlayerHotkey) {
alert(`Hotkey "${key.toUpperCase()}" is already assigned to another player.`);
return;
}
if (globalHotkeyInUse) {
alert(`Hotkey "${key.toUpperCase()}" is already assigned as the Global Stop/Pause hotkey.`);
return;
}
editablePlayer.hotkey = key;
}
}
}
function clearHotkey(type) {
if (type === 'player') {
editablePlayer.hotkey = '';
}
}
function submitForm() {
validateCurrentTimeFormat();
if (currentTimeFormatError.value) return;
const playerPayload = { ...editablePlayer };
// When saving, ensure isSkipped status is updated if time is max negative
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
playerPayload.isSkipped = true;
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
// If time was edited to be > maxNegative, unskip them.
playerPayload.isSkipped = false;
}
// If adding a new player, initialTimerSec should match currentTimerSec.
if (!isEditing.value) {
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
}
// If editing an existing player, and current time becomes the new "initial" baseline if reset
// This is debatable. The spec says "Set initial timer values per player".
// If user edits "remaining time", should it also update "initialTimer"?
// For now, let's assume "initialTimer" is only set when player is first added,
// or if explicitly edited through a separate mechanism (not present).
// So, playerPayload.initialTimerSec will be what was loaded or set for new player.
emit('save', playerPayload);
closeModal();
}
function closeModal() {
emit('close');
}
</script>

View File

@@ -1,72 +0,0 @@
<template>
<div
:class="['player-list-item flex items-center justify-between p-3 my-2 rounded-lg shadow cursor-pointer',
itemBgClass,
{ 'opacity-60': player.isSkipped,
'animate-pulsePositive': player.isTimerRunning && player.currentTimerSec >=0,
}]"
@click="handleTap"
>
<div class="flex items-center">
<!-- Conditional rendering for avatar -->
<img
v-if="player.avatar"
:src="player.avatar"
alt="Player Avatar"
class="w-12 h-12 rounded-full object-cover mr-3 border-2"
:class="player.isSkipped ? 'border-gray-500 filter grayscale' : 'border-blue-400 dark:border-blue-300'"
/>
<DefaultAvatarIcon
v-else
class="w-12 h-12 rounded-full object-cover mr-3 border-2 text-gray-400 dark:text-gray-500 bg-gray-200 dark:bg-gray-700 p-1"
:class="player.isSkipped ? 'border-gray-500 filter grayscale !text-gray-300 dark:!text-gray-600' : 'border-blue-400 dark:border-blue-300'"
/>
<div>
<h3 class="text-lg font-medium">{{ player.name }}</h3>
<p v-if="player.isSkipped" class="text-xs text-red-500 dark:text-red-400">SKIPPED</p>
</div>
</div>
<div class="flex flex-col items-end">
<TimerDisplay
:seconds="player.currentTimerSec"
:is-negative="player.currentTimerSec < 0"
:is-pulsating="player.isTimerRunning"
class="text-2xl"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import TimerDisplay from './TimerDisplay.vue';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue'; // Import the SVG component
// ... (rest of script setup: props, emit, handleTap, itemBgClass)
const props = defineProps({
player: {
type: Object,
required: true
}
});
const emit = defineEmits(['tapped']);
const handleTap = () => {
if (!props.player.isSkipped) {
emit('tapped');
}
};
const itemBgClass = computed(() => {
if (props.player.isSkipped) {
return 'bg-gray-200 dark:bg-gray-800 border border-gray-300 dark:border-gray-700';
}
if (props.player.isTimerRunning) {
return props.player.currentTimerSec < 0
? 'bg-red-100 dark:bg-red-900/80 border border-red-300 dark:border-red-700'
: 'bg-green-50 dark:bg-green-800/70 border border-green-200 dark:border-green-700';
}
return 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600';
});
</script>

View File

@@ -1,27 +0,0 @@
<template>
<span :class="timerClasses">
{{ formattedTime }}
</span>
</template>
<script setup>
import { computed } from 'vue';
import { formatTime } from '../utils/timeFormatter';
const props = defineProps({
seconds: {
type: Number,
required: true
},
isPulsating: Boolean, // For active timer
isNegative: Boolean, // For negative time text color
});
const formattedTime = computed(() => formatTime(props.seconds));
const timerClasses = computed(() => ({
'font-mono text-5xl md:text-7xl lg:text-8xl font-bold': true,
'text-red-500 dark:text-red-400': props.isNegative,
'animate-pulseNegative': props.isNegative && props.isPulsating, // Pulsate text if negative and active
}));
</script>

View File

@@ -1,26 +0,0 @@
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router' // router will be initialized here
import store from './store' // store will be initialized here
import './assets/tailwind.css'
const app = createApp(App)
// Dispatch loadState immediately after store is created and before app is mounted
// and before router is fully used by the app.
store.dispatch('loadState').then(() => {
// Now that the state is loaded (or attempted to be loaded),
// we can safely use the router and mount the app.
app.use(router)
app.use(store) // Using store here is fine, it's already created.
app.mount('#app')
}).catch(error => {
console.error("Failed to load initial state for the store:", error);
// Fallback: Mount the app even if state loading fails, guards should handle it.
// Or display an error message to the user.
// For now, let's still try to mount.
app.use(router)
app.use(store)
app.mount('#app')
});

View File

@@ -1,37 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import SetupView from '../views/SetupView.vue'
import GameView from '../views/GameView.vue'
import InfoView from '../views/InfoView.vue'
import store from '../store'
const routes = [
{
path: '/',
name: 'Setup',
component: SetupView
},
{
path: '/game',
name: 'Game',
component: GameView,
beforeEnter: (to, from, next) => {
if (store.state.players.length < 2) {
next({ name: 'Setup' });
} else {
next();
}
}
},
{
path: '/info',
name: 'Info',
component: InfoView
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes
})
export default router

View File

@@ -1,133 +0,0 @@
let audioContext;
let tickSoundBuffer; // For short tick
let passTurnSoundBuffer; // For 3s pass turn alert
let isMutedGlobally = false;
let continuousTickInterval = null; // For setInterval based continuous ticking
let passTurnSoundTimeout = null;
function getAudioContext() {
if (!audioContext && (window.AudioContext || window.webkitAudioContext)) {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
}
return audioContext;
}
function createBeepBuffer(frequency = 440, duration = 0.1, type = 'sine') {
const ctx = getAudioContext();
if (!ctx) return null;
const sampleRate = ctx.sampleRate;
const numFrames = duration * sampleRate;
const buffer = ctx.createBuffer(1, numFrames, sampleRate);
const data = buffer.getChannelData(0);
const gain = 0.1; // Reduce gain to make beeps softer
for (let i = 0; i < numFrames; i++) {
// Simple fade out
const currentGain = gain * (1 - (i / numFrames));
if (type === 'square') {
data[i] = (Math.sin(2 * Math.PI * frequency * (i / sampleRate)) >= 0 ? 1 : -1) * currentGain;
} else { // sine
data[i] = Math.sin(2 * Math.PI * frequency * (i / sampleRate)) * currentGain;
}
}
return buffer;
}
async function initSounds() {
const ctx = getAudioContext();
if (!ctx) return;
// Tick sound (shorter, slightly different pitch)
if (!tickSoundBuffer) {
// Using a square wave for a more 'digital' tick, short duration
tickSoundBuffer = createBeepBuffer(1000, 0.03, 'square');
}
// Pass turn alert sound (3 beeps)
if (!passTurnSoundBuffer) {
passTurnSoundBuffer = createBeepBuffer(660, 0.08, 'sine');
}
}
initSounds();
function playSoundBuffer(buffer) {
if (isMutedGlobally || !buffer || !audioContext || audioContext.state === 'suspended') return;
const source = audioContext.createBufferSource();
source.buffer = buffer;
source.connect(audioContext.destination);
source.start();
}
export const AudioService = {
setMuted(muted) {
isMutedGlobally = muted;
if (muted) {
this.stopContinuousTick();
this.cancelPassTurnSound();
}
},
// This is the single, short tick sound for "All Timers Running" mode.
_playSingleTick() {
playSoundBuffer(tickSoundBuffer);
},
playPassTurnAlert() {
if (isMutedGlobally || !audioContext || audioContext.state === 'suspended') return;
this.cancelPassTurnSound();
let count = 0;
const playAndSchedule = () => {
if (count < 3 && !isMutedGlobally && audioContext.state !== 'suspended') {
playSoundBuffer(passTurnSoundBuffer);
count++;
passTurnSoundTimeout = setTimeout(playAndSchedule, 1000); // Beep every second for 3s
} else {
passTurnSoundTimeout = null;
}
};
playAndSchedule();
},
cancelPassTurnSound() {
if (passTurnSoundTimeout) {
clearTimeout(passTurnSoundTimeout);
passTurnSoundTimeout = null;
}
},
startContinuousTick() {
this.stopContinuousTick(); // Clear any existing interval
if (isMutedGlobally || !audioContext || audioContext.state === 'suspended') return;
// Play immediately once, then set interval
this._playSingleTick();
continuousTickInterval = setInterval(() => {
if (!isMutedGlobally && audioContext.state !== 'suspended') {
this._playSingleTick();
} else {
this.stopContinuousTick(); // Stop if muted or context suspended during interval
}
}, 1000); // Tick every second
},
stopContinuousTick() {
if (continuousTickInterval) {
clearInterval(continuousTickInterval);
continuousTickInterval = null;
}
// Ensure no rogue oscillators are playing.
// If an oscillator was ever used directly and not disconnected, it could persist.
// The current implementation relies on BufferSource which stops automatically.
},
resumeContext() {
const ctx = getAudioContext();
if (ctx && ctx.state === 'suspended') {
ctx.resume().then(() => {
console.log("AudioContext resumed successfully.");
initSounds(); // Re-initialize sounds if context was suspended for long
}).catch(e => console.error("Error resuming AudioContext:", e));
} else if (ctx && !tickSoundBuffer) { // If context was fine but sounds not loaded
initSounds();
}
}
};

View File

@@ -1,69 +0,0 @@
export const CameraService = {
async getPhoto() {
return new Promise(async (resolve, reject) => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
reject(new Error('Camera API not available.'));
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
// Create a modal or an overlay to show the video stream and a capture button
const videoElement = document.createElement('video');
videoElement.srcObject = stream;
videoElement.setAttribute('playsinline', ''); // Required for iOS
videoElement.style.cssText = "position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); max-width: 90%; max-height: 70vh; z-index: 1001; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);";
const captureButton = document.createElement('button');
captureButton.textContent = 'Capture';
captureButton.style.cssText = "position: fixed; bottom: 10%; left: 50%; transform: translateX(-50%); z-index: 1002; padding: 12px 24px; background-color: #3b82f6; color: white; border: none; border-radius: 8px; font-size: 16px; cursor: pointer;";
const closeButton = document.createElement('button');
closeButton.textContent = 'Cancel';
closeButton.style.cssText = "position: fixed; top: 10px; right: 10px; z-index: 1002; padding: 8px 12px; background-color: #ef4444; color: white; border: none; border-radius: 8px; font-size: 14px; cursor: pointer;";
const overlay = document.createElement('div');
overlay.style.cssText = "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); z-index: 1000;";
document.body.appendChild(overlay);
document.body.appendChild(videoElement);
document.body.appendChild(captureButton);
document.body.appendChild(closeButton);
videoElement.onloadedmetadata = () => {
videoElement.play();
};
const cleanup = () => {
stream.getTracks().forEach(track => track.stop());
document.body.removeChild(videoElement);
document.body.removeChild(captureButton);
document.body.removeChild(closeButton);
document.body.removeChild(overlay);
};
captureButton.onclick = () => {
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const context = canvas.getContext('2d');
context.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL('image/png');
cleanup();
resolve(dataUrl);
};
closeButton.onclick = () => {
cleanup();
reject(new Error('User cancelled photo capture.'));
};
} catch (err) {
console.error("Error accessing camera: ", err);
reject(err);
}
});
}
};

View File

@@ -1,23 +0,0 @@
const STORAGE_KEY = 'nexusTimerState';
export const StorageService = {
getState() {
const savedState = localStorage.getItem(STORAGE_KEY);
if (savedState) {
try {
return JSON.parse(savedState);
} catch (e) {
console.error("Error parsing saved state from localStorage", e);
localStorage.removeItem(STORAGE_KEY); // Clear corrupted data
return null;
}
}
return null;
},
saveState(state) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
},
clearState() {
localStorage.removeItem(STORAGE_KEY);
}
};

View File

@@ -1,441 +0,0 @@
import { createStore } from 'vuex';
import { StorageService } from '../services/StorageService';
import { parseTime, formatTime } from '../utils/timeFormatter';
import defaultAvatar from '../assets/default-avatar.png';
const MAX_NEGATIVE_SECONDS = -(59 * 60 + 59); // -59:59
// Define predefined players
const predefinedPlayers = [
{
id: 'predefined-1', // Unique ID for predefined player 1
name: 'Player 1',
avatar: defaultAvatar, // Or a specific avatar path if you have one
initialTimerSec: 60 * 60, // 60:00
currentTimerSec: 60 * 60,
hotkey: '1', // Hotkey '1'
isSkipped: false,
isTimerRunning: false,
},
{
id: 'predefined-2', // Unique ID for predefined player 2
name: 'Player 2',
avatar: defaultAvatar, // Or a specific avatar path
initialTimerSec: 60 * 60, // 60:00
currentTimerSec: 60 * 60,
hotkey: '2', // Hotkey '2'
isSkipped: false,
isTimerRunning: false,
}
];
const initialState = {
players: JSON.parse(JSON.stringify(predefinedPlayers)), // Start with predefined players (deep copy)
globalHotkeyStopPause: null,
currentPlayerIndex: 0,
gameMode: 'normal',
isMuted: false,
theme: 'light',
gameRunning: false,
};
export default createStore({
state: () => { // This function already loads from storage ONCE during store creation
const persistedState = StorageService.getState();
if (persistedState) {
let playersToUse = persistedState.players;
if (!playersToUse || (playersToUse.length === 0 && !persistedState.hasOwnProperty('players'))) {
playersToUse = JSON.parse(JSON.stringify(predefinedPlayers));
} else if (persistedState.hasOwnProperty('players') && playersToUse.length === 0) {
playersToUse = [];
}
playersToUse = playersToUse.map(p => ({
...p,
id: p.id || Date.now().toString() + Math.random(),
avatar: p.avatar === undefined ? DEFAULT_AVATAR_MARKER : p.avatar,
initialTimerSec: typeof p.initialTimerSec === 'number' ? p.initialTimerSec : parseTime(p.initialTimer || "60:00"),
currentTimerSec: typeof p.currentTimerSec === 'number' ? p.currentTimerSec : (typeof p.initialTimerSec === 'number' ? p.initialTimerSec : parseTime(p.currentTimer || p.initialTimer || "60:00")),
isSkipped: p.isSkipped || false,
isTimerRunning: false,
hotkey: p.hotkey || null,
}));
return {
...initialState,
...persistedState,
players: playersToUse,
gameRunning: false,
currentPlayerIndex: persistedState.currentPlayerIndex !== undefined && persistedState.currentPlayerIndex < playersToUse.length ? persistedState.currentPlayerIndex : 0,
};
}
return JSON.parse(JSON.stringify(initialState));
},
mutations: {
SET_PLAYERS(state, players) {
state.players = players.map(p => ({
...p,
id: p.id || Date.now().toString() + Math.random(),
avatar: p.avatar || defaultAvatar,
initialTimerSec: typeof p.initialTimerSec === 'number' ? p.initialTimerSec : parseTime(p.initialTimer || "60:00"),
currentTimerSec: typeof p.currentTimerSec === 'number' ? p.currentTimerSec : (typeof p.initialTimerSec === 'number' ? p.initialTimerSec : parseTime(p.currentTimer || p.initialTimer || "60:00")),
isSkipped: p.isSkipped || false,
isTimerRunning: p.isTimerRunning || false, // Retain running state if explicitly set
hotkey: p.hotkey || null,
}));
},
ADD_PLAYER(state, player) {
const newPlayer = {
id: Date.now().toString() + Math.random(), // More robust unique ID
name: player.name || `Player ${state.players.length + 1}`,
avatar: player.avatar || defaultAvatar,
initialTimerSec: player.initialTimerSec || 3600,
currentTimerSec: player.initialTimerSec || 3600,
hotkey: player.hotkey || null,
isSkipped: false,
isTimerRunning: false,
};
if (state.players.length < 7) {
state.players.push(newPlayer);
}
},
UPDATE_PLAYER(state, updatedPlayer) {
const index = state.players.findIndex(p => p.id === updatedPlayer.id);
if (index !== -1) {
state.players[index] = { ...state.players[index], ...updatedPlayer };
}
},
DELETE_PLAYER(state, playerId) {
state.players = state.players.filter(p => p.id !== playerId);
if (state.currentPlayerIndex >= state.players.length && state.players.length > 0) {
state.currentPlayerIndex = state.players.length - 1;
} else if (state.players.length === 0) {
state.currentPlayerIndex = 0;
}
},
REORDER_PLAYERS(state, players) {
state.players = players;
},
SHUFFLE_PLAYERS(state) {
for (let i = state.players.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.players[i], state.players[j]] = [state.players[j], state.players[i]];
}
},
REVERSE_PLAYERS(state) {
state.players.reverse();
},
SET_CURRENT_PLAYER_INDEX(state, index) {
state.currentPlayerIndex = index;
},
SET_GAME_MODE(state, mode) {
state.gameMode = mode;
},
SET_IS_MUTED(state, muted) {
state.isMuted = muted;
},
TOGGLE_THEME(state) {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
SET_GLOBAL_HOTKEY_STOP_PAUSE(state, key) {
state.globalHotkeyStopPause = key;
},
DECREMENT_TIMER(state, { playerIndex }) {
const player = state.players[playerIndex];
if (player && player.isTimerRunning && !player.isSkipped) {
player.currentTimerSec--;
if (player.currentTimerSec < MAX_NEGATIVE_SECONDS) {
player.currentTimerSec = MAX_NEGATIVE_SECONDS;
player.isSkipped = true; // Auto-skip if max negative time reached
player.isTimerRunning = false;
}
}
},
RESET_PLAYER_TIMER(state, playerIndex) {
if (state.players[playerIndex]) {
state.players[playerIndex].currentTimerSec = state.players[playerIndex].initialTimerSec;
state.players[playerIndex].isSkipped = false;
state.players[playerIndex].isTimerRunning = false;
}
},
RESET_ALL_TIMERS(state) {
// When resetting, decide if you want to go back to *only* predefined players
// or reset existing players' timers. The current spec "restores all timers to initial values"
// implies resetting existing players. If it meant reverting to the initial player set,
// this logic would need to change to:
// state.players = JSON.parse(JSON.stringify(predefinedPlayers));
// For now, sticking to resetting current players' timers:
state.players.forEach(player => {
player.currentTimerSec = player.initialTimerSec;
player.isSkipped = false;
player.isTimerRunning = false;
});
state.currentPlayerIndex = 0;
state.gameMode = 'normal';
state.gameRunning = false;
},
START_PLAYER_TIMER(state, playerIndex) {
if(state.players[playerIndex] && !state.players[playerIndex].isSkipped) {
state.players[playerIndex].isTimerRunning = true;
state.gameRunning = true;
}
},
PAUSE_PLAYER_TIMER(state, playerIndex) {
if(state.players[playerIndex]) {
state.players[playerIndex].isTimerRunning = false;
}
if (!state.players.some(p => p.isTimerRunning)) {
state.gameRunning = false;
}
},
PAUSE_ALL_TIMERS(state) {
state.players.forEach(p => p.isTimerRunning = false);
state.gameRunning = false;
},
SET_GAME_RUNNING(state, isRunning) {
state.gameRunning = isRunning;
},
},
actions: {
loadState({ commit, state }) {
// The state initializer already did the main loading from localStorage.
// This action can be used for any *additional* setup after initial hydration
// or to re-apply certain defaults if needed.
// For now, it's mainly a confirmation that persisted state is used.
// Example: ensure theme is applied if it was loaded
// This is already handled by App.vue's watcher, but could be centralized.
// if (state.theme === 'dark') {
// document.documentElement.classList.add('dark');
// } else {
// document.documentElement.classList.remove('dark');
// }
console.log("Store state loaded/initialized.");
// It's good practice for actions to return a Promise if they are async
// or if other parts of the app expect to chain .then()
return Promise.resolve(); // Resolve immediately
},
saveState({ state }) {
StorageService.saveState({
players: state.players.map(p => ({
id: p.id,
name: p.name,
avatar: p.avatar,
initialTimerSec: p.initialTimerSec,
currentTimerSec: p.currentTimerSec,
hotkey: p.hotkey,
isSkipped: p.isSkipped,
})),
globalHotkeyStopPause: state.globalHotkeyStopPause,
currentPlayerIndex: state.currentPlayerIndex,
gameMode: state.gameMode,
isMuted: state.isMuted,
theme: state.theme,
});
},
addPlayer({ commit, dispatch }, player) {
commit('ADD_PLAYER', player);
dispatch('saveState');
},
updatePlayer({ commit, dispatch }, player) {
commit('UPDATE_PLAYER', player);
dispatch('saveState');
},
deletePlayer({ commit, dispatch }, playerId) {
commit('DELETE_PLAYER', playerId);
dispatch('saveState');
},
reorderPlayers({commit, dispatch}, players) { // This was in an earlier version
commit('REORDER_PLAYERS', players);
dispatch('saveState');
},
shufflePlayers({commit, dispatch}) {
commit('SHUFFLE_PLAYERS');
dispatch('saveState');
},
reversePlayers({commit, dispatch}) {
commit('REVERSE_PLAYERS');
dispatch('saveState');
},
toggleTheme({ commit, dispatch }) {
commit('TOGGLE_THEME');
dispatch('saveState');
},
setGlobalHotkey({ commit, dispatch }, key) {
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', key);
dispatch('saveState');
},
setMuted({ commit, dispatch }, muted) {
commit('SET_IS_MUTED', muted);
dispatch('saveState');
},
resetGame({ commit, dispatch }) { // This is for resetting timers during a game session
commit('RESET_ALL_TIMERS');
dispatch('saveState');
},
// This action is for the full reset from SetupView
fullResetApp({ commit, dispatch, state: currentGlobalState }) { // Use a different name for state here to avoid conflict
StorageService.clearState();
const freshInitialState = JSON.parse(JSON.stringify(initialState)); // Get a fresh copy
commit('SET_PLAYERS', freshInitialState.players);
commit('SET_CURRENT_PLAYER_INDEX', freshInitialState.currentPlayerIndex);
commit('SET_GAME_MODE', freshInitialState.gameMode);
commit('SET_IS_MUTED', freshInitialState.isMuted);
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', freshInitialState.globalHotkeyStopPause);
// Directly set theme instead of toggling
if (currentGlobalState.theme !== freshInitialState.theme) {
commit('SET_THEME', freshInitialState.theme);
}
commit('SET_GAME_RUNNING', false);
dispatch('saveState'); // Save this fresh state
},
tick({ commit, state }) {
if (state.gameMode === 'normal') {
if (state.players[state.currentPlayerIndex]?.isTimerRunning) {
commit('DECREMENT_TIMER', { playerIndex: state.currentPlayerIndex });
}
} else if (state.gameMode === 'allTimers') {
state.players.forEach((player, index) => {
if (player.isTimerRunning) {
commit('DECREMENT_TIMER', { playerIndex: index });
}
});
}
},
passTurn({ commit, state, dispatch }) {
const numPlayers = state.players.length;
if (numPlayers === 0) return;
const currentIdx = state.currentPlayerIndex;
const currentPlayerTimerWasRunning = state.players[currentIdx]?.isTimerRunning;
commit('PAUSE_PLAYER_TIMER', currentIdx);
let nextPlayerIndex = (currentIdx + 1) % numPlayers;
let skippedCount = 0;
while(state.players[nextPlayerIndex]?.isSkipped && skippedCount < numPlayers) {
nextPlayerIndex = (nextPlayerIndex + 1) % numPlayers;
skippedCount++;
}
if (skippedCount === numPlayers) {
commit('PAUSE_ALL_TIMERS');
dispatch('saveState');
return;
}
commit('SET_CURRENT_PLAYER_INDEX', nextPlayerIndex);
if (currentPlayerTimerWasRunning && !state.players[nextPlayerIndex].isSkipped) {
commit('START_PLAYER_TIMER', nextPlayerIndex);
} else {
if (state.players[nextPlayerIndex] && !state.players[nextPlayerIndex].isSkipped) {
commit('PAUSE_PLAYER_TIMER', nextPlayerIndex);
}
}
dispatch('saveState');
},
toggleCurrentPlayerTimerNormalMode({ commit, state, dispatch }) {
const player = state.players[state.currentPlayerIndex];
if (!player) return;
if (player.isTimerRunning) {
commit('PAUSE_PLAYER_TIMER', state.currentPlayerIndex);
} else if (!player.isSkipped) {
commit('START_PLAYER_TIMER', state.currentPlayerIndex);
}
dispatch('saveState');
},
togglePlayerTimerAllTimersMode({ commit, state, dispatch }, playerIndex) {
const player = state.players[playerIndex];
if (!player) return;
if (player.isTimerRunning) {
commit('PAUSE_PLAYER_TIMER', playerIndex);
} else if (!player.isSkipped) {
commit('START_PLAYER_TIMER', playerIndex);
}
const anyTimerRunning = state.players.some(p => p.isTimerRunning && !p.isSkipped);
if (!anyTimerRunning && state.players.length > 0 && state.gameMode === 'allTimers') {
// This auto-revert logic is now in GameView.vue watcher for better control over timing
}
dispatch('saveState');
},
globalStopPauseAll({ commit, state, dispatch }) {
if (state.gameMode === 'normal') {
dispatch('toggleCurrentPlayerTimerNormalMode');
} else {
const anyTimerRunning = state.players.some(p => p.isTimerRunning && !p.isSkipped);
if (anyTimerRunning) {
commit('PAUSE_ALL_TIMERS');
} else {
state.players.forEach((player, index) => {
if (!player.isSkipped) {
commit('START_PLAYER_TIMER', index);
}
});
}
}
dispatch('saveState');
},
switchToAllTimersMode({ commit, state, dispatch }) {
commit('SET_GAME_MODE', 'allTimers');
let anyStarted = false;
state.players.forEach((player, index) => {
if (!player.isSkipped) {
commit('START_PLAYER_TIMER', index);
anyStarted = true;
}
});
if(anyStarted) commit('SET_GAME_RUNNING', true);
else commit('SET_GAME_RUNNING', false);
dispatch('saveState');
},
switchToNormalMode({commit, state, dispatch}) {
commit('PAUSE_ALL_TIMERS');
commit('SET_GAME_MODE', 'normal');
// Determine current player for normal mode, respecting skips
let currentIdx = state.currentPlayerIndex;
let skippedCount = 0;
while(state.players[currentIdx]?.isSkipped && skippedCount < state.players.length) {
currentIdx = (currentIdx + 1) % state.players.length;
skippedCount++;
}
if (skippedCount < state.players.length) {
commit('SET_CURRENT_PLAYER_INDEX', currentIdx);
// Timer for this player should remain paused as per PAUSE_ALL_TIMERS
} else {
// All players skipped, game is effectively paused.
commit('SET_GAME_RUNNING', false);
}
dispatch('saveState');
}
},
getters: {
players: state => state.players,
currentPlayer: state => state.players[state.currentPlayerIndex],
nextPlayer: state => {
if (!state.players || state.players.length < 1) return null;
let nextIndex = (state.currentPlayerIndex + 1) % state.players.length;
let count = 0;
while(state.players[nextIndex]?.isSkipped && count < state.players.length) {
nextIndex = (nextIndex + 1) % state.players.length;
count++;
}
return state.players[nextIndex];
},
getPlayerById: (state) => (id) => state.players.find(p => p.id === id),
gameMode: state => state.gameMode,
isMuted: state => state.isMuted,
theme: state => state.theme,
globalHotkeyStopPause: state => state.globalHotkeyStopPause,
totalPlayers: state => state.players.length,
gameRunning: state => state.gameRunning,
maxNegativeTimeReached: () => MAX_NEGATIVE_SECONDS,
}
});

View File

@@ -1,32 +0,0 @@
export function formatTime(totalSeconds) {
const isNegative = totalSeconds < 0;
if (isNegative) {
totalSeconds = -totalSeconds;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const paddedMinutes = String(minutes).padStart(2, '0');
const paddedSeconds = String(seconds).padStart(2, '0');
return `${isNegative ? '-' : ''}${paddedMinutes}:${paddedSeconds}`;
}
export function parseTime(timeString) { // MM:SS or -MM:SS
if (!timeString || typeof timeString !== 'string') return 0;
const isNegative = timeString.startsWith('-');
if (isNegative) {
timeString = timeString.substring(1);
}
const parts = timeString.split(':');
if (parts.length !== 2) return 0;
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
if (isNaN(minutes) || isNaN(seconds)) return 0;
let totalSeconds = (minutes * 60) + seconds;
return isNegative ? -totalSeconds : totalSeconds;
}

View File

@@ -1,249 +0,0 @@
<template>
<div class="flex flex-col h-screen overflow-hidden" :class="{'dark': theme === 'dark'}">
<header class="p-3 bg-gray-100 dark:bg-gray-800 shadow-md flex justify-between items-center shrink-0">
<!-- ... header content ... -->
<div class="flex items-center space-x-2">
<button @click="navigateToSetup" class="btn btn-secondary text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-1" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg>
Setup
</button>
<button v-if="gameMode === 'normal'" @click="switchToAllTimersMode" class="btn btn-warning text-sm">
Run All Timers
</button>
<button v-if="gameMode === 'allTimers'" @click="switchToNormalMode" class="btn btn-warning text-sm">
Back to Normal Mode
</button>
</div>
<div class="flex items-center space-x-2">
<button @click="navigateToInfo" class="btn-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button @click="toggleMute" class="btn-icon">
<svg v-if="!isMuted" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15zm9.949-9.949a1 1 0 00-1.414 0L12 7.05l-2.085-2.085a1 1 0 00-1.414 1.414L10.586 8.5l-2.085 2.085a1 1 0 001.414 1.414L12 9.914l2.085 2.085a1 1 0 001.414-1.414L13.414 8.5l2.085-2.085a1 1 0 000-1.414z" /></svg>
</button>
</div>
</header>
<!-- Main content area should allow its children to use its full height -->
<main class="flex-grow overflow-hidden flex flex-col" ref="gameArea">
<!-- Normal Mode: This div itself needs to take full height of 'main' -->
<div v-if="gameMode === 'normal' && currentPlayer && nextPlayer" class="h-full flex flex-col">
<PlayerDisplay
:player="currentPlayer"
is-current-player-area
area-class="bg-gray-100 dark:bg-gray-800"
@tapped="handleCurrentPlayerTap"
class="flex-1 min-h-0"
/>
<div class="shrink-0 h-1 bg-blue-500"></div>
<PlayerDisplay
:player="nextPlayer"
is-next-player-area
area-class="bg-gray-200 dark:bg-gray-700"
@swiped-up="handlePassTurn"
class="flex-1 min-h-0"
/>
</div>
<!-- All Timers Running Mode -->
<div v-if="gameMode === 'allTimers'" class="p-4 h-full flex flex-col">
<div class="mb-4 flex justify-start items-center">
<h2 class="text-2xl font-semibold">All Timers Running</h2>
</div>
<div class="flex-grow overflow-y-auto space-y-2">
<PlayerListItem
v-for="(player) in playersInAllTimersView"
:key="player.id"
:player="player"
@tapped="() => handlePlayerTapAllTimers(indexInFullList(player.id))"
/>
<p v-if="playersInAllTimersView.length === 0 && players.length > 0 && anyTimerCouldRun" class="text-center text-gray-500 dark:text-gray-400 py-6">
All active timers paused.
</p>
<p v-if="playersInAllTimersView.length === 0 && players.length > 0 && !anyTimerCouldRun" class="text-center text-gray-500 dark:text-gray-400 py-6">
No players eligible to run. (All skipped or issue)
</p>
<p v-if="players.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-6">
No players to display.
</p>
</div>
</div>
</main>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import PlayerDisplay from '../components/PlayerDisplay.vue';
import PlayerListItem from '../components/PlayerListItem.vue';
import { AudioService } from '../services/AudioService';
const store = useStore();
const router = useRouter();
const theme = computed(() => store.getters.theme);
const players = computed(() => store.getters.players);
const currentPlayer = computed(() => store.getters.currentPlayer);
const nextPlayer = computed(() => store.getters.nextPlayer);
const gameMode = computed(() => store.getters.gameMode);
const isMuted = computed(() => store.getters.isMuted);
// const gameRunning = computed(() => store.getters.gameRunning);
let timerInterval = null;
const playersInAllTimersView = computed(() => {
if (gameMode.value === 'allTimers') {
if (!players.value) return [];
return players.value.filter(p => p.isTimerRunning && !p.isSkipped);
}
return [];
});
const anyTimerRunningInAllMode = computed(() => {
if (!players.value) return false;
return players.value.some(p => p.isTimerRunning && !p.isSkipped);
});
const anyTimerCouldRun = computed(() => {
if (!players.value) return false;
return players.value.some(p => !p.isSkipped);
});
const indexInFullList = (playerId) => {
if (!players.value) return -1;
return players.value.findIndex(p => p.id === playerId);
}
onMounted(() => {
if (!players.value || players.value.length < 2) {
router.push({ name: 'Setup' });
return;
}
timerInterval = setInterval(() => {
store.dispatch('tick');
}, 1000);
});
onUnmounted(() => {
clearInterval(timerInterval);
AudioService.stopContinuousTick();
AudioService.cancelPassTurnSound();
});
watch(gameMode, (newMode) => {
AudioService.stopContinuousTick();
AudioService.cancelPassTurnSound();
if (newMode === 'allTimers') {
if (anyTimerRunningInAllMode.value) {
AudioService.startContinuousTick();
}
} else {
if (currentPlayer.value && currentPlayer.value.isTimerRunning) {
AudioService.playPassTurnAlert();
}
}
});
watch(anyTimerRunningInAllMode, (isRunning) => {
if (gameMode.value === 'allTimers') {
if (isRunning) {
AudioService.startContinuousTick();
} else {
AudioService.stopContinuousTick();
}
}
});
watch(currentPlayer, (newPlayer, oldPlayer) => {
if (gameMode.value === 'normal' && newPlayer && newPlayer.isTimerRunning && oldPlayer && newPlayer.id !== oldPlayer.id) {
AudioService.playPassTurnAlert();
}
}, { deep: true });
watch(() => currentPlayer.value?.isTimerRunning, (isRunning, wasRunning) => {
if (gameMode.value === 'normal' && currentPlayer.value) {
if (isRunning === true && wasRunning === false) {
AudioService.playPassTurnAlert();
} else if (isRunning === false && wasRunning === true) {
AudioService.cancelPassTurnSound();
}
}
});
const navigateToSetup = () => {
const isAnyTimerActive = store.getters.gameRunning;
if (isAnyTimerActive) {
if (window.confirm('Game is active. Going to Setup will pause all timers. Continue?')) {
store.commit('PAUSE_ALL_TIMERS');
router.push({ name: 'Setup' });
}
} else {
router.push({ name: 'Setup' });
}
};
const navigateToInfo = () => {
if (store.getters.gameRunning) {
store.commit('PAUSE_ALL_TIMERS');
}
router.push({ name: 'Info' });
};
const toggleMute = () => {
store.dispatch('setMuted', !isMuted.value);
};
const handleCurrentPlayerTap = () => {
store.dispatch('toggleCurrentPlayerTimerNormalMode');
};
const handlePassTurn = () => {
if(currentPlayer.value && !currentPlayer.value.isSkipped) {
AudioService.cancelPassTurnSound();
const wasRunning = currentPlayer.value.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer;
if (wasRunning && gameMode.value === 'normal' && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
}
};
const handlePlayerTapAllTimers = (playerIndex) => {
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
};
const switchToAllTimersMode = () => {
store.dispatch('switchToAllTimersMode');
};
const switchToNormalMode = () => {
store.dispatch('switchToNormalMode');
};
watch(anyTimerRunningInAllMode, (anyRunning) => {
if (gameMode.value === 'allTimers' && !anyRunning && players.value && players.value.length > 0) {
const nonSkippedPlayersExist = players.value.some(p => !p.isSkipped);
if (nonSkippedPlayersExist) {
setTimeout(() => {
if(gameMode.value === 'allTimers' && !store.getters.players.some(p => p.isTimerRunning && !p.isSkipped)){
store.dispatch('switchToNormalMode');
}
}, 200);
} else {
AudioService.stopContinuousTick();
}
}
});
</script>

View File

@@ -1,59 +0,0 @@
<template>
<div class="flex-grow flex flex-col p-4 md:p-6 lg:p-8 items-center dark:bg-gray-800 text-gray-700 dark:text-gray-200">
<header class="w-full max-w-2xl mb-6 text-center">
<h1 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">About Nexus Timer</h1>
</header>
<main class="w-full max-w-2xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md prose dark:prose-invert">
<p>
Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion.
It provides a clear visual focus on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who is next.
</p>
<h2 class="text-xl font-semibold mt-6 mb-2">Key Features:</h2>
<ul>
<li>Circular player display focusing on Current and Next player.</li>
<li>Individual customizable countdown timers (MM:SS) per player.</li>
<li>Timers can go into negative time.</li>
<li>Two game modes: Normal (one timer runs) and All Timers Running.</li>
<li>Player management: Add, edit, delete, reorder players.</li>
<li>Photo avatars using device camera or defaults.</li>
<li>Configurable hotkeys for passing turns and global pause/resume.</li>
<li>Audio feedback for timer events.</li>
<li>Light/Dark theme options.</li>
<li>Persistent storage of game state.</li>
</ul>
<h2 class="text-xl font-semibold mt-6 mb-2">Source Code</h2>
<p>
The source code for Nexus Timer is available:
<a href="https://gitea.virtonline.eu/2HoursProject/nexus-timer.git" target="_blank" rel="noopener noreferrer" class="text-blue-500 hover:underline">
https://gitea.virtonline.eu/2HoursProject/nexus-timer.git
</a>
</p>
</main>
<footer class="mt-8 w-full max-w-2xl text-center">
<button @click="goBack" class="btn btn-primary">
Back to Game
</button>
</footer>
</div>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
const router = useRouter();
const store = useStore(); // Get store instance
const goBack = () => {
// Check if there are players to decide between game and setup
if (store.getters.players && store.getters.players.length >= 2) {
router.push({ name: 'Game' });
} else {
router.push({ name: 'Setup' });
}
};
</script>

View File

@@ -1,259 +0,0 @@
<template>
<div class="flex-grow flex flex-col p-4 md:p-6 lg:p-8 items-center dark:bg-gray-800">
<header class="w-full max-w-3xl mb-6 text-center">
<h1 class="text-4xl font-bold text-blue-600 dark:text-blue-400">Nexus Timer Setup</h1>
</header>
<!-- Player Management -->
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-semibold">Players ({{ players.length }}/7)</h2>
<button @click="openAddPlayerModal" class="btn btn-primary" :disabled="players.length >= 7">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-1" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /></svg>
Add Player
</button>
</div>
<p v-if="players.length < 2" class="text-sm text-yellow-600 dark:text-yellow-400 mb-3">At least 2 players are required to start.</p>
<div v-if="players.length > 0" class="space-y-3 mb-4">
<div v-for="(player, index) in players" :key="player.id" class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-600 rounded shadow-sm">
<div class="flex items-center flex-wrap">
<img
v-if="player.avatar"
:src="player.avatar"
alt="Player Avatar"
class="w-10 h-10 rounded-full object-cover mr-3"
/>
<DefaultAvatarIcon
v-else
class="w-10 h-10 rounded-full object-cover mr-3 text-gray-400 bg-gray-200 p-0.5"
/>
<span class="font-medium mr-2">{{ player.name }}</span>
<span class="mr-2 text-xs text-gray-500 dark:text-gray-400">({{ formatTime(player.currentTimerSec) }})</span>
<span v-if="player.hotkey" class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-700 text-blue-700 dark:text-blue-200 rounded">Hotkey: {{ player.hotkey.toUpperCase() }}</span>
</div>
<div class="space-x-1 flex items-center flex-shrink-0"> <!-- Reduced space-x for more buttons -->
<!-- Move Up Button -->
<button @click="movePlayerUp(index)" :disabled="index === 0" class="btn-icon text-gray-600 dark:text-gray-300 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7" />
</svg>
</button>
<!-- Move Down Button -->
<button @click="movePlayerDown(index)" :disabled="index === players.length - 1" class="btn-icon text-gray-600 dark:text-gray-300 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button>
<!-- Edit Button -->
<button @click="openEditPlayerModal(player)" class="btn-icon text-yellow-500 hover:text-yellow-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" /><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" /></svg>
</button>
<!-- Delete Button -->
<button @click="confirmDeletePlayer(player.id)" class="btn-icon text-red-500 hover:text-red-600">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" /></svg>
</button>
</div>
</div>
</div>
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-4">
No players added yet. Add at least 2 to start.
</div>
<div v-if="players.length > 1" class="flex space-x-2 mt-4">
<button @click="shufflePlayers" class="btn btn-secondary text-sm">Shuffle Order</button>
<button @click="reversePlayers" class="btn btn-secondary text-sm">Reverse Order</button>
</div>
</section>
<!-- Game Settings -->
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
<h2 class="text-2xl font-semibold mb-4">Game Settings</h2>
<div class="mb-4">
<label for="globalHotkey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">"Global Stop/Pause All" Hotkey</label>
<input
type="text"
id="globalHotkey"
:value="globalHotkeyDisplay"
@keydown.prevent="captureGlobalHotkey"
placeholder="Press a key"
class="input-base w-full sm:w-1/2"
readonly
>
<button type="button" @click="clearGlobalHotkey" class="text-xs text-blue-500 hover:underline mt-1 ml-2">Clear Hotkey</button>
</div>
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Dark Mode</span>
<button @click="toggleTheme" class="px-3 py-1.5 rounded-md text-sm font-medium" :class="theme === 'dark' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-800'">
{{ theme === 'dark' ? 'On' : 'Off' }}
</button>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mute Audio</span>
<button @click="toggleMute" class="px-3 py-1.5 rounded-md text-sm font-medium" :class="isMuted ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-800'">
{{ isMuted ? 'Muted' : 'Unmuted' }}
</button>
</div>
</section>
<section class="w-full max-w-3xl mt-4 space-y-3">
<button @click="saveAndClose" class="w-full btn btn-primary btn-lg text-xl py-3" :disabled="players.length < 2 && players.length !==0">
Save & Close
</button>
<button @click="resetPlayerTimersConfirm" class="w-full btn btn-warning py-2">
Reset Player Timers
</button>
<button @click="fullResetAppConfirm" class="w-full btn btn-danger py-2">
Reset Entire App Data
</button>
</section>
<PlayerForm
v-if="showPlayerModal"
:player="editingPlayer"
@close="closePlayerModal"
@save="savePlayer"
/>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import PlayerForm from '../components/PlayerForm.vue';
import { formatTime } from '../utils/timeFormatter';
import DefaultAvatarIcon from '../components/DefaultAvatarIcon.vue';
const store = useStore();
const router = useRouter();
const players = computed(() => store.getters.players);
const theme = computed(() => store.getters.theme);
const isMuted = computed(() => store.getters.isMuted);
const globalHotkey = computed(() => store.getters.globalHotkeyStopPause);
const globalHotkeyDisplay = computed(() => globalHotkey.value ? globalHotkey.value.toUpperCase() : '');
const showPlayerModal = ref(false);
const editingPlayer = ref(null);
const openAddPlayerModal = () => {
if (players.value.length < 7) {
editingPlayer.value = null;
showPlayerModal.value = true;
}
};
const openEditPlayerModal = (player) => {
editingPlayer.value = player;
showPlayerModal.value = true;
};
const closePlayerModal = () => {
showPlayerModal.value = false;
editingPlayer.value = null;
};
const savePlayer = (playerData) => {
if (playerData.id) {
store.dispatch('updatePlayer', playerData);
} else {
store.dispatch('addPlayer', {
name: playerData.name,
avatar: playerData.avatar,
initialTimerSec: playerData.initialTimerSec,
currentTimerSec: playerData.currentTimerSec,
hotkey: playerData.hotkey
});
}
closePlayerModal();
};
const confirmDeletePlayer = (playerId) => {
if (window.confirm('Are you sure you want to delete this player?')) {
store.dispatch('deletePlayer', playerId);
}
};
const shufflePlayers = () => {
store.dispatch('shufflePlayers');
};
const reversePlayers = () => {
store.dispatch('reversePlayers');
};
// --- Player Reordering Logic ---
const movePlayerUp = (index) => {
if (index > 0) {
const newPlayersOrder = [...players.value];
const temp = newPlayersOrder[index];
newPlayersOrder[index] = newPlayersOrder[index - 1];
newPlayersOrder[index - 1] = temp;
store.dispatch('reorderPlayers', newPlayersOrder);
}
};
const movePlayerDown = (index) => {
if (index < players.value.length - 1) {
const newPlayersOrder = [...players.value];
const temp = newPlayersOrder[index];
newPlayersOrder[index] = newPlayersOrder[index + 1];
newPlayersOrder[index + 1] = temp;
store.dispatch('reorderPlayers', newPlayersOrder);
}
};
// --- End Player Reordering Logic ---
const captureGlobalHotkey = (event) => {
event.preventDefault();
const key = event.key.toLowerCase();
if (key && !['control', 'alt', 'shift', 'meta'].includes(key)) {
const existingPlayerHotkey = store.state.players.some(p => p.hotkey === key);
if (existingPlayerHotkey) {
alert(`Hotkey "${key.toUpperCase()}" is already assigned to a player.`);
return;
}
store.dispatch('setGlobalHotkey', key);
}
};
const clearGlobalHotkey = () => {
store.dispatch('setGlobalHotkey', null);
};
const toggleTheme = () => {
store.dispatch('toggleTheme');
};
const toggleMute = () => {
store.dispatch('setMuted', !isMuted.value);
};
const saveAndClose = () => {
store.dispatch('saveState');
if (players.value.length >= 2) {
if (store.state.currentPlayerIndex >= players.value.length || store.state.currentPlayerIndex < 0) {
store.commit('SET_CURRENT_PLAYER_INDEX', 0);
}
store.commit('PAUSE_ALL_TIMERS');
store.commit('SET_GAME_MODE', 'normal');
router.push({ name: 'Game' });
} else if (players.value.length === 0) {
alert('Add at least 2 players to start a game.');
} else {
alert('At least 2 players are required to start a game.');
}
};
const resetPlayerTimersConfirm = () => {
if (window.confirm('Are you sure you want to reset all current players\' timers to their initial values? This will not delete players.')) {
store.dispatch('resetGame');
}
};
const fullResetAppConfirm = () => {
if (window.confirm('Are you sure you want to reset all app data? This includes all players, settings, and timer states. The app will revert to its default state with 2 predefined players.')) {
store.dispatch('fullResetApp');
}
};
</script>

345
style.css Normal file
View File

@@ -0,0 +1,345 @@
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
background-color: #1E1E2F; /* Dark background for the app */
color: #E0E0E0;
-webkit-tap-highlight-color: transparent; /* Disable tap highlight */
}
#app-container {
display: flex;
flex-direction: column;
height: 100vh; /* Full viewport height */
width: 100vw; /* Full viewport width */
}
.player-area {
flex: 1;
display: flex;
align-items: center;
justify-content: space-around; /* Distribute photo and info */
padding: 20px;
box-sizing: border-box;
text-align: center;
position: relative; /* For potential absolute positioned elements inside */
}
#current-player-area {
background-color: #2A2A3E; /* Slightly lighter dark shade for current player */
border-bottom: 2px solid #4A4A5E;
}
#next-player-area {
background-color: #242434; /* Slightly different shade for next player */
cursor: pointer; /* For swipe up indication */
}
.player-photo-container {
flex-shrink: 0;
}
.player-photo {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #E0E0E0;
background-color: #555; /* Placeholder if image fails */
}
#current-player-area .player-photo {
width: 120px;
height: 120px;
}
.player-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#current-player-name {
font-size: 2.5em;
margin: 10px 0;
font-weight: bold;
}
#next-player-name {
font-size: 1.8em;
margin: 5px 0;
}
.timer-display {
font-size: 3.5em;
font-weight: bold;
font-family: 'Courier New', Courier, monospace;
letter-spacing: 2px;
}
.small-timer {
font-size: 2em;
}
.timer-display.negative {
color: #FF6B6B; /* Red for negative time */
}
.pulsating-background {
animation: pulse-bg 1.5s infinite ease-in-out;
}
@keyframes pulse-bg {
0% { background-color: #2A2A3E; }
50% { background-color: #3A3A4E; }
100% { background-color: #2A2A3E; }
}
.pulsating-text {
animation: pulse-text 1.5s infinite ease-in-out;
}
@keyframes pulse-text {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.pulsating-negative-text .timer-display.negative {
animation: pulse-negative-text 1s infinite ease-in-out;
}
@keyframes pulse-negative-text {
0% { color: #FF6B6B; transform: scale(1); }
50% { color: #FF4040; transform: scale(1.05); }
100% { color: #FF6B6B; transform: scale(1); }
}
.player-skipped {
opacity: 0.4;
background-color: #333 !important; /* Distinctly greyed out */
}
.player-skipped .player-name, .player-skipped .timer-display {
color: #888 !important;
}
#controls {
display: flex;
justify-content: space-around;
padding: 10px 0;
background-color: #1A1A2A; /* Darker bar for controls */
position: fixed;
bottom: 0;
left: 0;
width: 100%;
box-shadow: 0 -2px 5px rgba(0,0,0,0.3);
}
.control-button {
padding: 10px 15px;
font-size: 0.9em;
background-color: #4A90E2;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
}
.control-button:hover {
background-color: #357ABD;
}
.control-button:active {
transform: scale(0.98);
}
/* Modal Styles */
.modal {
display: none; /* Hidden by default */
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.6);
color: #333; /* Text color inside modal */
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 90%;
max-width: 500px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-content h2, .modal-content h3 {
color: #333;
text-align: center;
}
.close-modal-btn {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
align-self: flex-end;
}
.close-modal-btn:hover,
.close-modal-btn:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
#player-list-editor {
margin-bottom: 20px;
max-height: 30vh; /* Limit height and make scrollable */
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
}
.player-editor-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #eee;
}
.player-editor-entry:last-child {
border-bottom: none;
}
.player-editor-entry span {
flex-grow: 1;
}
.player-editor-entry button {
margin-left: 5px;
padding: 5px 8px;
font-size: 0.8em;
}
#add-edit-player-form label {
display: block;
margin-top: 10px;
font-weight: bold;
color: #555;
}
#add-edit-player-form input[type="text"],
#add-edit-player-form input[type="file"],
#add-edit-player-form input[type="checkbox"] {
width: calc(100% - 22px);
padding: 10px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
#add-edit-player-form input[type="checkbox"] {
width: auto;
margin-right: 5px;
}
#add-edit-player-form input[type="file"] {
padding: 3px; /* Less padding for file input appearance */
}
.photo-preview-modal {
display: block;
max-width: 100px;
max-height: 100px;
margin: 10px auto;
border: 1px solid #ddd;
border-radius: 4px;
object-fit: cover;
}
#remove-photo-btn {
display: block;
margin: 5px auto 10px auto;
padding: 6px 10px;
font-size: 0.8em;
background-color: #e07070;
color: white;
border: none;
border-radius: 3px;
}
.player-form-buttons, .player-form-actions {
margin-top: 15px;
display: flex;
justify-content: space-around;
}
.player-form-actions button {
padding: 10px 15px;
}
.modal-main-action {
margin-top: 20px;
padding: 12px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
font-size: 1.1em;
cursor: pointer;
}
.modal-main-action:hover {
background-color: #45a049;
}
/* Responsive adjustments */
@media (max-width: 600px) {
#current-player-name {
font-size: 2em;
}
#next-player-name {
font-size: 1.5em;
}
.timer-display {
font-size: 2.8em;
}
.small-timer {
font-size: 1.8em;
}
.player-photo {
width: 80px;
height: 80px;
}
#current-player-area .player-photo {
width: 100px;
height: 100px;
}
.control-button {
font-size: 0.8em;
padding: 8px 10px;
}
.modal-content {
margin: 2% auto;
width: 95%;
max-height: 95vh;
}
}
body {
padding-bottom: 60px;
}

50
sw.js Normal file
View File

@@ -0,0 +1,50 @@
const CACHE_NAME = 'nexus-timer-cache-v3'; // Increment cache version
const URLS_TO_CACHE = [
'index.html',
'style.css',
'script.js',
'manifest.json',
'assets/default-avatar.svg',
// MP3 files removed as they are no longer used
'assets/icon-192x192.png',
'assets/icon-512x512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(URLS_TO_CACHE);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});

View File

@@ -1,29 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
darkMode: 'class', // or 'media' or 'class'
theme: {
extend: {
animation: {
pulsePositive: 'pulsePositive 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
pulseNegative: 'pulseNegative 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
pulsePositive: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '.7' },
},
pulseNegative: { // For text, maybe a color change or slight scale
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
'50%': { opacity: '.8', transform: 'scale(1.02)' },
}
}
},
},
plugins: [
require('@tailwindcss/typography'), // Add this line
],
}

View File

@@ -1,10 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 8080
}
})