without footer

This commit is contained in:
cpu
2025-05-08 01:25:54 +02:00
parent 04dd879c24
commit 1295ae4b5c
10 changed files with 462 additions and 313 deletions

View File

@@ -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

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'nexus-timer-cache-v1';
const CACHE_NAME = 'nexus-timer-cache-v2';
const urlsToCache = [
'/',
'/index.html',

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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

View File

@@ -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>

View File

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