vue.js used

This commit is contained in:
cpu
2025-05-07 22:17:29 +02:00
parent 049866ecd7
commit 04dd879c24
33 changed files with 4471 additions and 0 deletions

38
.dockerignore Normal file
View File

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

144
.gitignore vendored Normal file
View File

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

15
Dockerfile Normal file
View File

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

View File

@@ -50,6 +50,7 @@ For an enhanced tactile experience, Nexus Timer supports Smart Buttons based on
* 3-seconds ticking sound when the timer starts to alert players about the "Pass Turn". * 3-seconds ticking sound when the timer starts to alert players about the "Pass Turn".
* Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn. * Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn.
* To pass the turn: **Swipe Up** on the Next Player's area or have the Current Player press their "Pass Turn / My Pause" hotkey. The current timer pauses, the next player becomes Current, and their timer starts. * To pass the turn: **Swipe Up** on the Next Player's area or have the Current Player press their "Pass Turn / My Pause" hotkey. The current timer pauses, the next player becomes Current, and their timer starts.
* If the current player's timer is paused, and then the turn is passed (via swipe up or hotkey), the next player should become the current player, but their timer should not automatically start. It should remain paused.
2. **All Timers Running Mode:** 2. **All Timers Running Mode:**
* All active player timers run simultaneously. * All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers). * Enter by clicking "All Timers Mode" (starts all timers).

30
index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <!-- Will be served from public/favicon.ico -> dist/favicon.ico -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Nexus Timer</title>
<link rel="manifest" href="/manifest.json"> <!-- Will be served from public/manifest.json -> dist/manifest.json -->
<meta name="theme-color" content="#000000">
<link rel="apple-touch-icon" href="/icons/icon-192x192.png"> <!-- Will be served from public/icons/ -> dist/icons/ -->
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
// service-worker.js from public dir will be copied to dist root
navigator.serviceWorker.register('/service-worker.js')
.then(registration => {
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(error => {
console.log('ServiceWorker registration failed: ', error);
});
});
}
</script>
</body>
</html>

16
nginx.conf Normal file
View File

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

2281
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View File

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

6
postcss.config.js Normal file
View File

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

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

21
public/manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"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
View File

@@ -0,0 +1,49 @@
const CACHE_NAME = 'nexus-timer-cache-v1';
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);
}
})
);
})
);
});

98
src/App.vue Normal file
View File

@@ -0,0 +1,98 @@
// src/App.vue
<template>
<div :class="[theme, 'min-h-screen flex flex-col no-select']">
<router-view />
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, watch } from 'vue'; // Added onUnmounted
import { useStore } from 'vuex';
import { AudioService } from './services/AudioService';
const store = useStore();
const theme = computed(() => store.getters.theme);
onMounted(() => {
// store.dispatch('loadState'); // <-- REMOVE THIS LINE
applyTheme();
document.addEventListener('keydown', handleGlobalKeyDown);
const resumeAudio = () => {
AudioService.resumeContext();
// document.body.removeEventListener('click', resumeAudio); // These are fine with {once: true}
// document.body.removeEventListener('touchstart', resumeAudio);
};
document.body.addEventListener('click', resumeAudio, { once: true });
document.body.addEventListener('touchstart', resumeAudio, { once: true });
});
// Add onUnmounted to clean up the global event listener
onUnmounted(() => {
document.removeEventListener('keydown', handleGlobalKeyDown);
});
watch(theme, () => {
applyTheme();
});
watch(() => store.state.isMuted, (newMutedState) => {
AudioService.setMuted(newMutedState);
});
const applyTheme = () => {
if (store.getters.theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
};
const handleGlobalKeyDown = (event) => {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA' || event.target.isContentEditable) {
return;
}
const keyPressed = event.key.toLowerCase();
if (keyPressed === store.getters.globalHotkeyStopPause && store.getters.globalHotkeyStopPause) {
event.preventDefault();
store.dispatch('globalStopPauseAll');
return;
}
const currentPlayerFromStore = store.getters.currentPlayer; // Renamed to avoid conflict
const gameMode = store.getters.gameMode;
if (gameMode === 'normal' && currentPlayerFromStore && keyPressed === currentPlayerFromStore.hotkey) {
event.preventDefault();
const wasRunning = currentPlayerFromStore.isTimerRunning;
store.dispatch('passTurn').then(() => {
const newCurrentPlayer = store.getters.currentPlayer; // Get updated player
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
return;
}
if (gameMode === '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 {
height: 100%;
overscroll-behavior: none;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 B

43
src/assets/tailwind.css Normal file
View File

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

View File

@@ -0,0 +1,100 @@
<template>
<div
:class="['player-area p-4 flex flex-col items-center justify-center text-center relative h-1/2 no-select',
areaClass,
{ 'opacity-50': player.isSkipped,
'bg-green-100 dark:bg-green-800 animate-pulsePositive': player.isTimerRunning && player.currentTimerSec >=0 && !isNextPlayerArea,
'bg-red-100 dark:bg-red-900': player.isTimerRunning && player.currentTimerSec < 0 && !isNextPlayerArea,
}]"
@click="handleTap"
v-touch:swipe.up="handleSwipeUp"
>
<img
:src="player.avatar || defaultAvatar"
alt="Player Avatar"
class="rounded-full object-cover mb-4 border-4 shadow-lg"
style="width: 60vmin; height: 60vmin; max-width: 280px; max-height: 280px;"
:class="player.isSkipped ? 'border-gray-500 filter grayscale' : 'border-blue-500 dark:border-blue-400'"
/>
<h2 class="text-3xl md:text-4xl lg:text-5xl font-semibold mb-2 mt-2">{{ player.name }}</h2>
<TimerDisplay
:seconds="player.currentTimerSec"
:is-negative="player.currentTimerSec < 0"
:is-pulsating="player.isTimerRunning"
class="text-6xl md:text-8xl lg:text-9xl xl:text-[10rem]"
/>
<p v-if="player.isSkipped" class="text-red-500 dark:text-red-400 mt-2 font-semibold text-lg">SKIPPED</p>
<p v-if="isNextPlayerArea && !player.isSkipped" class="mt-3 text-base text-gray-600 dark:text-gray-400">(Swipe up to pass turn)</p>
</div>
</template>
<script setup>
import { computed } from 'vue';
import TimerDisplay from './TimerDisplay.vue';
import defaultAvatar from '../assets/default-avatar.png';
// Basic touch directive
const vTouch = {
mounted: (el, binding) => {
if (binding.arg === 'swipe' && binding.modifiers.up) {
let touchstartX = 0;
let touchstartY = 0;
let touchendX = 0;
let touchendY = 0;
const swipeThreshold = 50; // Min pixels for swipe
el.addEventListener('touchstart', function(event) {
touchstartX = event.changedTouches[0].screenX;
touchstartY = event.changedTouches[0].screenY;
}, { passive: true });
el.addEventListener('touchend', function(event) {
touchendX = event.changedTouches[0].screenX;
touchendY = event.changedTouches[0].screenY;
handleGesture();
}, { passive: true });
function handleGesture() {
const deltaY = touchstartY - touchendY;
const deltaX = Math.abs(touchendX - touchstartX);
if (deltaY > swipeThreshold && deltaY > deltaX) {
if (typeof binding.value === 'function') {
binding.value();
}
}
}
}
}
};
const props = defineProps({
player: {
type: Object,
required: true
},
isCurrentPlayerArea: Boolean,
isNextPlayerArea: Boolean,
areaClass: String,
});
const emit = defineEmits(['tapped', 'swiped-up']);
const handleTap = () => {
if (props.isCurrentPlayerArea && !props.player.isSkipped) {
emit('tapped');
}
};
const handleSwipeUp = () => {
if (props.isNextPlayerArea && !props.player.isSkipped) {
emit('swiped-up');
}
};
</script>
<style scoped>
.player-area {
transition: background-color 0.3s ease, opacity 0.3s ease;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex justify-center items-center p-4" @click.self="closeModal">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-md">
<h2 class="text-2xl font-semibold mb-4">{{ isEditing ? 'Edit Player' : 'Add New Player' }}</h2>
<form @submit.prevent="submitForm">
<div class="mb-4">
<label for="playerName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input type="text" id="playerName" v-model="editablePlayer.name" required class="input-base">
</div>
<div class="mb-4">
<label for="initialTime" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Initial Timer (MM:SS)</label>
<input type="text" id="initialTime" v-model="initialTimeFormatted" @blur="validateTimeFormat" placeholder="e.g., 60:00" required class="input-base">
<p v-if="timeFormatError" class="text-red-500 text-xs mt-1">{{ timeFormatError }}</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">
<img :src="editablePlayer.avatar || defaultAvatar" alt="Avatar" class="w-16 h-16 rounded-full object-cover mr-4 border">
<button type="button" @click="capturePhoto" class="btn btn-secondary text-sm mr-2">Take Photo</button>
<button type="button" @click="useDefaultAvatar" class="btn btn-secondary text-sm">Default</button>
</div>
<p v-if="cameraError" class="text-red-500 text-xs mt-1">{{ cameraError }}</p>
</div>
<div class="mb-4">
<label for="playerHotkey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">"Pass Turn / My Pause" Hotkey</label>
<input
type="text"
id="playerHotkey"
v-model="editablePlayer.hotkey"
@keydown.prevent="captureHotkey($event, 'player')"
placeholder="Press a key"
class="input-base"
readonly
>
<button type="button" @click="clearHotkey('player')" class="text-xs text-blue-500 hover:underline mt-1">Clear Hotkey</button>
</div>
<div class="flex justify-end space-x-3">
<button type="button" @click="closeModal" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!!timeFormatError">{{ isEditing ? 'Save Changes' : 'Add Player' }}</button>
</div>
</form>
</div>
</div>
</template>
<script setup>
// **** FIX: Add 'computed' to the import statement ****
import { ref, reactive, watch, onMounted, computed } from 'vue';
import { useStore } from 'vuex';
import { CameraService } from '../services/CameraService';
import { formatTime, parseTime } from '../utils/timeFormatter';
import defaultAvatarPath from '../assets/default-avatar.png';
import { AudioService } from '../services/AudioService'; // Added import for AudioService
const props = defineProps({
player: Object, // Player object for editing, null for adding
});
const emit = defineEmits(['close', 'save']);
const store = useStore();
const defaultAvatar = defaultAvatarPath;
const isEditing = computed(() => !!props.player); // This line was causing the error
const editablePlayer = reactive({
id: null,
name: '',
avatar: defaultAvatar,
initialTimerSec: 3600, // 60:00
hotkey: '',
});
const initialTimeFormatted = ref('60:00');
const timeFormatError = ref('');
const cameraError = ref('');
onMounted(() => {
if (isEditing.value && props.player) {
editablePlayer.id = props.player.id;
editablePlayer.name = props.player.name;
editablePlayer.avatar = props.player.avatar || defaultAvatar;
editablePlayer.initialTimerSec = props.player.initialTimerSec;
editablePlayer.hotkey = props.player.hotkey || '';
initialTimeFormatted.value = formatTime(props.player.initialTimerSec);
} else {
initialTimeFormatted.value = formatTime(editablePlayer.initialTimerSec);
}
});
watch(initialTimeFormatted, (newTime) => {
validateTimeFormat();
if (!timeFormatError.value) {
editablePlayer.initialTimerSec = parseTime(newTime);
}
});
function validateTimeFormat() {
const time = initialTimeFormatted.value;
if (!/^(?:[0-5]?\d:[0-5]\d)$/.test(time) && !/^(?:[0-9]+:[0-5]\d)$/.test(time)) { // Allow more than 59 minutes for setup
timeFormatError.value = 'Invalid time format. Use MM:SS (e.g., 05:30 or 70:00).';
} else {
const parsed = parseTime(time);
if (parsed < 1) { // Minimum 1 second
timeFormatError.value = 'Timer must be at least 00:01.';
} else {
timeFormatError.value = '';
}
}
}
async function capturePhoto() {
cameraError.value = '';
try {
AudioService.resumeContext(); // Ensure audio context is active for camera sounds if any
const photoDataUrl = await CameraService.getPhoto();
editablePlayer.avatar = photoDataUrl;
} catch (error) {
console.error('Failed to capture photo:', error);
cameraError.value = error.message || 'Could not capture photo.';
}
}
function useDefaultAvatar() {
editablePlayer.avatar = defaultAvatar;
}
function captureHotkey(event, type) {
event.preventDefault();
const key = event.key.toLowerCase();
// Avoid modifier keys alone, allow combinations if needed, but spec says single keypresses
if (key && !['control', 'alt', 'shift', 'meta'].includes(key)) {
if (type === 'player') {
// Check if hotkey is already used by another player or global hotkey
const existingPlayerHotkey = store.state.players.some(p => p.hotkey === key && p.id !== editablePlayer.id);
const globalHotkeyInUse = store.state.globalHotkeyStopPause === key;
if (existingPlayerHotkey) {
alert(`Hotkey "${key.toUpperCase()}" is already assigned to another player.`);
return;
}
if (globalHotkeyInUse) {
alert(`Hotkey "${key.toUpperCase()}" is already assigned as the Global Stop/Pause hotkey.`);
return;
}
editablePlayer.hotkey = key;
}
}
}
function clearHotkey(type) {
if (type === 'player') {
editablePlayer.hotkey = '';
}
}
function submitForm() {
validateTimeFormat();
if (timeFormatError.value) return;
const playerPayload = { ...editablePlayer };
// Ensure currentTimerSec is also updated if initialTimerSec changes and it's a new player or reset is implied
if (!isEditing.value || (props.player && playerPayload.initialTimerSec !== props.player.initialTimerSec)) {
playerPayload.currentTimerSec = playerPayload.initialTimerSec;
}
emit('save', playerPayload);
closeModal();
}
function closeModal() {
emit('close');
}
</script>

View File

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

View File

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

26
src/main.js Normal file
View File

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

37
src/router/index.js Normal file
View File

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

View File

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

View File

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

View File

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

444
src/store/index.js Normal file
View File

@@ -0,0 +1,444 @@
import { createStore } from 'vuex';
import { StorageService } from '../services/StorageService';
import { parseTime, formatTime } from '../utils/timeFormatter';
import defaultAvatar from '../assets/default-avatar.png';
const MAX_NEGATIVE_SECONDS = -(59 * 60 + 59); // -59:59
// Define predefined players
const predefinedPlayers = [
{
id: 'predefined-1', // Unique ID for predefined player 1
name: 'Player 1',
avatar: defaultAvatar, // Or a specific avatar path if you have one
initialTimerSec: 60 * 60, // 60:00
currentTimerSec: 60 * 60,
hotkey: '1', // Hotkey '1'
isSkipped: false,
isTimerRunning: false,
},
{
id: 'predefined-2', // Unique ID for predefined player 2
name: 'Player 2',
avatar: defaultAvatar, // Or a specific avatar path
initialTimerSec: 60 * 60, // 60:00
currentTimerSec: 60 * 60,
hotkey: '2', // Hotkey '2'
isSkipped: false,
isTimerRunning: false,
}
];
const initialState = {
players: JSON.parse(JSON.stringify(predefinedPlayers)), // Start with predefined players (deep copy)
globalHotkeyStopPause: null,
currentPlayerIndex: 0,
gameMode: 'normal',
isMuted: false,
theme: 'light',
gameRunning: false,
};
export default createStore({
state: () => {
const persistedState = StorageService.getState();
if (persistedState) {
let playersToUse = persistedState.players || JSON.parse(JSON.stringify(predefinedPlayers)); // Use persisted or default
// Ensure avatar defaults and correct timer parsing if missing or in old format
playersToUse = playersToUse.map(p => ({
...p,
id: p.id || Date.now().toString() + Math.random(), // Ensure ID if missing
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: false, // Reset running state on load
hotkey: p.hotkey || null, // Ensure hotkey exists
}));
// If persisted state had no players, but we want predefined, use them.
// This logic could be refined: if the user explicitly deleted all players,
// we might not want to re-add predefined ones.
// For now, if persisted players array is empty, we'll use predefined ones.
if (persistedState.players && persistedState.players.length === 0) {
// User might have deleted all players, respect that if a 'players' key exists and is empty.
// If they want a clean slate without predefined, they delete them from UI.
// If 'players' key itself is missing from persistedState, then use predefined.
} else if (!persistedState.players) {
playersToUse = JSON.parse(JSON.stringify(predefinedPlayers));
}
return {
...initialState, // Base defaults
...persistedState, // Persisted values override defaults
players: playersToUse, // Specifically use the processed players
gameRunning: false, // Always start with game not running
currentPlayerIndex: persistedState.currentPlayerIndex !== undefined ? persistedState.currentPlayerIndex : 0, // Ensure valid index
};
}
// If no persisted state, return a deep copy of the initial state (which includes predefined players)
return JSON.parse(JSON.stringify(initialState));
},
mutations: {
SET_PLAYERS(state, players) {
state.players = players.map(p => ({
...p,
id: p.id || Date.now().toString() + Math.random(),
avatar: p.avatar || defaultAvatar,
initialTimerSec: typeof p.initialTimerSec === 'number' ? p.initialTimerSec : parseTime(p.initialTimer || "60:00"),
currentTimerSec: typeof p.currentTimerSec === 'number' ? p.currentTimerSec : (typeof p.initialTimerSec === 'number' ? p.initialTimerSec : parseTime(p.currentTimer || p.initialTimer || "60:00")),
isSkipped: p.isSkipped || false,
isTimerRunning: p.isTimerRunning || false, // Retain running state if explicitly set
hotkey: p.hotkey || null,
}));
},
ADD_PLAYER(state, player) {
const newPlayer = {
id: Date.now().toString() + Math.random(), // More robust unique ID
name: player.name || `Player ${state.players.length + 1}`,
avatar: player.avatar || defaultAvatar,
initialTimerSec: player.initialTimerSec || 3600,
currentTimerSec: player.initialTimerSec || 3600,
hotkey: player.hotkey || null,
isSkipped: false,
isTimerRunning: false,
};
if (state.players.length < 7) {
state.players.push(newPlayer);
}
},
UPDATE_PLAYER(state, updatedPlayer) {
const index = state.players.findIndex(p => p.id === updatedPlayer.id);
if (index !== -1) {
state.players[index] = { ...state.players[index], ...updatedPlayer };
}
},
DELETE_PLAYER(state, playerId) {
state.players = state.players.filter(p => p.id !== playerId);
if (state.currentPlayerIndex >= state.players.length && state.players.length > 0) {
state.currentPlayerIndex = state.players.length - 1;
} else if (state.players.length === 0) {
state.currentPlayerIndex = 0;
}
},
REORDER_PLAYERS(state, players) {
state.players = players;
},
SHUFFLE_PLAYERS(state) {
for (let i = state.players.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[state.players[i], state.players[j]] = [state.players[j], state.players[i]];
}
},
REVERSE_PLAYERS(state) {
state.players.reverse();
},
SET_CURRENT_PLAYER_INDEX(state, index) {
state.currentPlayerIndex = index;
},
SET_GAME_MODE(state, mode) {
state.gameMode = mode;
},
SET_IS_MUTED(state, muted) {
state.isMuted = muted;
},
TOGGLE_THEME(state) {
state.theme = state.theme === 'light' ? 'dark' : 'light';
},
SET_GLOBAL_HOTKEY_STOP_PAUSE(state, key) {
state.globalHotkeyStopPause = key;
},
DECREMENT_TIMER(state, { playerIndex }) {
const player = state.players[playerIndex];
if (player && player.isTimerRunning && !player.isSkipped) {
player.currentTimerSec--;
if (player.currentTimerSec < MAX_NEGATIVE_SECONDS) {
player.currentTimerSec = MAX_NEGATIVE_SECONDS;
player.isSkipped = true; // Auto-skip if max negative time reached
player.isTimerRunning = false;
}
}
},
RESET_PLAYER_TIMER(state, playerIndex) {
if (state.players[playerIndex]) {
state.players[playerIndex].currentTimerSec = state.players[playerIndex].initialTimerSec;
state.players[playerIndex].isSkipped = false;
state.players[playerIndex].isTimerRunning = false;
}
},
RESET_ALL_TIMERS(state) {
// When resetting, decide if you want to go back to *only* predefined players
// or reset existing players' timers. The current spec "restores all timers to initial values"
// implies resetting existing players. If it meant reverting to the initial player set,
// this logic would need to change to:
// state.players = JSON.parse(JSON.stringify(predefinedPlayers));
// For now, sticking to resetting current players' timers:
state.players.forEach(player => {
player.currentTimerSec = player.initialTimerSec;
player.isSkipped = false;
player.isTimerRunning = false;
});
state.currentPlayerIndex = 0;
state.gameMode = 'normal';
state.gameRunning = false;
},
// ... other mutations, actions, getters remain the same
// Make sure PAUSE_ALL_TIMERS, START_PLAYER_TIMER, PAUSE_PLAYER_TIMER update gameRunning
START_PLAYER_TIMER(state, playerIndex) {
if(state.players[playerIndex] && !state.players[playerIndex].isSkipped) {
state.players[playerIndex].isTimerRunning = true;
state.gameRunning = true;
}
},
PAUSE_PLAYER_TIMER(state, playerIndex) {
if(state.players[playerIndex]) {
state.players[playerIndex].isTimerRunning = false;
}
if (!state.players.some(p => p.isTimerRunning)) {
state.gameRunning = false;
}
},
PAUSE_ALL_TIMERS(state) {
state.players.forEach(p => p.isTimerRunning = false);
state.gameRunning = false;
},
SET_GAME_RUNNING(state, isRunning) {
state.gameRunning = isRunning;
},
},
actions: {
loadState({ commit }) {
// This action is effectively handled by the state initializer function now.
// We can keep it for explicitness or if we add more complex loading logic later.
// For now, the state initializer is doing the heavy lifting.
// If there are other things to initialize that aren't covered by state(), do them here.
},
saveState({ state }) {
StorageService.saveState({
players: state.players.map(p => ({ // Save a clean version of players
id: p.id,
name: p.name,
avatar: p.avatar === defaultAvatar ? null : p.avatar, // Don't save default avatar string if it's the default
initialTimerSec: p.initialTimerSec,
// currentTimerSec: p.currentTimerSec, // Don't save currentTimerSec to always start fresh? Or save it. Spec: "timer states ... are saved"
currentTimerSec: p.currentTimerSec,
hotkey: p.hotkey,
isSkipped: p.isSkipped,
// isTimerRunning is transient, should not be saved as running
})),
globalHotkeyStopPause: state.globalHotkeyStopPause,
currentPlayerIndex: state.currentPlayerIndex,
gameMode: state.gameMode,
isMuted: state.isMuted,
theme: state.theme,
// gameRunning is transient, should not be saved as true
});
},
addPlayer({ commit, dispatch }, player) {
commit('ADD_PLAYER', player);
dispatch('saveState');
},
updatePlayer({ commit, dispatch }, player) {
commit('UPDATE_PLAYER', player);
dispatch('saveState');
},
deletePlayer({ commit, dispatch }, playerId) {
commit('DELETE_PLAYER', playerId);
dispatch('saveState');
},
reorderPlayers({commit, dispatch}, players) {
commit('REORDER_PLAYERS', players);
dispatch('saveState');
},
shufflePlayers({commit, dispatch}) {
commit('SHUFFLE_PLAYERS');
dispatch('saveState');
},
reversePlayers({commit, dispatch}) {
commit('REVERSE_PLAYERS');
dispatch('saveState');
},
toggleTheme({ commit, dispatch }) {
commit('TOGGLE_THEME');
dispatch('saveState');
},
setGlobalHotkey({ commit, dispatch }, key) {
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', key);
dispatch('saveState');
},
setMuted({ commit, dispatch }, muted) {
commit('SET_IS_MUTED', muted);
dispatch('saveState');
},
resetGame({ commit, dispatch }) { // This is for resetting timers during a game session
commit('RESET_ALL_TIMERS');
dispatch('saveState');
},
// This action is for the full reset from SetupView
fullResetApp({ commit, dispatch }) {
StorageService.clearState();
// Re-initialize state to default, which includes predefined players
// This is tricky because the store is already created.
// Easiest is to rely on a page reload after clearing storage,
// or manually reset each piece of state to initialState.
commit('SET_PLAYERS', JSON.parse(JSON.stringify(predefinedPlayers)));
commit('SET_CURRENT_PLAYER_INDEX', initialState.currentPlayerIndex);
commit('SET_GAME_MODE', initialState.gameMode);
commit('SET_IS_MUTED', initialState.isMuted);
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', initialState.globalHotkeyStopPause);
// Manually set theme if it needs to be reset from initialState too
if (store.state.theme !== initialState.theme) {
commit('TOGGLE_THEME'); // This assumes toggle flips it, adjust if direct set is better
}
commit('SET_GAME_RUNNING', false);
dispatch('saveState'); // Save this fresh state
},
tick({ commit, state }) {
if (state.gameMode === 'normal') {
if (state.players[state.currentPlayerIndex]?.isTimerRunning) {
commit('DECREMENT_TIMER', { playerIndex: state.currentPlayerIndex });
}
} else if (state.gameMode === 'allTimers') {
state.players.forEach((player, index) => {
if (player.isTimerRunning) {
commit('DECREMENT_TIMER', { playerIndex: index });
}
});
}
},
passTurn({ commit, state, dispatch }) {
const numPlayers = state.players.length;
if (numPlayers === 0) return;
const currentIdx = state.currentPlayerIndex;
const currentPlayerTimerWasRunning = state.players[currentIdx]?.isTimerRunning;
commit('PAUSE_PLAYER_TIMER', currentIdx);
let nextPlayerIndex = (currentIdx + 1) % numPlayers;
let skippedCount = 0;
while(state.players[nextPlayerIndex]?.isSkipped && skippedCount < numPlayers) {
nextPlayerIndex = (nextPlayerIndex + 1) % numPlayers;
skippedCount++;
}
if (skippedCount === numPlayers) {
commit('PAUSE_ALL_TIMERS');
dispatch('saveState');
return;
}
commit('SET_CURRENT_PLAYER_INDEX', nextPlayerIndex);
if (currentPlayerTimerWasRunning && !state.players[nextPlayerIndex].isSkipped) {
commit('START_PLAYER_TIMER', nextPlayerIndex);
} else {
if (state.players[nextPlayerIndex] && !state.players[nextPlayerIndex].isSkipped) {
commit('PAUSE_PLAYER_TIMER', nextPlayerIndex);
}
}
dispatch('saveState');
},
toggleCurrentPlayerTimerNormalMode({ commit, state, dispatch }) {
const player = state.players[state.currentPlayerIndex];
if (!player) return;
if (player.isTimerRunning) {
commit('PAUSE_PLAYER_TIMER', state.currentPlayerIndex);
} else if (!player.isSkipped) {
commit('START_PLAYER_TIMER', state.currentPlayerIndex);
}
dispatch('saveState');
},
togglePlayerTimerAllTimersMode({ commit, state, dispatch }, playerIndex) {
const player = state.players[playerIndex];
if (!player) return;
if (player.isTimerRunning) {
commit('PAUSE_PLAYER_TIMER', playerIndex);
} else if (!player.isSkipped) {
commit('START_PLAYER_TIMER', playerIndex);
}
const anyTimerRunning = state.players.some(p => p.isTimerRunning && !p.isSkipped);
if (!anyTimerRunning && state.players.length > 0 && state.gameMode === 'allTimers') {
// This auto-revert logic is now in GameView.vue watcher for better control over timing
}
dispatch('saveState');
},
globalStopPauseAll({ commit, state, dispatch }) {
if (state.gameMode === 'normal') {
dispatch('toggleCurrentPlayerTimerNormalMode');
} else {
const anyTimerRunning = state.players.some(p => p.isTimerRunning && !p.isSkipped);
if (anyTimerRunning) {
commit('PAUSE_ALL_TIMERS');
} else {
state.players.forEach((player, index) => {
if (!player.isSkipped) {
commit('START_PLAYER_TIMER', index);
}
});
}
}
dispatch('saveState');
},
switchToAllTimersMode({ commit, state, dispatch }) {
commit('SET_GAME_MODE', 'allTimers');
let anyStarted = false;
state.players.forEach((player, index) => {
if (!player.isSkipped) {
commit('START_PLAYER_TIMER', index);
anyStarted = true;
}
});
if(anyStarted) commit('SET_GAME_RUNNING', true);
else commit('SET_GAME_RUNNING', false);
dispatch('saveState');
},
switchToNormalMode({commit, state, dispatch}) {
commit('PAUSE_ALL_TIMERS');
commit('SET_GAME_MODE', 'normal');
// Determine current player for normal mode, respecting skips
let currentIdx = state.currentPlayerIndex;
let skippedCount = 0;
while(state.players[currentIdx]?.isSkipped && skippedCount < state.players.length) {
currentIdx = (currentIdx + 1) % state.players.length;
skippedCount++;
}
if (skippedCount < state.players.length) {
commit('SET_CURRENT_PLAYER_INDEX', currentIdx);
// Timer for this player should remain paused as per PAUSE_ALL_TIMERS
} else {
// All players skipped, game is effectively paused.
commit('SET_GAME_RUNNING', false);
}
dispatch('saveState');
}
},
getters: {
players: state => state.players,
currentPlayer: state => state.players[state.currentPlayerIndex],
nextPlayer: state => {
if (!state.players || state.players.length < 1) return null;
let nextIndex = (state.currentPlayerIndex + 1) % state.players.length;
let count = 0;
while(state.players[nextIndex]?.isSkipped && count < state.players.length) {
nextIndex = (nextIndex + 1) % state.players.length;
count++;
}
return state.players[nextIndex];
},
getPlayerById: (state) => (id) => state.players.find(p => p.id === id),
gameMode: state => state.gameMode,
isMuted: state => state.isMuted,
theme: state => state.theme,
globalHotkeyStopPause: state => state.globalHotkeyStopPause,
totalPlayers: state => state.players.length,
gameRunning: state => state.gameRunning,
maxNegativeTimeReached: () => MAX_NEGATIVE_SECONDS,
}
});

View File

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

257
src/views/GameView.vue Normal file
View File

@@ -0,0 +1,257 @@
<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">
<div class="flex items-center space-x-2">
<button @click="navigateToSetup" class="btn btn-secondary text-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-1" viewBox="0 0 20 20" fill="currentColor"><path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" /></svg>
Setup
</button>
<button v-if="gameMode === 'normal'" @click="switchToAllTimersMode" class="btn btn-warning text-sm">
Run All Timers
</button>
<button v-if="gameMode === 'allTimers'" @click="switchToNormalMode" class="btn btn-warning text-sm">
Back to Normal Mode
</button>
</div>
<div class="flex items-center space-x-2">
<button @click="navigateToInfo" class="btn-icon">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<button @click="toggleMute" class="btn-icon">
<!-- Mute/Unmute SVG -->
<svg v-if="!isMuted" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15zm9.949-9.949a1 1 0 00-1.414 0L12 7.05l-2.085-2.085a1 1 0 00-1.414 1.414L10.586 8.5l-2.085 2.085a1 1 0 001.414 1.414L12 9.914l2.085 2.085a1 1 0 001.414-1.414L13.414 8.5l2.085-2.085a1 1 0 000-1.414z" /></svg>
</button>
</div>
</header>
<main class="flex-grow overflow-y-auto" ref="gameArea">
<!-- Normal Mode -->
<div v-if="gameMode === 'normal' && currentPlayer && nextPlayer" class="h-full flex flex-col">
<PlayerDisplay
:player="currentPlayer"
is-current-player-area
area-class="bg-gray-100 dark:bg-gray-800"
@tapped="handleCurrentPlayerTap"
/>
<div class="shrink-0 h-1 bg-blue-500"></div>
<PlayerDisplay
:player="nextPlayer"
is-next-player-area
area-class="bg-gray-200 dark:bg-gray-700"
@swiped-up="handlePassTurn"
/>
</div>
<!-- All Timers Running Mode -->
<div v-if="gameMode === 'allTimers'" class="p-4 h-full flex flex-col">
<!-- Removed the "Pause All / Resume All" button from here -->
<div class="mb-4 flex justify-start items-center"> <!-- Changed justify-between to justify-start -->
<h2 class="text-2xl font-semibold">All Timers Running</h2>
</div>
<div class="flex-grow overflow-y-auto space-y-2">
<PlayerListItem
v-for="(player) in playersInAllTimersView"
:key="player.id"
:player="player"
@tapped="() => handlePlayerTapAllTimers(indexInFullList(player.id))"
/>
<p v-if="playersInAllTimersView.length === 0 && players.length > 0 && anyTimerCouldRun()" class="text-center text-gray-500 dark:text-gray-400 py-6">
All active timers paused.
</p>
<p v-if="playersInAllTimersView.length === 0 && players.length > 0 && !anyTimerCouldRun()" class="text-center text-gray-500 dark:text-gray-400 py-6">
No players eligible to run. (All skipped or issue)
</p>
<p v-if="players.length === 0" class="text-center text-gray-500 dark:text-gray-400 py-6">
No players to display.
</p>
</div>
</div>
</main>
<footer class="p-3 bg-gray-100 dark:bg-gray-800 shadow-inner shrink-0 text-center">
<span class="text-xs text-gray-500 dark:text-gray-400">Nexus Timer</span>
</footer>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useStore } from 'vuex';
import { useRouter } from 'vue-router';
import PlayerDisplay from '../components/PlayerDisplay.vue';
import PlayerListItem from '../components/PlayerListItem.vue';
import { AudioService } from '../services/AudioService';
const store = useStore();
const router = useRouter();
const theme = computed(() => store.getters.theme);
const players = computed(() => store.getters.players);
const currentPlayer = computed(() => store.getters.currentPlayer);
const nextPlayer = computed(() => store.getters.nextPlayer);
const gameMode = computed(() => store.getters.gameMode);
const isMuted = computed(() => store.getters.isMuted);
const gameRunning = computed(() => store.getters.gameRunning);
let timerInterval = null;
const playersInAllTimersView = computed(() => {
if (gameMode.value === 'allTimers') {
return players.value.filter(p => p.isTimerRunning && !p.isSkipped);
}
return [];
});
const anyTimerRunningInAllMode = computed(() => {
return players.value.some(p => p.isTimerRunning && !p.isSkipped);
});
const anyTimerCouldRun = computed(() => {
return players.value.some(p => !p.isSkipped);
});
const indexInFullList = (playerId) => {
return players.value.findIndex(p => p.id === playerId);
}
onMounted(() => {
if (players.value.length < 2) {
router.push({ name: 'Setup' });
return;
}
timerInterval = setInterval(() => {
store.dispatch('tick');
}, 1000);
});
onUnmounted(() => {
clearInterval(timerInterval);
AudioService.stopContinuousTick();
AudioService.cancelPassTurnSound();
});
// Watch gameMode for audio changes
watch(gameMode, (newMode) => {
// Always stop any ongoing sounds when mode changes
AudioService.stopContinuousTick();
AudioService.cancelPassTurnSound();
if (newMode === 'allTimers') {
if (anyTimerRunningInAllMode.value) { // If timers are already running when switching TO this mode
AudioService.startContinuousTick();
}
} else { // normal mode
if (currentPlayer.value && currentPlayer.value.isTimerRunning) {
AudioService.playPassTurnAlert();
}
}
});
// Watch specific timer states for audio in AllTimers mode (for ticking)
watch(anyTimerRunningInAllMode, (isRunning) => {
if (gameMode.value === 'allTimers') { // Only act if in allTimers mode
if (isRunning) {
AudioService.startContinuousTick();
} else {
AudioService.stopContinuousTick();
}
}
});
// Watch current player for pass turn alert in Normal mode
watch(currentPlayer, (newPlayer, oldPlayer) => {
if (gameMode.value === 'normal' && newPlayer && newPlayer.isTimerRunning && oldPlayer && newPlayer.id !== oldPlayer.id) {
AudioService.playPassTurnAlert();
}
}, { deep: true });
// Watch current player's timer running state for audio in Normal mode (manual tap)
watch(() => currentPlayer.value?.isTimerRunning, (isRunning, wasRunning) => {
if (gameMode.value === 'normal' && currentPlayer.value) {
if (isRunning === true && wasRunning === false) {
AudioService.playPassTurnAlert();
} else if (isRunning === false && wasRunning === true) {
AudioService.cancelPassTurnSound();
}
}
});
const navigateToSetup = () => {
const isAnyTimerActive = store.getters.gameRunning;
if (isAnyTimerActive) {
if (window.confirm('Game is active. Going to Setup will pause all timers. Continue?')) {
store.commit('PAUSE_ALL_TIMERS');
router.push({ name: 'Setup' });
}
} else {
router.push({ name: 'Setup' });
}
};
const navigateToInfo = () => {
if (store.getters.gameRunning) {
store.commit('PAUSE_ALL_TIMERS');
}
router.push({ name: 'Info' });
};
const toggleMute = () => {
store.dispatch('setMuted', !isMuted.value);
};
const handleCurrentPlayerTap = () => {
store.dispatch('toggleCurrentPlayerTimerNormalMode');
};
const handlePassTurn = () => {
if(currentPlayer.value && !currentPlayer.value.isSkipped) {
AudioService.cancelPassTurnSound();
const wasRunning = currentPlayer.value.isTimerRunning;
store.dispatch('passTurn').then(() => {
if (wasRunning && gameMode.value === 'normal' && currentPlayer.value && currentPlayer.value.isTimerRunning) {
AudioService.playPassTurnAlert();
}
});
}
};
const handlePlayerTapAllTimers = (playerIndex) => {
store.dispatch('togglePlayerTimerAllTimersMode', playerIndex);
};
// handleStopStartAllTimers button was removed from the template for AllTimersMode
const switchToAllTimersMode = () => {
store.dispatch('switchToAllTimersMode');
};
const switchToNormalMode = () => {
store.dispatch('switchToNormalMode'); // This action already pauses all timers
// Audio for normal mode (if current player starts) is handled by watchers
};
// Watcher for auto-reverting from All Timers mode
watch(anyTimerRunningInAllMode, (anyRunning) => {
if (gameMode.value === 'allTimers' && !anyRunning && players.value.length > 0) {
const nonSkippedPlayersExist = players.value.some(p => !p.isSkipped);
if (nonSkippedPlayersExist) {
setTimeout(() => {
if(gameMode.value === 'allTimers' && !store.getters.players.some(p => p.isTimerRunning && !p.isSkipped)){
store.dispatch('switchToNormalMode');
}
}, 200);
} else {
AudioService.stopContinuousTick();
}
}
});
</script>

59
src/views/InfoView.vue Normal file
View File

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

216
src/views/SetupView.vue Normal file
View File

@@ -0,0 +1,216 @@
<template>
<div class="flex-grow flex flex-col p-4 md:p-6 lg:p-8 items-center dark:bg-gray-800">
<header class="w-full max-w-3xl mb-6 text-center">
<h1 class="text-4xl font-bold text-blue-600 dark:text-blue-400">Nexus Timer Setup</h1>
</header>
<!-- Player Management -->
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
<div class="flex justify-between items-center mb-4">
<h2 class="text-2xl font-semibold">Players ({{ players.length }}/7)</h2>
<button @click="openAddPlayerModal" class="btn btn-primary" :disabled="players.length >= 7">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 inline mr-1" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clip-rule="evenodd" /></svg>
Add Player
</button>
</div>
<p v-if="players.length < 2" class="text-sm text-yellow-600 dark:text-yellow-400 mb-3">At least 2 players are required to start.</p>
<div v-if="players.length > 0" class="space-y-3 mb-4">
<div v-for="(player, index) in players" :key="player.id" class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-600 rounded shadow-sm">
<div class="flex items-center flex-wrap"> <!-- Added flex-wrap for smaller screens -->
<img :src="player.avatar" alt="Avatar" class="w-10 h-10 rounded-full object-cover mr-3">
<span class="font-medium mr-2">{{ player.name }}</span>
<span class="mr-2 text-xs text-gray-500 dark:text-gray-400">({{ formatTime(player.initialTimerSec) }})</span>
<!-- Changed HK to Hotkey -->
<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-2 flex-shrink-0"> <!-- Added flex-shrink-0 -->
<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>
</div>
</div>
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-4">
No players added yet. Add at least 2 to start.
</div>
<div v-if="players.length > 1" class="flex space-x-2 mt-4">
<button @click="shufflePlayers" class="btn btn-secondary text-sm">Shuffle Order</button>
<button @click="reversePlayers" class="btn btn-secondary text-sm">Reverse Order</button>
</div>
</section>
<!-- Game Settings -->
<section class="w-full max-w-3xl bg-white dark:bg-gray-700 p-6 rounded-lg shadow-md mb-6">
<h2 class="text-2xl font-semibold mb-4">Game Settings</h2>
<div class="mb-4">
<label for="globalHotkey" class="block text-sm font-medium text-gray-700 dark:text-gray-300">"Global Stop/Pause All" Hotkey</label>
<input
type="text"
id="globalHotkey"
:value="globalHotkeyDisplay"
@keydown.prevent="captureGlobalHotkey"
placeholder="Press a key"
class="input-base w-full sm:w-1/2"
readonly
>
<button type="button" @click="clearGlobalHotkey" class="text-xs text-blue-500 hover:underline mt-1 ml-2">Clear Hotkey</button>
</div>
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Dark Mode</span>
<button @click="toggleTheme" class="px-3 py-1.5 rounded-md text-sm font-medium" :class="theme === 'dark' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-800'">
{{ theme === 'dark' ? 'On' : 'Off' }}
</button>
</div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Mute Audio</span>
<button @click="toggleMute" class="px-3 py-1.5 rounded-md text-sm font-medium" :class="isMuted ? 'bg-red-500 text-white' : 'bg-gray-200 text-gray-800'">
{{ isMuted ? 'Muted' : 'Unmuted' }}
</button>
</div>
</section>
<!-- Actions -->
<section class="w-full max-w-3xl mt-4 space-y-3">
<button @click="saveAndClose" class="w-full btn btn-primary btn-lg text-xl py-3" :disabled="players.length < 2 && players.length !==0">
Save & Close
</button>
<button @click="resetPlayerTimersConfirm" class="w-full btn btn-warning py-2">
Reset Player Timers
</button>
<button @click="fullResetAppConfirm" class="w-full btn btn-danger py-2">
Reset Entire App Data
</button>
</section>
<PlayerForm
v-if="showPlayerModal"
:player="editingPlayer"
@close="closePlayerModal"
@save="savePlayer"
/>
</div>
</template>
<script setup>
// ... (script setup remains the same)
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';
const store = useStore();
const router = useRouter();
const players = computed(() => store.getters.players);
const theme = computed(() => store.getters.theme);
const isMuted = computed(() => store.getters.isMuted);
const globalHotkey = computed(() => store.getters.globalHotkeyStopPause);
const globalHotkeyDisplay = computed(() => globalHotkey.value ? globalHotkey.value.toUpperCase() : '');
const showPlayerModal = ref(false);
const editingPlayer = ref(null);
const openAddPlayerModal = () => {
if (players.value.length < 7) {
editingPlayer.value = null;
showPlayerModal.value = true;
}
};
const openEditPlayerModal = (player) => {
editingPlayer.value = player;
showPlayerModal.value = true;
};
const closePlayerModal = () => {
showPlayerModal.value = false;
editingPlayer.value = null;
};
const savePlayer = (playerData) => {
if (playerData.id) {
store.dispatch('updatePlayer', playerData);
} else {
store.dispatch('addPlayer', {
name: playerData.name,
avatar: playerData.avatar,
initialTimerSec: playerData.initialTimerSec,
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 captureGlobalHotkey = (event) => {
event.preventDefault();
const key = event.key.toLowerCase();
if (key && !['control', 'alt', 'shift', 'meta'].includes(key)) {
const existingPlayerHotkey = store.state.players.some(p => p.hotkey === key);
if (existingPlayerHotkey) {
alert(`Hotkey "${key.toUpperCase()}" is already assigned to a player.`);
return;
}
store.dispatch('setGlobalHotkey', key);
}
};
const clearGlobalHotkey = () => {
store.dispatch('setGlobalHotkey', null);
};
const toggleTheme = () => {
store.dispatch('toggleTheme');
};
const toggleMute = () => {
store.dispatch('setMuted', !isMuted.value);
};
const saveAndClose = () => {
store.dispatch('saveState');
if (players.value.length >= 2) {
if (store.state.currentPlayerIndex >= players.value.length || store.state.currentPlayerIndex < 0) { // Ensure valid index
store.commit('SET_CURRENT_PLAYER_INDEX', 0);
}
// When saving and closing from setup, always start in normal mode, paused.
store.commit('PAUSE_ALL_TIMERS');
store.commit('SET_GAME_MODE', 'normal');
router.push({ name: 'Game' });
} else if (players.value.length === 0) {
alert('Add at least 2 players to start a game.');
} else {
alert('At least 2 players are required to start a game.');
}
};
const resetPlayerTimersConfirm = () => {
if (window.confirm('Are you sure you want to reset all current players\' timers to their initial values? This will not delete players.')) {
store.dispatch('resetGame');
}
};
const fullResetAppConfirm = () => {
if (window.confirm('Are you sure you want to reset all app data? This includes all players, settings, and timer states. The app will revert to its default state with 2 predefined players.')) {
store.dispatch('fullResetApp');
}
};
</script>

29
tailwind.config.js Normal file
View File

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

10
vite.config.js Normal file
View File

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