Files
nexus-timer/src/components/PlayerForm.vue
cpu 19fbae06fc PWA fixed
added systemd service howto

traefik

nginix set_real_ip_from

improved readme

visuals fixed on mobile

labels removed

updated readme

fixed visuals

overlay for the hotkey

disable screen lock

clean up

git precommit hooks

clean up

clean up

update

check for update feature

added build-time information

fixed date

clean up

added hook script

fix

fix

fix

hooks fixed

webhook setup

players stay in run all timers mode

mqtt

mqtt allways connected

mqtt messages work

capturing mqtt in edit player

mqtt in Setup

updated readme

state of the mqtt

Global Pass turn
2025-05-14 23:57:38 +02:00

327 lines
14 KiB
Vue

<template>
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex justify-center items-center p-4" @click.self="closeModal">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg shadow-xl w-full max-w-md">
<h2 class="text-2xl font-semibold mb-4">{{ isEditing ? 'Edit Player' : 'Add New Player' }}</h2>
<form @submit.prevent="submitForm">
<div class="mb-4">
<label for="playerName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name</label>
<input type="text" id="playerName" v-model="editablePlayer.name" required class="input-base mt-1">
</div>
<div class="mb-4">
<label for="remainingTime" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Remaining Time (MM:SS or -MM:SS)</label>
<input type="text" id="remainingTime" v-model="currentTimeFormatted" @blur="validateCurrentTimeFormat" placeholder="e.g., 55:30 or -02:15" required class="input-base mt-1">
<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
v-if="editablePlayer.avatar"
:src="editablePlayer.avatar"
alt="Avatar"
class="w-16 h-16 rounded-full object-cover mr-4 border"
/>
<DefaultAvatarIcon v-else class="w-16 h-16 rounded-full object-cover mr-4 border text-gray-400 bg-gray-200 p-1"/>
<button type="button" @click="capturePhoto" class="btn btn-secondary text-sm mr-2">Take Photo</button>
<button type="button" @click="useDefaultAvatar" class="btn btn-secondary text-sm">Default</button>
</div>
<p v-if="cameraError" class="text-red-500 text-xs mt-1">{{ cameraError }}</p>
</div>
<div class="mb-4">
<p class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">"Pass Turn / My Pause" Trigger:</p>
<div class="flex items-center justify-between space-x-4">
<div class="flex items-center">
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">Hotkey:</span>
<button
type="button"
@click="startCapturePlayerHotkey"
class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-center"
>
<span>{{ editablePlayer.hotkey ? editablePlayer.hotkey.toUpperCase() : '-' }}</span>
</button>
<button v-if="editablePlayer.hotkey" type="button" @click="clearPlayerHotkey" class="playerform-clear-btn">Clear</button>
<span v-else class="playerform-clear-btn-placeholder"></span>
</div>
<div class="flex items-center">
<span class="text-sm mr-2 text-gray-600 dark:text-gray-400">MQTT:</span>
<button
type="button"
@click="startCapturePlayerMqttChar"
class="input-base w-12 h-8 font-mono text-lg p-0 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 flex items-center justify-center"
>
<span>{{ editablePlayer.mqttChar ? editablePlayer.mqttChar.toUpperCase() : '-' }}</span>
</button>
<button v-if="editablePlayer.mqttChar" type="button" @click="clearPlayerMqttChar" class="playerform-clear-btn">Clear</button>
<span v-else class="playerform-clear-btn-placeholder"></span>
</div>
</div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button type="button" @click="closeModal" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="!!currentTimeFormatError">{{ isEditing ? 'Save Changes' : 'Add Player' }}</button>
</div>
</form>
</div>
<HotkeyCaptureOverlay
:visible="isCapturingPlayerHotkey"
@captured="handlePlayerHotkeyCaptured"
@cancel="cancelCapturePlayerHotkey"
/>
<MqttCharCaptureOverlay
:visible="isCapturingPlayerMqttChar"
@cancel="cancelCapturePlayerMqttChar"
/>
</div>
</template>
<style scoped>
.playerform-clear-btn {
@apply text-xs text-blue-500 hover:underline ml-2 px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-600;
min-width: 40px; /* Adjust to match "Clear" button width */
display: inline-block;
text-align: center;
}
.playerform-clear-btn-placeholder {
@apply ml-2 px-2 py-1;
min-width: 40px; /* Match width */
display: inline-block;
visibility: hidden;
}
</style>
<script setup>
import { ref, reactive, watch, onMounted, onUnmounted, computed } from 'vue';
import { useStore } from 'vuex';
import { CameraService } from '../services/CameraService';
import { formatTime, parseTime } from '../utils/timeFormatter';
import DefaultAvatarIcon from './DefaultAvatarIcon.vue';
import HotkeyCaptureOverlay from './HotkeyCaptureOverlay.vue';
import MqttCharCaptureOverlay from './MqttCharCaptureOverlay.vue';
import { AudioService } from '../services/AudioService';
import { MqttService } from '../services/MqttService';
const props = defineProps({ player: Object });
const emit = defineEmits(['close', 'save']);
const store = useStore();
const DEFAULT_AVATAR_MARKER = null;
const isEditing = computed(() => !!props.player);
const editablePlayer = reactive({
id: null, name: '', avatar: DEFAULT_AVATAR_MARKER,
initialTimerSec: 3600, currentTimerSec: 3600,
hotkey: '', mqttChar: ''
});
const currentTimeFormatted = ref('60:00');
const currentTimeFormatError = ref('');
const cameraError = ref('');
const maxNegativeSeconds = computed(() => store.getters.maxNegativeTimeReached);
const isCapturingPlayerHotkey = ref(false);
const isCapturingPlayerMqttChar = ref(false);
const startCapturePlayerHotkey = () => { isCapturingPlayerHotkey.value = true; };
const cancelCapturePlayerHotkey = () => { isCapturingPlayerHotkey.value = false; };
const clearPlayerHotkey = () => { editablePlayer.hotkey = ''; };
const startCapturePlayerMqttChar = () => {
if (MqttService.connectionStatus.value !== 'connected') {
alert('MQTT broker is not connected. Please connect in Setup first.');
return;
}
isCapturingPlayerMqttChar.value = true;
MqttService.startMqttCharCapture(handlePlayerMqttCharCaptured);
};
const cancelCapturePlayerMqttChar = () => {
isCapturingPlayerMqttChar.value = false;
MqttService.stopMqttCharCapture();
};
const clearPlayerMqttChar = () => { editablePlayer.mqttChar = ''; };
const checkGlobalConflict = (charKey) => {
if (store.state.globalHotkeyStopPause === charKey) return `"${charKey.toUpperCase()}" is Global Stop/Pause Hotkey.`;
if (store.state.globalHotkeyRunAll === charKey) return `"${charKey.toUpperCase()}" is Global Run All Hotkey.`;
if (store.state.globalMqttStopPause === charKey) return `"${charKey.toUpperCase()}" is Global Stop/Pause MQTT.`;
if (store.state.globalMqttRunAll === charKey) return `"${charKey.toUpperCase()}" is Global Run All MQTT.`;
if (store.state.globalHotkeyPassTurn === charKey) return `"${charKey.toUpperCase()}" is Global Pass Turn Hotkey.`; // Check new global
if (store.state.globalMqttPassTurn === charKey) return `"${charKey.toUpperCase()}" is Global Pass Turn MQTT.`; // Check new global
return null;
};
const handlePlayerHotkeyCaptured = (key) => {
isCapturingPlayerHotkey.value = false;
if (key.length !== 1) return;
const globalConflictMsg = checkGlobalConflict(key);
if (globalConflictMsg) { alert(globalConflictMsg); return; }
const otherPlayerHotkeyConflict = store.state.players.find(p => p.id !== editablePlayer.id && p.hotkey === key);
if (otherPlayerHotkeyConflict) { alert(`Hotkey "${key.toUpperCase()}" is already used by player "${otherPlayerHotkeyConflict.name}".`); return; }
editablePlayer.hotkey = key;
};
const handlePlayerMqttCharCaptured = (charKey) => {
isCapturingPlayerMqttChar.value = false;
if (charKey.length !== 1) return;
const globalConflictMsg = checkGlobalConflict(charKey);
if (globalConflictMsg) { alert(globalConflictMsg); return; }
const otherPlayerMqttConflict = store.state.players.find(p => p.id !== editablePlayer.id && p.mqttChar === charKey);
if (otherPlayerMqttConflict) { alert(`MQTT Char "${charKey.toUpperCase()}" is already used by player "${otherPlayerMqttConflict.name}".`); return; }
editablePlayer.mqttChar = charKey;
};
onMounted(() => {
if (isEditing.value && props.player) {
editablePlayer.id = props.player.id;
editablePlayer.name = props.player.name;
editablePlayer.avatar = props.player.avatar;
editablePlayer.initialTimerSec = props.player.initialTimerSec;
editablePlayer.currentTimerSec = props.player.currentTimerSec;
editablePlayer.hotkey = props.player.hotkey || '';
editablePlayer.mqttChar = props.player.mqttChar || '';
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
} else {
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
editablePlayer.initialTimerSec = 3600;
editablePlayer.currentTimerSec = 3600;
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
}
});
onUnmounted(() => {
if (isCapturingPlayerMqttChar.value) {
MqttService.stopMqttCharCapture();
}
});
watch(currentTimeFormatted, (newTime) => {
validateCurrentTimeFormat();
if (!currentTimeFormatError.value) {
editablePlayer.currentTimerSec = parseTime(newTime);
if (!isEditing.value) {
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
}
}
});
const validateCurrentTimeFormat = () => {
const time = currentTimeFormatted.value;
const isNegativeInput = time.startsWith('-');
if (!/^-?(?:[0-9]+:[0-5]\d)$/.test(time)) {
currentTimeFormatError.value = 'Invalid format. Use MM:SS or -MM:SS (e.g., 05:30, -02:15).';
} else {
const parsedSeconds = parseTime(time);
if (isNegativeInput && parsedSeconds > 0) {
currentTimeFormatError.value = 'Negative time should parse to negative seconds.';
} else if (parsedSeconds < maxNegativeSeconds.value) {
currentTimeFormatError.value = `Time cannot be less than ${formatTime(maxNegativeSeconds.value)}.`;
}
else {
currentTimeFormatError.value = '';
}
}
};
const capturePhoto = async () => {
cameraError.value = '';
try {
AudioService.resumeContext();
const photoDataUrl = await CameraService.getPhoto();
editablePlayer.avatar = photoDataUrl;
} catch (error) {
console.error('Failed to capture photo:', error);
cameraError.value = error.message || 'Could not capture photo.';
}
};
const useDefaultAvatar = () => { editablePlayer.avatar = DEFAULT_AVATAR_MARKER; };
const submitForm = () => {
validateCurrentTimeFormat();
if (currentTimeFormatError.value) return;
const playerPayload = { ...editablePlayer };
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
playerPayload.isSkipped = true;
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
playerPayload.isSkipped = false;
}
if (!isEditing.value) {
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
}
emit('save', playerPayload);
closeModal();
};
const closeModal = () => { emit('close'); };
// Fill in full definitions from previous version if needed
onMounted.value = () => {
if (isEditing.value && props.player) {
editablePlayer.id = props.player.id;
editablePlayer.name = props.player.name;
editablePlayer.avatar = props.player.avatar;
editablePlayer.initialTimerSec = props.player.initialTimerSec;
editablePlayer.currentTimerSec = props.player.currentTimerSec;
editablePlayer.hotkey = props.player.hotkey || '';
editablePlayer.mqttChar = props.player.mqttChar || '';
currentTimeFormatted.value = formatTime(props.player.currentTimerSec);
} else {
editablePlayer.avatar = DEFAULT_AVATAR_MARKER;
editablePlayer.initialTimerSec = 3600;
editablePlayer.currentTimerSec = 3600;
currentTimeFormatted.value = formatTime(editablePlayer.currentTimerSec);
}
};
onUnmounted.value = () => {
if (isCapturingPlayerMqttChar.value) {
MqttService.stopMqttCharCapture();
}
};
watch(currentTimeFormatted, (newTime) => {
validateCurrentTimeFormat();
if (!currentTimeFormatError.value) {
editablePlayer.currentTimerSec = parseTime(newTime);
if (!isEditing.value) {
editablePlayer.initialTimerSec = editablePlayer.currentTimerSec;
}
}
});
validateCurrentTimeFormat.value = () => {
const time = currentTimeFormatted.value;
const isNegativeInput = time.startsWith('-');
if (!/^-?(?:[0-9]+:[0-5]\d)$/.test(time)) {
currentTimeFormatError.value = 'Invalid format. Use MM:SS or -MM:SS (e.g., 05:30, -02:15).';
} else {
const parsedSeconds = parseTime(time);
if (isNegativeInput && parsedSeconds > 0) {
currentTimeFormatError.value = 'Negative time should parse to negative seconds.';
} else if (parsedSeconds < maxNegativeSeconds.value) {
currentTimeFormatError.value = `Time cannot be less than ${formatTime(maxNegativeSeconds.value)}.`;
}
else {
currentTimeFormatError.value = '';
}
}
};
capturePhoto.value = async () => {
cameraError.value = '';
try {
AudioService.resumeContext();
const photoDataUrl = await CameraService.getPhoto();
editablePlayer.avatar = photoDataUrl;
} catch (error) {
console.error('Failed to capture photo:', error);
cameraError.value = error.message || 'Could not capture photo.';
}
};
useDefaultAvatar.value = () => { editablePlayer.avatar = DEFAULT_AVATAR_MARKER; };
submitForm.value = () => {
validateCurrentTimeFormat();
if (currentTimeFormatError.value) return;
const playerPayload = { ...editablePlayer };
if (playerPayload.currentTimerSec <= maxNegativeSeconds.value) {
playerPayload.isSkipped = true;
} else if (playerPayload.isSkipped && playerPayload.currentTimerSec > maxNegativeSeconds.value) {
playerPayload.isSkipped = false;
}
if (!isEditing.value) {
playerPayload.initialTimerSec = playerPayload.currentTimerSec;
}
emit('save', playerPayload);
closeModal();
};
closeModal.value = () => { emit('close'); };
</script>