Compare commits
6 Commits
main
...
1295ae4b5c
| Author | SHA1 | Date | |
|---|---|---|---|
| 1295ae4b5c | |||
| 04dd879c24 | |||
| 049866ecd7 | |||
| f7e5969f52 | |||
| 4ec23960cc | |||
| 9a9e9344cc |
@@ -7,8 +7,6 @@ node_modules
|
||||
|
||||
# Docker specific files (if any, other than Dockerfile itself)
|
||||
# .dockerignore (to avoid including itself if context changes)
|
||||
docker
|
||||
systemd
|
||||
|
||||
# Local development environment files
|
||||
.env
|
||||
|
||||
2
.gitignore
vendored
@@ -1,5 +1,3 @@
|
||||
# docker labels
|
||||
labels
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the Vue.js application
|
||||
FROM node:24-alpine AS builder
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
@@ -8,10 +8,8 @@ RUN npm run build
|
||||
|
||||
# Stage 2: Serve the application with Nginx
|
||||
FROM nginx:stable-alpine
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
RUN find /usr/share/nginx/html -mindepth 1 -delete
|
||||
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;"]
|
||||
260
README.md
@@ -1,172 +1,126 @@
|
||||
# Nexus Timer 🕰️✨
|
||||
|
||||
Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially. It focuses on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who's next.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Core Concept](#core-concept)
|
||||
2. [Target Audience](#target-audience)
|
||||
3. [Key Features at a Glance](#key-features-at-a-glance)
|
||||
4. [Getting Started (User Guide)](#getting-started-user-guide)
|
||||
5. [Remote Control Options](#remote-control-options)
|
||||
6. [Development, Deployment, and Architecture](#development-deployment-and-architecture)
|
||||
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.
|
||||
|
||||
## 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, perfect for board games, RPGs, 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** 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.
|
||||
|
||||
## Target Audience
|
||||
|
||||
Game enthusiasts who play turn-based games (board games, tabletop RPGs, card games) and need a visually clear and customizable timer solution.
|
||||
|
||||
## Key Features at a Glance
|
||||
## Tech Stack
|
||||
|
||||
* **Dynamic Player Display:** Clear focus on Current & Next player (Normal Mode) or a list of active timers (All Timers Mode).
|
||||
* **Individual Timers:** Customizable (MM:SS), supports negative time, auto-skips players at max negative time.
|
||||
* **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)
|
||||
|
||||
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.
|
||||
|
||||
* **Buttons:** Connect 3 physical buttons, potentially extended (e.g., via 1.5m wires) for easy player access.
|
||||
* **Configuration:**
|
||||
* **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:**
|
||||
1. **Normal Mode (Default):** 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:**
|
||||
* 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:**
|
||||
* **Normal Mode:** Only the current player's timer runs.
|
||||
* **All Timers Mode:** All active players' timers run simultaneously.
|
||||
* **Flexible Player Setup:** Add, edit, delete, and reorder up to 99 players. Use camera for avatars or defaults.
|
||||
* **Intuitive Controls:** Swipe up to pass turns, tap to pause/resume.
|
||||
* **Customizable Triggers:**
|
||||
* **Keyboard Hotkeys:** Assign unique keys for player turns and global actions (Stop/Pause All, Run All Timers). *This will allow the connected Bluetooth HID device to act like a remote buttons. See [HID Smart Buttons](docs/remote-control.md#hid-smart-buttons)*
|
||||
* **MQTT Remote Triggers:** Assign unique characters for remote activation of player turns and global actions via an MQTT broker. *This will allow the players use their own smartphone to remote control their timers. See [Android Shortcut Setup](docs/remote-control.md#android-shortcut-setup)*
|
||||
* **PWA Ready:** Installable on desktop and mobile, offline capable, with update notifications and screen wake lock.
|
||||
* **Audio & Visual Cues:** Ticking sounds, alerts, light/dark themes, and clear timer states.
|
||||
* **Persistent Settings:** Your setup and game state are saved locally.
|
||||
|
||||
## Getting Started (User Guide)
|
||||
|
||||
This guide walks you through setting up and using Nexus Timer.
|
||||
|
||||
### 1. Installation (PWA)
|
||||
|
||||
Nexus Timer is a Progressive Web App (PWA), meaning you can install it on your device for a native-like experience.
|
||||
* **Mobile:** Open Nexus Timer in a compatible browser (e.g., Chrome on Android, Safari on iOS). Look for an "Add to Home Screen" or "Install App" option in the browser menu.
|
||||
* **Desktop:** In browsers like Chrome or Edge, look for an install icon in the address bar or an "Install App" option in the browser menu.
|
||||
|
||||
### 2. Initial Setup (First Use)
|
||||
|
||||
When you first open Nexus Timer (or after a full reset), it starts with two predefined players to get you going quickly.
|
||||
* If you have at least two players defined (either predefined or previously saved), the app will **automatically start in Normal Mode**.
|
||||
* If no players are defined, you'll be taken to the **Setup Screen**.
|
||||
|
||||
### 3. The Setup Screen
|
||||
|
||||
This is where you configure your game:
|
||||
|
||||
* **Players Section:**
|
||||
* **View Players:** Shows the current number of players (e.g., "Players (3)").
|
||||
* **Add Player:** Click to add a new player (up to 99). You can set their:
|
||||
* **Name**
|
||||
* **Avatar:** Use your device camera or a default icon.
|
||||
* **Remaining Time:** Set their starting timer (MM:SS). This can also be negative (e.g., -02:30).
|
||||
* **"Pass Turn / My Pause" Hotkey:** Click the display area (shows "-") and press a single keyboard key. This key will be used by this player to pass their turn (Normal Mode) or pause their own timer (All Timers Mode).
|
||||
* **"Pass Turn / My Pause" MQTT Character:** Click the display area (shows "-") and enter a single character. This character, when received via MQTT, will trigger the same action as the player's hotkey. (See [Remote Control Options](#remote-control-options)).
|
||||
* **Edit Player:** Click the pencil icon next to a player to modify their details.
|
||||
* **Delete Player:** Click the trash icon to remove a player.
|
||||
* **Reorder Players:** Use the up/down arrow icons to change player turn order.
|
||||
* **Shuffle/Reverse Order:** Buttons to quickly randomize or reverse the player list.
|
||||
* **MQTT Broker (for Remote Control):**
|
||||
* **URL:** Enter the WebSocket URL of your MQTT broker (e.g., `ws://your-broker-ip:9001`).
|
||||
* **Connect/Disconnect:** Toggle the connection to your broker. The status (Connected, Disconnected, Error) will be displayed.
|
||||
* **Global Triggers (Hotkeys & MQTT):**
|
||||
* Assign a unique keyboard **Hotkey** and/or a unique **MQTT Character** for:
|
||||
* **Global "Stop/Pause All":** Pauses the current player's timer (Normal Mode) or all running timers (All Timers Mode). If all are paused in All Timers Mode, this resumes them.
|
||||
* **Global "Run All Timers":** Switches from Normal Mode to All Timers Mode and starts all active player timers.
|
||||
* **Global "Pass Turn":** Passes the turn to the next player (works in both modes).
|
||||
* Click the display area (shows "-") next to "Hotkey" or "MQTT" to set the trigger.
|
||||
|
||||
* **Other Settings:**
|
||||
* **Dark Mode:** Toggle between light and dark themes.
|
||||
* **Mute Audio:** Turn all app sounds on or off.
|
||||
* **Action Buttons:**
|
||||
* **Save & Close:** Saves your current setup and starts the game (if at least 2 players are configured), taking you to the Game View.
|
||||
* **Reset Player Timers:** Resets all current players' timers back to their initially set values and pauses the game. Player list and other settings remain.
|
||||
* **Reset Entire App Data:** Clears *all* data (players, settings, timer states) and resets the app to its default state (with 2 predefined players).
|
||||
|
||||
### 4. Game View: Playing the Game
|
||||
|
||||
#### Normal Mode (Default)
|
||||
|
||||
* **Display:** Shows the **Current Player** (top half) and **Next Player** (bottom half).
|
||||
* **Timer:** Only the Current Player's timer runs.
|
||||
* Timers count down. If they reach 00:00, they continue into **negative time** (e.g., -00:01, -00:02...).
|
||||
* If a player reaches the maximum negative time (default -59:59), they are **skipped** automatically until their timer is edited or reset. Skipped players are visually distinct.
|
||||
* **Passing the Turn:**
|
||||
1. **Swipe Up** on the Next Player's area (bottom half).
|
||||
2. Or, the Current Player presses their assigned "Pass Turn / My Pause" **Hotkey** or sends their **MQTT Character**.
|
||||
* *Behavior:* The current player's timer pauses, the next non-skipped player becomes "Current," and their timer starts.
|
||||
* *Sound:* A 3-second ticking sound alerts players when a new turn starts.
|
||||
* **Pausing/Resuming the Current Player's Timer (without passing):**
|
||||
1. **Tap** on the Current Player's area (top half).
|
||||
2. Or, use the "Global Stop/Pause All" **Hotkey** or **MQTT Character**.
|
||||
* *Behavior:* If the current player's timer was paused when the turn was passed to them, it will remain paused. They need to tap their area or use a global resume trigger to start it.
|
||||
* **Switching to All Timers Mode:** Click the "Run All Timers" button in the header.
|
||||
|
||||
#### All Timers Mode
|
||||
|
||||
* **Display:** Shows a list of all non-skipped players with their timers.
|
||||
* **Timers:** All active (non-skipped) players' timers run simultaneously.
|
||||
* A continuous ticking sound plays while any timer is active in this mode.
|
||||
* **Entering this Mode:**
|
||||
* Click "Run All Timers" in the Game View header (when in Normal Mode).
|
||||
* Or, use the "Global Run All Timers" **Hotkey** or **MQTT Character**.
|
||||
* All non-skipped players' timers will start.
|
||||
* **Pausing/Resuming Individual Timers:**
|
||||
* **Tap** on a player in the list to pause/resume their specific timer.
|
||||
* A player can press their own "Pass Turn / My Pause" **Hotkey** or send their **MQTT Character** to pause their *own* timer.
|
||||
1. **Normal Mode (Default):**
|
||||
* 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.
|
||||
* 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.
|
||||
* 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:**
|
||||
* All active player timers run simultaneously.
|
||||
* Enter by clicking "All Timers Mode" (starts all timers).
|
||||
* Continuous ticking sound when active.
|
||||
* Initially, all players are shown in a list with their photo, name and timer value.
|
||||
* 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.
|
||||
* Only players with a running timer are shown in the list.
|
||||
* 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-7 players).
|
||||
* Use device camera (access via browser API) or default avatars for the player's picture.
|
||||
* 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 the "Global Stop/Pause All" hotkey (single keypresses). E.g.: Use the Player's 3 "long press" action to insert the key.
|
||||
* Re-order players (drag-and-drop planned), reverse, shuffle.
|
||||
* **Intuitive Controls:**
|
||||
* **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal 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.
|
||||
* **Visuals:**
|
||||
* Players with running timers are clearly indicated (e.g., green/red border, pulsing background).
|
||||
* Players with paused timers are visually distinct (e.g., dimmer, yellowish border) but remain in the list.
|
||||
* **Global Pause/Resume:**
|
||||
* The "Global Stop/Pause All" **Hotkey** or **MQTT Character** will:
|
||||
* Pause all currently running timers if any are active.
|
||||
* Resume all previously running (and still active, non-skipped) timers if all were paused.
|
||||
* **Reverting to Normal Mode:**
|
||||
* Click "Back to Normal Mode" in the Game View header.
|
||||
* Automatically reverts if all player timers in this mode are paused (and at least one non-skipped player exists).
|
||||
* All timers will be paused when switching back to Normal Mode. The player who was "Current" before switching to All Timers mode (or the first player if starting fresh) will be the new Current Player.
|
||||
* 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.
|
||||
|
||||
### 5. Info Screen
|
||||
## UI/UX Considerations (For AI Generation)
|
||||
|
||||
* Accessible via the "Info" icon (ℹ️) in the Game View header.
|
||||
* Displays "About" information, key features, and the app's build time.
|
||||
* Includes a "Check for Update" button to manually trigger a PWA update check.
|
||||
* Provides a link to the source code.
|
||||
* **Minimalist Design:** Focus on clarity and ease of use. Avoid clutter.
|
||||
* **Large, Clear Timers:** Timers should be easily readable at a glance.
|
||||
* **Color Coding:** Use color to indicate timer state (e.g., green for running, red for negative time, grey for skipped).
|
||||
* **Responsive Layout:** The UI should adapt to different (mobile phone) screen sizes.
|
||||
* **Touch-Friendly:** Buttons and interactive elements should be large enough for easy tapping.
|
||||
|
||||
## Remote Control Options
|
||||
## Data Model (For AI Generation)
|
||||
|
||||
Nexus Timer supports remote control via physical HID buttons or MQTT messages.
|
||||
For detailed setup instructions, please see the [Remote Control Options Guide](docs/remote-control.md).
|
||||
|
||||
## Development, Deployment, and Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
Nexus Timer is built using modern web technologies:
|
||||
|
||||
- **Framework:** Vue.js 3 with Composition API
|
||||
- **State Management:** Vuex 4
|
||||
- **Routing:** Vue Router 4
|
||||
- **Styling:** Tailwind CSS with Typography plugin
|
||||
- **Build Tool:** Vite
|
||||
- **Real-time Communication:** MQTT (via mqtt.js)
|
||||
|
||||
### Build and Development
|
||||
|
||||
The project uses Vite as its build tool, providing fast hot module replacement (HMR) during development.
|
||||
```json
|
||||
{
|
||||
"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
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
For Developer Setup, see [Developer Setup Guide](docs/development.md).
|
||||
|
||||
For Deployment Setup, see [Deployment Setup Guide](docs/deployment.md).
|
||||
|
||||
For Architecture Docs, see [Architecture](docs/architecture.md).
|
||||
docker build -t nexus-timer .
|
||||
```
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 35 MiB |
|
Before Width: | Height: | Size: 310 KiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 664 KiB |
|
Before Width: | Height: | Size: 205 KiB |
@@ -1,87 +0,0 @@
|
||||
# Nexus Game Controller
|
||||
|
||||
A game controller using a Seeed Studio XIAO nRF52840 Sense board that emulates a Bluetooth keyboard.
|
||||
|
||||
## Hardware
|
||||
- 1 pcs [Seeed Studio XIAO nRF52840 Sense](https://www.seeedstudio.com/Seeed-XIAO-BLE-Sense-nRF52840-p-5253.html)
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
- 1 pcs [Battery Small](https://techfun.sk/produkt/li-pol-bateria-mala-rozne-typy-do-1000mah/)
|
||||
|
||||

|
||||
|
||||
- 6 pcs [Push Buttons](https://rpishop.cz/komponenty/6128-pimoroni-cerne-arkadove-tlacitko.html) connected to pins D1-D6.
|
||||
|
||||

|
||||
|
||||
- 3 pcs [Push Buttons](https://techfun.sk/produkt/tlacidlo-pbs-110-momentove-normalne-otvorene/) connected to pins D8-D10.
|
||||
|
||||

|
||||
|
||||
- 3 pcs [cables with Jack 3,5mm mono](https://www.kabel.sk/kabel-3-5mm-mono-m-m-1-5m-cierny-p21091)
|
||||
|
||||

|
||||
|
||||
- 6 pcs [Panel Mounting 3.5mm Mono Jack Socket](https://www.rapidonline.com/bkl-72314-panel-mounting-3-5mm-mono-jack-socket-with-switch-50-1508)
|
||||
|
||||

|
||||
|
||||
- 1 pcs [Power Switch](https://techfun.sk/produkt/jednoduchy-prepinac/)
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
- **Bluetooth LE Keyboard:** Connects to any computer or mobile device as a standard keyboard.
|
||||
- **Multi-Event Buttons:** Each of the 9 buttons supports three types of interactions:
|
||||
- Single Click
|
||||
- Double Click
|
||||
- Long Press
|
||||
- **Character Mapping:** Each button event sends a unique character.
|
||||
- **Serial Debugging:** Outputs button events and Bluetooth connection status to the serial monitor.
|
||||
|
||||
## Schema
|
||||
|
||||
Connect each button to its corresponding pin. For reliable input readings add hardware debouncing: use a pull-up resistor 10 kΩ and an RC filter with a 100 nF capacitor across the switch — an RC time constant around 1 ms works well.
|
||||
|
||||

|
||||
|
||||
## Button Mappings
|
||||
|
||||
| Button | Single Click | Double Click | Long Press |
|
||||
|--------|--------------|--------------|------------|
|
||||
| D1 | `a` | `b` | `c` |
|
||||
| D2 | `d` | `e` | `f` |
|
||||
| D3 | `g` | `h` | `i` |
|
||||
| D4 | `j` | `k` | `l` |
|
||||
| D5 | `m` | `n` | `o` |
|
||||
| D6 | `p` | `q` | `r` |
|
||||
| D8 | `s` | `t` | `u` |
|
||||
| D9 | `v` | `w` | `x` |
|
||||
| D10 | `y` | `z` | `1` |
|
||||
|
||||
## How to Use
|
||||
|
||||
1. **Setup Arduino IDE:**
|
||||
- Install the "Seeed nRF52 Boards" package.
|
||||
- Select "Seeed XIAO BLE Sense - nRF52840" as the board.
|
||||
- Install the "Adafruit Bluefruit nRF52" library.
|
||||
2. **Upload:** Compile and upload the [sketch](sketch.ino) to your XIAO board.
|
||||
3. **Connect:** Scan for Bluetooth devices on your computer or mobile device and connect to "Nexus Game Controller".
|
||||
4. **Play:** Press the buttons to send keystrokes. Open the Serial Monitor at 115200 baud to see debug information.
|
||||
|
||||
## 3D Print
|
||||
|
||||

|
||||
|
||||
You can import the [3mf file](Krabicka_hexagon.3mf) to your slicer for printing.
|
||||
|
||||
## Customize your design
|
||||
|
||||
Open the [3D model](Krabicka_hexagon.FCStd) in FreeCAD to model your changes.
|
||||
|
||||
## Final Product
|
||||
|
||||

|
||||
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 78 KiB |
@@ -1,177 +0,0 @@
|
||||
#include <bluefruit.h>
|
||||
|
||||
// Pin definitions for the 9 buttons
|
||||
const int buttonPins[] = {1, 2, 3, 4, 5, 6, 8, 9, 10};
|
||||
const int numButtons = sizeof(buttonPins) / sizeof(buttonPins[0]);
|
||||
|
||||
// Button state tracking
|
||||
struct Button {
|
||||
int pin;
|
||||
bool lastState;
|
||||
unsigned long lastDebounceTime;
|
||||
unsigned long pressTime;
|
||||
int clickCount;
|
||||
bool isPressed;
|
||||
bool longPressHandled;
|
||||
};
|
||||
|
||||
Button buttons[numButtons];
|
||||
|
||||
// Debounce and timing constants
|
||||
const unsigned long debounceDelay = 50;
|
||||
const unsigned long doubleClickDelay = 400;
|
||||
const unsigned long longPressDelay = 1000;
|
||||
|
||||
// BLE Keyboard object
|
||||
BLEDis bledis;
|
||||
BLEHidAdafruit blehid;
|
||||
|
||||
// Character mapping for each event
|
||||
// 9 buttons * 3 events/button = 27 characters
|
||||
const char eventChars[numButtons][3] = {
|
||||
{'a', 'b', 'c'}, // Button D1: single, double, long
|
||||
{'d', 'e', 'f'}, // Button D2: single, double, long
|
||||
{'g', 'h', 'i'}, // Button D3: single, double, long
|
||||
{'j', 'k', 'l'}, // Button D4: single, double, long
|
||||
{'m', 'n', 'o'}, // Button D5: single, double, long
|
||||
{'p', 'q', 'r'}, // Button D6: single, double, long
|
||||
{'s', 't', 'u'}, // Button D8: single, double, long
|
||||
{'v', 'w', 'x'}, // Button D9: single, double, long
|
||||
{'y', 'z', '1'} // Button D10: single, double, long
|
||||
};
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
//while ( !Serial ) delay(10); // Wait for serial port to connect.
|
||||
|
||||
Serial.println("Nexus Game Controller Starting...");
|
||||
|
||||
// Initialize buttons
|
||||
for (int i = 0; i < numButtons; i++) {
|
||||
buttons[i].pin = buttonPins[i];
|
||||
pinMode(buttons[i].pin, INPUT_PULLUP);
|
||||
buttons[i].lastState = HIGH;
|
||||
buttons[i].lastDebounceTime = 0;
|
||||
buttons[i].pressTime = 0;
|
||||
buttons[i].clickCount = 0;
|
||||
buttons[i].isPressed = false;
|
||||
buttons[i].longPressHandled = false;
|
||||
}
|
||||
|
||||
// Setup Bluetooth
|
||||
Bluefruit.begin();
|
||||
Bluefruit.setName("Nexus Game Controller");
|
||||
Bluefruit.setTxPower(4);
|
||||
|
||||
// Configure and start BLE services
|
||||
bledis.setManufacturer("Seeed Studio");
|
||||
bledis.setModel("XIAO nRF52840 Sense");
|
||||
bledis.begin();
|
||||
|
||||
blehid.begin();
|
||||
|
||||
// Start advertising
|
||||
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
|
||||
Bluefruit.Advertising.addTxPower();
|
||||
Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_KEYBOARD);
|
||||
Bluefruit.Advertising.addService(blehid);
|
||||
Bluefruit.Advertising.addName();
|
||||
|
||||
Bluefruit.Advertising.restartOnDisconnect(true);
|
||||
Bluefruit.Advertising.setInterval(32, 244); // in unit of 0.625 ms
|
||||
Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast advertising mode
|
||||
Bluefruit.Advertising.start(0); // 0 = Don't stop advertising after n seconds
|
||||
|
||||
Serial.println("Advertising...");
|
||||
|
||||
// Set up connection/disconnection callbacks
|
||||
Bluefruit.Periph.setConnectCallback(connect_callback);
|
||||
Bluefruit.Periph.setDisconnectCallback(disconnect_callback);
|
||||
}
|
||||
|
||||
void connect_callback(uint16_t conn_handle) {
|
||||
(void) conn_handle;
|
||||
Serial.println("Bluetooth connected");
|
||||
}
|
||||
|
||||
void disconnect_callback(uint16_t conn_handle, uint8_t reason) {
|
||||
(void) conn_handle;
|
||||
(void) reason;
|
||||
Serial.println("Bluetooth disconnected");
|
||||
}
|
||||
|
||||
|
||||
void loop() {
|
||||
for (int i = 0; i < numButtons; i++) {
|
||||
handleButton(&buttons[i], i);
|
||||
}
|
||||
}
|
||||
|
||||
void handleButton(Button* b, int buttonIndex) {
|
||||
bool reading = digitalRead(b->pin);
|
||||
|
||||
// Debounce logic
|
||||
if (reading != b->lastState) {
|
||||
b->lastDebounceTime = millis();
|
||||
}
|
||||
|
||||
if ((millis() - b->lastDebounceTime) > debounceDelay) {
|
||||
// If the button state has been stable
|
||||
if (reading == LOW && !b->isPressed) { // Button just pressed
|
||||
b->isPressed = true;
|
||||
b->pressTime = millis();
|
||||
b->clickCount++;
|
||||
b->longPressHandled = false;
|
||||
} else if (reading == HIGH && b->isPressed) { // Button just released
|
||||
b->isPressed = false;
|
||||
if (!b->longPressHandled) {
|
||||
// It's a click, but we need to wait for a potential double click
|
||||
} else {
|
||||
// This was a long press, so we do nothing on release
|
||||
b->longPressHandled = false; // Reset for next time
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for long press
|
||||
if (b->isPressed && !b->longPressHandled && (millis() - b->pressTime > longPressDelay)) {
|
||||
triggerEvent(buttonIndex, 2); // Long Press
|
||||
b->longPressHandled = true;
|
||||
b->clickCount = 0; // Reset click count after a long press
|
||||
}
|
||||
|
||||
// Check for single/double click timeout
|
||||
if (b->clickCount > 0 && !b->isPressed && (millis() - b->pressTime > doubleClickDelay)) {
|
||||
if (!b->longPressHandled) {
|
||||
if (b->clickCount == 1) {
|
||||
triggerEvent(buttonIndex, 0); // Single Click
|
||||
} else if (b->clickCount == 2) {
|
||||
triggerEvent(buttonIndex, 1); // Double Click
|
||||
}
|
||||
}
|
||||
b->clickCount = 0;
|
||||
}
|
||||
|
||||
b->lastState = reading;
|
||||
}
|
||||
|
||||
void triggerEvent(int buttonIndex, int eventType) {
|
||||
// eventType: 0 = single, 1 = double, 2 = long
|
||||
char key = eventChars[buttonIndex][eventType];
|
||||
|
||||
const char* eventStr[] = {"single click", "double click", "long press"};
|
||||
|
||||
Serial.print("Button D");
|
||||
Serial.print(buttonPins[buttonIndex]);
|
||||
Serial.print(" triggered ");
|
||||
Serial.print(eventStr[eventType]);
|
||||
Serial.print(" event. Character '");
|
||||
Serial.print(key);
|
||||
Serial.println("' has been sent.");
|
||||
|
||||
if (Bluefruit.connected()) {
|
||||
blehid.keyPress(key);
|
||||
delay(10); // a small delay to prevent flooding
|
||||
blehid.keyRelease();
|
||||
}
|
||||
}
|
||||
BIN
assets/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 398 B |
BIN
assets/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 770 B |
@@ -1,8 +0,0 @@
|
||||
traefik.enable=true
|
||||
traefik.docker.network=traefik
|
||||
traefik.http.routers.virt-nexus-timer.rule=Host("nexus-timer.virtonline.eu")
|
||||
traefik.http.routers.virt-nexus-timer.service=virt-nexus-timer
|
||||
traefik.http.routers.virt-nexus-timer.tls=true
|
||||
traefik.http.routers.virt-nexus-timer.tls.certResolver=default
|
||||
traefik.http.routers.virt-nexus-timer.entrypoints=web-secure
|
||||
traefik.http.services.virt-nexus-timer.loadbalancer.server.port=80
|
||||
@@ -1,124 +0,0 @@
|
||||
## Data Model
|
||||
|
||||
The application maintains a comprehensive data model for managing game state:
|
||||
|
||||
```typescript
|
||||
interface Player {
|
||||
id: string; // Unique identifier for the player
|
||||
name: string; // Player's name
|
||||
avatar: string | null; // Player's avatar (can be null for default)
|
||||
initialTimerSec: number; // Initial timer value in seconds
|
||||
currentTimerSec: number; // Current timer value in seconds
|
||||
hotkey: string | null; // Keyboard hotkey for player control
|
||||
mqttChar: string | null; // MQTT character for player control
|
||||
isSkipped: boolean; // Whether the player is skipped
|
||||
isTimerRunning: boolean; // Whether the player's timer is running
|
||||
isCurrent: boolean; // Whether this is the current player
|
||||
isNext: boolean; // Whether this is the next player
|
||||
}
|
||||
|
||||
interface GameState {
|
||||
players: Player[]; // Array of all players
|
||||
currentPlayerIndex: number; // Index of the current player
|
||||
gameMode: 'normal' | 'all-timers'; // Current game mode
|
||||
gameRunning: boolean; // Whether the game is currently running
|
||||
isMuted: boolean; // Whether audio is muted
|
||||
theme: 'light' | 'dark'; // Current theme
|
||||
mqttBrokerUrl: string; // MQTT broker connection URL
|
||||
mqttConnectDesired: boolean; // Whether MQTT connection is desired
|
||||
mqttConnected: boolean; // Current MQTT connection status
|
||||
mqttReconnectAttempts: number; // Number of reconnection attempts
|
||||
mqttError: string | null; // Current MQTT error state
|
||||
|
||||
// Global Hotkey Controls
|
||||
globalHotkeyStopPause: string | null; // Hotkey for global stop/pause
|
||||
globalHotkeyRunAll: string | null; // Hotkey for run all timers
|
||||
globalHotkeyPassTurn: string | null; // Hotkey for global pass turn
|
||||
|
||||
// Global MQTT Controls
|
||||
globalMqttStopPause: string | null; // MQTT character for global stop/pause
|
||||
globalMqttRunAll: string | null; // MQTT character for run all timers
|
||||
globalMqttPassTurn: string | null; // MQTT character for global pass turn
|
||||
}
|
||||
```
|
||||
|
||||
## Build-time Information & Service Worker Versioning
|
||||
|
||||
The application incorporates build-time information and a mechanism for service worker updates:
|
||||
|
||||
1. **Version Management**
|
||||
- Build timestamp
|
||||
- Git commit hash
|
||||
- Environment variables
|
||||
|
||||
2. **Service Worker**
|
||||
- Automatic updates
|
||||
- Cache management
|
||||
- Offline-first approach
|
||||
- Update notifications
|
||||
|
||||
3. **Build Process**
|
||||
- Environment-specific configurations
|
||||
- Asset optimization
|
||||
- Code splitting
|
||||
- Tree-shaking
|
||||
|
||||
This architecture ensures a maintainable, scalable, and performant application that meets the requirements of a multi-player timer system with remote control capabilities.
|
||||
* **Minimalist Design:** Focus on clarity and ease of use. Avoid clutter.
|
||||
* **Large, Clear Timers:** Timers should be easily readable at a glance.
|
||||
* **Color Coding:** Use color to indicate timer state (e.g., green for running, red for negative time, grey for skipped).
|
||||
* **Responsive Layout:** The UI should adapt to different (mobile phone) screen sizes.
|
||||
* **Touch-Friendly:** Buttons and interactive elements should be large enough for easy tapping.
|
||||
|
||||
## 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.
|
||||
* **MQTT.js** for MQTT communication
|
||||
* **Web Audio API:** For audio feedback (ticking sounds, alerts).
|
||||
* **Browser API:** For capturing Players' photo.
|
||||
* **Screen Wake Lock API:** For preventing of the screen lock in a PWA.
|
||||
* **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:** Vith Vite (Vue.js)
|
||||
* **Tailwind CSS:** Utility-first CSS framework for rapid UI development.
|
||||
|
||||
## Data Model (Conceptual for Setup)
|
||||
```json
|
||||
{
|
||||
"players": [
|
||||
{
|
||||
"id": "1", "name": "Alice", "avatar": "image_data_or_default",
|
||||
"initialTimerSec": 3600, "currentTimerSec": 3600,
|
||||
"hotkey": "a", "mqttChar": "x", "isSkipped": false
|
||||
}
|
||||
],
|
||||
"globalHotkeyStopPause": "s",
|
||||
"globalMqttStopPause": "p",
|
||||
"globalHotkeyRunAll": "r",
|
||||
"globalMqttRunAll": "t",
|
||||
"globalHotkeyPassTurn": "n",
|
||||
"globalMqttPassTurn": "m",
|
||||
"mqttBrokerUrl": "ws://localhost:9001",
|
||||
"mqttConnectDesired": true,
|
||||
"currentPlayerIndex": 0,
|
||||
"gameMode": "normal",
|
||||
"isMuted": false,
|
||||
"theme": "light"
|
||||
}
|
||||
```
|
||||
|
||||
## Build-time Information & Service Worker Versioning
|
||||
The application incorporates build-time information and a mechanism for service worker updates:
|
||||
|
||||
1. **Build Timestamp:**
|
||||
* The build date and time are automatically injected into the application during the Vite build process.
|
||||
* This is configured in `vite.config.js` using Vite's `define` feature, making `import.meta.env.VITE_APP_BUILD_TIME` available in the Vue components.
|
||||
* The timestamp (formatted for the `sk-SK` locale) is displayed on the "About" screen (`src/views/InfoView.vue`).
|
||||
|
||||
2. **Service Worker Cache Versioning:**
|
||||
* The `CACHE_VERSION` constant within the service worker (`src/sw.js`) is also dynamically generated during the Vite build.
|
||||
* `vite.config.js` uses the `define` feature to replace a placeholder (`__APP_CACHE_VERSION__`) in `src/sw.js` with a unique version string. This string typically incorporates the application's version from `package.json` and a build timestamp (`Date.now()`) to ensure uniqueness.
|
||||
* The `src/sw.js` file is configured as a separate Rollup entry point in `vite.config.js` so that Vite processes it and performs this replacement, outputting the final `service-worker.js` to the `dist` directory root.
|
||||
* When a new version of the app is deployed with a changed `service-worker.js` (due to this new `CACHE_VERSION`), the browser detects the difference. The updated service worker installs, and upon activation, it clears out old caches associated with previous versions. This mechanism is key to how the PWA updates and provides users with the latest assets. The "Check for Update" feature on the "About" screen manually triggers the browser to check for a new `service-worker.js` file.
|
||||
@@ -1,169 +0,0 @@
|
||||
# Deployment Guide: Nexus Timer
|
||||
|
||||
This guide outlines the steps to deploy and manage the Nexus Timer application on a server using Docker, Traefik (as a reverse proxy), and systemd, with an optional automated deployment via Gitea webhooks.
|
||||
|
||||
## Table of Contents
|
||||
1. [Initial Server Setup](#initial-server-setup)
|
||||
2. [Exposing the App Behind Traefik](#exposing-the-app-behind-traefik-reverse-proxy)
|
||||
3. [Automating Updates with Webhooks (Gitea)](#automating-updates-with-webhooks-gitea)
|
||||
4. [Manual Updates (Fallback)](#manual-updates-fallback)
|
||||
5. [Troubleshooting & Logs](#troubleshooting--logs)
|
||||
|
||||
## Initial Server Setup
|
||||
|
||||
### On the Server
|
||||
Navigate to your preferred service directory on the server (e.g., `/virt`).
|
||||
```bash
|
||||
cd /virt
|
||||
```
|
||||
Clone the repository (only the latest commit for faster cloning).
|
||||
```bash
|
||||
git clone --depth 1 https://gitea.virtonline.eu/2HoursProject/nexus-timer.git
|
||||
cd nexus-timer
|
||||
```
|
||||
If you will run the container on the Docker network `traefik` (or any other pre-existing network), find its IP subnet to allow Nginx inside the container to correctly identify the real client IP.
|
||||
```bash
|
||||
docker network inspect traefik --format '{{(index .IPAM.Config 0).Subnet}}'
|
||||
```
|
||||
You'll get an output like `172.22.0.0/16`.
|
||||
|
||||
Set this subnet in the `nginx.conf` file within your cloned repository before building the image. For example:
|
||||
```bash
|
||||
set_real_ip_from 172.22.0.0/16;
|
||||
```
|
||||
Build the Docker image for the application.
|
||||
```bash
|
||||
docker build -t virt-nexus-timer .
|
||||
```
|
||||
## Exposing the App Behind Traefik (Reverse Proxy)
|
||||
This setup assumes you have Traefik running and configured to watch for Docker labels.
|
||||
|
||||
### Review the provided docker labels and systemd service file
|
||||
|
||||
The `docker/traefik.labels` file contains Docker labels that Traefik uses for routing and HTTPS. Review and adjust them if necessary.
|
||||
Copy the example label file to its destination (one level up, to be read by systemd).
|
||||
```bash
|
||||
cp docker/traefik.labels labels
|
||||
```
|
||||
View the example systemd service definition to understand how the Docker container will be managed.
|
||||
```bash
|
||||
cat systemd/virt-nexus-timer.service
|
||||
```
|
||||
### Create the systemd service
|
||||
Use `systemctl edit` to create or overwrite the service file. This is the recommended way to manage custom systemd units.
|
||||
```bash
|
||||
sudo systemctl edit --force --full virt-nexus-timer.service
|
||||
```
|
||||
The editor will open. Paste the content from your `systemd/virt-nexus-timer.service` file into the editor, then save and exit (e.g., Ctrl+X, then Y, then Enter in nano).
|
||||
|
||||
Enable the service to start on system boot and start it immediately.
|
||||
```bash
|
||||
sudo systemctl enable --now virt-nexus-timer.service
|
||||
```
|
||||
Check the service status to ensure it's running correctly.
|
||||
```bash
|
||||
systemctl status virt-nexus-timer.service
|
||||
```
|
||||
Look for "active (running)".
|
||||
|
||||
### Test the web application
|
||||
Verify that the application is accessible via HTTPS through Traefik:
|
||||
```bash
|
||||
curl https://nexus-timer.virtonline.eu
|
||||
```
|
||||
Or open it in your browser:
|
||||
[https://nexus-timer.virtonline.eu](https://nexus-timer.virtonline.eu)
|
||||
|
||||
## Automating Updates with Webhooks (Gitea)
|
||||
Instead of manually pulling, building, and restarting on the server, you can automate this process using a webhook. Gitea will notify a webhook service running on your server, which will then execute a deployment script.
|
||||
|
||||
Install the `webhook` service
|
||||
```bash
|
||||
sudo apt install webhook
|
||||
```
|
||||
Allow your Gitea instance to reach the webhook service on your server (e.g., `10.0.0.1:9000`). Ensure Gitea's `ALLOWED_HOST_LIST` in its `app.ini` includes this IP.
|
||||
### The Redeployment Script
|
||||
The `webhook` service will execute the script `hooks/redeploy.sh`. If webhook runs as a non-root user (recommended), that user will need passwordless sudo permission to restart the `virt-nexus-timer.service`.
|
||||
You can grant this by editing the sudoers file:
|
||||
```bash
|
||||
sudo visudo
|
||||
```
|
||||
and adding a line like:
|
||||
```bash
|
||||
webhooksvc ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart virt-nexus-timer.service
|
||||
```
|
||||
Replace `webhooksvc` with the actual user webhook runs as. If webhook runs as root, this is not necessary but less secure overall.
|
||||
### Configure the `webhook` Service
|
||||
Create or edit the main webhook configuration file, typically at `/etc/webhook.conf`. Add the JSON object from `hooks/webhook.conf` to the array in the file (or create the file if it's new).
|
||||
**Note:** Replace `YOUR_VERY_STRONG_SECRET_TOKEN_HERE_REPLACE_ME` with a strong, unique secret.
|
||||
### Set Up webhook as a Systemd Service
|
||||
Use `systemctl edit` to create or overwrite the service file. This is the recommended way to manage custom systemd units.
|
||||
```bash
|
||||
sudo systemctl edit --force --full webhook.service
|
||||
```
|
||||
The editor will open. Paste the content from your `systemd/webhook.service` file into the editor, then save and exit (e.g., Ctrl+X, then Y, then Enter in nano).
|
||||
|
||||
Enable, and start the webhook service:
|
||||
```bash
|
||||
sudo systemctl enable --now webhook.service
|
||||
sudo systemctl status webhook.service
|
||||
```
|
||||
### Configure Webhook in Gitea
|
||||
1. Navigate to your Gitea repository: `https://gitea.virtonline.eu/2HoursProject/nexus-timer`
|
||||
2. Go to `Settings -> Webhooks`.
|
||||
3. Click Add Webhook and choose `Gitea`.
|
||||
4. **Target URL**: `http://10.0.0.1:9000/hooks/redeploy-nexus-timer`
|
||||
*(The redeploy-nexus-timer part must match the id in your /etc/webhook.conf)*
|
||||
5. **HTTP Method**: POST
|
||||
6. **POST Content Type**: application/json
|
||||
7. **Secret**: Enter the exact same strong secret token you used in `/etc/webhook.conf` (e.g., `YOUR_VERY_STRONG_SECRET_TOKEN_HERE_REPLACE_ME`).
|
||||
8. **Trigger On**:
|
||||
Select `Push Events`.
|
||||
You can further refine this to specific branches if your `webhook.conf` doesn't already filter by branch (though redundant filtering is fine).
|
||||
9. Ensure `Enable this webhook` is checked.
|
||||
10. Click `Add Webhook`.
|
||||
### Test the Webhook
|
||||
* In Gitea, on the Webhooks settings page for the webhook you just created, click `Test Delivery`.
|
||||
* Check the Gitea UI for the response. It should show a `200 OK` and include the output from your `redeploy.sh` script.
|
||||
* Push a small change to your `main` (or configured) branch to trigger a real deployment.
|
||||
### Firewall Considerations
|
||||
If your server has a firewall (e.g., `ufw`), ensure that port `9000` (or whichever port you configured for `webhook`) is allowed for incoming connections from your Gitea server's IP address or network.
|
||||
|
||||
Example for `ufw` allowing any connection to `10.0.0.1:9000` (restrict source IP if possible):
|
||||
```bash
|
||||
sudo ufw allow to 10.0.0.1 port 9000 proto tcp comment 'Gitea Webhook'
|
||||
```
|
||||
If Gitea is external, use its specific source IP instead of 'any' for better security.
|
||||
```bash
|
||||
sudo ufw allow from <GITEA_SERVER_IP> to 10.0.0.1 port 9000 proto tcp comment 'Gitea Webhook'
|
||||
```
|
||||
Reload `ufw` if changes are made:
|
||||
```bash
|
||||
sudo ufw reload
|
||||
```
|
||||
|
||||
## Manual Updates (Fallback)
|
||||
Navigate to the application directory on your server.
|
||||
```bash
|
||||
cd /virt/nexus-timer
|
||||
```
|
||||
Pull the latest changes from the repository, rebuild the Docker image, and restart the systemd service.
|
||||
```bash
|
||||
git pull && docker build -t virt-nexus-timer . && sudo systemctl restart virt-nexus-timer.service
|
||||
```
|
||||
The previously installed Progressive Web App (PWA) should update automatically upon next launch or offer an upgrade prompt.
|
||||
|
||||
## Troubleshooting & Logs
|
||||
View real-time logs for the application service:
|
||||
```bash
|
||||
journalctl -fu virt-nexus-timer.service
|
||||
```
|
||||
View real-time logs for the `webhook` service:
|
||||
```bash
|
||||
journalctl -fu webhook.service
|
||||
```
|
||||
Check the custom log file for the redeployment script (if configured):
|
||||
```bash
|
||||
tail -f /var/log/webhook-redeploy-nexus-timer.log
|
||||
```
|
||||
Check Gitea's webhook delivery logs for request/response details and errors.
|
||||
@@ -1,71 +0,0 @@
|
||||
# Table of Contents
|
||||
1. [Developer Setup Guide](#developer-setup-guide)
|
||||
2. [Modify the app](#modify-the-app)
|
||||
3. [Test the PWA locally](#test-the-pwa-locally)
|
||||
4. [Commit & Push](#commit--push)
|
||||
|
||||
## Developer Setup Guide
|
||||
|
||||
### Project Structure
|
||||
|
||||
The project follows a Vue.js 3 architecture with the following key directories:
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # Static assets
|
||||
├── components/ # Reusable Vue components
|
||||
├── services/ # Business logic and API services
|
||||
├── store/ # Vuex 4 state management
|
||||
├── utils/ # Utility functions
|
||||
├── views/ # Page-level components
|
||||
├── router/ # Vue Router configuration
|
||||
└── App.vue # Main application component
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js (LTS version recommended)
|
||||
- npm (comes with Node.js)
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This will start the development server with hot module replacement. The application is built using Vite, which provides:
|
||||
|
||||
- Fast cold start
|
||||
- Instant HMR
|
||||
- Optimized build process
|
||||
- Modern ES module support
|
||||
|
||||
## Development Workflow
|
||||
|
||||
1. **State Management**: Use Vuex 4 for state management. All state should be handled through the store in `src/store/`.
|
||||
2. **Components**: Create reusable components in `src/components/`. Use Vue 3 Composition API for component logic.
|
||||
3. **Routing**: Define routes in `src/router/index.js` using Vue Router 4.
|
||||
4. **Styling**: Use Tailwind CSS for styling. Additional styles can be added in `src/assets/styles/`.
|
||||
5. **Services**: Place business logic and external API calls in `src/services/`.
|
||||
6. **Utils**: Common utility functions should be placed in `src/utils/`.
|
||||
|
||||
### Modify the app
|
||||
Make code changes...
|
||||
|
||||
### Test the PWA locally
|
||||
Open it in your browser:
|
||||
[http://localhost:8080/](http://localhost:8080/)
|
||||
|
||||
### Commit & Push
|
||||
Stage changes, commit and push
|
||||
```bash
|
||||
git add .
|
||||
git commit -m 'My cool feature'
|
||||
git push
|
||||
```
|
||||
@@ -1,190 +0,0 @@
|
||||
# Remote Control Options
|
||||
|
||||
## Table of Contents
|
||||
1. [HID Smart Buttons](#hid-smart-buttons)
|
||||
2. [MQTT Remote Control](#mqtt-remote-control)
|
||||
- [Mosquitto Installation Guide](#mosquitto-installation-guide)
|
||||
- [Mosquitto MQTT Broker using `Termux` app](#mosquitto-mqtt-broker-using-termux-app)
|
||||
- [Mosquitto MQTT Broker with VPN on Android 16's Native Linux VM](#mosquitto-mqtt-broker-with-vpn-on-android-16s-native-linux-vm)
|
||||
- [Android Shortcut Setup](#android-shortcut-setup)
|
||||
- [Configure `Quick Tap` gesture to trigger the shortcut](#configure-quick-tap-gesture-to-trigger-the-shortcut)
|
||||
- [Testing with `mosquitto_pub` (via Termux)](#testing-with-mosquitto_pub-via-termux)
|
||||
- [Nexus Timer (PWA) Setup](#nexus-timer-pwa-setup)
|
||||
|
||||
|
||||
## HID Smart Buttons
|
||||
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 emulating a keyboard.
|
||||
|
||||
* **Buttons:** Connect 3 physical buttons, potentially extended e.g., via 1.5m wires for easy player access.
|
||||
* **Configuration:**
|
||||
* **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.
|
||||
* **If Player 3 is Game Admin:**
|
||||
* **Player 3's Button:** Long Press: Emulates a key press (e.g., 's'). Configure as the "Global Stop/Pause All" hotkey in the app.
|
||||
* **Player 3's Button:** Double Click: Emulates a key press (e.g., 'x'). Configure as the "Global Run All Timers" hotkey in the app.
|
||||
|
||||
The code for the XIAO nRF52840 module with a 3D printing files can be found in the [arduino](../arduino) subdirectory.
|
||||
|
||||
## MQTT Remote Control
|
||||
Alternatively to a dedicated smart buttons, players can use their smartphones to send commands to Nexus Timer via MQTT. This requires an MQTT broker (like Mosquitto) on the same network.
|
||||
|
||||
### Mosquitto Installation Guide
|
||||
|
||||
#### Mosquitto MQTT Broker using `Termux` app
|
||||
|
||||
1. **Install Termux** from the [Play Store](https://play.google.com/store/apps/details?id=com.termux) and run.
|
||||
2. **Update packages and install Mosquitto in Termux:**
|
||||
```bash
|
||||
pkg update && pkg upgrade
|
||||
pkg install mosquitto
|
||||
```
|
||||
3. **Configure the MQTT Broker:**
|
||||
```bash
|
||||
nano $PREFIX/etc/mosquitto/mosquitto.conf
|
||||
```
|
||||
Add the following configuration, then save and exit:
|
||||
```ini
|
||||
# MQTT listener on port 1883
|
||||
# MQTT connection from the HTTP Shortcut app
|
||||
listener 1883 0.0.0.0
|
||||
protocol mqtt
|
||||
|
||||
# WebSocket listener on port 9001
|
||||
# MQTT over WebSocket connection from the PWA (Web App)
|
||||
listener 9001 0.0.0.0
|
||||
protocol websockets
|
||||
|
||||
# Allow clients to connect without username/password
|
||||
allow_anonymous true
|
||||
```
|
||||
4. **Run Mosquitto with the configuration:**
|
||||
```bash
|
||||
mosquitto -c $PREFIX/etc/mosquitto/mosquitto.conf
|
||||
```
|
||||
---
|
||||
|
||||
#### Mosquitto MQTT Broker with VPN on Android 16's Native Linux VM
|
||||
|
||||
This guide details how to install and configure the Mosquitto MQTT broker within the native `Linux Virtual Machine environment` introduced in Android 16. Note that ports exposed by the Mosquitto cannot be reached from LAN. The workarround is using a VPN (wireguard).
|
||||
|
||||
1. **Enable the Linux Development Environment**
|
||||
|
||||
First, you must activate the Linux VM on your Android 16 device.
|
||||
|
||||
* Navigate to **Settings > About Phone**.
|
||||
* Tap on the **Build Number** seven (7) times to unlock **Developer options**.
|
||||
* Go back and navigate to **Settings > System > Developer options**.
|
||||
* Find and enable the **Linux development environment** toggle.
|
||||
* Once enabled, a new **Terminal** application will be added to your app drawer.
|
||||
|
||||
2. **Install Mosquitto**
|
||||
|
||||
Open the **Terminal** app to access your Debian-based Linux environment.
|
||||
|
||||
* First, update and upgrade your system's package lists to ensure all sources are current.
|
||||
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade
|
||||
```
|
||||
|
||||
* Next, use the `apt` package manager to install the Mosquitto broker and the command-line clients.
|
||||
|
||||
```bash
|
||||
sudo apt install mosquitto mosquitto-clients
|
||||
```
|
||||
|
||||
3. **Configure the MQTT Broker**
|
||||
|
||||
Create a custom configuration file to control the broker's behavior. The recommended practice on Debian is to place new configurations in the `/etc/mosquitto/conf.d/` directory.
|
||||
|
||||
* Use a command-line text editor like `nano` to create a new configuration file.
|
||||
|
||||
```bash
|
||||
sudo nano /etc/mosquitto/conf.d/local.conf
|
||||
```
|
||||
|
||||
* Add the following lines to the file. This configuration sets up listeners for both standard MQTT and WebSockets traffic and permits connections without authentication.
|
||||
|
||||
```ini
|
||||
# MQTT listener on port 1883
|
||||
# MQTT connection from the HTTP Shortcut app
|
||||
listener 1883 0.0.0.0
|
||||
protocol mqtt
|
||||
|
||||
# WebSocket listener on port 9001
|
||||
# MQTT over WebSocket connection from the PWA (Web App)
|
||||
listener 9001 0.0.0.0
|
||||
protocol websockets
|
||||
|
||||
# Allow clients to connect without username/password
|
||||
allow_anonymous true
|
||||
```
|
||||
|
||||
* Save the file and exit the text editor (in `nano`, press `Ctrl+X`, then `Y`, then `Enter`).
|
||||
|
||||
4. **Run and Manage the Mosquitto Service**
|
||||
|
||||
The Mosquitto broker runs as a system service. After installation, it should start automatically.
|
||||
|
||||
* To apply your new configuration, restart the Mosquitto service:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart mosquitto
|
||||
```
|
||||
|
||||
* You can verify that the service is running correctly by checking its status:
|
||||
|
||||
```bash
|
||||
systemctl status mosquitto
|
||||
```
|
||||
|
||||
* (Optional) To have the service not start automatically every time you boot up your Linux VM, you can disable it:
|
||||
```bash
|
||||
sudo systemctl disable mosquitto
|
||||
```
|
||||
|
||||
Your Mosquitto MQTT broker is now successfully configured and running on your Android 16 device.
|
||||
|
||||
---
|
||||
|
||||
### Android Shortcut Setup
|
||||
* Install the `HTTP Shortcuts` app from the [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts).
|
||||
* Create a new shortcut.
|
||||
* Advanced Types -> MQTT Shortcut.
|
||||
* Shortcut name: `Pass Turn/My Pause`
|
||||
* Basic Settings: Set the MQTT server's address to `tcp://localhost:1883`. *(Note: `HTTP Shortcuts` app uses TCP, while the PWA (Web app) uses WebSockets to connect to the same broker.)*
|
||||
* Messages:
|
||||
* Set Topic to: `game`. *(Note: The topic name is hardcoded in the PWA.)*
|
||||
* Set Payload to a single character (e.g. `a`). Each player will set a unique character (i.e. `a-z, A-Z, 0-10`).
|
||||
* Go back and save the configuration.
|
||||
* Create homescreen icon for the `Pass Turn/My Pause` shortcut:
|
||||
* Tap and hold on an empty space on the homscreen.
|
||||
* Select widgets.
|
||||
* Search for HTTP and select the `HTTP Shortcuts` app.
|
||||
* Select the icon and assign the `Pass Turn/My Pause` shortcut.
|
||||
* An icon will appear on the homescreen.
|
||||
* Tap the icon to trigger the game action.
|
||||
---
|
||||
|
||||
### Configure `Quick Tap` gesture to trigger the shortcut:
|
||||
* Go to Android `settings`.
|
||||
* Navigate to `System -> Gestures -> Quick Tap to start actions`.
|
||||
* Select `Open app` and click the configure icon.
|
||||
* Select `HTTP Shortcuts` app and click the configure icon.
|
||||
* Select `Pass Turn/My Pause` shortcut.
|
||||
* Double-Tap on the back of the phone to trigger the game action.
|
||||
---
|
||||
|
||||
### Testing with `mosquitto_pub` (via Terminal/Termux):
|
||||
* Run: `mosquitto_pub -h <BROKER_IP> -p <TCP_PORT> -t game -m "X"`
|
||||
* Replace `<BROKER_IP>` with your Mosquitto broker's IP (e.g. `localhost`).
|
||||
* Replace `<TCP_PORT>` with Mosquitto's TCP port (e.g., 1883).
|
||||
* `-t game`: The topic the PWA listens on.
|
||||
* `-m "X"`: The single character message (e.g., "a", "b", "s"). This "X" should match the MQTT char configured in Nexus Timer for the desired action.
|
||||
---
|
||||
|
||||
### Nexus Timer (PWA) Setup
|
||||
* Enter the MQTT Broker URL `ws://localhost:9001` in the Setup screen and connect.
|
||||
* Assign unique single characters as **MQTT Triggers** for each player's "Pass Turn / My Pause" action.
|
||||
* Assign unique single characters as **MQTT Triggers** for "Global Stop/Pause All" and "Global Run All Timers".
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eo pipefail # Exit on error, treat unset variables as an error, and propagate pipeline errors
|
||||
|
||||
APP_DIR="/virt/nexus-timer"
|
||||
IMAGE_NAME="virt-nexus-timer"
|
||||
SERVICE_NAME="virt-nexus-timer.service"
|
||||
LOG_FILE="/var/log/webhook-redeploy-nexus-timer.log" # Optional: for script-specific logging
|
||||
|
||||
# Redirect stdout and stderr to a log file and also to the console (for webhook response)
|
||||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||||
|
||||
echo "----------------------------------------------------"
|
||||
echo "Webhook redeploy-nexus-timer triggered at $(date)"
|
||||
echo "Branch/Ref: $1"
|
||||
echo "Repository: $2"
|
||||
echo "----------------------------------------------------"
|
||||
|
||||
# Ensure script is run from the app directory
|
||||
cd "$APP_DIR" || { echo "ERROR: Failed to cd to $APP_DIR. Exiting."; exit 1; }
|
||||
|
||||
# Optional: Check if the trigger is for the correct branch (e.g., main or master)
|
||||
# TARGET_BRANCH="refs/heads/main" # Adjust to your primary branch name
|
||||
# if [ "$1" != "$TARGET_BRANCH" ]; then
|
||||
# echo "Webhook triggered for branch $1, but only deploying $TARGET_BRANCH. Exiting."
|
||||
# exit 0 # Exit successfully to not show an error in Gitea for non-target branches
|
||||
# fi
|
||||
|
||||
echo "Pulling latest changes from Git (main branch)..."
|
||||
# Ensure you are on the correct branch first or specify it in the pull
|
||||
# git checkout main # Uncomment if your repo might be on other branches
|
||||
git pull --rebase origin main || { echo "ERROR: git pull failed. Exiting."; exit 1; } # Adjust 'main' if needed
|
||||
|
||||
echo "Building Docker image $IMAGE_NAME..."
|
||||
docker build -t "$IMAGE_NAME" . || { echo "ERROR: docker build failed. Exiting."; exit 1; }
|
||||
|
||||
echo "Restarting systemd service $SERVICE_NAME..."
|
||||
# This command might require sudo privileges. See note below.
|
||||
sudo systemctl restart "$SERVICE_NAME" || { echo "ERROR: systemctl restart failed. Exiting."; exit 1; }
|
||||
|
||||
echo "Deployment finished successfully at $(date)."
|
||||
echo "----------------------------------------------------"
|
||||
|
||||
exit 0
|
||||
@@ -1,38 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "redeploy-nexus-timer",
|
||||
"execute-command": "/virt/nexus-timer/hooks/redeploy.sh",
|
||||
"command-working-directory": "/virt/nexus-timer",
|
||||
"pass-arguments-to-command": [
|
||||
{ "source": "payload", "name": "ref" },
|
||||
{ "source": "payload", "name": "repository.full_name" }
|
||||
],
|
||||
"trigger-rule": {
|
||||
"and": [
|
||||
{
|
||||
"match": {
|
||||
"type": "payload-hmac-sha256",
|
||||
"secret": "YOUR_VERY_STRONG_SECRET_TOKEN_HERE_REPLACE_ME",
|
||||
"parameter": {
|
||||
"source": "header",
|
||||
"name": "X-Gitea-Signature"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": {
|
||||
"type": "value",
|
||||
"value": "refs/heads/main",
|
||||
"parameter": {
|
||||
"source": "payload",
|
||||
"name": "ref"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"include-command-output-in-response": true,
|
||||
"include-command-output-in-response-on-error": true,
|
||||
"response-message": "Webhook processed."
|
||||
}
|
||||
]
|
||||
41
nginx.conf
@@ -1,47 +1,16 @@
|
||||
# Existing log format often uses $remote_addr by default
|
||||
# log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
# '$status $body_bytes_sent "$http_referer" '
|
||||
# '"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
# Add these lines within the http {} block, OR server {} block
|
||||
# (http block is generally preferred for these directives)
|
||||
# Make sure they are *before* the access_log directive if possible.
|
||||
|
||||
# --- Real IP Configuration ---
|
||||
# Replace with the ACTUAL IP range(s) of your Traefik Docker network(s)
|
||||
set_real_ip_from 172.22.0.0/16; # Example: Trust IPs from this subnet
|
||||
# You can add multiple set_real_ip_from lines if needed
|
||||
# set_real_ip_from 192.168.1.0/24; # Example if Traefik was also on another network
|
||||
|
||||
# Which header contains the real client IP?
|
||||
# X-Forwarded-For handles multiple proxies better. X-Real-IP is simpler if only Traefik.
|
||||
real_ip_header X-Real-IP;
|
||||
|
||||
# If using X-Forwarded-For, tell Nginx how to process it.
|
||||
# 'on' means find the *last* IP address that is NOT from a trusted proxy.
|
||||
# This is usually correct when behind one or more trusted proxies.
|
||||
real_ip_recursive off;
|
||||
# --- End Real IP Configuration ---
|
||||
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
|
||||
# Use the 'realip' processed $remote_addr in logs
|
||||
# Ensure your access_log format uses $remote_addr (like 'combined' or 'main')
|
||||
access_log /var/log/nginx/access.log combined; # Or your preferred format using $remote_addr
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
# It's common practice to put realip config in the http block
|
||||
# If your default.conf is included inside an existing http block in nginx.conf,
|
||||
# placing the realip directives *outside* the server block but *inside* http is standard.
|
||||
# If this file IS your entire http block, place them just before the server block.
|
||||
# Optional: Add headers for PWA, security, etc.
|
||||
# location ~* \.(?:manifest\.json)$ {
|
||||
# add_header Cache-Control "no-cache";
|
||||
# }
|
||||
}
|
||||
459
package-lock.json
generated
@@ -8,7 +8,6 @@
|
||||
"name": "nexus-timer",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"mqtt": "^5.13.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"vuex": "^4.0.2"
|
||||
@@ -64,14 +63,6 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
|
||||
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
|
||||
@@ -573,23 +564,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.17.tgz",
|
||||
"integrity": "sha512-wIX2aSZL5FE+MR0JlvF87BNVrtFWf6AE6rxSE9X7OwnVvoyCQjpzSRJ+M87se/4QCkCiebQAqrJ0y6fwIyi7nw==",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/readable-stream": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.18.tgz",
|
||||
"integrity": "sha512-21jK/1j+Wg+7jVw1xnSwy/2Q1VgVjWuFssbYGTREPUBeZ+rqVFl2udq0IkxzPC0ZhOzVceUbyIACFZKLqKEBlA==",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"safe-buffer": "~5.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
|
||||
@@ -699,17 +673,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz",
|
||||
"integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ=="
|
||||
},
|
||||
"node_modules/abort-controller": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
||||
"dependencies": {
|
||||
"event-target-shim": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.5"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
|
||||
@@ -802,25 +765,6 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@@ -833,17 +777,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-6.1.0.tgz",
|
||||
"integrity": "sha512-ClDyJGQkc8ZtzdAAbAwBmhMSpwN/sC9HA8jxdYm6nVUbCfZbe2mgza4qh7AuEYyEPB/c4Kznf9s66bnsKMQDjw==",
|
||||
"dependencies": {
|
||||
"@types/readable-stream": "^4.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
@@ -897,34 +830,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -1017,38 +922,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/commist": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz",
|
||||
"integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw=="
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -1080,22 +953,6 @@
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/didyoumean": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
|
||||
@@ -1188,22 +1045,6 @@
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="
|
||||
},
|
||||
"node_modules/event-target-shim": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-glob": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||
@@ -1232,18 +1073,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-unique-numbers": {
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz",
|
||||
"integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.8",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.19.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||
@@ -1361,47 +1190,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/help-me": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
|
||||
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
|
||||
"integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
|
||||
"dependencies": {
|
||||
"jsbn": "1.1.0",
|
||||
"sprintf-js": "^1.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -1498,20 +1286,6 @@
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
},
|
||||
"node_modules/js-sdsl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz",
|
||||
"integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/js-sdsl"
|
||||
}
|
||||
},
|
||||
"node_modules/jsbn": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
|
||||
"integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A=="
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -1551,7 +1325,8 @@
|
||||
"node_modules/lru-cache": {
|
||||
"version": "10.4.3",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="
|
||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.17",
|
||||
@@ -1598,14 +1373,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||
@@ -1615,50 +1382,6 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt": {
|
||||
"version": "5.13.0",
|
||||
"resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.13.0.tgz",
|
||||
"integrity": "sha512-pR+z+ChxFl3n8AKLQbTONVOOg/jl4KiKQRBAi78tjd6PksOWvl1nl9L8ZHOZ3MiavZfrUOjok2ddwc1VymGWRg==",
|
||||
"dependencies": {
|
||||
"commist": "^3.2.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"debug": "^4.4.0",
|
||||
"help-me": "^5.0.0",
|
||||
"lru-cache": "^10.4.3",
|
||||
"minimist": "^1.2.8",
|
||||
"mqtt-packet": "^9.0.2",
|
||||
"number-allocator": "^1.0.14",
|
||||
"readable-stream": "^4.7.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"socks": "^2.8.3",
|
||||
"split2": "^4.2.0",
|
||||
"worker-timers": "^7.1.8",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"bin": {
|
||||
"mqtt": "build/bin/mqtt.js",
|
||||
"mqtt_pub": "build/bin/pub.js",
|
||||
"mqtt_sub": "build/bin/sub.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mqtt-packet": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz",
|
||||
"integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==",
|
||||
"dependencies": {
|
||||
"bl": "^6.0.8",
|
||||
"debug": "^4.3.4",
|
||||
"process-nextick-args": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/mz": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -1711,15 +1434,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/number-allocator": {
|
||||
"version": "1.0.14",
|
||||
"resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz",
|
||||
"integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.1",
|
||||
"js-sdsl": "4.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -1952,19 +1666,6 @@
|
||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/process": {
|
||||
"version": "0.11.10",
|
||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -1994,21 +1695,6 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
||||
"dependencies": {
|
||||
"abort-controller": "^3.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"events": "^3.3.0",
|
||||
"process": "^0.11.10",
|
||||
"string_decoder": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -2051,11 +1737,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "3.29.5",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz",
|
||||
@@ -2095,11 +1776,6 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -2133,28 +1809,6 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/smart-buffer": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||
"engines": {
|
||||
"node": ">= 6.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socks": {
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz",
|
||||
"integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==",
|
||||
"dependencies": {
|
||||
"ip-address": "^9.0.5",
|
||||
"smart-buffer": "^4.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0",
|
||||
"npm": ">= 3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2163,46 +1817,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
|
||||
"integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder/node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||
@@ -2409,21 +2023,6 @@
|
||||
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||
@@ -2457,7 +2056,8 @@
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.5.14",
|
||||
@@ -2574,37 +2174,6 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers": {
|
||||
"version": "7.1.8",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz",
|
||||
"integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.5",
|
||||
"tslib": "^2.6.2",
|
||||
"worker-timers-broker": "^6.1.8",
|
||||
"worker-timers-worker": "^7.0.71"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-broker": {
|
||||
"version": "6.1.8",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz",
|
||||
"integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.5",
|
||||
"fast-unique-numbers": "^8.0.13",
|
||||
"tslib": "^2.6.2",
|
||||
"worker-timers-worker": "^7.0.71"
|
||||
}
|
||||
},
|
||||
"node_modules/worker-timers-worker": {
|
||||
"version": "7.0.71",
|
||||
"resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz",
|
||||
"integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.5",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||
@@ -2696,26 +2265,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
|
||||
"integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz",
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"mqtt": "^5.13.0",
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"vuex": "^4.0.2"
|
||||
|
||||
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -1,55 +1,21 @@
|
||||
{
|
||||
"name": "Nexus Timer - Multiplayer Turn Timer",
|
||||
"short_name": "NexusTimer",
|
||||
"description": "Dynamic multiplayer timer for games, workshops, or sequential turns. Focuses on current & next player.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay"],
|
||||
"background_color": "#1f2937",
|
||||
"theme_color": "#3b82f6",
|
||||
"orientation": "portrait",
|
||||
"lang": "en-US",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/maskable-icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Game Setup",
|
||||
"short_name": "Setup",
|
||||
"description": "Configure players and settings",
|
||||
"url": "/?utm_source=homescreen",
|
||||
"icons": [{ "src": "/icons/shortcut-setup-96x96.png", "sizes": "96x96" }]
|
||||
},
|
||||
{
|
||||
"name": "About Nexus Timer",
|
||||
"short_name": "About",
|
||||
"description": "Information about the app",
|
||||
"url": "/info?utm_source=homescreen",
|
||||
"icons": [{ "src": "/icons/shortcut-info-96x96.png", "sizes": "96x96" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
49
public/service-worker.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
});
|
||||
315
src/App.vue
@@ -1,227 +1,34 @@
|
||||
<template>
|
||||
<div :class="[theme, 'min-h-screen flex flex-col no-select']">
|
||||
<!-- Optional: Update Notification UI -->
|
||||
<div v-if="showUpdateBar" class="bg-blue-600 text-white p-3 text-center text-sm shadow-md">
|
||||
A new version is available!
|
||||
<button @click="refreshApp" class="ml-4 font-semibold underline hover:text-blue-200">REFRESH</button>
|
||||
<button @click="showUpdateBar = false" class="ml-4 font-semibold absolute right-3 top-1/2 transform -translate-y-1/2 p-1">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Optional: Install Button (place somewhere appropriate, e.g., header or settings) -->
|
||||
<!-- Example: Adding to App.vue for demo, ideally place in SetupView or a menu -->
|
||||
<button v-if="showInstallButton" @click="promptInstall" class="fixed bottom-4 right-4 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded shadow-lg z-50">
|
||||
Install App
|
||||
</button>
|
||||
|
||||
<router-view class="flex-grow" />
|
||||
<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, ref } from 'vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { AudioService } from './services/AudioService';
|
||||
import { MqttService } from './services/MqttService';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
const theme = computed(() => store.getters.theme);
|
||||
|
||||
const handleMqttMessage = (char) => { // This is the general command handler
|
||||
console.log(`App.vue: Processing MQTT char (as command): '${char}'`);
|
||||
const currentRouteName = router.currentRoute.value.name;
|
||||
|
||||
// Global MQTT Stop/Pause All
|
||||
if (char === store.getters.globalMqttStopPause && store.getters.globalMqttStopPause) {
|
||||
if (currentRouteName === 'Game') {
|
||||
store.dispatch('globalStopPauseAll');
|
||||
console.log("MQTT Command: Global Stop/Pause All triggered");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Global MQTT Run All Timers
|
||||
if (char === store.getters.globalMqttRunAll && store.getters.globalMqttRunAll) {
|
||||
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
|
||||
store.dispatch('switchToAllTimersMode');
|
||||
console.log("MQTT Command: Global Run All Timers triggered");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Global MQTT Pass Turn
|
||||
if (char === store.getters.globalMqttPassTurn && store.getters.globalMqttPassTurn) {
|
||||
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
|
||||
// Similar logic to player-specific pass turn, but not tied to current player's MQTT char
|
||||
const currentPlayer = store.getters.currentPlayer;
|
||||
if (currentPlayer && !currentPlayer.isSkipped) { // Ensure there's a current player to pass from
|
||||
AudioService.cancelPassTurnSound();
|
||||
const wasRunning = currentPlayer.isTimerRunning;
|
||||
store.dispatch('passTurn').then(() => {
|
||||
const newCurrentPlayer = store.getters.currentPlayer;
|
||||
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
});
|
||||
console.log("MQTT Command: Global Pass Turn triggered");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Player specific MQTT Pass Turn / My Pause
|
||||
if (currentRouteName === 'Game') {
|
||||
const gameModeInStore = store.getters.gameMode;
|
||||
const currentPlayerInStore = store.getters.currentPlayer;
|
||||
|
||||
if (gameModeInStore === 'normal' && currentPlayerInStore && char === currentPlayerInStore.mqttChar) {
|
||||
const wasRunning = currentPlayerInStore.isTimerRunning;
|
||||
store.dispatch('passTurn').then(() => {
|
||||
const newCurrentPlayer = store.getters.currentPlayer;
|
||||
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
});
|
||||
console.log(`MQTT Command: Player ${currentPlayerInStore.name} Pass Turn triggered`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameModeInStore === 'allTimers') {
|
||||
// Find player by mqttChar. Ensure players array exists.
|
||||
const playerToToggle = store.state.players?.find(p => p.mqttChar === char && !p.isSkipped);
|
||||
if (playerToToggle) {
|
||||
const playerIndex = store.state.players.indexOf(playerToToggle);
|
||||
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
|
||||
console.log(`MQTT Command: Player ${playerToToggle.name} Timer Toggle triggered`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- PWA Install Prompt Logic ---
|
||||
const deferredPrompt = ref(null);
|
||||
const showInstallButton = ref(false);
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
||||
e.preventDefault();
|
||||
// Stash the event so it can be triggered later.
|
||||
deferredPrompt.value = e;
|
||||
// Update UI to notify the user they can add to home screen
|
||||
showInstallButton.value = true;
|
||||
console.log('`beforeinstallprompt` event was fired.');
|
||||
});
|
||||
|
||||
const promptInstall = async () => {
|
||||
if (!deferredPrompt.value) {
|
||||
alert('Install prompt not available.');
|
||||
return;
|
||||
}
|
||||
showInstallButton.value = false; // Hide the button once prompt is shown
|
||||
// Show the prompt
|
||||
deferredPrompt.value.prompt();
|
||||
// Wait for the user to respond to the prompt
|
||||
const { outcome } = await deferredPrompt.value.userChoice;
|
||||
console.log(`User response to the install prompt: ${outcome}`);
|
||||
// We've used the prompt, and can't use it again, throw it away
|
||||
deferredPrompt.value = null;
|
||||
};
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
// Hide the install button if the app is installed
|
||||
showInstallButton.value = false;
|
||||
deferredPrompt.value = null;
|
||||
console.log('Nexus Timer was installed.');
|
||||
// Optionally: Send analytics event
|
||||
});
|
||||
// --- End PWA Install Prompt Logic ---
|
||||
|
||||
// --- PWA Update Logic ---
|
||||
const showUpdateBar = ref(false);
|
||||
let newWorker;
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
if (registration.waiting) {
|
||||
console.log("SW Update: Found waiting worker on load");
|
||||
newWorker = registration.waiting;
|
||||
showUpdateBar.value = true;
|
||||
return;
|
||||
}
|
||||
registration.addEventListener('updatefound', () => {
|
||||
console.log("SW Update: New worker found, installing...");
|
||||
newWorker = registration.installing;
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log("SW Update: New worker installed and ready");
|
||||
showUpdateBar.value = true; // Show refresh bar
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Detect controller change and refresh the page.
|
||||
// Can happen if skipWaiting() is used and the page wasn't manually refreshed.
|
||||
let refreshing;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (refreshing) return;
|
||||
console.log("SW Update: Controller changed, refreshing page.");
|
||||
refreshing = true;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
const refreshApp = () => {
|
||||
showUpdateBar.value = false;
|
||||
if (newWorker) {
|
||||
console.log("SW Update: Sending skipWaiting message");
|
||||
newWorker.postMessage({ action: 'skipWaiting' });
|
||||
// Page should reload via 'controllerchange' listener above
|
||||
} else {
|
||||
console.warn("SW Update: Refresh called but no new worker found.");
|
||||
window.location.reload(); // Fallback reload
|
||||
}
|
||||
};
|
||||
// --- End PWA Update Logic ---
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('loadState').then(() => {
|
||||
// Sync with OS theme preference on initial load if no theme is saved yet
|
||||
// Note: Store initializer already loads saved theme if it exists.
|
||||
// This logic should perhaps check if the loaded theme matches OS pref
|
||||
// or only apply OS pref if no theme was loaded from storage.
|
||||
// Simplified: Apply loaded theme regardless for now.
|
||||
console.log("App.vue: Store has finished loading state.");
|
||||
applyTheme();
|
||||
|
||||
// Example: Sync with OS theme only if no theme saved (requires store modification)
|
||||
// syncWithOsThemeIfNeeded();
|
||||
|
||||
// Auto-connect MQTT only if URL is stored AND user desires connection
|
||||
const storedBrokerUrl = store.getters.mqttBrokerUrl;
|
||||
const connectDesired = store.getters.mqttConnectDesired; // Get the flag
|
||||
|
||||
if (storedBrokerUrl &&
|
||||
connectDesired && // Check the flag
|
||||
MqttService.connectionStatus.value !== 'connected' &&
|
||||
MqttService.connectionStatus.value !== 'connecting') {
|
||||
console.log("App.vue: Auto-connecting to stored MQTT broker (user desired):", storedBrokerUrl);
|
||||
MqttService.connect(storedBrokerUrl);
|
||||
} else if (storedBrokerUrl && !connectDesired) {
|
||||
console.log("App.vue: MQTT Broker URL is stored, but user previously disconnected. Not auto-connecting.");
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error("App.vue: Error during store.dispatch('loadState'):", error);
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
MqttService.setGeneralMessageHandler(handleMqttMessage); // Set handler regardless of initial connection
|
||||
|
||||
const resumeAudio = () => { AudioService.resumeContext(); /* ... remove listeners ... */ };
|
||||
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 });
|
||||
MqttService.setGeneralMessageHandler(handleMqttMessage);
|
||||
});
|
||||
|
||||
watch(theme, () => {
|
||||
@@ -241,93 +48,57 @@ const applyTheme = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Optional: Function to sync with OS theme
|
||||
// const syncWithOsThemeIfNeeded = () => {
|
||||
// // Requires modification to store state/initializer to know if theme was explicitly set/saved
|
||||
// const themeWasLoaded = store.getters.theme !== initialState.theme; // Example check needed
|
||||
// if (!themeWasLoaded && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
// console.log("Syncing with OS dark theme preference.");
|
||||
// store.dispatch('toggleTheme'); // Assumes toggle sets it correctly
|
||||
// }
|
||||
// }
|
||||
|
||||
// handleGlobalKeyDown (remains the same as previous version)
|
||||
const handleGlobalKeyDown = (event) => {
|
||||
const targetElement = event.target;
|
||||
if (targetElement.tagName === 'INPUT' ||
|
||||
targetElement.tagName === 'TEXTAREA' ||
|
||||
targetElement.isContentEditable) {
|
||||
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keyPressed = event.key.toLowerCase();
|
||||
const currentRouteName = router.currentRoute.value.name;
|
||||
|
||||
|
||||
if (keyPressed === store.getters.globalHotkeyStopPause && store.getters.globalHotkeyStopPause) {
|
||||
event.preventDefault();
|
||||
if (currentRouteName === 'Game') {
|
||||
store.dispatch('globalStopPauseAll');
|
||||
}
|
||||
store.dispatch('globalStopPauseAll');
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPressed === store.getters.globalHotkeyRunAll && store.getters.globalHotkeyRunAll) {
|
||||
const currentPlayerInStore = store.getters.currentPlayer;
|
||||
const gameModeInStore = store.getters.gameMode;
|
||||
|
||||
if (gameModeInStore === 'normal' && currentPlayerInStore && keyPressed === currentPlayerInStore.hotkey) {
|
||||
event.preventDefault();
|
||||
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
|
||||
store.dispatch('switchToAllTimersMode');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyPressed === store.getters.globalHotkeyPassTurn && store.getters.globalHotkeyPassTurn) {
|
||||
event.preventDefault();
|
||||
if (currentRouteName === 'Game' && store.getters.gameMode === 'normal') {
|
||||
const currentPlayer = store.getters.currentPlayer;
|
||||
if (currentPlayer && !currentPlayer.isSkipped) {
|
||||
AudioService.cancelPassTurnSound();
|
||||
const wasRunning = currentPlayer.isTimerRunning;
|
||||
store.dispatch('passTurn').then(() => {
|
||||
const newCurrentPlayer = store.getters.currentPlayer;
|
||||
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
});
|
||||
console.log("Hotkey: Global Pass Turn triggered");
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentRouteName === 'Game') {
|
||||
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();
|
||||
}
|
||||
});
|
||||
} else 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);
|
||||
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 {
|
||||
html, body, #app { /* These styles are critical */
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
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>
|
||||
@@ -7,17 +7,6 @@ body {
|
||||
overscroll-behavior-y: contain; /* Prevents pull-to-refresh on mobile */
|
||||
}
|
||||
|
||||
/* Safe area insets for mobile devices (status bar, home indicator) */
|
||||
.safe-area-padding {
|
||||
padding-top: env(safe-area-inset-top);
|
||||
/* padding-bottom: env(safe-area-inset-bottom); */
|
||||
}
|
||||
|
||||
.safe-area-height {
|
||||
height: calc(100dvh - env(safe-area-inset-top));
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Basic button styling */
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded font-semibold focus:outline-none focus:ring-2 focus:ring-opacity-50;
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-[100]"
|
||||
@click.self="cancel"
|
||||
@keydown.esc="cancel"
|
||||
tabindex="0"
|
||||
ref="overlay"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
|
||||
<h3 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
|
||||
Press a Key
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Waiting for a single key press to assign as the hotkey.
|
||||
<br>
|
||||
(Esc to cancel)
|
||||
</p>
|
||||
<div class="animate-pulse text-blue-500 dark:text-blue-400 text-xl">
|
||||
Listening...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
});
|
||||
const emit = defineEmits(['captured', 'cancel']);
|
||||
|
||||
const overlay = ref(null);
|
||||
|
||||
const handleKeyPress = (event) => {
|
||||
if (!props.visible) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Prevent further propagation if overlay is active
|
||||
|
||||
let key = event.key;
|
||||
if (event.code === 'Space') {
|
||||
key = ' ';
|
||||
} else if (key.length > 1 && key !== ' ') { // Ignore modifiers, Enter, Tab etc.
|
||||
if (key === 'Escape') { // Handle Escape specifically for cancellation
|
||||
cancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
key = key.toLowerCase();
|
||||
emit('captured', key);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
watch(() => props.visible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await nextTick(); // Ensure overlay is in DOM
|
||||
overlay.value?.focus(); // Focus the overlay to capture keydown events directly
|
||||
document.addEventListener('keydown', handleKeyPress, { capture: true }); // Use capture phase
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleKeyPress, { capture: true });
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeyPress, { capture: true });
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Ensure overlay is above other modals if any (PlayerForm uses z-50) */
|
||||
.z-\[100\] {
|
||||
z-index: 100;
|
||||
}
|
||||
</style>
|
||||
@@ -1,65 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 bg-gray-900 bg-opacity-75 flex items-center justify-center z-[100]"
|
||||
@click.self="handleCancel"
|
||||
@keydown.esc="handleCancel"
|
||||
tabindex="0"
|
||||
ref="overlay"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 p-8 rounded-lg shadow-xl text-center">
|
||||
<h3 class="text-2xl font-semibold text-gray-800 dark:text-gray-100 mb-4">
|
||||
Send MQTT Signal
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||
Send a single character message to the MQTT topic '{{ MqttService.MQTT_TOPIC_GAME }}'.
|
||||
<br>
|
||||
(Press Esc on keyboard or click backdrop to cancel)
|
||||
</p>
|
||||
<div class="animate-pulse text-purple-500 dark:text-purple-400 text-xl">
|
||||
Listening for MQTT...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick, onUnmounted } from 'vue';
|
||||
import { MqttService } from '../services/MqttService'; // To display topic name
|
||||
|
||||
const props = defineProps({
|
||||
visible: Boolean,
|
||||
});
|
||||
const emit = defineEmits(['cancel']); // Only emits 'cancel', 'captured' will be handled by parent via service
|
||||
|
||||
const overlay = ref(null);
|
||||
|
||||
// No direct key press handling here for MQTT capture, parent will manage via MqttService
|
||||
const handleCancel = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
|
||||
watch(() => props.visible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await nextTick();
|
||||
overlay.value?.focus(); // Focus for Esc key to cancel
|
||||
// Add specific keyboard listener just for Esc on this overlay
|
||||
document.addEventListener('keydown', escKeyHandler);
|
||||
} else {
|
||||
document.removeEventListener('keydown', escKeyHandler);
|
||||
}
|
||||
});
|
||||
|
||||
// Specific Esc handler for this overlay
|
||||
const escKeyHandler = (event) => {
|
||||
if (props.visible && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', escKeyHandler);
|
||||
});
|
||||
</script>
|
||||
@@ -11,7 +11,7 @@
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="mb-4 md:mb-4 relative"
|
||||
class="mb-6 md:mb-4 relative"
|
||||
:style="{
|
||||
width: avatarSize.width,
|
||||
height: avatarSize.height,
|
||||
@@ -32,23 +32,27 @@
|
||||
</div>
|
||||
|
||||
<!-- Player Name -->
|
||||
<h2 class="font-semibold mb-4 md:mb-6 text-5xl sm:text-2xl md:text-3xl lg:text-5xl">
|
||||
<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';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
|
||||
|
||||
const vTouch = {
|
||||
mounted: (el, binding) => {
|
||||
@@ -97,26 +101,33 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['tapped', 'swiped-up']);
|
||||
|
||||
const avatarSize = ref({ width: '120px', height: '120px' });
|
||||
// --- 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');
|
||||
const isNormalModeContext = document.querySelector('.player-area')?.parentElement?.classList.contains('flex-col'); // Heuristic
|
||||
|
||||
if (isNormalModeContext) {
|
||||
const availableHeight = screenHeight / 2;
|
||||
let size = Math.min(availableHeight * 0.5, screenWidth * 0.4, 175);
|
||||
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.7, screenWidth * 0.6, 230);
|
||||
} else if (screenWidth < 1024) { // Tablet
|
||||
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' };
|
||||
}
|
||||
};
|
||||
@@ -130,6 +141,8 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateAvatarSize);
|
||||
});
|
||||
// --- End Responsive Avatar Size Logic ---
|
||||
|
||||
|
||||
const handleTap = () => {
|
||||
if (props.isCurrentPlayerArea && !props.player.isSkipped) {
|
||||
|
||||
@@ -5,210 +5,132 @@
|
||||
<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 mt-1">
|
||||
<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 mt-1">
|
||||
<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"/>
|
||||
<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">
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">"Pass Turn / My Pause" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="startCapturePlayerHotkey"
|
||||
class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span>{{ editablePlayer.hotkey ? editablePlayer.hotkey.toUpperCase() : '-' }}</span>
|
||||
</button>
|
||||
<button v-if="editablePlayer.hotkey" type="button" @click="clearPlayerHotkey" class="playerform-clear-btn">Clear</button>
|
||||
<span v-else class="playerform-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="startCapturePlayerMqttChar"
|
||||
class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-center"
|
||||
>
|
||||
<span>{{ editablePlayer.mqttChar ? editablePlayer.mqttChar.toUpperCase() : '-' }}</span>
|
||||
</button>
|
||||
<button v-if="editablePlayer.mqttChar" type="button" @click="clearPlayerMqttChar" class="playerform-clear-btn">Clear</button>
|
||||
<span v-else class="playerform-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</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 mt-6">
|
||||
<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>
|
||||
|
||||
<HotkeyCaptureOverlay
|
||||
:visible="isCapturingPlayerHotkey"
|
||||
@captured="handlePlayerHotkeyCaptured"
|
||||
@cancel="cancelCapturePlayerHotkey"
|
||||
/>
|
||||
<MqttCharCaptureOverlay
|
||||
:visible="isCapturingPlayerMqttChar"
|
||||
@cancel="cancelCapturePlayerMqttChar"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.playerform-clear-btn {
|
||||
@apply text-xs text-blue-500 hover:underline ml-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-600;
|
||||
min-width: 40px; /* Adjust to match "Clear" button width */
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
}
|
||||
.playerform-clear-btn-placeholder {
|
||||
@apply ml-2 px-2 py-1;
|
||||
min-width: 40px; /* Match width */
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted, onUnmounted, computed } from 'vue';
|
||||
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 HotkeyCaptureOverlay from './HotkeyCaptureOverlay.vue';
|
||||
import MqttCharCaptureOverlay from './MqttCharCaptureOverlay.vue';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue'; // Import SVG component
|
||||
import { AudioService } from '../services/AudioService';
|
||||
import { MqttService } from '../services/MqttService';
|
||||
|
||||
const props = defineProps({ player: Object });
|
||||
const props = defineProps({
|
||||
player: Object,
|
||||
});
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const store = useStore();
|
||||
const DEFAULT_AVATAR_MARKER = null;
|
||||
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, currentTimerSec: 3600,
|
||||
hotkey: '', mqttChar: ''
|
||||
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');
|
||||
|
||||
const currentTimeFormatted = ref('60:00'); // For "Remaining Time"
|
||||
const currentTimeFormatError = ref('');
|
||||
const cameraError = ref('');
|
||||
|
||||
const maxNegativeSeconds = computed(() => store.getters.maxNegativeTimeReached);
|
||||
|
||||
const isCapturingPlayerHotkey = ref(false);
|
||||
const isCapturingPlayerMqttChar = ref(false);
|
||||
|
||||
const startCapturePlayerHotkey = () => { isCapturingPlayerHotkey.value = true; };
|
||||
const cancelCapturePlayerHotkey = () => { isCapturingPlayerHotkey.value = false; };
|
||||
const clearPlayerHotkey = () => { editablePlayer.hotkey = ''; };
|
||||
|
||||
const startCapturePlayerMqttChar = () => {
|
||||
if (MqttService.connectionStatus.value !== 'connected') {
|
||||
alert('MQTT broker is not connected. Please connect in Setup first.');
|
||||
return;
|
||||
}
|
||||
isCapturingPlayerMqttChar.value = true;
|
||||
MqttService.startMqttCharCapture(handlePlayerMqttCharCaptured);
|
||||
};
|
||||
const cancelCapturePlayerMqttChar = () => {
|
||||
isCapturingPlayerMqttChar.value = false;
|
||||
MqttService.stopMqttCharCapture();
|
||||
};
|
||||
const clearPlayerMqttChar = () => { editablePlayer.mqttChar = ''; };
|
||||
|
||||
const checkGlobalConflict = (charKey) => {
|
||||
if (store.state.globalHotkeyStopPause === charKey) return `"${charKey.toUpperCase()}" is Global Stop/Pause Hotkey.`;
|
||||
if (store.state.globalHotkeyRunAll === charKey) return `"${charKey.toUpperCase()}" is Global Run All Hotkey.`;
|
||||
if (store.state.globalMqttStopPause === charKey) return `"${charKey.toUpperCase()}" is Global Stop/Pause MQTT.`;
|
||||
if (store.state.globalMqttRunAll === charKey) return `"${charKey.toUpperCase()}" is Global Run All MQTT.`;
|
||||
if (store.state.globalHotkeyPassTurn === charKey) return `"${charKey.toUpperCase()}" is Global Pass Turn Hotkey.`; // Check new global
|
||||
if (store.state.globalMqttPassTurn === charKey) return `"${charKey.toUpperCase()}" is Global Pass Turn MQTT.`; // Check new global
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePlayerHotkeyCaptured = (key) => {
|
||||
isCapturingPlayerHotkey.value = false;
|
||||
if (key.length !== 1) return;
|
||||
const globalConflictMsg = checkGlobalConflict(key);
|
||||
if (globalConflictMsg) { alert(globalConflictMsg); return; }
|
||||
const otherPlayerHotkeyConflict = store.state.players.find(p => p.id !== editablePlayer.id && p.hotkey === key);
|
||||
if (otherPlayerHotkeyConflict) { alert(`Hotkey "${key.toUpperCase()}" is already used by player "${otherPlayerHotkeyConflict.name}".`); return; }
|
||||
editablePlayer.hotkey = key;
|
||||
};
|
||||
|
||||
const handlePlayerMqttCharCaptured = (charKey) => {
|
||||
isCapturingPlayerMqttChar.value = false;
|
||||
if (charKey.length !== 1) return;
|
||||
const globalConflictMsg = checkGlobalConflict(charKey);
|
||||
if (globalConflictMsg) { alert(globalConflictMsg); return; }
|
||||
const otherPlayerMqttConflict = store.state.players.find(p => p.id !== editablePlayer.id && p.mqttChar === charKey);
|
||||
if (otherPlayerMqttConflict) { alert(`MQTT Char "${charKey.toUpperCase()}" is already used by player "${otherPlayerMqttConflict.name}".`); return; }
|
||||
editablePlayer.mqttChar = charKey;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (isEditing.value && props.player) {
|
||||
editablePlayer.id = props.player.id;
|
||||
editablePlayer.name = props.player.name;
|
||||
editablePlayer.avatar = props.player.avatar;
|
||||
editablePlayer.initialTimerSec = props.player.initialTimerSec;
|
||||
editablePlayer.currentTimerSec = props.player.currentTimerSec;
|
||||
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 || '';
|
||||
editablePlayer.mqttChar = props.player.mqttChar || '';
|
||||
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
|
||||
} else {
|
||||
} else { // Adding new player
|
||||
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
|
||||
editablePlayer.initialTimerSec = 3600;
|
||||
editablePlayer.currentTimerSec = 3600;
|
||||
editablePlayer.initialTimerSec = 3600; // Default initial for new player
|
||||
editablePlayer.currentTimerSec = 3600; // Default current for new player
|
||||
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (isCapturingPlayerMqttChar.value) {
|
||||
MqttService.stopMqttCharCapture();
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
const validateCurrentTimeFormat = () => {
|
||||
|
||||
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) {
|
||||
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)}.`;
|
||||
@@ -217,8 +139,10 @@ const validateCurrentTimeFormat = () => {
|
||||
currentTimeFormatError.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
const capturePhoto = async () => {
|
||||
}
|
||||
|
||||
|
||||
async function capturePhoto() {
|
||||
cameraError.value = '';
|
||||
try {
|
||||
AudioService.resumeContext();
|
||||
@@ -228,100 +152,68 @@ const capturePhoto = async () => {
|
||||
console.error('Failed to capture photo:', error);
|
||||
cameraError.value = error.message || 'Could not capture photo.';
|
||||
}
|
||||
};
|
||||
const useDefaultAvatar = () => { editablePlayer.avatar = DEFAULT_AVATAR_MARKER; };
|
||||
const submitForm = () => {
|
||||
validateCurrentTimeFormat();
|
||||
if (currentTimeFormatError.value) return;
|
||||
const playerPayload = { ...editablePlayer };
|
||||
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
|
||||
playerPayload.isSkipped = true;
|
||||
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
|
||||
playerPayload.isSkipped = false;
|
||||
}
|
||||
if (!isEditing.value) {
|
||||
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
|
||||
}
|
||||
emit('save', playerPayload);
|
||||
closeModal();
|
||||
};
|
||||
const closeModal = () => { emit('close'); };
|
||||
}
|
||||
|
||||
// Fill in full definitions from previous version if needed
|
||||
onMounted.value = () => {
|
||||
if (isEditing.value && props.player) {
|
||||
editablePlayer.id = props.player.id;
|
||||
editablePlayer.name = props.player.name;
|
||||
editablePlayer.avatar = props.player.avatar;
|
||||
editablePlayer.initialTimerSec = props.player.initialTimerSec;
|
||||
editablePlayer.currentTimerSec = props.player.currentTimerSec;
|
||||
editablePlayer.hotkey = props.player.hotkey || '';
|
||||
editablePlayer.mqttChar = props.player.mqttChar || '';
|
||||
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
|
||||
} else {
|
||||
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
|
||||
editablePlayer.initialTimerSec = 3600;
|
||||
editablePlayer.currentTimerSec = 3600;
|
||||
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
|
||||
}
|
||||
};
|
||||
onUnmounted.value = () => {
|
||||
if (isCapturingPlayerMqttChar.value) {
|
||||
MqttService.stopMqttCharCapture();
|
||||
}
|
||||
};
|
||||
watch(currentTimeFormatted, (newTime) => {
|
||||
validateCurrentTimeFormat();
|
||||
if (!currentTimeFormatError.value) {
|
||||
editablePlayer.currentTimerSec = parseTime(newTime);
|
||||
if (!isEditing.value) {
|
||||
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
|
||||
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;
|
||||
}
|
||||
}
|
||||
});
|
||||
validateCurrentTimeFormat.value = () => {
|
||||
const time = currentTimeFormatted.value;
|
||||
const isNegativeInput = time.startsWith('-');
|
||||
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) {
|
||||
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 = '';
|
||||
}
|
||||
if (globalHotkeyInUse) {
|
||||
alert(`Hotkey "${key.toUpperCase()}" is already assigned as the Global Stop/Pause hotkey.`);
|
||||
return;
|
||||
}
|
||||
editablePlayer.hotkey = key;
|
||||
}
|
||||
};
|
||||
capturePhoto.value = async () => {
|
||||
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.';
|
||||
}
|
||||
};
|
||||
useDefaultAvatar.value = () => { editablePlayer.avatar = DEFAULT_AVATAR_MARKER; };
|
||||
submitForm.value = () => {
|
||||
}
|
||||
|
||||
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();
|
||||
};
|
||||
closeModal.value = () => { emit('close'); };
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
@@ -1,35 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['player-list-item flex items-center justify-between p-3 my-2 rounded-lg shadow cursor-pointer transition-all duration-200 ease-in-out',
|
||||
itemStateClasses,
|
||||
{ 'opacity-50 filter grayscale contrast-75': player.isSkipped },
|
||||
{ 'opacity-75': !player.isTimerRunning && !player.isSkipped }
|
||||
]"
|
||||
: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-400 dark:border-gray-600' :
|
||||
player.isTimerRunning ? 'border-green-400 dark:border-green-500' :
|
||||
'border-yellow-400 dark:border-yellow-500'"
|
||||
: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 p-1"
|
||||
:class="player.isSkipped ? 'text-gray-400 bg-gray-200 border-gray-400 dark:text-gray-600 dark:bg-gray-700 dark:border-gray-600' :
|
||||
player.isTimerRunning ? 'text-green-600 bg-green-100 border-green-400 dark:text-green-300 dark:bg-green-800 dark:border-green-500' :
|
||||
'text-yellow-600 bg-yellow-50 border-yellow-400 dark:text-yellow-300 dark:bg-yellow-800 dark:border-yellow-500'"
|
||||
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" :class="{'text-gray-500 dark:text-gray-400': !player.isTimerRunning && !player.isSkipped}">
|
||||
{{ player.name }}
|
||||
</h3>
|
||||
<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>
|
||||
<p v-else-if="!player.isTimerRunning" class="text-xs text-yellow-600 dark:text-yellow-400">Paused</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end">
|
||||
@@ -38,7 +32,6 @@
|
||||
:is-negative="player.currentTimerSec < 0"
|
||||
:is-pulsating="player.isTimerRunning"
|
||||
class="text-2xl"
|
||||
:class="{'opacity-80': !player.isTimerRunning && !player.isSkipped}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,8 +40,9 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import TimerDisplay from './TimerDisplay.vue';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue'; // Import the SVG component
|
||||
|
||||
// ... (rest of script setup: props, emit, handleTap, itemBgClass)
|
||||
const props = defineProps({
|
||||
player: {
|
||||
type: Object,
|
||||
@@ -59,22 +53,20 @@ const props = defineProps({
|
||||
const emit = defineEmits(['tapped']);
|
||||
|
||||
const handleTap = () => {
|
||||
// Allow tapping only if not skipped, to pause/resume their timer
|
||||
if (!props.player.isSkipped) {
|
||||
emit('tapped');
|
||||
}
|
||||
};
|
||||
|
||||
const itemStateClasses = computed(() => {
|
||||
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-400 dark:border-red-600 animate-pulsePositive' // Pulsate background if running positive
|
||||
: 'bg-green-50 dark:bg-green-900/70 border border-green-400 dark:border-green-600 animate-pulsePositive';
|
||||
? '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';
|
||||
}
|
||||
// Paused state
|
||||
return 'bg-yellow-50 dark:bg-yellow-900/50 border border-yellow-300 dark:border-yellow-700 hover:bg-yellow-100 dark:hover:bg-yellow-900/70';
|
||||
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>
|
||||
@@ -1,59 +1,37 @@
|
||||
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';
|
||||
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,
|
||||
beforeEnter: (to, from, next) => {
|
||||
// Check if we are navigating FROM the Game view.
|
||||
// If so, the user explicitly clicked the Setup button, so allow it.
|
||||
if (from.name === 'Game') {
|
||||
console.log('Router Guard: Allowing navigation from Game to Setup.');
|
||||
next(); // Allow navigation to Setup
|
||||
return; // Stop further processing of this guard
|
||||
}
|
||||
|
||||
// Original logic for initial load or other navigations TO Setup:
|
||||
if (store.state.players && store.state.players.length >= 2) {
|
||||
// If 2 or more players exist (and not coming from Game), redirect to Game.
|
||||
console.log('Router Guard: Players found, redirecting to Game (not coming from Game).');
|
||||
next({ name: 'Game', replace: true });
|
||||
} else {
|
||||
// Otherwise (fewer than 2 players), allow navigation to the Setup view.
|
||||
console.log('Router Guard: Not enough players, proceeding to Setup.');
|
||||
next();
|
||||
}
|
||||
}
|
||||
component: SetupView
|
||||
},
|
||||
{
|
||||
path: '/game',
|
||||
name: 'Game',
|
||||
component: GameView,
|
||||
beforeEnter: (to, from, next) => {
|
||||
// Keep this guard: prevent direct access to /game without enough players
|
||||
if (!store.state.players || store.state.players.length < 2) {
|
||||
console.log('Router Guard: Attempted to access Game without enough players, redirecting to Setup.');
|
||||
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;
|
||||
export default router
|
||||
@@ -1,164 +0,0 @@
|
||||
// src/services/MqttService.js
|
||||
import mqtt from 'mqtt';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const client = ref(null);
|
||||
const connectionStatus = ref('disconnected');
|
||||
const error = ref(null);
|
||||
const receivedMessages = ref([]); // For debugging
|
||||
const MQTT_TOPIC_GAME = 'game';
|
||||
|
||||
let generalMessageHandlerCallback = null; // For App.vue to handle game commands
|
||||
|
||||
// --- MQTT Character Capture State ---
|
||||
const isCapturingMqttChar = ref(false);
|
||||
const mqttCharCaptureCallback = ref(null); // Function to call when a char is captured
|
||||
|
||||
const startMqttCharCapture = (onCapturedCallback) => {
|
||||
console.log("MQTT Service: Starting character capture mode.");
|
||||
isCapturingMqttChar.value = true;
|
||||
mqttCharCaptureCallback.value = onCapturedCallback;
|
||||
};
|
||||
|
||||
const stopMqttCharCapture = () => {
|
||||
console.log("MQTT Service: Stopping character capture mode.");
|
||||
isCapturingMqttChar.value = false;
|
||||
mqttCharCaptureCallback.value = null;
|
||||
};
|
||||
// --- End MQTT Character Capture State ---
|
||||
|
||||
const connect = async (brokerUrl = 'ws://localhost:9001') => {
|
||||
// ... (existing connect logic)
|
||||
if (client.value && client.value.connected) { return; }
|
||||
if (connectionStatus.value === 'connecting') { return; }
|
||||
console.log(`MQTT: Attempting to connect to ${brokerUrl}...`);
|
||||
connectionStatus.value = 'connecting';
|
||||
error.value = null;
|
||||
let fullBrokerUrl = brokerUrl;
|
||||
if (!brokerUrl.startsWith('ws://') && !brokerUrl.startsWith('wss://')) {
|
||||
if (brokerUrl.includes(':1883')) {
|
||||
fullBrokerUrl = `ws://${brokerUrl}`;
|
||||
} else if (!brokerUrl.includes(':')) {
|
||||
fullBrokerUrl = `ws://${brokerUrl}:9001`;
|
||||
} else {
|
||||
fullBrokerUrl = `ws://${brokerUrl}`;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const connectFn = typeof mqtt === 'function' ? mqtt : (mqtt.connect || (mqtt.default && mqtt.default.connect));
|
||||
if (typeof connectFn !== 'function') {
|
||||
throw new Error("MQTT connect function not found.");
|
||||
}
|
||||
client.value = connectFn(fullBrokerUrl, {
|
||||
reconnectPeriod: 5000,
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
client.value.on('connect', () => {
|
||||
console.log('MQTT: Connected!');
|
||||
connectionStatus.value = 'connected';
|
||||
error.value = null;
|
||||
client.value.subscribe(MQTT_TOPIC_GAME, (err) => {
|
||||
if (!err) { console.log(`MQTT: Subscribed to "${MQTT_TOPIC_GAME}"`); }
|
||||
else {
|
||||
console.error('MQTT: Subscr. error:', err);
|
||||
connectionStatus.value = 'error'; error.value = 'Subscription failed.';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
client.value.on('message', (topic, message) => {
|
||||
const msgString = message.toString();
|
||||
const char = msgString.charAt(0).toLowerCase(); // Process as lowercase single char
|
||||
console.log(`MQTT: Received on "${topic}": '${msgString}' -> processed char: '${char}'`);
|
||||
receivedMessages.value.push({ topic, message: msgString, time: new Date() });
|
||||
|
||||
if (topic === MQTT_TOPIC_GAME && char.length === 1) {
|
||||
if (isCapturingMqttChar.value && mqttCharCaptureCallback.value) {
|
||||
console.log(`MQTT Service: Captured char '${char}' for registration.`);
|
||||
mqttCharCaptureCallback.value(char); // Pass char to the specific capture handler
|
||||
stopMqttCharCapture(); // Stop capture mode after one char
|
||||
} else if (generalMessageHandlerCallback) {
|
||||
// Normal message processing if not in capture mode
|
||||
generalMessageHandlerCallback(char);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
client.value.on('error', (err) => { /* ... existing error handling ... */
|
||||
console.error('MQTT: Connection error:', err);
|
||||
connectionStatus.value = 'error';
|
||||
error.value = err.message || 'Connection error';
|
||||
});
|
||||
client.value.on('reconnect', () => { /* ... existing reconnect handling ... */
|
||||
console.log('MQTT: Reconnecting...');
|
||||
connectionStatus.value = 'connecting';
|
||||
});
|
||||
client.value.on('offline', () => { /* ... existing offline handling ... */
|
||||
console.log('MQTT: Client offline.');
|
||||
});
|
||||
client.value.on('close', () => { /* ... existing close handling ... */
|
||||
console.log('MQTT: Connection closed.');
|
||||
if (connectionStatus.value !== 'error' && connectionStatus.value !== 'connecting') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) { /* ... existing catch ... */
|
||||
console.error('MQTT: Setup error during connect call:', err);
|
||||
connectionStatus.value = 'error';
|
||||
error.value = err.message || 'Setup failed.';
|
||||
if(client.value && typeof client.value.end === 'function') client.value.end(true);
|
||||
client.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
if (client.value) {
|
||||
console.log('MQTT: Disconnecting/Stopping connection attempt...');
|
||||
// Set status immediately to give user feedback, 'close' event will confirm
|
||||
// but if it was 'connecting', it might not emit 'close' if it never truly connected.
|
||||
const wasConnecting = connectionStatus.value === 'connecting';
|
||||
|
||||
client.value.end(true, () => { // true forces close and stops reconnect attempts
|
||||
console.log('MQTT: client.end() callback executed.');
|
||||
// The 'close' event listener on the client should handle final cleanup
|
||||
// like setting client.value = null and connectionStatus.value = 'disconnected'.
|
||||
// If it was just 'connecting' and never connected, 'close' might not fire reliably.
|
||||
if (wasConnecting && connectionStatus.value !== 'disconnected') {
|
||||
connectionStatus.value = 'disconnected';
|
||||
client.value = null; // Ensure cleanup if 'close' doesn't fire from 'connecting' state
|
||||
}
|
||||
});
|
||||
// If it was 'connecting', we might want to immediately reflect disconnected state
|
||||
// as 'end(true)' stops further attempts.
|
||||
if (wasConnecting) {
|
||||
connectionStatus.value = 'disconnected';
|
||||
// Note: client.value will be fully nulled on the 'close' event or after end() callback.
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('MQTT: No active client to disconnect.');
|
||||
connectionStatus.value = 'disconnected'; // Ensure status is correct
|
||||
}
|
||||
};
|
||||
|
||||
const setGeneralMessageHandler = (handler) => { // Renamed for clarity
|
||||
generalMessageHandlerCallback = handler;
|
||||
};
|
||||
|
||||
export const MqttService = {
|
||||
connect,
|
||||
disconnect,
|
||||
setGeneralMessageHandler, // Use this for App.vue game commands
|
||||
connectionStatus,
|
||||
error,
|
||||
receivedMessages,
|
||||
MQTT_TOPIC_GAME,
|
||||
getClient: () => client.value,
|
||||
// New methods for capture mode
|
||||
startMqttCharCapture,
|
||||
stopMqttCharCapture,
|
||||
isCapturingMqttChar // Expose reactive state if needed elsewhere, though not directly used by components
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
let wakeLock = null;
|
||||
let wakeLockActive = false;
|
||||
|
||||
const requestWakeLock = async () => {
|
||||
if ('wakeLock' in navigator && !wakeLockActive) {
|
||||
try {
|
||||
wakeLock = await navigator.wakeLock.request('screen');
|
||||
wakeLockActive = true;
|
||||
console.log('Screen Wake Lock activated.');
|
||||
|
||||
wakeLock.addEventListener('release', () => {
|
||||
console.log('Screen Wake Lock was released.');
|
||||
wakeLockActive = false;
|
||||
wakeLock = null; // Clear the reference
|
||||
// Optionally, re-request if it was released unexpectedly and should be active
|
||||
// For now, we'll let it be re-requested manually by the app logic
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`Failed to acquire Screen Wake Lock: ${err.name}, ${err.message}`);
|
||||
wakeLock = null;
|
||||
wakeLockActive = false;
|
||||
}
|
||||
} else {
|
||||
console.warn('Screen Wake Lock API not supported or already active.');
|
||||
}
|
||||
};
|
||||
|
||||
const releaseWakeLock = async () => {
|
||||
if (wakeLock && wakeLockActive) {
|
||||
try {
|
||||
await wakeLock.release();
|
||||
// The 'release' event listener on wakeLock itself will set wakeLockActive = false and wakeLock = null
|
||||
} catch (err) {
|
||||
console.error(`Failed to release Screen Wake Lock: ${err.name}, ${err.message}`);
|
||||
// Even if release fails, mark as inactive to allow re-request
|
||||
wakeLock = null;
|
||||
wakeLockActive = false;
|
||||
}
|
||||
} else {
|
||||
// console.log('No active Screen Wake Lock to release or already released.');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle visibility changes to re-acquire lock if necessary
|
||||
const handleVisibilityChange = () => {
|
||||
if (wakeLock !== null && document.visibilityState === 'visible') {
|
||||
// If we had a wake lock and the page became visible again,
|
||||
// it might have been released by the browser. Try to re-acquire.
|
||||
// This behavior is usually handled automatically by the browser with the 'release' event
|
||||
// but can be a fallback. For now, we rely on manual re-request.
|
||||
// console.log('Page visible, checking wake lock status.');
|
||||
} else if (document.visibilityState === 'hidden' && wakeLockActive) {
|
||||
// The browser usually releases the wake lock when tab is hidden.
|
||||
// Our 'release' event listener should handle this.
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
// document.addEventListener('fullscreenchange', handleVisibilityChange); // Also useful for fullscreen
|
||||
|
||||
export const WakeLockService = {
|
||||
request: requestWakeLock,
|
||||
release: releaseWakeLock,
|
||||
isActive: () => wakeLockActive,
|
||||
};
|
||||
@@ -1,16 +1,16 @@
|
||||
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
|
||||
const DEFAULT_AVATAR_MARKER = null; // Ensure this is defined
|
||||
|
||||
// Define predefined players
|
||||
const predefinedPlayers = [
|
||||
{
|
||||
id: 'predefined-1', // Unique ID for predefined player 1
|
||||
name: 'Player 1',
|
||||
avatar: DEFAULT_AVATAR_MARKER, // Or a specific avatar path if you have one
|
||||
avatar: defaultAvatar, // Or a specific avatar path if you have one
|
||||
initialTimerSec: 60 * 60, // 60:00
|
||||
currentTimerSec: 60 * 60,
|
||||
hotkey: '1', // Hotkey '1'
|
||||
@@ -20,7 +20,7 @@ const predefinedPlayers = [
|
||||
{
|
||||
id: 'predefined-2', // Unique ID for predefined player 2
|
||||
name: 'Player 2',
|
||||
avatar: DEFAULT_AVATAR_MARKER, // Or a specific avatar path
|
||||
avatar: defaultAvatar, // Or a specific avatar path
|
||||
initialTimerSec: 60 * 60, // 60:00
|
||||
currentTimerSec: 60 * 60,
|
||||
hotkey: '2', // Hotkey '2'
|
||||
@@ -32,84 +32,72 @@ const predefinedPlayers = [
|
||||
const initialState = {
|
||||
players: JSON.parse(JSON.stringify(predefinedPlayers)), // Start with predefined players (deep copy)
|
||||
globalHotkeyStopPause: null,
|
||||
globalHotkeyRunAll: null,
|
||||
globalHotkeyPassTurn: null,
|
||||
globalMqttStopPause: null,
|
||||
globalMqttRunAll: null,
|
||||
globalMqttPassTurn: null,
|
||||
mqttBrokerUrl: 'ws://localhost:9001',
|
||||
mqttConnectDesired: false,
|
||||
currentPlayerIndex: 0,
|
||||
gameMode: 'normal',
|
||||
isMuted: false,
|
||||
theme: 'dark',
|
||||
theme: 'light',
|
||||
gameRunning: false,
|
||||
};
|
||||
|
||||
// Helper function to create a new player object
|
||||
const createPlayerObject = (playerData = {}) => ({
|
||||
id: playerData.id || Date.now().toString() + Math.random(),
|
||||
name: playerData.name || `Player ${store.state.players.length + 1}`, // Access store carefully here or pass length
|
||||
avatar: playerData.avatar === undefined ? DEFAULT_AVATAR_MARKER : playerData.avatar,
|
||||
initialTimerSec: playerData.initialTimerSec || 3600,
|
||||
currentTimerSec: playerData.currentTimerSec || playerData.initialTimerSec || 3600,
|
||||
hotkey: playerData.hotkey || null,
|
||||
mqttChar: playerData.mqttChar || null, // New
|
||||
isSkipped: playerData.isSkipped || false,
|
||||
isTimerRunning: playerData.isTimerRunning || false,
|
||||
});
|
||||
|
||||
export default createStore({
|
||||
state: () => {
|
||||
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.map(p => createPlayerObject(p))));
|
||||
playersToUse = JSON.parse(JSON.stringify(predefinedPlayers));
|
||||
} else if (persistedState.hasOwnProperty('players') && playersToUse.length === 0) {
|
||||
playersToUse = [];
|
||||
}
|
||||
|
||||
playersToUse = playersToUse.map(p_persisted => {
|
||||
const p_base = predefinedPlayers.find(p_def => p_def.id === p_persisted.id) || {};
|
||||
return createPlayerObject({ ...p_base, ...p_persisted, isTimerRunning: false });
|
||||
});
|
||||
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, // Start with all initial state defaults
|
||||
...persistedState, // Override with persisted values
|
||||
players: playersToUse, // Specifically set processed players
|
||||
globalHotkeyPassTurn: persistedState.globalHotkeyPassTurn || initialState.globalHotkeyPassTurn,
|
||||
globalMqttPassTurn: persistedState.globalMqttPassTurn || initialState.globalMqttPassTurn,
|
||||
globalHotkeyStopPause: persistedState.globalHotkeyStopPause || initialState.globalHotkeyStopPause,
|
||||
globalMqttStopPause: persistedState.globalMqttStopPause || initialState.globalMqttStopPause,
|
||||
globalHotkeyRunAll: persistedState.globalHotkeyRunAll || initialState.globalHotkeyRunAll,
|
||||
globalMqttRunAll: persistedState.globalMqttRunAll || initialState.globalMqttRunAll,
|
||||
mqttBrokerUrl: persistedState.mqttBrokerUrl || initialState.mqttBrokerUrl,
|
||||
mqttConnectDesired: persistedState.hasOwnProperty('mqttConnectDesired') ? persistedState.mqttConnectDesired : initialState.mqttConnectDesired,
|
||||
gameRunning: false, // Always start non-running
|
||||
...initialState,
|
||||
...persistedState,
|
||||
players: playersToUse,
|
||||
gameRunning: false,
|
||||
currentPlayerIndex: persistedState.currentPlayerIndex !== undefined && persistedState.currentPlayerIndex < playersToUse.length ? persistedState.currentPlayerIndex : 0,
|
||||
};
|
||||
}
|
||||
// If no persisted state, deep copy initialState which includes new MQTT fields
|
||||
const newInitialState = JSON.parse(JSON.stringify(initialState));
|
||||
newInitialState.players = newInitialState.players.map(p => createPlayerObject(p));
|
||||
return newInitialState;
|
||||
return JSON.parse(JSON.stringify(initialState));
|
||||
},
|
||||
mutations: {
|
||||
ADD_PLAYER(state, playerConfig) {
|
||||
if (state.players.length < 99) {
|
||||
const newPlayer = createPlayerObject({
|
||||
name: playerConfig.name,
|
||||
avatar: playerConfig.avatar,
|
||||
initialTimerSec: playerConfig.initialTimerSec,
|
||||
currentTimerSec: playerConfig.currentTimerSec, // Set from form
|
||||
hotkey: playerConfig.hotkey,
|
||||
mqttChar: playerConfig.mqttChar // From form
|
||||
});
|
||||
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);
|
||||
} else {
|
||||
alert("Maximum player limit (99) reached.");
|
||||
}
|
||||
},
|
||||
UPDATE_PLAYER(state, updatedPlayer) {
|
||||
@@ -118,35 +106,6 @@ export default createStore({
|
||||
state.players[index] = { ...state.players[index], ...updatedPlayer };
|
||||
}
|
||||
},
|
||||
SET_MQTT_BROKER_URL(state, url) {
|
||||
state.mqttBrokerUrl = url;
|
||||
state.mqttConnectDesired = true;
|
||||
},
|
||||
SET_MQTT_CONNECT_DESIRED(state, desired) {
|
||||
state.mqttConnectDesired = desired;
|
||||
},
|
||||
SET_GLOBAL_HOTKEY_STOP_PAUSE(state, key) {
|
||||
state.globalHotkeyStopPause = key;
|
||||
},
|
||||
SET_GLOBAL_HOTKEY_RUN_ALL(state, key) {
|
||||
state.globalHotkeyRunAll = key;
|
||||
},
|
||||
SET_GLOBAL_HOTKEY_PASS_TURN(state, key) {
|
||||
state.globalHotkeyPassTurn = key;
|
||||
},
|
||||
SET_GLOBAL_MQTT_PASS_TURN(state, char) {
|
||||
state.globalMqttPassTurn = char;
|
||||
},
|
||||
SET_GLOBAL_MQTT_STOP_PAUSE(state, char) {
|
||||
state.globalMqttStopPause = char;
|
||||
},
|
||||
SET_GLOBAL_MQTT_RUN_ALL(state, char) {
|
||||
state.globalMqttRunAll = char;
|
||||
},
|
||||
SET_PLAYERS(state, playersData) { // Used by fullResetApp and potentially reorder
|
||||
state.players = playersData.map(p => createPlayerObject(p));
|
||||
},
|
||||
SET_THEME(state, theme) { state.theme = theme; },
|
||||
DELETE_PLAYER(state, playerId) {
|
||||
state.players = state.players.filter(p => p.id !== playerId);
|
||||
if (state.currentPlayerIndex >= state.players.length && state.players.length > 0) {
|
||||
@@ -179,8 +138,8 @@ export default createStore({
|
||||
TOGGLE_THEME(state) {
|
||||
state.theme = state.theme === 'light' ? 'dark' : 'light';
|
||||
},
|
||||
SET_THEME(state, theme) {
|
||||
state.theme = theme;
|
||||
SET_GLOBAL_HOTKEY_STOP_PAUSE(state, key) {
|
||||
state.globalHotkeyStopPause = key;
|
||||
},
|
||||
DECREMENT_TIMER(state, { playerIndex }) {
|
||||
const player = state.players[playerIndex];
|
||||
@@ -259,20 +218,16 @@ export default createStore({
|
||||
},
|
||||
saveState({ state }) {
|
||||
StorageService.saveState({
|
||||
players: state.players.map(p => ({ // Persist mqttChar for players
|
||||
id: p.id, name: p.name, avatar: p.avatar,
|
||||
initialTimerSec: p.initialTimerSec, currentTimerSec: p.currentTimerSec,
|
||||
hotkey: p.hotkey, mqttChar: p.mqttChar, // Save mqttChar
|
||||
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,
|
||||
})),
|
||||
globalHotkeyPassTurn: state.globalHotkeyPassTurn,
|
||||
globalMqttPassTurn: state.globalMqttPassTurn,
|
||||
globalHotkeyStopPause: state.globalHotkeyStopPause,
|
||||
globalHotkeyRunAll: state.globalHotkeyRunAll,
|
||||
globalMqttStopPause: state.globalMqttStopPause,
|
||||
globalMqttRunAll: state.globalMqttRunAll,
|
||||
mqttBrokerUrl: state.mqttBrokerUrl,
|
||||
mqttConnectDesired: state.mqttConnectDesired,
|
||||
currentPlayerIndex: state.currentPlayerIndex,
|
||||
gameMode: state.gameMode,
|
||||
isMuted: state.isMuted,
|
||||
@@ -283,30 +238,6 @@ export default createStore({
|
||||
commit('ADD_PLAYER', player);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setMqttBrokerUrl({ commit, dispatch }, url) {
|
||||
commit('SET_MQTT_BROKER_URL', url);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setMqttConnectDesired({ commit, dispatch }, desired) {
|
||||
commit('SET_MQTT_CONNECT_DESIRED', desired);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setGlobalMqttStopPause({ commit, dispatch }, char) {
|
||||
commit('SET_GLOBAL_MQTT_STOP_PAUSE', char);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setGlobalMqttRunAll({ commit, dispatch }, char) {
|
||||
commit('SET_GLOBAL_MQTT_RUN_ALL', char);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setGlobalHotkeyPassTurn({ commit, dispatch }, key) {
|
||||
commit('SET_GLOBAL_HOTKEY_PASS_TURN', key);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setGlobalMqttPassTurn({ commit, dispatch }, char) {
|
||||
commit('SET_GLOBAL_MQTT_PASS_TURN', char);
|
||||
dispatch('saveState');
|
||||
},
|
||||
updatePlayer({ commit, dispatch }, player) {
|
||||
commit('UPDATE_PLAYER', player);
|
||||
dispatch('saveState');
|
||||
@@ -315,10 +246,10 @@ export default createStore({
|
||||
commit('DELETE_PLAYER', playerId);
|
||||
dispatch('saveState');
|
||||
},
|
||||
reorderPlayers({commit, dispatch}, players) {
|
||||
reorderPlayers({commit, dispatch}, players) { // This was in an earlier version
|
||||
commit('REORDER_PLAYERS', players);
|
||||
dispatch('saveState');
|
||||
},
|
||||
},
|
||||
shufflePlayers({commit, dispatch}) {
|
||||
commit('SHUFFLE_PLAYERS');
|
||||
dispatch('saveState');
|
||||
@@ -339,41 +270,27 @@ export default createStore({
|
||||
commit('SET_IS_MUTED', muted);
|
||||
dispatch('saveState');
|
||||
},
|
||||
resetGame({ commit, dispatch }) {
|
||||
resetGame({ commit, dispatch }) { // This is for resetting timers during a game session
|
||||
commit('RESET_ALL_TIMERS');
|
||||
dispatch('saveState');
|
||||
},
|
||||
setGlobalHotkeyStopPause({ commit, dispatch }, key) {
|
||||
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', key);
|
||||
dispatch('saveState');
|
||||
},
|
||||
setGlobalHotkeyRunAll({ commit, dispatch }, key) {
|
||||
commit('SET_GLOBAL_HOTKEY_RUN_ALL', key);
|
||||
dispatch('saveState');
|
||||
},
|
||||
fullResetApp({ commit, dispatch, state: currentGlobalState }) {
|
||||
// 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));
|
||||
freshInitialState.players = freshInitialState.players.map(p => createPlayerObject(p));
|
||||
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_MQTT_BROKER_URL', freshInitialState.mqttBrokerUrl);
|
||||
commit('SET_MQTT_CONNECT_DESIRED', freshInitialState.mqttConnectDesired);
|
||||
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', freshInitialState.globalHotkeyStopPause);
|
||||
commit('SET_GLOBAL_MQTT_STOP_PAUSE', freshInitialState.globalMqttStopPause);
|
||||
commit('SET_GLOBAL_HOTKEY_RUN_ALL', freshInitialState.globalHotkeyRunAll);
|
||||
commit('SET_GLOBAL_MQTT_RUN_ALL', freshInitialState.globalMqttRunAll);
|
||||
commit('SET_GLOBAL_HOTKEY_PASS_TURN', freshInitialState.globalHotkeyPassTurn);
|
||||
commit('SET_GLOBAL_MQTT_PASS_TURN', freshInitialState.globalMqttPassTurn);
|
||||
|
||||
// Directly set theme instead of toggling
|
||||
if (currentGlobalState.theme !== freshInitialState.theme) {
|
||||
commit('SET_THEME', freshInitialState.theme);
|
||||
}
|
||||
commit('SET_GAME_RUNNING', false);
|
||||
dispatch('saveState');
|
||||
dispatch('saveState'); // Save this fresh state
|
||||
},
|
||||
tick({ commit, state }) {
|
||||
if (state.gameMode === 'normal') {
|
||||
@@ -516,14 +433,7 @@ export default createStore({
|
||||
gameMode: state => state.gameMode,
|
||||
isMuted: state => state.isMuted,
|
||||
theme: state => state.theme,
|
||||
mqttBrokerUrl: state => state.mqttBrokerUrl,
|
||||
mqttConnectDesired: state => state.mqttConnectDesired,
|
||||
globalHotkeyStopPause: state => state.globalHotkeyStopPause,
|
||||
globalMqttStopPause: state => state.globalMqttStopPause,
|
||||
globalHotkeyRunAll: state => state.globalHotkeyRunAll,
|
||||
globalMqttRunAll: state => state.globalMqttRunAll,
|
||||
globalHotkeyPassTurn: state => state.globalHotkeyPassTurn,
|
||||
globalMqttPassTurn: state => state.globalMqttPassTurn,
|
||||
totalPlayers: state => state.players.length,
|
||||
gameRunning: state => state.gameRunning,
|
||||
maxNegativeTimeReached: () => MAX_NEGATIVE_SECONDS,
|
||||
|
||||
166
src/sw.js
@@ -1,166 +0,0 @@
|
||||
const CACHE_VERSION = typeof __APP_CACHE_VERSION__ !== 'undefined'
|
||||
? __APP_CACHE_VERSION__
|
||||
: 'nexus-timer-cache-fallback-dev-vManual';
|
||||
|
||||
const APP_SHELL_URLS = [
|
||||
// Precache the root (index.html) explicitly for better offline fallback
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/favicon.ico',
|
||||
'/icons/icon-192x192.png',
|
||||
'/icons/icon-512x512.png',
|
||||
'/icons/maskable-icon-192x192.png',
|
||||
'/icons/maskable-icon-512x512.png',
|
||||
'/icons/shortcut-setup-96x96.png',
|
||||
'/icons/shortcut-info-96x96.png',
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
console.log(`[SW ${CACHE_VERSION}] Install`);
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_VERSION)
|
||||
.then(cache => {
|
||||
console.log(`[SW ${CACHE_VERSION}] Caching app shell essentials`);
|
||||
return cache.addAll(APP_SHELL_URLS);
|
||||
})
|
||||
.then(() => {
|
||||
console.log(`[SW ${CACHE_VERSION}] Skip waiting on install.`);
|
||||
return self.skipWaiting();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
console.log(`[SW ${CACHE_VERSION}] Activate`);
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_VERSION) {
|
||||
console.log(`[SW ${CACHE_VERSION}] Deleting old cache: ${cacheName}`);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
}).then(() => {
|
||||
console.log(`[SW ${CACHE_VERSION}] Clients claimed.`);
|
||||
return self.clients.claim();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method !== 'GET' || !url.protocol.startsWith('http')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 1: Network First, then Cache for Navigation/HTML requests
|
||||
if (request.mode === 'navigate' || request.destination === 'document' || url.pathname === '/') {
|
||||
// console.log(`[SW ${CACHE_VERSION}] NetworkFirst for navigation/document: ${request.url}`);
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(networkResponse => {
|
||||
// If successful, cache the response and return it
|
||||
if (networkResponse.ok) {
|
||||
const responseToCache = networkResponse.clone();
|
||||
caches.open(CACHE_VERSION).then(cache => {
|
||||
// For navigations, it's often best to cache the specific URL requested
|
||||
// as well as potentially updating the '/' cache if this is the root.
|
||||
cache.put(request, responseToCache);
|
||||
if (url.pathname === '/') { // Also update root cache if it's the index
|
||||
const rootResponseClone = networkResponse.clone(); // Need another clone
|
||||
cache.put('/', rootResponseClone);
|
||||
}
|
||||
});
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(async () => {
|
||||
// Network failed. Try to serve from cache.
|
||||
// console.warn(`[SW ${CACHE_VERSION}] Network fetch failed for ${request.url}. Attempting cache.`);
|
||||
|
||||
// 1. Try matching the specific request first (e.g. /info, /game)
|
||||
const cachedResponse = await caches.match(request);
|
||||
if (cachedResponse) {
|
||||
// console.log(`[SW ${CACHE_VERSION}] Serving from cache (specific request): ${request.url}`);
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// 2. If specific request not found, try serving the app shell ('/')
|
||||
// This is crucial for SPAs to work offline.
|
||||
const appShellResponse = await caches.match('/');
|
||||
if (appShellResponse) {
|
||||
// console.log(`[SW ${CACHE_VERSION}] Serving app shell ('/') from cache for: ${request.url}`);
|
||||
return appShellResponse;
|
||||
}
|
||||
|
||||
// 3. If even the app shell is not in cache (shouldn't happen if install was successful)
|
||||
console.error(`[SW ${CACHE_VERSION}] CRITICAL: Network and cache miss for navigation AND app shell ('/') for: ${request.url}`);
|
||||
// Return a very basic offline message, but ideally this state is avoided.
|
||||
return new Response(
|
||||
`<h1>Offline</h1><p>The application is currently offline and the requested page could not be loaded from the cache. Please check your connection.</p>`,
|
||||
{ headers: { 'Content-Type': 'text/html' } }
|
||||
);
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Stale-While-Revalidate for assets (CSS, JS, images, fonts, workers)
|
||||
if (request.destination === 'style' ||
|
||||
request.destination === 'script' ||
|
||||
request.destination === 'worker' ||
|
||||
request.destination === 'image' ||
|
||||
request.destination === 'font') {
|
||||
// console.log(`[SW ${CACHE_VERSION}] StaleWhileRevalidate for asset: ${request.url}`);
|
||||
event.respondWith(
|
||||
caches.open(CACHE_VERSION).then(cache => {
|
||||
return cache.match(request).then(cachedResponse => {
|
||||
const fetchPromise = fetch(request).then(networkResponse => {
|
||||
if (networkResponse.ok) {
|
||||
const responseToCache = networkResponse.clone();
|
||||
cache.put(request, responseToCache);
|
||||
}
|
||||
return networkResponse;
|
||||
}).catch(err => {
|
||||
// If fetch fails, and we served from cache, it's fine.
|
||||
// If cache also missed, this error will propagate.
|
||||
// console.warn(`[SW ${CACHE_VERSION}] SWR: Network fetch error for ${request.url}`, err);
|
||||
throw err;
|
||||
});
|
||||
return cachedResponse || fetchPromise;
|
||||
}).catch(() => {
|
||||
// Fallback to network if cache.match fails
|
||||
// console.warn(`[SW ${CACHE_VERSION}] SWR: Cache match error for ${request.url}, trying network directly.`);
|
||||
return fetch(request);
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: Cache First for other types of requests (e.g., manifest.json if not in APP_SHELL_URLS)
|
||||
// console.log(`[SW ${CACHE_VERSION}] CacheFirst for: ${request.url}`);
|
||||
event.respondWith(
|
||||
caches.match(request)
|
||||
.then(response => {
|
||||
return response || fetch(request).then(networkResponse => {
|
||||
if(networkResponse.ok) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_VERSION).then(cache => cache.put(request, responseClone));
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.action === 'skipWaiting') {
|
||||
console.log(`[SW ${CACHE_VERSION}] Received skipWaiting message, activating new SW.`);
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-screen overflow-hidden" :class="{'dark': theme === 'dark'}">
|
||||
<!-- Header Bar -->
|
||||
<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>
|
||||
@@ -28,8 +28,9 @@
|
||||
</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 -->
|
||||
<!-- 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"
|
||||
@@ -51,21 +52,20 @@
|
||||
<!-- 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 Mode</h2>
|
||||
<h2 class="text-2xl font-semibold">All Timers Running</h2>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto space-y-2">
|
||||
<!-- Use playersToListInAllTimersMode -->
|
||||
<PlayerListItem
|
||||
v-for="(player) in playersToListInAllTimersMode"
|
||||
v-for="(player) in playersInAllTimersView"
|
||||
:key="player.id"
|
||||
:player="player"
|
||||
@tapped="() => handlePlayerTapAllTimers(indexInFullList(player.id))"
|
||||
/>
|
||||
<p v-if="playersToListInAllTimersMode.length === 0 && players.length > 0 && anyTimerCouldRun" class="text-center text-gray-500 dark:text-gray-400 py-6">
|
||||
All players are skipped or an issue occurred.
|
||||
<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="playersToListInAllTimersMode.length === 0 && players.length > 0 && !anyTimerCouldRun" class="text-center text-gray-500 dark:text-gray-400 py-6">
|
||||
All players are skipped.
|
||||
<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.
|
||||
@@ -83,7 +83,6 @@ import { useRouter } from 'vue-router';
|
||||
import PlayerDisplay from '../components/PlayerDisplay.vue';
|
||||
import PlayerListItem from '../components/PlayerListItem.vue';
|
||||
import { AudioService } from '../services/AudioService';
|
||||
import { WakeLockService } from '../services/WakeLockService';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -94,19 +93,18 @@ 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);
|
||||
// const gameRunning = computed(() => store.getters.gameRunning);
|
||||
|
||||
let timerInterval = null;
|
||||
|
||||
// Shows all non-skipped players when in 'allTimers' mode.
|
||||
const playersToListInAllTimersMode = computed(() => {
|
||||
if (gameMode.value === 'allTimers' && players.value) {
|
||||
return players.value.filter(p => !p.isSkipped);
|
||||
const playersInAllTimersView = computed(() => {
|
||||
if (gameMode.value === 'allTimers') {
|
||||
if (!players.value) return [];
|
||||
return players.value.filter(p => p.isTimerRunning && !p.isSkipped);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// This computed is still used for audio logic and auto-revert
|
||||
const anyTimerRunningInAllMode = computed(() => {
|
||||
if (!players.value) return false;
|
||||
return players.value.some(p => p.isTimerRunning && !p.isSkipped);
|
||||
@@ -123,8 +121,7 @@ const indexInFullList = (playerId) => {
|
||||
return players.value.findIndex(p => p.id === playerId);
|
||||
}
|
||||
|
||||
// ... (onMounted, onUnmounted, watchers, navigation, and other methods remain the same)
|
||||
onMounted(async () => {
|
||||
onMounted(() => {
|
||||
if (!players.value || players.value.length < 2) {
|
||||
router.push({ name: 'Setup' });
|
||||
return;
|
||||
@@ -133,30 +130,12 @@ onMounted(async () => {
|
||||
timerInterval = setInterval(() => {
|
||||
store.dispatch('tick');
|
||||
}, 1000);
|
||||
|
||||
if (gameRunning.value) {
|
||||
await WakeLockService.request();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(async () => {
|
||||
onUnmounted(() => {
|
||||
clearInterval(timerInterval);
|
||||
AudioService.stopContinuousTick();
|
||||
AudioService.cancelPassTurnSound();
|
||||
await WakeLockService.release();
|
||||
});
|
||||
|
||||
watch(gameRunning, async (isRunning) => {
|
||||
if (isRunning) {
|
||||
await WakeLockService.request();
|
||||
} else {
|
||||
if (!WakeLockService.isActive()) return;
|
||||
setTimeout(async () => {
|
||||
if (!store.getters.gameRunning && WakeLockService.isActive()) {
|
||||
await WakeLockService.release();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
watch(gameMode, (newMode) => {
|
||||
@@ -201,8 +180,7 @@ watch(() => currentPlayer.value?.isTimerRunning, (isRunning, wasRunning) => {
|
||||
});
|
||||
|
||||
|
||||
const navigateToSetup = async () => {
|
||||
await WakeLockService.release();
|
||||
const navigateToSetup = () => {
|
||||
const isAnyTimerActive = store.getters.gameRunning;
|
||||
if (isAnyTimerActive) {
|
||||
if (window.confirm('Game is active. Going to Setup will pause all timers. Continue?')) {
|
||||
@@ -214,16 +192,21 @@ const navigateToSetup = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToInfo = async () => {
|
||||
await WakeLockService.release();
|
||||
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 toggleMute = () => {
|
||||
store.dispatch('setMuted', !isMuted.value);
|
||||
};
|
||||
|
||||
const handleCurrentPlayerTap = () => {
|
||||
store.dispatch('toggleCurrentPlayerTimerNormalMode');
|
||||
};
|
||||
|
||||
const handlePassTurn = () => {
|
||||
if(currentPlayer.value && !currentPlayer.value.isSkipped) {
|
||||
AudioService.cancelPassTurnSound();
|
||||
@@ -236,26 +219,29 @@ const handlePassTurn = () => {
|
||||
});
|
||||
}
|
||||
};
|
||||
const handlePlayerTapAllTimers = (playerIndex) => { store.dispatch('togglePlayerTimerAllTimersMode', playerIndex); };
|
||||
const switchToAllTimersMode = () => { store.dispatch('switchToAllTimersMode'); };
|
||||
const switchToNormalMode = () => { store.dispatch('switchToNormalMode'); };
|
||||
|
||||
// Auto-revert logic
|
||||
const handlePlayerTapAllTimers = (playerIndex) => {
|
||||
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
|
||||
};
|
||||
|
||||
const switchToAllTimersMode = () => {
|
||||
store.dispatch('switchToAllTimersMode');
|
||||
};
|
||||
|
||||
const switchToNormalMode = () => {
|
||||
store.dispatch('switchToNormalMode');
|
||||
};
|
||||
|
||||
watch(anyTimerRunningInAllMode, (anyRunning) => {
|
||||
// Only revert if we are IN allTimers mode and NO timers are running
|
||||
if (gameMode.value === 'allTimers' && !anyRunning && players.value && players.value.length > 0) {
|
||||
const nonSkippedPlayersExist = players.value.some(p => !p.isSkipped);
|
||||
if (nonSkippedPlayersExist) {
|
||||
setTimeout(() => {
|
||||
// Double check condition before switching, state might change rapidly
|
||||
if(gameMode.value === 'allTimers' && !store.getters.players.some(p => p.isTimerRunning && !p.isSkipped)){
|
||||
console.log("All timers paused in AllTimersMode, reverting to Normal Mode.");
|
||||
store.dispatch('switchToNormalMode');
|
||||
}
|
||||
}, 250); // A small delay to prevent flickering if a timer is immediately restarted
|
||||
} else {
|
||||
// All players are skipped, so stay in all timers mode but paused.
|
||||
console.log("All players skipped in AllTimersMode, staying paused.");
|
||||
}, 200);
|
||||
} else {
|
||||
AudioService.stopContinuousTick();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,7 @@
|
||||
<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-2 text-center">
|
||||
<h1 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-1">About Nexus Timer</h1>
|
||||
<div class="flex items-center justify-center space-x-2 text-xs text-gray-500 dark:text-gray-400 mb-4">
|
||||
<span>Build: <span class="font-mono">{{ buildTime }}</span></span>
|
||||
<button
|
||||
v-if="canCheckForUpdate"
|
||||
@click="checkForUpdates"
|
||||
class="text-blue-500 hover:text-blue-700 dark:hover:text-blue-300 underline text-xs"
|
||||
:disabled="checkingForUpdate"
|
||||
>
|
||||
{{ checkingForUpdate ? 'Checking...' : 'Check for Update' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="updateStatusMessage" class="text-sm mt-1" :class="updateError ? 'text-red-500' : 'text-green-500'">
|
||||
{{ updateStatusMessage }}
|
||||
</p>
|
||||
<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">
|
||||
@@ -30,15 +16,12 @@
|
||||
<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 (max 99 players).</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 actions.</li>
|
||||
<li>**MQTT integration for remote control using unique characters per action.**</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>
|
||||
<li>Screen Wake Lock to keep screen on during active gameplay.</li>
|
||||
<li>PWA installability with update checks.</li>
|
||||
</ul>
|
||||
|
||||
<h2 class="text-xl font-semibold mt-6 mb-2">Source Code</h2>
|
||||
@@ -59,65 +42,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore } from 'vuex';
|
||||
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const buildTime = ref('N/A');
|
||||
|
||||
onMounted(() => {
|
||||
buildTime.value = import.meta.env.VITE_APP_BUILD_TIME || 'Unknown Build Time';
|
||||
});
|
||||
|
||||
const canCheckForUpdate = ref('serviceWorker' in navigator);
|
||||
const checkingForUpdate = ref(false);
|
||||
const updateStatusMessage = ref('');
|
||||
const updateError = ref(false);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
updateStatusMessage.value = 'Service Worker API not supported.';
|
||||
updateError.value = true;
|
||||
return;
|
||||
}
|
||||
checkingForUpdate.value = true;
|
||||
updateStatusMessage.value = 'Checking for updates...';
|
||||
updateError.value = false;
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (!registration) {
|
||||
updateStatusMessage.value = 'No active service worker. Try reloading.';
|
||||
updateError.value = true;
|
||||
checkingForUpdate.value = false;
|
||||
return;
|
||||
}
|
||||
await registration.update();
|
||||
setTimeout(() => {
|
||||
const newWorker = registration.waiting;
|
||||
if (newWorker) {
|
||||
updateStatusMessage.value = 'New version downloaded! Refresh prompt may appear.';
|
||||
updateError.value = false;
|
||||
} else if (registration.active && registration.installing) {
|
||||
updateStatusMessage.value = 'New version installing...';
|
||||
updateError.value = false;
|
||||
} else {
|
||||
updateStatusMessage.value = 'You are on the latest version.';
|
||||
updateError.value = false;
|
||||
}
|
||||
checkingForUpdate.value = false;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error('Error checking for PWA updates:', error);
|
||||
updateStatusMessage.value = 'Error checking updates. See console.';
|
||||
updateError.value = true;
|
||||
checkingForUpdate.value = false;
|
||||
}
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
<h1 class="text-4xl font-bold text-blue-600 dark:text-blue-400">Nexus Timer Setup</h1>
|
||||
</header>
|
||||
|
||||
<!-- Player Management (no changes) -->
|
||||
<!-- Player Management -->
|
||||
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
|
||||
<!-- ... Player list ... -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-2xl font-semibold">Players ({{ players.length }})</h2>
|
||||
<button @click="openAddPlayerModal" class="btn btn-primary" :disabled="players.length >= 99">
|
||||
<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-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
|
||||
@@ -24,118 +24,64 @@
|
||||
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"/>
|
||||
<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 mr-1">
|
||||
HK: {{ player.hotkey.toUpperCase() }}
|
||||
</span>
|
||||
<span v-if="player.mqttChar" class="text-xs px-1.5 py-0.5 bg-purple-100 dark:bg-purple-700 text-purple-700 dark:text-purple-200 rounded">
|
||||
MQTT: {{ player.mqttChar.toUpperCase() }}
|
||||
</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">
|
||||
<button @click="movePlayerUp(index)" :disabled="index === 0" class="btn-icon"><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>
|
||||
<button @click="movePlayerDown(index)" :disabled="index === players.length - 1" class="btn-icon"><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>
|
||||
<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>
|
||||
<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 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. Add at least 2.</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">
|
||||
<!-- MQTT Broker URL -->
|
||||
<div class="mb-6">
|
||||
<label for="mqttBrokerUrl" class="block text-sm font-medium text-gray-700 dark:text-gray-300">MQTT Broker URL</label>
|
||||
<div class="mt-1 flex rounded-md shadow-sm">
|
||||
<input type="text" id="mqttBrokerUrl" v-model="localMqttBrokerUrl" placeholder="ws://broker.example.com:9001" class="input-base flex-1 rounded-none rounded-l-md" :disabled="mqttConnectionStatus === 'connected' || mqttConnectionStatus === 'connecting'">
|
||||
<button @click="toggleMqttConnection"
|
||||
:class="['btn inline-flex items-center px-4 rounded-l-none rounded-r-md border border-l-0 text-white',
|
||||
mqttConnectionStatus === 'connected' ? 'bg-red-500 hover:bg-red-600 border-red-600' :
|
||||
mqttConnectionStatus === 'connecting' ? 'bg-yellow-500 hover:bg-yellow-600 border-yellow-600 !text-black' :
|
||||
'btn-primary border-blue-500']"
|
||||
>
|
||||
{{ mqttConnectionStatus === 'connected' ? 'Disconnect' : (mqttConnectionStatus === 'connecting' ? 'Stop' : 'Connect') }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="mqttError" class="text-xs text-red-500 mt-1">{{ mqttError }}</p>
|
||||
<p v-else-if="mqttConnectionStatus === 'connected'" class="text-xs text-green-500 mt-1">Connected to {{ store.getters.mqttBrokerUrl }}. Subscribed to '{{ MqttService.MQTT_TOPIC_GAME }}'.</p>
|
||||
<p v-else-if="mqttConnectionStatus === 'connecting'" class="text-xs text-yellow-600 mt-1">Connecting to {{ localMqttBrokerUrl }}... (Click "Stop" to cancel)</p>
|
||||
<p v-else-if="mqttConnectionStatus === 'disconnected'" class="text-xs text-gray-500 mt-1">Not connected.</p>
|
||||
<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>
|
||||
|
||||
<!-- Global Triggers -->
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Global "Toggle Current/Pause All" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4 mb-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button type="button" @click="startCaptureGlobalHotkey('stopPause')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalHotkeyStopPauseDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalHotkeyStopPause" type="button" @click="clearGlobalHotkey('stopPause')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button type="button" @click="startCaptureGlobalMqttChar('stopPause')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalMqttStopPauseDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalMqttStopPause" type="button" @click="clearGlobalMqttChar('stopPause')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Global "Run All Timers" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4 mb-3">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button type="button" @click="startCaptureGlobalHotkey('runAll')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalHotkeyRunAllDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalHotkeyRunAll" type="button" @click="clearGlobalHotkey('runAll')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button type="button" @click="startCaptureGlobalMqttChar('runAll')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalMqttRunAllDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalMqttRunAll" type="button" @click="clearGlobalMqttChar('runAll')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Global "Pass Turn" Trigger:</p>
|
||||
<div class="flex items-center justify-between space-x-4 mb-6">
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
|
||||
<button type="button" @click="startCaptureGlobalHotkey('passTurn')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalHotkeyPassTurnDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalHotkeyPassTurn" type="button" @click="clearGlobalHotkey('passTurn')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
|
||||
<button type="button" @click="startCaptureGlobalMqttChar('passTurn')" class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer flex items-center justify-center">
|
||||
<span>{{ globalMqttPassTurnDisplay || '-' }}</span>
|
||||
</button>
|
||||
<button v-if="globalMqttPassTurn" type="button" @click="clearGlobalMqttChar('passTurn')" class="trigger-clear-btn">Clear</button>
|
||||
<span v-else class="trigger-clear-btn-placeholder"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mb-4 mt-6">
|
||||
<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' }}
|
||||
@@ -150,49 +96,33 @@
|
||||
</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>
|
||||
<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"/>
|
||||
|
||||
<HotkeyCaptureOverlay
|
||||
:visible="isCapturingGlobalHotkey"
|
||||
@captured="handleGlobalHotkeyCaptured"
|
||||
@cancel="cancelCaptureGlobalHotkey"
|
||||
/>
|
||||
<MqttCharCaptureOverlay
|
||||
:visible="isCapturingGlobalMqttChar"
|
||||
@cancel="cancelCaptureGlobalMqttChar"
|
||||
<PlayerForm
|
||||
v-if="showPlayerModal"
|
||||
:player="editingPlayer"
|
||||
@close="closePlayerModal"
|
||||
@save="savePlayer"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.trigger-clear-btn {
|
||||
@apply text-xs text-blue-500 hover:underline ml-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-600;
|
||||
min-width: 40px; /* Adjust this width to match the "Clear" button's typical width */
|
||||
display: inline-block; /* Important for width and centering if text-align used */
|
||||
text-align: center; /* Center the "Clear" text if button width is larger */
|
||||
}
|
||||
.trigger-clear-btn-placeholder {
|
||||
@apply ml-2 px-2 py-1; /* Match horizontal spacing and padding */
|
||||
min-width: 40px; /* Match the width of the actual clear button */
|
||||
display: inline-block; /* To take up space */
|
||||
visibility: hidden; /* Makes it take space but not be visible */
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onUnmounted } from 'vue';
|
||||
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';
|
||||
import HotkeyCaptureOverlay from '../components/HotkeyCaptureOverlay.vue';
|
||||
import MqttCharCaptureOverlay from '../components/MqttCharCaptureOverlay.vue';
|
||||
import { MqttService } from '../services/MqttService';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -200,46 +130,29 @@ const router = useRouter();
|
||||
const players = computed(() => store.getters.players);
|
||||
const theme = computed(() => store.getters.theme);
|
||||
const isMuted = computed(() => store.getters.isMuted);
|
||||
|
||||
const localMqttBrokerUrl = ref(store.getters.mqttBrokerUrl);
|
||||
watch(() => store.getters.mqttBrokerUrl, (newUrl) => { localMqttBrokerUrl.value = newUrl; });
|
||||
const mqttConnectionStatus = MqttService.connectionStatus;
|
||||
const mqttError = MqttService.error;
|
||||
|
||||
const globalHotkeyStopPause = computed(() => store.getters.globalHotkeyStopPause);
|
||||
const globalHotkeyStopPauseDisplay = computed(() => globalHotkeyStopPause.value ? globalHotkeyStopPause.value.toUpperCase() : '');
|
||||
const globalMqttStopPause = computed(() => store.getters.globalMqttStopPause);
|
||||
const globalMqttStopPauseDisplay = computed(() => globalMqttStopPause.value ? globalMqttStopPause.value.toUpperCase() : '');
|
||||
|
||||
const globalHotkeyRunAll = computed(() => store.getters.globalHotkeyRunAll);
|
||||
const globalHotkeyRunAllDisplay = computed(() => globalHotkeyRunAll.value ? globalHotkeyRunAll.value.toUpperCase() : '');
|
||||
const globalMqttRunAll = computed(() => store.getters.globalMqttRunAll);
|
||||
const globalMqttRunAllDisplay = computed(() => globalMqttRunAll.value ? globalMqttRunAll.value.toUpperCase() : '');
|
||||
|
||||
const globalHotkeyPassTurn = computed(() => store.getters.globalHotkeyPassTurn);
|
||||
const globalHotkeyPassTurnDisplay = computed(() => globalHotkeyPassTurn.value ? globalHotkeyPassTurn.value.toUpperCase() : '');
|
||||
const globalMqttPassTurn = computed(() => store.getters.globalMqttPassTurn);
|
||||
const globalMqttPassTurnDisplay = computed(() => globalMqttPassTurn.value ? globalMqttPassTurn.value.toUpperCase() : '');
|
||||
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 < 99) {
|
||||
if (players.value.length < 7) {
|
||||
editingPlayer.value = null;
|
||||
showPlayerModal.value = true;
|
||||
} else {
|
||||
alert("Maximum player limit (99) reached.");
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -249,19 +162,27 @@ const savePlayer = (playerData) => {
|
||||
avatar: playerData.avatar,
|
||||
initialTimerSec: playerData.initialTimerSec,
|
||||
currentTimerSec: playerData.currentTimerSec,
|
||||
hotkey: playerData.hotkey,
|
||||
mqttChar: playerData.mqttChar
|
||||
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'); };
|
||||
|
||||
const shufflePlayers = () => {
|
||||
store.dispatch('shufflePlayers');
|
||||
};
|
||||
|
||||
const reversePlayers = () => {
|
||||
store.dispatch('reversePlayers');
|
||||
};
|
||||
|
||||
// --- Player Reordering Logic ---
|
||||
const movePlayerUp = (index) => {
|
||||
if (index > 0) {
|
||||
const newPlayersOrder = [...players.value];
|
||||
@@ -271,6 +192,7 @@ const movePlayerUp = (index) => {
|
||||
store.dispatch('reorderPlayers', newPlayersOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const movePlayerDown = (index) => {
|
||||
if (index < players.value.length - 1) {
|
||||
const newPlayersOrder = [...players.value];
|
||||
@@ -280,115 +202,33 @@ const movePlayerDown = (index) => {
|
||||
store.dispatch('reorderPlayers', newPlayersOrder);
|
||||
}
|
||||
};
|
||||
// --- End Player Reordering Logic ---
|
||||
|
||||
|
||||
const isCapturingGlobalHotkey = ref(false);
|
||||
const isCapturingGlobalMqttChar = ref(false);
|
||||
const currentGlobalActionType = ref(null); // 'stopPause', 'runAll', or 'passTurn'
|
||||
|
||||
|
||||
const startCaptureGlobalHotkey = (actionType) => {
|
||||
currentGlobalActionType.value = actionType;
|
||||
isCapturingGlobalHotkey.value = true;
|
||||
};
|
||||
const cancelCaptureGlobalHotkey = () => {
|
||||
isCapturingGlobalHotkey.value = false;
|
||||
currentGlobalActionType.value = null;
|
||||
};
|
||||
const clearGlobalHotkey = (actionType) => {
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalHotkeyStopPause', null);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalHotkeyRunAll', null);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalHotkeyPassTurn', null);
|
||||
};
|
||||
const handleGlobalHotkeyCaptured = (key) => {
|
||||
isCapturingGlobalHotkey.value = false;
|
||||
const actionType = currentGlobalActionType.value;
|
||||
if (!actionType || key.length !== 1) { currentGlobalActionType.value = null; return; }
|
||||
let conflictMessage = '';
|
||||
if (store.state.players.some(p => p.hotkey === key)) {
|
||||
conflictMessage = `Hotkey "${key.toUpperCase()}" is already used by a player.`;
|
||||
}
|
||||
else if (actionType === 'stopPause') {
|
||||
if (store.state.globalHotkeyRunAll === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Run All Timers Hotkey.`;
|
||||
if (store.state.globalHotkeyPassTurn === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Pass Turn Hotkey.`;
|
||||
} else if (actionType === 'runAll') {
|
||||
if (store.state.globalHotkeyStopPause === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Stop/Pause Hotkey.`;
|
||||
if (store.state.globalHotkeyPassTurn === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Pass Turn Hotkey.`;
|
||||
} else if (actionType === 'passTurn') {
|
||||
if (store.state.globalHotkeyStopPause === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Stop/Pause Hotkey.`;
|
||||
if (store.state.globalHotkeyRunAll === key) conflictMessage = `Hotkey "${key.toUpperCase()}" is Global Run All Hotkey.`;
|
||||
}
|
||||
if (conflictMessage) { alert(conflictMessage); currentGlobalActionType.value = null; return; }
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalHotkeyStopPause', key);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalHotkeyRunAll', key);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalHotkeyPassTurn', key);
|
||||
currentGlobalActionType.value = null;
|
||||
};
|
||||
|
||||
const startCaptureGlobalMqttChar = (actionType) => {
|
||||
if (MqttService.connectionStatus.value !== 'connected') {
|
||||
alert('MQTT broker is not connected. Please connect first.');
|
||||
return;
|
||||
}
|
||||
currentGlobalActionType.value = actionType;
|
||||
isCapturingGlobalMqttChar.value = true;
|
||||
MqttService.startMqttCharCapture(handleGlobalMqttCharCapturedDirect);
|
||||
};
|
||||
const cancelCaptureGlobalMqttChar = () => {
|
||||
isCapturingGlobalMqttChar.value = false;
|
||||
currentGlobalActionType.value = null;
|
||||
MqttService.stopMqttCharCapture();
|
||||
};
|
||||
const clearGlobalMqttChar = (actionType) => {
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalMqttStopPause', null);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalMqttRunAll', null);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalMqttPassTurn', null);
|
||||
};
|
||||
const handleGlobalMqttCharCapturedDirect = (charKey) => {
|
||||
isCapturingGlobalMqttChar.value = false;
|
||||
const actionType = currentGlobalActionType.value;
|
||||
if (!actionType || charKey.length !== 1) { currentGlobalActionType.value = null; return; }
|
||||
let conflictMessage = '';
|
||||
if (store.state.players.some(p => p.mqttChar === charKey)) {
|
||||
conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is already used by a player.`;
|
||||
}
|
||||
else if (actionType === 'stopPause') {
|
||||
if (store.state.globalMqttRunAll === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Run All Timers MQTT Char.`;
|
||||
if (store.state.globalMqttPassTurn === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Pass Turn MQTT Char.`;
|
||||
} else if (actionType === 'runAll') {
|
||||
if (store.state.globalMqttStopPause === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Stop/Pause MQTT Char.`;
|
||||
if (store.state.globalMqttPassTurn === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Pass Turn MQTT Char.`;
|
||||
} else if (actionType === 'passTurn') {
|
||||
if (store.state.globalMqttStopPause === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Stop/Pause MQTT Char.`;
|
||||
if (store.state.globalMqttRunAll === charKey) conflictMessage = `MQTT Char "${charKey.toUpperCase()}" is Global Run All Timers MQTT Char.`;
|
||||
}
|
||||
if (conflictMessage) { alert(conflictMessage); currentGlobalActionType.value = null; return; }
|
||||
if (actionType === 'stopPause') store.dispatch('setGlobalMqttStopPause', charKey);
|
||||
else if (actionType === 'runAll') store.dispatch('setGlobalMqttRunAll', charKey);
|
||||
else if (actionType === 'passTurn') store.dispatch('setGlobalMqttPassTurn', charKey);
|
||||
currentGlobalActionType.value = null;
|
||||
};
|
||||
|
||||
const toggleMqttConnection = async () => {
|
||||
if (MqttService.connectionStatus.value === 'connected' || MqttService.connectionStatus.value === 'connecting') {
|
||||
MqttService.disconnect();
|
||||
await store.dispatch('setMqttConnectDesired', false);
|
||||
} else {
|
||||
if (!localMqttBrokerUrl.value.trim()) { alert("Please enter MQTT Broker URL (e.g., ws://host:port)."); return; }
|
||||
try {
|
||||
const url = new URL(localMqttBrokerUrl.value);
|
||||
if(!url.protocol.startsWith('ws')) { throw new Error("URL must start with ws:// or wss://"); }
|
||||
} catch (e) { alert(`Invalid MQTT Broker URL: ${e.message}. Please use format ws://host:port or wss://host:port.`); return; }
|
||||
await store.dispatch('setMqttBrokerUrl', localMqttBrokerUrl.value);
|
||||
MqttService.connect(localMqttBrokerUrl.value);
|
||||
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);
|
||||
};
|
||||
onUnmounted(() => {
|
||||
if (MqttService.isCapturingMqttChar.value) { MqttService.stopMqttCharCapture(); }
|
||||
});
|
||||
|
||||
const toggleTheme = () => { store.dispatch('toggleTheme'); };
|
||||
const toggleMute = () => { store.dispatch('setMuted', !isMuted.value); };
|
||||
const saveAndClose = () => {
|
||||
store.dispatch('saveState');
|
||||
if (players.value.length >= 2) {
|
||||
@@ -404,11 +244,13 @@ const saveAndClose = () => {
|
||||
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');
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
[Unit]
|
||||
Description=nexus-timer (virt-nexus-timer)
|
||||
Requires=docker.service
|
||||
After=network.target docker.service
|
||||
DefaultDependencies=no
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment="HOME=/root"
|
||||
ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker stop -t 3 virt-nexus-timer 2>/dev/null || true'
|
||||
ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-nexus-timer 2>/dev/null || true'
|
||||
|
||||
ExecStart=/usr/bin/env docker run \
|
||||
--rm \
|
||||
--name=virt-nexus-timer \
|
||||
--network=traefik \
|
||||
--label-file /virt/nexus-timer/labels \
|
||||
virt-nexus-timer
|
||||
|
||||
ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker stop -t 3 virt-nexus-timer 2>/dev/null || true'
|
||||
ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-nexus-timer 2>/dev/null || true'
|
||||
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
SyslogIdentifier=virt-nexus-timer
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,35 +0,0 @@
|
||||
[Unit]
|
||||
Description=Small server for creating HTTP endpoints (hooks)
|
||||
Documentation=https://github.com/adnanh/webhook/
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/etc/webhook.conf
|
||||
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Clear any existing ExecStart from a base unit file, if any
|
||||
ExecStart=
|
||||
# Path to webhook, IP, port, path to hooks config, verbose logging, hot-reloading config
|
||||
ExecStart=/usr/bin/webhook -ip 10.0.0.1 -port 9000 -verbose -nopanic -hooks /etc/webhook.conf
|
||||
|
||||
# --- Security & User (Recommended) ---
|
||||
# 1. Create a dedicated user:
|
||||
# sudo useradd --system --no-create-home --shell /bin/false webhooksvc
|
||||
# 2. Ensure this user can read /etc/webhook.conf and execute redeploy.sh
|
||||
# sudo chown webhooksvc:webhooksvc /etc/webhook.conf && sudo chmod 640 /etc/webhook.conf
|
||||
# Also grant sudo rights for systemctl restart as mentioned in Step 3.1.
|
||||
# Uncomment and use if you created the 'webhooksvc' user:
|
||||
# User=webhooksvc
|
||||
# Group=webhooksvc
|
||||
# If running as root (less secure), leave User/Group commented.
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStopSec=30s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,53 +1,10 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import fs from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
|
||||
const appVersion = packageJson.version;
|
||||
|
||||
// Get current date (this will be the server's local time where the build runs)
|
||||
const now = new Date();
|
||||
|
||||
// Options for date formatting, targeting CET/CEST
|
||||
const dateTimeFormatOptionsCEST = {
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false, // Use 24-hour format
|
||||
timeZone: 'Europe/Bratislava' // Or 'Europe/Prague', 'Europe/Berlin', etc. (any major CET/CEST city)
|
||||
// This will automatically handle Daylight Saving Time (CEST vs CET)
|
||||
};
|
||||
|
||||
// Generate build time string using a specific time zone that observes CET/CEST
|
||||
const appBuildTime = now.toLocaleString('sk-SK', dateTimeFormatOptionsCEST) + " CEST/CET"; // Add timezone indicator for clarity
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 8080
|
||||
},
|
||||
define: {
|
||||
'import.meta.env.VITE_APP_BUILD_TIME': JSON.stringify(appBuildTime),
|
||||
'__APP_CACHE_VERSION__': JSON.stringify(`nexus-timer-cache-v${appVersion}-${Date.now()}`)
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
sw: resolve(__dirname, 'src/sw.js')
|
||||
},
|
||||
output: {
|
||||
entryFileNames: assetInfo => {
|
||||
if (assetInfo.name === 'sw') {
|
||||
return 'service-worker.js';
|
||||
}
|
||||
return 'assets/[name]-[hash].js';
|
||||
},
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
}
|
||||
},
|
||||
emptyOutDir: true,
|
||||
}
|
||||
});
|
||||
})
|
||||