|
|
|
@@ -23,9 +23,60 @@
|
|
|
|
|
::-webkit-scrollbar-track { background: rgb(15 23 42); }
|
|
|
|
|
::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 4px; }
|
|
|
|
|
::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); }
|
|
|
|
|
|
|
|
|
|
.toast {
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 20px;
|
|
|
|
|
right: 20px;
|
|
|
|
|
padding: 12px 20px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
animation: slideIn 0.3s ease-out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes slideIn {
|
|
|
|
|
from { transform: translateX(400px); opacity: 0; }
|
|
|
|
|
to { transform: translateX(0); opacity: 1; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes slideOut {
|
|
|
|
|
from { transform: translateX(0); opacity: 1; }
|
|
|
|
|
to { transform: translateX(400px); opacity: 0; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toast.hiding {
|
|
|
|
|
animation: slideOut 0.3s ease-out forwards;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.toast-success { background: rgb(34 197 94); color: white; }
|
|
|
|
|
.toast-error { background: rgb(239 68 68); color: white; }
|
|
|
|
|
.toast-warning { background: rgb(251 146 60); color: white; }
|
|
|
|
|
.toast-info { background: rgb(59 130 246); color: white; }
|
|
|
|
|
|
|
|
|
|
.error-banner {
|
|
|
|
|
background: linear-gradient(to right, rgb(239 68 68), rgb(220 38 38));
|
|
|
|
|
color: white;
|
|
|
|
|
padding: 12px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
border-bottom: 2px solid rgb(185 28 28);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dx-callsign {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.dx-callsign:hover {
|
|
|
|
|
text-shadow: 0 0 8px rgba(59, 130, 246, 0.8);
|
|
|
|
|
transform: scale(1.05);
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
|
|
|
|
|
<div id="error-banner"></div>
|
|
|
|
|
<div id="toast-container"></div>
|
|
|
|
|
<div id="app" class="full-height p-4"></div>
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
@@ -42,6 +93,7 @@
|
|
|
|
|
totalContacts: 0,
|
|
|
|
|
clusterStatus: 'disconnected',
|
|
|
|
|
flexStatus: 'disconnected',
|
|
|
|
|
myCallsign: '',
|
|
|
|
|
filters: { skimmer: false, ft8: false, ft4: false }
|
|
|
|
|
},
|
|
|
|
|
topSpotters: [],
|
|
|
|
@@ -66,15 +118,91 @@
|
|
|
|
|
band12M: false,
|
|
|
|
|
band10M: false,
|
|
|
|
|
band6M: false
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
error: null,
|
|
|
|
|
retryCount: 0,
|
|
|
|
|
maxRetries: 3
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Système de notification Toast
|
|
|
|
|
function showToast(message, type = 'info') {
|
|
|
|
|
const container = document.getElementById('toast-container');
|
|
|
|
|
const toast = document.createElement('div');
|
|
|
|
|
toast.className = `toast toast-${type}`;
|
|
|
|
|
toast.innerHTML = `
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
${type === 'success' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ''}
|
|
|
|
|
${type === 'error' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>' : ''}
|
|
|
|
|
${type === 'warning' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path></svg>' : ''}
|
|
|
|
|
<span>${message}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
container.appendChild(toast);
|
|
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toast.classList.add('hiding');
|
|
|
|
|
setTimeout(() => toast.remove(), 300);
|
|
|
|
|
}, 3000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bannière d'erreur persistante
|
|
|
|
|
function showErrorBanner(message) {
|
|
|
|
|
const banner = document.getElementById('error-banner');
|
|
|
|
|
banner.innerHTML = `
|
|
|
|
|
<div class="flex items-center justify-between max-w-7xl mx-auto">
|
|
|
|
|
<div class="flex items-center gap-2">
|
|
|
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
|
|
|
</svg>
|
|
|
|
|
<span>${message}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick="hideErrorBanner()" class="hover:bg-red-700 px-3 py-1 rounded">✕</button>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
banner.className = 'error-banner';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideErrorBanner() {
|
|
|
|
|
document.getElementById('error-banner').className = '';
|
|
|
|
|
document.getElementById('error-banner').innerHTML = '';
|
|
|
|
|
state.error = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Gestion des erreurs réseau avec retry
|
|
|
|
|
async function fetchWithRetry(url, options = {}, retries = state.maxRetries) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(url, options);
|
|
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.retryCount = 0;
|
|
|
|
|
if (state.error) {
|
|
|
|
|
hideErrorBanner();
|
|
|
|
|
showToast('Connexion rétablie', 'success');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
state.retryCount++;
|
|
|
|
|
|
|
|
|
|
if (state.retryCount <= retries) {
|
|
|
|
|
console.warn(`Tentative ${state.retryCount}/${retries} échouée, nouvelle tentative...`);
|
|
|
|
|
await new Promise(resolve => setTimeout(resolve, 1000 * state.retryCount));
|
|
|
|
|
return fetchWithRetry(url, options, retries);
|
|
|
|
|
} else {
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function fetchData() {
|
|
|
|
|
try {
|
|
|
|
|
const [statsRes, spotsRes, spottersRes] = await Promise.all([
|
|
|
|
|
fetch(`${API_BASE_URL}/stats`),
|
|
|
|
|
fetch(`${API_BASE_URL}/spots?limit=100`),
|
|
|
|
|
fetch(`${API_BASE_URL}/spotters`)
|
|
|
|
|
fetchWithRetry(`${API_BASE_URL}/stats`),
|
|
|
|
|
fetchWithRetry(`${API_BASE_URL}/spots?limit=100`),
|
|
|
|
|
fetchWithRetry(`${API_BASE_URL}/spotters`)
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const statsData = await statsRes.json();
|
|
|
|
@@ -89,15 +217,51 @@
|
|
|
|
|
if (spottersData.success) state.topSpotters = spottersData.data || [];
|
|
|
|
|
|
|
|
|
|
state.loading = false;
|
|
|
|
|
render();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error fetching data:', error);
|
|
|
|
|
console.error('Erreur lors du chargement des données:', error);
|
|
|
|
|
state.loading = false;
|
|
|
|
|
state.error = error.message;
|
|
|
|
|
|
|
|
|
|
if (!document.getElementById('error-banner').innerHTML) {
|
|
|
|
|
showErrorBanner(`Impossible de se connecter au serveur (${error.message}). Tentative de reconnexion...`);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
render();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fonction pour envoyer l'indicatif DX via UDP à Log4OM et tuner la radio Flex
|
|
|
|
|
async function sendCallsignToLog4OM(callsign, frequency, mode) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetchWithRetry(`${API_BASE_URL}/send-callsign`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({
|
|
|
|
|
callsign: callsign,
|
|
|
|
|
frequency: frequency,
|
|
|
|
|
mode: mode
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (data.success) {
|
|
|
|
|
showToast(`${callsign} envoyé - Radio tunée sur ${frequency} en ${mode}`, 'success');
|
|
|
|
|
} else {
|
|
|
|
|
showToast('Échec de l\'envoi', 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Erreur lors de l\'envoi:', error);
|
|
|
|
|
showToast(`Erreur: ${error.message}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Filtre optimisé avec cache
|
|
|
|
|
let filterCache = new Map();
|
|
|
|
|
|
|
|
|
|
function applySpotFilters() {
|
|
|
|
|
const cacheKey = JSON.stringify(state.spotFilters);
|
|
|
|
|
|
|
|
|
|
// Ne pas utiliser le cache si "showAll" est actif
|
|
|
|
|
if (state.spotFilters.showAll) {
|
|
|
|
|
state.filteredSpots = state.spots;
|
|
|
|
|
return;
|
|
|
|
@@ -133,13 +297,8 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (typeFiltersActive) {
|
|
|
|
|
if (state.spotFilters.showNewDXCC) {
|
|
|
|
|
const isNewDXCC = !!spot.NewDXCC;
|
|
|
|
|
const isNewBand = !!spot.NewBand;
|
|
|
|
|
const isNewMode = !!spot.NewMode;
|
|
|
|
|
if (isNewDXCC && !(isNewBand && isNewMode)) {
|
|
|
|
|
matchesType = true;
|
|
|
|
|
}
|
|
|
|
|
if (state.spotFilters.showNewDXCC && !!spot.NewDXCC) {
|
|
|
|
|
matchesType = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.spotFilters.showNewBandMode && !!spot.NewBand && !!spot.NewMode) matchesType = true;
|
|
|
|
@@ -201,10 +360,13 @@
|
|
|
|
|
|
|
|
|
|
async function sendCommand() {
|
|
|
|
|
const command = state.command.trim();
|
|
|
|
|
if (!command) return;
|
|
|
|
|
if (!command) {
|
|
|
|
|
showToast('Veuillez entrer une commande', 'warning');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_BASE_URL}/send-command`, {
|
|
|
|
|
const response = await fetchWithRetry(`${API_BASE_URL}/send-command`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ command })
|
|
|
|
@@ -213,18 +375,20 @@
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (data.success) {
|
|
|
|
|
state.command = '';
|
|
|
|
|
alert('Command sent successfully!');
|
|
|
|
|
showToast('Commande envoyée avec succès!', 'success');
|
|
|
|
|
render();
|
|
|
|
|
} else {
|
|
|
|
|
showToast('Échec de l\'envoi de la commande', 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error sending command:', error);
|
|
|
|
|
alert('Failed to send command');
|
|
|
|
|
console.error('Erreur lors de l\'envoi de la commande:', error);
|
|
|
|
|
showToast(`Erreur: ${error.message}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function updateFilter(filterName, value) {
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_BASE_URL}/filters`, {
|
|
|
|
|
const response = await fetchWithRetry(`${API_BASE_URL}/filters`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ [filterName]: value })
|
|
|
|
@@ -233,37 +397,41 @@
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (data.success) {
|
|
|
|
|
state.stats.filters[filterName] = value;
|
|
|
|
|
showToast(`Filtre ${filterName} mis à jour`, 'success');
|
|
|
|
|
fetchData();
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error updating filters:', error);
|
|
|
|
|
console.error('Erreur lors de la mise à jour du filtre:', error);
|
|
|
|
|
showToast(`Erreur de mise à jour: ${error.message}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function shutdownApp() {
|
|
|
|
|
// if (!confirm('Are you sure you want to shutdown FlexDXCluster?')) {
|
|
|
|
|
// return;
|
|
|
|
|
// }
|
|
|
|
|
if (!confirm('Êtes-vous sûr de vouloir arrêter FlexDXCluster?')) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_BASE_URL}/shutdown`, {
|
|
|
|
|
const response = await fetchWithRetry(`${API_BASE_URL}/shutdown`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' }
|
|
|
|
|
});
|
|
|
|
|
}, 1);
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
if (data.success) {
|
|
|
|
|
// alert('FlexDXCluster is shutting down...');
|
|
|
|
|
// Redirect to a shutdown page or close
|
|
|
|
|
document.body.innerHTML = '<div class="min-h-screen flex items-center justify-center bg-slate-900 text-white"><div class="text-center"><h1 class="text-4xl font-bold mb-4">FlexDXCluster Stopped</h1><p class="text-slate-400">The application has been shut down successfully.</p></div></div>';
|
|
|
|
|
showToast('FlexDXCluster s\'arrête...', 'info');
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
document.body.innerHTML = '<div class="min-h-screen flex items-center justify-center bg-slate-900 text-white"><div class="text-center"><h1 class="text-4xl font-bold mb-4">FlexDXCluster Arrêté</h1><p class="text-slate-400">L\'application a été arrêtée avec succès.</p></div></div>';
|
|
|
|
|
}, 1000);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Error shutting down:', error);
|
|
|
|
|
alert('Failed to shutdown application');
|
|
|
|
|
console.error('Erreur lors de l\'arrêt:', error);
|
|
|
|
|
showToast(`Impossible d\'arrêter l\'application: ${error.message}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPriorityColor(spot) {
|
|
|
|
|
if (spot.DX === state.stats.myCallsign) return 'bg-red-500/20 text-red-400 border-red-500/50';
|
|
|
|
|
if (spot.NewDXCC) return 'bg-green-500/20 text-green-400 border-green-500/50';
|
|
|
|
|
if (spot.NewBand && spot.NewMode) return 'bg-purple-500/20 text-purple-400 border-purple-500/50';
|
|
|
|
|
if (spot.NewBand) return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
|
|
|
|
@@ -273,6 +441,7 @@
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStatusLabel(spot) {
|
|
|
|
|
if (spot.DX === state.stats.myCallsign) return 'Mon Call';
|
|
|
|
|
if (spot.NewDXCC) return 'New DXCC';
|
|
|
|
|
if (spot.NewBand && spot.NewMode) return 'New B&M';
|
|
|
|
|
if (spot.NewBand) return 'New Band';
|
|
|
|
@@ -282,30 +451,41 @@
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache pour les compteurs
|
|
|
|
|
let countCache = {};
|
|
|
|
|
|
|
|
|
|
function countSpotsByType(type) {
|
|
|
|
|
if (countCache[type] !== undefined) return countCache[type];
|
|
|
|
|
|
|
|
|
|
let count;
|
|
|
|
|
switch(type) {
|
|
|
|
|
case 'newDXCC': return state.spots.filter(s => s.NewDXCC).length;
|
|
|
|
|
case 'newBand': return state.spots.filter(s => s.NewBand && !s.NewMode).length;
|
|
|
|
|
case 'newMode': return state.spots.filter(s => s.NewMode && !s.NewBand).length;
|
|
|
|
|
case 'newBandMode': return state.spots.filter(s => s.NewBand && s.NewMode).length;
|
|
|
|
|
case 'newSlot': return state.spots.filter(s => s.NewSlot && !s.NewDXCC && !s.NewBand && !s.NewMode).length;
|
|
|
|
|
case 'worked': return state.spots.filter(s => s.Worked).length;
|
|
|
|
|
case '160M': return state.spots.filter(s => s.Band === '160M').length;
|
|
|
|
|
case '80M': return state.spots.filter(s => s.Band === '80M').length;
|
|
|
|
|
case '60M': return state.spots.filter(s => s.Band === '60M').length;
|
|
|
|
|
case '40M': return state.spots.filter(s => s.Band === '40M').length;
|
|
|
|
|
case '30M': return state.spots.filter(s => s.Band === '30M').length;
|
|
|
|
|
case '20M': return state.spots.filter(s => s.Band === '20M').length;
|
|
|
|
|
case '17M': return state.spots.filter(s => s.Band === '17M').length;
|
|
|
|
|
case '15M': return state.spots.filter(s => s.Band === '15M').length;
|
|
|
|
|
case '12M': return state.spots.filter(s => s.Band === '12M').length;
|
|
|
|
|
case '10M': return state.spots.filter(s => s.Band === '10M').length;
|
|
|
|
|
case '6M': return state.spots.filter(s => s.Band === '6M').length;
|
|
|
|
|
default: return state.spots.length;
|
|
|
|
|
case 'newDXCC': count = state.spots.filter(s => s.NewDXCC).length; break;
|
|
|
|
|
case 'newBand': count = state.spots.filter(s => s.NewBand && !s.NewMode).length; break;
|
|
|
|
|
case 'newMode': count = state.spots.filter(s => s.NewMode && !s.NewBand).length; break;
|
|
|
|
|
case 'newBandMode': count = state.spots.filter(s => s.NewBand && s.NewMode).length; break;
|
|
|
|
|
case 'newSlot': count = state.spots.filter(s => s.NewSlot && !s.NewDXCC && !s.NewBand && !s.NewMode).length; break;
|
|
|
|
|
case 'worked': count = state.spots.filter(s => s.Worked).length; break;
|
|
|
|
|
case '160M': count = state.spots.filter(s => s.Band === '160M').length; break;
|
|
|
|
|
case '80M': count = state.spots.filter(s => s.Band === '80M').length; break;
|
|
|
|
|
case '60M': count = state.spots.filter(s => s.Band === '60M').length; break;
|
|
|
|
|
case '40M': count = state.spots.filter(s => s.Band === '40M').length; break;
|
|
|
|
|
case '30M': count = state.spots.filter(s => s.Band === '30M').length; break;
|
|
|
|
|
case '20M': count = state.spots.filter(s => s.Band === '20M').length; break;
|
|
|
|
|
case '17M': count = state.spots.filter(s => s.Band === '17M').length; break;
|
|
|
|
|
case '15M': count = state.spots.filter(s => s.Band === '15M').length; break;
|
|
|
|
|
case '12M': count = state.spots.filter(s => s.Band === '12M').length; break;
|
|
|
|
|
case '10M': count = state.spots.filter(s => s.Band === '10M').length; break;
|
|
|
|
|
case '6M': count = state.spots.filter(s => s.Band === '6M').length; break;
|
|
|
|
|
default: count = state.spots.length;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
countCache[type] = count;
|
|
|
|
|
return count;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render() {
|
|
|
|
|
countCache = {};
|
|
|
|
|
|
|
|
|
|
const app = document.getElementById('app');
|
|
|
|
|
|
|
|
|
|
if (state.loading) {
|
|
|
|
@@ -313,12 +493,16 @@
|
|
|
|
|
<div class="h-full flex items-center justify-center">
|
|
|
|
|
<div class="text-center">
|
|
|
|
|
<div class="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
|
|
|
|
|
<p class="text-slate-400">Loading dashboard...</p>
|
|
|
|
|
<p class="text-slate-400">Chargement du tableau de bord...</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store scroll position before re-rendering
|
|
|
|
|
const spotsContainer = document.querySelector('.scrollable');
|
|
|
|
|
const scrollTop = spotsContainer ? spotsContainer.scrollTop : 0;
|
|
|
|
|
|
|
|
|
|
app.innerHTML = `
|
|
|
|
|
<div class="flex items-center justify-between mb-3">
|
|
|
|
@@ -341,10 +525,10 @@
|
|
|
|
|
<span class="text-slate-400">Flex</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick="shutdownApp()" class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
|
|
|
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
Shutdown
|
|
|
|
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
</svg>
|
|
|
|
|
Shutdown
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -388,43 +572,34 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="grid grid-cols-2 gap-3 mb-3">
|
|
|
|
|
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
|
|
|
|
<div class="flex gap-2 items-center">
|
|
|
|
|
<input id="commandInput" type="text" value="${state.command}" placeholder="Command..."
|
|
|
|
|
class="flex-1 px-3 py-1.5 text-sm bg-slate-900/50 border border-slate-600 rounded focus:outline-none focus:border-blue-500 text-white" />
|
|
|
|
|
<button onclick="sendCommand()" class="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 rounded transition-colors">Send</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
|
|
|
|
<div class="flex gap-3 text-sm">
|
|
|
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" ${state.stats.filters.skimmer ? 'checked' : ''}
|
|
|
|
|
onchange="updateFilter('skimmer', this.checked)" class="w-4 h-4 rounded" />
|
|
|
|
|
<span>Skimmer</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" ${state.stats.filters.ft8 ? 'checked' : ''}
|
|
|
|
|
onchange="updateFilter('ft8', this.checked)" class="w-4 h-4 rounded" />
|
|
|
|
|
<span>FT8</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" ${state.stats.filters.ft4 ? 'checked' : ''}
|
|
|
|
|
onchange="updateFilter('ft4', this.checked)" class="w-4 h-4 rounded" />
|
|
|
|
|
<span>FT4</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50 mb-3">
|
|
|
|
|
<div class="flex gap-3 text-sm">
|
|
|
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" ${state.stats.filters.skimmer ? 'checked' : ''}
|
|
|
|
|
onchange="updateFilter('skimmer', this.checked)" class="w-4 h-4 rounded" />
|
|
|
|
|
<span>Skimmer</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" ${state.stats.filters.ft8 ? 'checked' : ''}
|
|
|
|
|
onchange="updateFilter('ft8', this.checked)" class="w-4 h-4 rounded" />
|
|
|
|
|
<span>FT8</span>
|
|
|
|
|
</label>
|
|
|
|
|
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
|
|
|
<input type="checkbox" ${state.stats.filters.ft4 ? 'checked' : ''}
|
|
|
|
|
onchange="updateFilter('ft4', this.checked)" class="w-4 h-4 rounded" />
|
|
|
|
|
<span>FT4</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50 mb-3">
|
|
|
|
|
<div class="grid grid-cols-2 gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="text-xs font-bold text-slate-400 mb-2">TYPE FILTERS</h3>
|
|
|
|
|
<h3 class="text-xs font-bold text-slate-400 mb-2">FILTRES PAR TYPE</h3>
|
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
|
|
|
<button onclick="toggleSpotFilter('showAll')"
|
|
|
|
|
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showAll ? 'bg-blue-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
|
|
|
|
|
All (${state.spots.length})
|
|
|
|
|
Tous (${state.spots.length})
|
|
|
|
|
</button>
|
|
|
|
|
<button onclick="toggleSpotFilter('showNewDXCC')"
|
|
|
|
|
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showNewDXCC ? 'bg-green-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
|
|
|
|
@@ -453,7 +628,7 @@
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 class="text-xs font-bold text-slate-400 mb-2">BAND FILTERS</h3>
|
|
|
|
|
<h3 class="text-xs font-bold text-slate-400 mb-2">FILTRES PAR BANDE</h3>
|
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
|
|
|
<button onclick="toggleSpotFilter('band160M')"
|
|
|
|
|
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band160M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
|
|
|
|
@@ -506,8 +681,8 @@
|
|
|
|
|
|
|
|
|
|
<div class="grid grid-cols-4 gap-3" style="height: calc(100vh - 360px);">
|
|
|
|
|
<div class="col-span-3 bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden">
|
|
|
|
|
<div class="p-3 border-b border-slate-700/50">
|
|
|
|
|
<h2 class="text-lg font-bold">Recent Spots (${state.filteredSpots.length})</h2>
|
|
|
|
|
<div class="p-3 border-b border-slate-700/50 flex items-center justify-between">
|
|
|
|
|
<h2 class="text-lg font-bold">Spots Récents (${state.filteredSpots.length})</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="scrollable">
|
|
|
|
|
<table class="w-full">
|
|
|
|
@@ -519,14 +694,20 @@
|
|
|
|
|
<th class="p-2">Mode</th>
|
|
|
|
|
<th class="p-2">Spotter</th>
|
|
|
|
|
<th class="p-2">Time</th>
|
|
|
|
|
<th class="p-2">Country</th>
|
|
|
|
|
<th class="p-2">Status</th>
|
|
|
|
|
<th class="p-2">Pays</th>
|
|
|
|
|
<th class="p-2">Statut</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
${state.filteredSpots.map(spot => `
|
|
|
|
|
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm">
|
|
|
|
|
<td class="p-2 font-bold text-blue-400">${spot.DX}</td>
|
|
|
|
|
<td class="p-2">
|
|
|
|
|
<span class="font-bold text-blue-400 dx-callsign"
|
|
|
|
|
onclick="sendCallsignToLog4OM('${spot.DX}', '${spot.FrequencyMhz}', '${spot.Mode}')"
|
|
|
|
|
title="Cliquez pour envoyer à Log4OM et tuner la radio">
|
|
|
|
|
${spot.DX}
|
|
|
|
|
</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td class="p-2 font-mono text-xs">${spot.FrequencyMhz}</td>
|
|
|
|
|
<td class="p-2"><span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">${spot.Band}</span></td>
|
|
|
|
|
<td class="p-2"><span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">${spot.Mode}</span></td>
|
|
|
|
@@ -564,14 +745,11 @@
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
const commandInput = document.getElementById('commandInput');
|
|
|
|
|
if (commandInput) {
|
|
|
|
|
commandInput.addEventListener('input', (e) => {
|
|
|
|
|
state.command = e.target.value;
|
|
|
|
|
});
|
|
|
|
|
commandInput.addEventListener('keypress', (e) => {
|
|
|
|
|
if (e.key === 'Enter') sendCommand();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Restore scroll position after re-rendering
|
|
|
|
|
const newSpotsContainer = document.querySelector('.scrollable');
|
|
|
|
|
if (newSpotsContainer && scrollTop > 0) {
|
|
|
|
|
newSpotsContainer.scrollTop = scrollTop;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|