without footer
This commit is contained in:
@@ -1,4 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="7" r="4" stroke="#000000" stroke-width="2"/>
|
||||
<path d="M4 21C4 17.134 7.58172 14 12 14C16.4183 14 20 17.134 20 21" stroke="#000000" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 273 B |
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'nexus-timer-cache-v1';
|
||||
const CACHE_NAME = 'nexus-timer-cache-v2';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
48
src/App.vue
48
src/App.vue
@@ -1,12 +1,11 @@
|
||||
// src/App.vue
|
||||
<template>
|
||||
<div :class="[theme, 'min-h-screen flex flex-col no-select']">
|
||||
<router-view />
|
||||
<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, onUnmounted, watch } from 'vue'; // Added onUnmounted
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { AudioService } from './services/AudioService';
|
||||
|
||||
@@ -14,25 +13,24 @@ const store = useStore();
|
||||
const theme = computed(() => store.getters.theme);
|
||||
|
||||
onMounted(() => {
|
||||
// store.dispatch('loadState'); // <-- REMOVE THIS LINE
|
||||
store.dispatch('loadState').then(() => {
|
||||
console.log("App.vue: Store has finished loading state.");
|
||||
applyTheme();
|
||||
}).catch(error => {
|
||||
console.error("App.vue: Error during store.dispatch('loadState'):", error);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', handleGlobalKeyDown);
|
||||
|
||||
const resumeAudio = () => {
|
||||
AudioService.resumeContext();
|
||||
// document.body.removeEventListener('click', resumeAudio); // These are fine with {once: true}
|
||||
// document.body.removeEventListener('touchstart', resumeAudio);
|
||||
document.body.removeEventListener('click', resumeAudio);
|
||||
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();
|
||||
});
|
||||
@@ -63,14 +61,14 @@ const handleGlobalKeyDown = (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPlayerFromStore = store.getters.currentPlayer; // Renamed to avoid conflict
|
||||
const gameMode = store.getters.gameMode;
|
||||
const currentPlayerInStore = store.getters.currentPlayer;
|
||||
const gameModeInStore = store.getters.gameMode;
|
||||
|
||||
if (gameMode === 'normal' && currentPlayerFromStore && keyPressed === currentPlayerFromStore.hotkey) {
|
||||
if (gameModeInStore === 'normal' && currentPlayerInStore && keyPressed === currentPlayerInStore.hotkey) {
|
||||
event.preventDefault();
|
||||
const wasRunning = currentPlayerFromStore.isTimerRunning;
|
||||
const wasRunning = currentPlayerInStore.isTimerRunning;
|
||||
store.dispatch('passTurn').then(() => {
|
||||
const newCurrentPlayer = store.getters.currentPlayer; // Get updated player
|
||||
const newCurrentPlayer = store.getters.currentPlayer;
|
||||
if (wasRunning && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
@@ -78,7 +76,7 @@ const handleGlobalKeyDown = (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (gameMode === 'allTimers') {
|
||||
if (gameModeInStore === 'allTimers') {
|
||||
const playerToToggle = store.state.players.find(p => p.hotkey === keyPressed && !p.isSkipped);
|
||||
if (playerToToggle) {
|
||||
event.preventDefault();
|
||||
@@ -87,12 +85,20 @@ const handleGlobalKeyDown = (event) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
html, body, #app {
|
||||
html, body, #app { /* These styles are critical */
|
||||
height: 100%;
|
||||
margin: 0; /* Ensure no default margin */
|
||||
padding: 0; /* Ensure no default padding */
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
/* Ensure #app's direct child (from router-view) can also flex expand if needed */
|
||||
/* This might not be necessary if GameView itself handles its height with flex */
|
||||
/* #app > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
} */
|
||||
</style>
|
||||
9
src/components/DefaultAvatarIcon.vue
Normal file
9
src/components/DefaultAvatarIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// No script needed for a simple SVG icon component
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
:class="['player-area p-4 flex flex-col items-center justify-center text-center relative h-1/2 no-select',
|
||||
:class="['player-area p-2 md:p-4 flex flex-col items-center justify-center text-center relative no-select',
|
||||
areaClass,
|
||||
{ 'opacity-50': player.isSkipped,
|
||||
'bg-green-100 dark:bg-green-800 animate-pulsePositive': player.isTimerRunning && player.currentTimerSec >=0 && !isNextPlayerArea,
|
||||
@@ -8,32 +8,52 @@
|
||||
}]"
|
||||
@click="handleTap"
|
||||
v-touch:swipe.up="handleSwipeUp"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="mb-6 md:mb-4 relative"
|
||||
:style="{
|
||||
width: avatarSize.width,
|
||||
height: avatarSize.height,
|
||||
}"
|
||||
>
|
||||
<img
|
||||
:src="player.avatar || defaultAvatar"
|
||||
v-if="player.avatar"
|
||||
:src="player.avatar"
|
||||
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="rounded-full object-cover border-2 md:border-4 shadow-lg w-full h-full"
|
||||
:class="player.isSkipped ? 'border-gray-500 filter grayscale' : 'border-blue-500 dark:border-blue-400'"
|
||||
/>
|
||||
<h2 class="text-3xl md:text-4xl lg:text-5xl font-semibold mb-2 mt-2">{{ player.name }}</h2>
|
||||
<DefaultAvatarIcon
|
||||
v-else
|
||||
class="rounded-full object-cover border-2 md:border-4 shadow-lg text-gray-400 dark:text-gray-500 bg-gray-200 dark:bg-gray-700 p-1 md:p-2 w-full h-full"
|
||||
:class="player.isSkipped ? 'border-gray-500 filter grayscale !text-gray-300 dark:!text-gray-600' : 'border-blue-500 dark:border-blue-400'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Player Name -->
|
||||
<h2 class="font-semibold mb-6 md:mb-6 text-5xl sm:text-2xl md:text-3xl lg:text-5xl">
|
||||
{{ player.name }}
|
||||
</h2>
|
||||
|
||||
<!-- Timer Display -->
|
||||
<TimerDisplay
|
||||
:seconds="player.currentTimerSec"
|
||||
:is-negative="player.currentTimerSec < 0"
|
||||
:is-pulsating="player.isTimerRunning"
|
||||
class="text-6xl md:text-8xl lg:text-9xl xl:text-[10rem]"
|
||||
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-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>
|
||||
|
||||
<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 } from 'vue';
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue';
|
||||
import TimerDisplay from './TimerDisplay.vue';
|
||||
import defaultAvatar from '../assets/default-avatar.png';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
|
||||
|
||||
// Basic touch directive
|
||||
const vTouch = {
|
||||
mounted: (el, binding) => {
|
||||
if (binding.arg === 'swipe' && binding.modifiers.up) {
|
||||
@@ -41,7 +61,7 @@ const vTouch = {
|
||||
let touchstartY = 0;
|
||||
let touchendX = 0;
|
||||
let touchendY = 0;
|
||||
const swipeThreshold = 50; // Min pixels for swipe
|
||||
const swipeThreshold = 50;
|
||||
|
||||
el.addEventListener('touchstart', function(event) {
|
||||
touchstartX = event.changedTouches[0].screenX;
|
||||
@@ -68,6 +88,7 @@ const vTouch = {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const props = defineProps({
|
||||
player: {
|
||||
type: Object,
|
||||
@@ -80,6 +101,49 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['tapped', 'swiped-up']);
|
||||
|
||||
// --- Responsive Avatar Size Logic ---
|
||||
const avatarSize = ref({ width: '120px', height: '120px' }); // Default mobile size
|
||||
|
||||
const calculateAvatarSize = () => {
|
||||
const screenWidth = window.innerWidth;
|
||||
const screenHeight = window.innerHeight;
|
||||
const isNormalModeContext = document.querySelector('.player-area')?.parentElement?.classList.contains('flex-col'); // Heuristic
|
||||
|
||||
if (isNormalModeContext) { // Larger sizes for Normal Mode split screen
|
||||
// Use vmin for responsiveness but cap it for desktop
|
||||
// Each player display gets roughly half the screen height in Normal mode.
|
||||
// Avatar should be a significant portion of that, but not overwhelming on desktop.
|
||||
const availableHeight = screenHeight / 2; // Approximate height for one player display
|
||||
let size = Math.min(availableHeight * 0.5, screenWidth * 0.4, 140); // Cap at 220px for desktop
|
||||
|
||||
if (screenWidth < 768) { // Mobile
|
||||
size = Math.min(availableHeight * 0.6, screenWidth * 0.5, 210); // Smaller cap for mobile
|
||||
} else if (screenWidth < 1024) { // Tablet / Small Desktop
|
||||
size = Math.min(availableHeight * 0.55, screenWidth * 0.45, 180);
|
||||
}
|
||||
// Ensure a minimum size
|
||||
size = Math.max(size, 100);
|
||||
|
||||
|
||||
avatarSize.value = { width: `${size}px`, height: `${size}px` };
|
||||
} else {
|
||||
// Fallback or different logic for other contexts if PlayerDisplay is reused
|
||||
avatarSize.value = { width: '120px', height: '120px' };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
calculateAvatarSize();
|
||||
window.addEventListener('resize', calculateAvatarSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', calculateAvatarSize);
|
||||
});
|
||||
// --- End Responsive Avatar Size Logic ---
|
||||
|
||||
|
||||
const handleTap = () => {
|
||||
if (props.isCurrentPlayerArea && !props.player.isSkipped) {
|
||||
emit('tapped');
|
||||
@@ -92,9 +156,3 @@ const handleSwipeUp = () => {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-area {
|
||||
transition: background-color 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
@@ -9,15 +9,26 @@
|
||||
</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>
|
||||
<!-- Changed label and v-model -->
|
||||
<label for="remainingTime" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Remaining Time (MM:SS or -MM:SS)</label>
|
||||
<input type="text" id="remainingTime" v-model="currentTimeFormatted" @blur="validateCurrentTimeFormat" placeholder="e.g., 55:30 or -02:15" required class="input-base">
|
||||
<p v-if="currentTimeFormatError" class="text-red-500 text-xs mt-1">{{ currentTimeFormatError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Avatar</label>
|
||||
<div class="mt-1 flex items-center">
|
||||
<img :src="editablePlayer.avatar || defaultAvatar" alt="Avatar" class="w-16 h-16 rounded-full object-cover mr-4 border">
|
||||
<!-- Conditional rendering for avatar -->
|
||||
<img
|
||||
v-if="editablePlayer.avatar"
|
||||
:src="editablePlayer.avatar"
|
||||
alt="Avatar"
|
||||
class="w-16 h-16 rounded-full object-cover mr-4 border"
|
||||
/>
|
||||
<DefaultAvatarIcon
|
||||
v-else
|
||||
class="w-16 h-16 rounded-full object-cover mr-4 border text-gray-400 bg-gray-200 p-1"
|
||||
/>
|
||||
<button type="button" @click="capturePhoto" class="btn btn-secondary text-sm mr-2">Take Photo</button>
|
||||
<button type="button" @click="useDefaultAvatar" class="btn btn-secondary text-sm">Default</button>
|
||||
</div>
|
||||
@@ -40,101 +51,118 @@
|
||||
|
||||
<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>
|
||||
<button type="submit" class="btn btn-primary" :disabled="!!currentTimeFormatError">{{ isEditing ? 'Save Changes' : 'Add Player' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</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
|
||||
<script setup>
|
||||
import { ref, reactive, watch, onMounted, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { CameraService } from '../services/CameraService';
|
||||
import { formatTime, parseTime } from '../utils/timeFormatter';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue'; // Import SVG component
|
||||
import { AudioService } from '../services/AudioService';
|
||||
|
||||
const props = defineProps({
|
||||
player: Object, // Player object for editing, null for adding
|
||||
});
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
const props = defineProps({
|
||||
player: Object,
|
||||
});
|
||||
const emit = defineEmits(['close', 'save']);
|
||||
|
||||
const store = useStore();
|
||||
const defaultAvatar = defaultAvatarPath;
|
||||
const store = useStore();
|
||||
const DEFAULT_AVATAR_MARKER = null; // Align with store
|
||||
|
||||
const isEditing = computed(() => !!props.player); // This line was causing the error
|
||||
const editablePlayer = reactive({
|
||||
const isEditing = computed(() => !!props.player);
|
||||
const editablePlayer = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
avatar: defaultAvatar,
|
||||
initialTimerSec: 3600, // 60:00
|
||||
avatar: DEFAULT_AVATAR_MARKER,
|
||||
initialTimerSec: 3600, // Still keep initial for reference or new players
|
||||
currentTimerSec: 3600, // This will be edited
|
||||
hotkey: '',
|
||||
});
|
||||
});
|
||||
|
||||
const initialTimeFormatted = ref('60:00');
|
||||
const timeFormatError = ref('');
|
||||
const cameraError = ref('');
|
||||
const currentTimeFormatted = ref('60:00'); // For "Remaining Time"
|
||||
const currentTimeFormatError = ref('');
|
||||
const cameraError = ref('');
|
||||
|
||||
onMounted(() => {
|
||||
const maxNegativeSeconds = computed(() => store.getters.maxNegativeTimeReached);
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
if (isEditing.value && props.player) {
|
||||
editablePlayer.id = props.player.id;
|
||||
editablePlayer.name = props.player.name;
|
||||
editablePlayer.avatar = props.player.avatar || defaultAvatar;
|
||||
editablePlayer.initialTimerSec = props.player.initialTimerSec;
|
||||
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 || '';
|
||||
initialTimeFormatted.value = formatTime(props.player.initialTimerSec);
|
||||
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
|
||||
} else { // Adding new player
|
||||
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
|
||||
editablePlayer.initialTimerSec = 3600; // Default initial for new player
|
||||
editablePlayer.currentTimerSec = 3600; // Default current for new player
|
||||
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
|
||||
}
|
||||
});
|
||||
|
||||
watch(currentTimeFormatted, (newTime) => {
|
||||
validateCurrentTimeFormat();
|
||||
if (!currentTimeFormatError.value) {
|
||||
editablePlayer.currentTimerSec = parseTime(newTime);
|
||||
// If editing, currentTimerSec might differ from initialTimerSec
|
||||
// If adding new, initialTimerSec should also be set to this value.
|
||||
if (!isEditing.value) {
|
||||
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function validateCurrentTimeFormat() {
|
||||
const time = currentTimeFormatted.value;
|
||||
const isNegativeInput = time.startsWith('-');
|
||||
// Regex for MM:SS or -MM:SS
|
||||
// Allows more than 59 minutes for setup (e.g. 70:00 or -70:00 is technically allowed by parseTime)
|
||||
// But usually for remaining time, it reflects the actual state.
|
||||
if (!/^-?(?:[0-9]+:[0-5]\d)$/.test(time)) {
|
||||
currentTimeFormatError.value = 'Invalid format. Use MM:SS or -MM:SS (e.g., 05:30, -02:15).';
|
||||
} else {
|
||||
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 = '';
|
||||
const parsedSeconds = parseTime(time);
|
||||
if (isNegativeInput && parsedSeconds > 0) { // Should be negative or zero
|
||||
currentTimeFormatError.value = 'Negative time should parse to negative seconds.';
|
||||
} else if (parsedSeconds < maxNegativeSeconds.value) {
|
||||
currentTimeFormatError.value = `Time cannot be less than ${formatTime(maxNegativeSeconds.value)}.`;
|
||||
}
|
||||
else {
|
||||
currentTimeFormatError.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function capturePhoto() {
|
||||
async function capturePhoto() {
|
||||
cameraError.value = '';
|
||||
try {
|
||||
AudioService.resumeContext(); // Ensure audio context is active for camera sounds if any
|
||||
AudioService.resumeContext();
|
||||
const photoDataUrl = await CameraService.getPhoto();
|
||||
editablePlayer.avatar = photoDataUrl;
|
||||
} catch (error) {
|
||||
console.error('Failed to capture photo:', error);
|
||||
cameraError.value = error.message || 'Could not capture photo.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function useDefaultAvatar() {
|
||||
editablePlayer.avatar = defaultAvatar;
|
||||
}
|
||||
function useDefaultAvatar() {
|
||||
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
|
||||
}
|
||||
|
||||
function captureHotkey(event, type) {
|
||||
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) {
|
||||
@@ -148,30 +176,44 @@
|
||||
editablePlayer.hotkey = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearHotkey(type) {
|
||||
function clearHotkey(type) {
|
||||
if (type === 'player') {
|
||||
editablePlayer.hotkey = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
validateTimeFormat();
|
||||
if (timeFormatError.value) return;
|
||||
function submitForm() {
|
||||
validateCurrentTimeFormat();
|
||||
if (currentTimeFormatError.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;
|
||||
|
||||
// When saving, ensure isSkipped status is updated if time is max negative
|
||||
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
|
||||
playerPayload.isSkipped = true;
|
||||
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
|
||||
// If time was edited to be > maxNegative, unskip them.
|
||||
playerPayload.isSkipped = false;
|
||||
}
|
||||
|
||||
// If adding a new player, initialTimerSec should match currentTimerSec.
|
||||
if (!isEditing.value) {
|
||||
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
|
||||
}
|
||||
// If editing an existing player, and current time becomes the new "initial" baseline if reset
|
||||
// This is debatable. The spec says "Set initial timer values per player".
|
||||
// If user edits "remaining time", should it also update "initialTimer"?
|
||||
// For now, let's assume "initialTimer" is only set when player is first added,
|
||||
// or if explicitly edited through a separate mechanism (not present).
|
||||
// So, playerPayload.initialTimerSec will be what was loaded or set for new player.
|
||||
|
||||
emit('save', playerPayload);
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
function closeModal() {
|
||||
emit('close');
|
||||
}
|
||||
</script>
|
||||
}
|
||||
</script>
|
||||
@@ -8,12 +8,19 @@
|
||||
@click="handleTap"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<!-- Conditional rendering for avatar -->
|
||||
<img
|
||||
:src="player.avatar || defaultAvatar"
|
||||
v-if="player.avatar"
|
||||
:src="player.avatar"
|
||||
alt="Player Avatar"
|
||||
class="w-12 h-12 rounded-full object-cover mr-3 border-2"
|
||||
:class="player.isSkipped ? 'border-gray-500 filter grayscale' : 'border-blue-400 dark:border-blue-300'"
|
||||
/>
|
||||
<DefaultAvatarIcon
|
||||
v-else
|
||||
class="w-12 h-12 rounded-full object-cover mr-3 border-2 text-gray-400 dark:text-gray-500 bg-gray-200 dark:bg-gray-700 p-1"
|
||||
:class="player.isSkipped ? 'border-gray-500 filter grayscale !text-gray-300 dark:!text-gray-600' : 'border-blue-400 dark:border-blue-300'"
|
||||
/>
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">{{ player.name }}</h3>
|
||||
<p v-if="player.isSkipped" class="text-xs text-red-500 dark:text-red-400">SKIPPED</p>
|
||||
@@ -26,17 +33,16 @@
|
||||
: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';
|
||||
import DefaultAvatarIcon from './DefaultAvatarIcon.vue'; // Import the SVG component
|
||||
|
||||
// ... (rest of script setup: props, emit, handleTap, itemBgClass)
|
||||
const props = defineProps({
|
||||
player: {
|
||||
type: Object,
|
||||
|
||||
@@ -40,45 +40,36 @@ const initialState = {
|
||||
};
|
||||
|
||||
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 || JSON.parse(JSON.stringify(predefinedPlayers)); // Use persisted or default
|
||||
let playersToUse = persistedState.players;
|
||||
|
||||
if (!playersToUse || (playersToUse.length === 0 && !persistedState.hasOwnProperty('players'))) {
|
||||
playersToUse = JSON.parse(JSON.stringify(predefinedPlayers));
|
||||
} else if (persistedState.hasOwnProperty('players') && playersToUse.length === 0) {
|
||||
playersToUse = [];
|
||||
}
|
||||
|
||||
// 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,
|
||||
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, // Reset running state on load
|
||||
hotkey: p.hotkey || null, // Ensure hotkey exists
|
||||
isTimerRunning: false,
|
||||
hotkey: p.hotkey || null,
|
||||
}));
|
||||
|
||||
// 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
|
||||
...initialState,
|
||||
...persistedState,
|
||||
players: playersToUse,
|
||||
gameRunning: false,
|
||||
currentPlayerIndex: persistedState.currentPlayerIndex !== undefined && persistedState.currentPlayerIndex < playersToUse.length ? persistedState.currentPlayerIndex : 0,
|
||||
};
|
||||
}
|
||||
// If no persisted state, return a deep copy of the initial state (which includes predefined players)
|
||||
return JSON.parse(JSON.stringify(initialState));
|
||||
},
|
||||
mutations: {
|
||||
@@ -184,8 +175,6 @@ export default createStore({
|
||||
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;
|
||||
@@ -209,31 +198,40 @@ export default createStore({
|
||||
},
|
||||
},
|
||||
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.
|
||||
loadState({ commit, state }) {
|
||||
// The state initializer already did the main loading from localStorage.
|
||||
// This action can be used for any *additional* setup after initial hydration
|
||||
// or to re-apply certain defaults if needed.
|
||||
// For now, it's mainly a confirmation that persisted state is used.
|
||||
|
||||
// Example: ensure theme is applied if it was loaded
|
||||
// This is already handled by App.vue's watcher, but could be centralized.
|
||||
// if (state.theme === 'dark') {
|
||||
// document.documentElement.classList.add('dark');
|
||||
// } else {
|
||||
// document.documentElement.classList.remove('dark');
|
||||
// }
|
||||
console.log("Store state loaded/initialized.");
|
||||
// It's good practice for actions to return a Promise if they are async
|
||||
// or if other parts of the app expect to chain .then()
|
||||
return Promise.resolve(); // Resolve immediately
|
||||
},
|
||||
saveState({ state }) {
|
||||
StorageService.saveState({
|
||||
players: state.players.map(p => ({ // Save a clean version of players
|
||||
players: state.players.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
avatar: p.avatar === defaultAvatar ? null : p.avatar, // Don't save default avatar string if it's the default
|
||||
avatar: p.avatar,
|
||||
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) {
|
||||
@@ -248,7 +246,7 @@ 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');
|
||||
},
|
||||
@@ -277,20 +275,19 @@ export default createStore({
|
||||
dispatch('saveState');
|
||||
},
|
||||
// This action is for the full reset from SetupView
|
||||
fullResetApp({ commit, dispatch }) {
|
||||
fullResetApp({ commit, dispatch, state: currentGlobalState }) { // Use a different name for state here to avoid conflict
|
||||
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
|
||||
const freshInitialState = JSON.parse(JSON.stringify(initialState)); // Get a fresh copy
|
||||
|
||||
commit('SET_PLAYERS', freshInitialState.players);
|
||||
commit('SET_CURRENT_PLAYER_INDEX', freshInitialState.currentPlayerIndex);
|
||||
commit('SET_GAME_MODE', freshInitialState.gameMode);
|
||||
commit('SET_IS_MUTED', freshInitialState.isMuted);
|
||||
commit('SET_GLOBAL_HOTKEY_STOP_PAUSE', freshInitialState.globalHotkeyStopPause);
|
||||
|
||||
// Directly set theme instead of toggling
|
||||
if (currentGlobalState.theme !== freshInitialState.theme) {
|
||||
commit('SET_THEME', freshInitialState.theme);
|
||||
}
|
||||
commit('SET_GAME_RUNNING', false);
|
||||
dispatch('saveState'); // Save this fresh state
|
||||
|
||||
@@ -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>
|
||||
@@ -22,21 +22,22 @@
|
||||
</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 -->
|
||||
<!-- Main content area should allow its children to use its full height -->
|
||||
<main class="flex-grow overflow-hidden flex flex-col" ref="gameArea">
|
||||
<!-- Normal Mode: This div itself needs to take full height of 'main' -->
|
||||
<div v-if="gameMode === 'normal' && currentPlayer && nextPlayer" class="h-full flex flex-col">
|
||||
<PlayerDisplay
|
||||
:player="currentPlayer"
|
||||
is-current-player-area
|
||||
area-class="bg-gray-100 dark:bg-gray-800"
|
||||
@tapped="handleCurrentPlayerTap"
|
||||
class="flex-1 min-h-0"
|
||||
/>
|
||||
<div class="shrink-0 h-1 bg-blue-500"></div>
|
||||
<PlayerDisplay
|
||||
@@ -44,13 +45,13 @@
|
||||
is-next-player-area
|
||||
area-class="bg-gray-200 dark:bg-gray-700"
|
||||
@swiped-up="handlePassTurn"
|
||||
class="flex-1 min-h-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- All Timers Running Mode -->
|
||||
<div v-if="gameMode === 'allTimers'" class="p-4 h-full flex flex-col">
|
||||
<!-- Removed the "Pause All / Resume All" button from here -->
|
||||
<div class="mb-4 flex justify-start items-center"> <!-- Changed justify-between to justify-start -->
|
||||
<div class="mb-4 flex justify-start items-center">
|
||||
<h2 class="text-2xl font-semibold">All Timers Running</h2>
|
||||
</div>
|
||||
<div class="flex-grow overflow-y-auto space-y-2">
|
||||
@@ -60,10 +61,10 @@
|
||||
: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">
|
||||
<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">
|
||||
<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">
|
||||
@@ -72,10 +73,6 @@
|
||||
</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>
|
||||
|
||||
@@ -96,32 +93,36 @@ 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;
|
||||
|
||||
const playersInAllTimersView = computed(() => {
|
||||
if (gameMode.value === 'allTimers') {
|
||||
if (!players.value) return [];
|
||||
return players.value.filter(p => p.isTimerRunning && !p.isSkipped);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const anyTimerRunningInAllMode = computed(() => {
|
||||
if (!players.value) return false;
|
||||
return players.value.some(p => p.isTimerRunning && !p.isSkipped);
|
||||
});
|
||||
|
||||
const anyTimerCouldRun = computed(() => {
|
||||
if (!players.value) return false;
|
||||
return players.value.some(p => !p.isSkipped);
|
||||
});
|
||||
|
||||
|
||||
const indexInFullList = (playerId) => {
|
||||
if (!players.value) return -1;
|
||||
return players.value.findIndex(p => p.id === playerId);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (players.value.length < 2) {
|
||||
if (!players.value || players.value.length < 2) {
|
||||
router.push({ name: 'Setup' });
|
||||
return;
|
||||
}
|
||||
@@ -137,26 +138,23 @@ onUnmounted(() => {
|
||||
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
|
||||
if (anyTimerRunningInAllMode.value) {
|
||||
AudioService.startContinuousTick();
|
||||
}
|
||||
} else { // normal mode
|
||||
} else {
|
||||
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 (gameMode.value === 'allTimers') {
|
||||
if (isRunning) {
|
||||
AudioService.startContinuousTick();
|
||||
} else {
|
||||
@@ -165,14 +163,12 @@ watch(anyTimerRunningInAllMode, (isRunning) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -216,7 +212,8 @@ const handlePassTurn = () => {
|
||||
AudioService.cancelPassTurnSound();
|
||||
const wasRunning = currentPlayer.value.isTimerRunning;
|
||||
store.dispatch('passTurn').then(() => {
|
||||
if (wasRunning && gameMode.value === 'normal' && currentPlayer.value && currentPlayer.value.isTimerRunning) {
|
||||
const newCurrentPlayer = store.getters.currentPlayer;
|
||||
if (wasRunning && gameMode.value === 'normal' && newCurrentPlayer && newCurrentPlayer.isTimerRunning) {
|
||||
AudioService.playPassTurnAlert();
|
||||
}
|
||||
});
|
||||
@@ -227,20 +224,16 @@ 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
|
||||
store.dispatch('switchToNormalMode');
|
||||
};
|
||||
|
||||
// Watcher for auto-reverting from All Timers mode
|
||||
watch(anyTimerRunningInAllMode, (anyRunning) => {
|
||||
if (gameMode.value === 'allTimers' && !anyRunning && players.value.length > 0) {
|
||||
if (gameMode.value === 'allTimers' && !anyRunning && players.value && players.value.length > 0) {
|
||||
const nonSkippedPlayersExist = players.value.some(p => !p.isSkipped);
|
||||
if (nonSkippedPlayersExist) {
|
||||
setTimeout(() => {
|
||||
@@ -253,5 +246,4 @@ watch(anyTimerRunningInAllMode, (anyRunning) => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -17,17 +17,39 @@
|
||||
|
||||
<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">
|
||||
<div class="flex items-center flex-wrap">
|
||||
<img
|
||||
v-if="player.avatar"
|
||||
:src="player.avatar"
|
||||
alt="Player Avatar"
|
||||
class="w-10 h-10 rounded-full object-cover mr-3"
|
||||
/>
|
||||
<DefaultAvatarIcon
|
||||
v-else
|
||||
class="w-10 h-10 rounded-full object-cover mr-3 text-gray-400 bg-gray-200 p-0.5"
|
||||
/>
|
||||
<span class="font-medium mr-2">{{ player.name }}</span>
|
||||
<span class="mr-2 text-xs text-gray-500 dark:text-gray-400">({{ formatTime(player.initialTimerSec) }})</span>
|
||||
<!-- Changed HK to Hotkey -->
|
||||
<span class="mr-2 text-xs text-gray-500 dark:text-gray-400">({{ formatTime(player.currentTimerSec) }})</span>
|
||||
<span v-if="player.hotkey" class="text-xs px-1.5 py-0.5 bg-blue-100 dark:bg-blue-700 text-blue-700 dark:text-blue-200 rounded">Hotkey: {{ player.hotkey.toUpperCase() }}</span>
|
||||
</div>
|
||||
<div class="space-x-2 flex-shrink-0"> <!-- Added flex-shrink-0 -->
|
||||
<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>
|
||||
@@ -73,7 +95,6 @@
|
||||
</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
|
||||
@@ -96,12 +117,12 @@
|
||||
</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';
|
||||
import DefaultAvatarIcon from '../components/DefaultAvatarIcon.vue';
|
||||
|
||||
const store = useStore();
|
||||
const router = useRouter();
|
||||
@@ -140,6 +161,7 @@ const savePlayer = (playerData) => {
|
||||
name: playerData.name,
|
||||
avatar: playerData.avatar,
|
||||
initialTimerSec: playerData.initialTimerSec,
|
||||
currentTimerSec: playerData.currentTimerSec,
|
||||
hotkey: playerData.hotkey
|
||||
});
|
||||
}
|
||||
@@ -160,6 +182,28 @@ const reversePlayers = () => {
|
||||
store.dispatch('reversePlayers');
|
||||
};
|
||||
|
||||
// --- Player Reordering Logic ---
|
||||
const movePlayerUp = (index) => {
|
||||
if (index > 0) {
|
||||
const newPlayersOrder = [...players.value];
|
||||
const temp = newPlayersOrder[index];
|
||||
newPlayersOrder[index] = newPlayersOrder[index - 1];
|
||||
newPlayersOrder[index - 1] = temp;
|
||||
store.dispatch('reorderPlayers', newPlayersOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const movePlayerDown = (index) => {
|
||||
if (index < players.value.length - 1) {
|
||||
const newPlayersOrder = [...players.value];
|
||||
const temp = newPlayersOrder[index];
|
||||
newPlayersOrder[index] = newPlayersOrder[index + 1];
|
||||
newPlayersOrder[index + 1] = temp;
|
||||
store.dispatch('reorderPlayers', newPlayersOrder);
|
||||
}
|
||||
};
|
||||
// --- End Player Reordering Logic ---
|
||||
|
||||
const captureGlobalHotkey = (event) => {
|
||||
event.preventDefault();
|
||||
const key = event.key.toLowerCase();
|
||||
@@ -188,10 +232,9 @@ const toggleMute = () => {
|
||||
const saveAndClose = () => {
|
||||
store.dispatch('saveState');
|
||||
if (players.value.length >= 2) {
|
||||
if (store.state.currentPlayerIndex >= players.value.length || store.state.currentPlayerIndex < 0) { // Ensure valid index
|
||||
if (store.state.currentPlayerIndex >= players.value.length || store.state.currentPlayerIndex < 0) {
|
||||
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' });
|
||||
|
||||
Reference in New Issue
Block a user