up
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { soundManager } from './lib/soundManager.js';
|
||||
import Header from './components/Header.svelte';
|
||||
import StatsCards from './components/StatsCards.svelte';
|
||||
import FilterBar from './components/FilterBar.svelte';
|
||||
@@ -317,9 +318,6 @@
|
||||
}, 30000); // 30 secondes
|
||||
}
|
||||
break;
|
||||
case 'spotters':
|
||||
topSpotters = message.data || [];
|
||||
break;
|
||||
case 'watchlist':
|
||||
watchlist = message.data || [];
|
||||
spotCache.saveMetadata('watchlist', watchlist).catch(err => console.error('Cache save error:', err));
|
||||
@@ -336,11 +334,29 @@
|
||||
case 'dxccProgress':
|
||||
dxccProgress = message.data || { worked: 0, total: 340, percentage: 0 };
|
||||
break;
|
||||
case 'milestone': // ✅ AJOUTER
|
||||
case 'milestone':
|
||||
const milestoneData = message.data;
|
||||
const toastType = milestoneData.type === 'qso' ? 'milestone' : 'band';
|
||||
showToast(milestoneData.message, toastType);
|
||||
break;
|
||||
case 'watchlistAlert':
|
||||
// Dispatch custom event for watchlist alert
|
||||
const alertEvent = new CustomEvent('watchlistAlert', {
|
||||
detail: message.data
|
||||
});
|
||||
window.dispatchEvent(alertEvent);
|
||||
|
||||
// Play sound if enabled
|
||||
if (message.data.playSound) {
|
||||
soundManager.playWatchlistAlert('medium');
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
showToast(
|
||||
`🎯 ${message.data.callsign} spotted on ${message.data.band} ${message.data.mode}!`,
|
||||
'success'
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,6 +571,7 @@ async function shutdownApp() {
|
||||
{solarData}
|
||||
{wsStatus}
|
||||
{cacheLoaded}
|
||||
{soundManager}
|
||||
on:shutdown={shutdownApp}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
export let solarData;
|
||||
export let wsStatus;
|
||||
export let cacheLoaded = false;
|
||||
export let soundManager;
|
||||
|
||||
let soundEnabled = true;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
|
||||
function toggleSound() {
|
||||
soundEnabled = !soundEnabled;
|
||||
soundManager.setEnabled(soundEnabled);
|
||||
}
|
||||
|
||||
function getSFIColor(sfi) {
|
||||
const value = parseInt(sfi);
|
||||
if (isNaN(value)) return 'text-slate-500';
|
||||
@@ -111,6 +118,22 @@
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
on:click={toggleSound}
|
||||
title="{soundEnabled ? 'Disable' : 'Enable'} sound alerts"
|
||||
class="px-3 py-1.5 rounded transition-colors {soundEnabled ? 'bg-blue-600 hover:bg-blue-700' : 'bg-slate-700 hover:bg-slate-600'} flex items-center gap-2">
|
||||
{#if soundEnabled}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="text-xs hidden sm:inline">{soundEnabled ? 'Sound ON' : 'Sound OFF'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
on:click={() => dispatch('shutdown')}
|
||||
class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
}
|
||||
|
||||
function getPriorityColor(spot) {
|
||||
const inWatchlist = watchlist.some(pattern =>
|
||||
spot.DX === pattern || spot.DX.startsWith(pattern)
|
||||
const inWatchlist = watchlist.some(entry =>
|
||||
spot.DX === entry.callsign || spot.DX.startsWith(entry.callsign)
|
||||
);
|
||||
|
||||
if (inWatchlist) return 'bg-pink-500/20 text-pink-400 border-pink-500/50';
|
||||
@@ -35,8 +35,8 @@
|
||||
}
|
||||
|
||||
function getStatusLabel(spot) {
|
||||
const inWatchlist = watchlist.some(pattern =>
|
||||
spot.DX === pattern || spot.DX.startsWith(pattern)
|
||||
const inWatchlist = watchlist.some(entry =>
|
||||
spot.DX === entry.callsign || spot.DX.startsWith(entry.callsign)
|
||||
);
|
||||
|
||||
if (inWatchlist) return 'Watchlist';
|
||||
@@ -49,6 +49,12 @@
|
||||
if (spot.Worked) return 'Worked';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getCleanComment(spot) {
|
||||
// Retirer le commentaire original brut s'il existe
|
||||
if (!spot.OriginalComment) return '';
|
||||
return spot.OriginalComment.trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden h-full">
|
||||
@@ -59,14 +65,15 @@
|
||||
<!-- Header fixe -->
|
||||
<div class="bg-slate-900/50 flex-shrink-0">
|
||||
<div class="flex text-left text-xs text-slate-400 font-semibold">
|
||||
<div class="p-2" style="width: 12%;">DX</div>
|
||||
<div class="p-2" style="width: 25%;">Country</div>
|
||||
<div class="p-2" style="width: 12%;">Freq</div>
|
||||
<div class="p-2" style="width: 8%;">Band</div>
|
||||
<div class="p-2" style="width: 8%;">Mode</div>
|
||||
<div class="p-2" style="width: 12%;">Spotter</div>
|
||||
<div class="p-2" style="width: 8%;">Time</div>
|
||||
<div class="p-2" style="width: 15%;">Status</div>
|
||||
<div class="p-2" style="width: 10%;">DX</div>
|
||||
<div class="p-2" style="width: 18%;">Country</div>
|
||||
<div class="p-2" style="width: 10%;">Freq</div>
|
||||
<div class="p-2" style="width: 7%;">Band</div>
|
||||
<div class="p-2" style="width: 7%;">Mode</div>
|
||||
<div class="p-2" style="width: 10%;">Spotter</div>
|
||||
<div class="p-2" style="width: 7%;">Time</div>
|
||||
<div class="p-2" style="width: 18%;">Comment</div>
|
||||
<div class="p-2" style="width: 13%;">Status</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,7 +81,7 @@
|
||||
<div class="flex-1 overflow-hidden" bind:this={container}>
|
||||
<VirtualList items={spots} {itemHeight} let:item>
|
||||
<div class="flex border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm" style="height: {itemHeight}px;">
|
||||
<div class="p-2 flex items-center" style="width: 12%;">
|
||||
<div class="p-2 flex items-center" style="width: 10%;">
|
||||
<button
|
||||
class="font-bold text-blue-400 hover:text-blue-300 transition-colors truncate w-full text-left"
|
||||
on:click={() => handleSpotClick(item)}
|
||||
@@ -82,21 +89,24 @@
|
||||
{item.DX}
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 25%;" title={item.CountryName || 'N/A'}>
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 18%;" title={item.CountryName || 'N/A'}>
|
||||
{item.CountryName || 'N/A'}
|
||||
</div>
|
||||
<div class="p-2 flex items-center font-mono text-xs" style="width: 12%;">{item.FrequencyMhz}</div>
|
||||
<div class="p-2 flex items-center" style="width: 8%;">
|
||||
<div class="p-2 flex items-center font-mono text-xs" style="width: 10%;">{item.FrequencyMhz}</div>
|
||||
<div class="p-2 flex items-center" style="width: 7%;">
|
||||
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">{item.Band}</span>
|
||||
</div>
|
||||
<div class="p-2 flex items-center" style="width: 8%;">
|
||||
<div class="p-2 flex items-center" style="width: 7%;">
|
||||
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">{item.Mode}</span>
|
||||
</div>
|
||||
<div class="p-2 flex items-center text-slate-300 text-xs truncate" style="width: 12%;" title={item.SpotterCallsign}>
|
||||
<div class="p-2 flex items-center text-slate-300 text-xs truncate" style="width: 10%;" title={item.SpotterCallsign}>
|
||||
{item.SpotterCallsign}
|
||||
</div>
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs" style="width: 8%;">{item.UTCTime}</div>
|
||||
<div class="p-2 flex items-center" style="width: 15%;">
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs" style="width: 7%;">{item.UTCTime}</div>
|
||||
<div class="p-2 flex items-center text-slate-400 text-xs truncate" style="width: 18%;" title={getCleanComment(item)}>
|
||||
{getCleanComment(item)}
|
||||
</div>
|
||||
<div class="p-2 flex items-center" style="width: 13%;">
|
||||
{#if getStatusLabel(item)}
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-semibold border {getPriorityColor(item)} truncate">
|
||||
{getStatusLabel(item)}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-7 gap-3 mb-3">
|
||||
<div class="grid grid-cols-[repeat(4,1fr)_auto] gap-3 mb-3 items-center">
|
||||
<!-- Total Spots -->
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -21,6 +22,7 @@
|
||||
<p class="text-xs text-slate-400 mt-1">Total Spots</p>
|
||||
</div>
|
||||
|
||||
<!-- New DXCC -->
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -31,71 +33,75 @@
|
||||
<p class="text-xs text-slate-400 mt-1">New DXCC</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
<div class="text-xl font-bold text-purple-400">{stats.activeSpotters}</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-1">Spotters</p>
|
||||
</div>
|
||||
|
||||
<!-- Telnet Clients -->
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
<div class="text-xl font-bold text-orange-400">{stats.connectedClients}</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-1">Clients</p>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3 bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
||||
<div class="flex items-center justify-center gap-6 h-full">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<!-- Total Contacts -->
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div class="text-xl font-bold text-purple-400">{stats.totalContacts.toLocaleString()}</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-400 mt-1">QSOs</p>
|
||||
</div>
|
||||
|
||||
<!-- Cluster Filters -->
|
||||
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50 h-full">
|
||||
<div class="flex items-center justify-center gap-4 h-full">
|
||||
<span class="text-xs text-slate-400 font-semibold whitespace-nowrap">Cluster Filters:</span>
|
||||
|
||||
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stats.filters.skimmer}
|
||||
on:change={(e) => handleFilterChange('skimmer', e.target.checked)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-sm font-medium">CW</span>
|
||||
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-xs font-medium whitespace-nowrap">Skimmer</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stats.filters.ft8}
|
||||
on:change={(e) => handleFilterChange('ft8', e.target.checked)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-sm font-medium">FT8</span>
|
||||
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-xs font-medium">FT8</span>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stats.filters.ft4}
|
||||
on:change={(e) => handleFilterChange('ft4', e.target.checked)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-sm font-medium">FT4</span>
|
||||
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-xs font-medium">FT4</span>
|
||||
</label>
|
||||
|
||||
<!-- ✅ AJOUTER ce switch Beacon -->
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<label class="flex items-center gap-1.5 cursor-pointer hover:bg-slate-700/30 px-2 py-1 rounded transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={stats.filters.beacon}
|
||||
on:change={(e) => handleFilterChange('beacon', e.target.checked)}
|
||||
class="sr-only peer"
|
||||
/>
|
||||
<div class="relative w-11 h-6 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-sm font-medium">Beacon</span>
|
||||
<div class="relative w-9 h-5 bg-slate-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
<span class="text-xs font-medium">Beacon</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script>
|
||||
export let topSpotters;
|
||||
export let spots;
|
||||
|
||||
const BANDS = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M'];
|
||||
@@ -30,24 +29,7 @@
|
||||
</script>
|
||||
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div class="p-3 border-b border-slate-700/50">
|
||||
<h2 class="text-lg font-bold">Top 3 Spotters</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-3">
|
||||
{#each topSpotters.slice(0, 3) as spotter, index}
|
||||
<div class="flex items-center justify-between mb-2 p-2 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<span class="text-sm font-semibold">{spotter.Spotter}</span>
|
||||
</div>
|
||||
<span class="text-slate-400 font-mono text-xs">{spotter.NumberofSpots}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
<div class="p-3 border-t border-slate-700/50">
|
||||
<h2 class="text-lg font-bold mb-3">Band Propagation</h2>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script>
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { soundManager } from '../lib/soundManager.js';
|
||||
|
||||
export let watchlist;
|
||||
export let spots;
|
||||
@@ -10,50 +11,64 @@
|
||||
let newCallsign = '';
|
||||
let watchlistSpots = [];
|
||||
let refreshInterval;
|
||||
let editingNotes = {};
|
||||
let tempNotes = {};
|
||||
|
||||
$: matchingSpots = countWatchlistSpots(spots, watchlist);
|
||||
|
||||
// ✅ Tri alphanumérique simple : 0-9 puis A-Z
|
||||
$: displayList = getDisplayList(watchlist, watchlistSpots, showOnlyActive);
|
||||
|
||||
// ✅ Rafraîchir automatiquement les spots de la watchlist
|
||||
$: if (watchlist.length > 0) {
|
||||
fetchWatchlistSpots();
|
||||
}
|
||||
|
||||
// ✅ Rafraîchir aussi quand les spots changent (temps réel)
|
||||
$: if (spots.length > 0 && watchlist.length > 0) {
|
||||
fetchWatchlistSpots();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Rafraîchir toutes les 10 secondes pour être sûr
|
||||
refreshInterval = setInterval(() => {
|
||||
if (watchlist.length > 0) {
|
||||
fetchWatchlistSpots();
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
// Listen for watchlist alerts
|
||||
window.addEventListener('watchlistAlert', handleWatchlistAlert);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
window.removeEventListener('watchlistAlert', handleWatchlistAlert);
|
||||
});
|
||||
|
||||
function handleWatchlistAlert(event) {
|
||||
const { callsign, playSound } = event.detail;
|
||||
|
||||
if (playSound) {
|
||||
soundManager.playWatchlistAlert('medium');
|
||||
}
|
||||
|
||||
// Show toast notification
|
||||
dispatch('toast', {
|
||||
message: `🎯 ${callsign} spotted!`,
|
||||
type: 'success'
|
||||
});
|
||||
}
|
||||
|
||||
function getDisplayList(wl, wlSpots, activeOnly) {
|
||||
let list = wl;
|
||||
|
||||
if (activeOnly) {
|
||||
// Filtrer pour ne montrer que les callsigns avec des spots actifs
|
||||
list = wl.filter(cs => {
|
||||
const spots = wlSpots.filter(s => s.dx === cs || s.dx.startsWith(cs));
|
||||
list = wl.filter(entry => {
|
||||
const spots = wlSpots.filter(s => s.dx === entry.callsign || s.dx.startsWith(entry.callsign));
|
||||
return spots.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Tri alphanumérique : 0-9 puis A-Z
|
||||
return [...list].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
||||
// Sort alphabetically
|
||||
return [...list].sort((a, b) => a.callsign.localeCompare(b.callsign, 'en', { numeric: true }));
|
||||
}
|
||||
|
||||
async function fetchWatchlistSpots() {
|
||||
@@ -71,28 +86,25 @@
|
||||
|
||||
function countWatchlistSpots(allSpots, wl) {
|
||||
return allSpots.filter(spot =>
|
||||
wl.some(pattern => spot.DX === pattern || spot.DX.startsWith(pattern))
|
||||
wl.some(entry => spot.DX === entry.callsign || spot.DX.startsWith(entry.callsign))
|
||||
).length;
|
||||
}
|
||||
|
||||
function getMatchingSpotsForCallsign(callsign) {
|
||||
const spots = watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
|
||||
|
||||
// ✅ Trier par bande d'abord, puis par heure
|
||||
const bandOrder = { '160M': 0, '80M': 1, '60M': 2, '40M': 3, '30M': 4, '20M': 5, '17M': 6, '15M': 7, '12M': 8, '10M': 9, '6M': 10 };
|
||||
|
||||
return spots.sort((a, b) => {
|
||||
// Trier par bande en premier
|
||||
const bandA = bandOrder[a.band] ?? 99;
|
||||
const bandB = bandOrder[b.band] ?? 99;
|
||||
if (bandA !== bandB) return bandA - bandB;
|
||||
|
||||
function getMatchingSpotsForCallsign(callsign) {
|
||||
const spots = watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
|
||||
|
||||
// Si même bande, trier par heure (plus récent en premier)
|
||||
const timeA = a.utcTime || "00:00";
|
||||
const timeB = b.utcTime || "00:00";
|
||||
return timeB.localeCompare(timeA);
|
||||
});
|
||||
}
|
||||
const bandOrder = { '160M': 0, '80M': 1, '60M': 2, '40M': 3, '30M': 4, '20M': 5, '17M': 6, '15M': 7, '12M': 8, '10M': 9, '6M': 10 };
|
||||
|
||||
return spots.sort((a, b) => {
|
||||
const bandA = bandOrder[a.band] ?? 99;
|
||||
const bandB = bandOrder[b.band] ?? 99;
|
||||
if (bandA !== bandB) return bandA - bandB;
|
||||
|
||||
const timeA = a.utcTime || "00:00";
|
||||
const timeB = b.utcTime || "00:00";
|
||||
return timeB.localeCompare(timeA);
|
||||
});
|
||||
}
|
||||
|
||||
async function addToWatchlist() {
|
||||
const callsign = newCallsign.trim().toUpperCase();
|
||||
@@ -115,7 +127,7 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
dispatch('toast', { message: `${callsign} added to watchlist`, type: 'success' });
|
||||
await fetchWatchlistSpots();
|
||||
} else {
|
||||
dispatch('toast', { message: 'Failed to add callsign', type: 'error' });
|
||||
dispatch('toast', { message: data.error || 'Failed to add callsign', type: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding to watchlist:', error);
|
||||
@@ -144,6 +156,51 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSound(callsign, playSound) {
|
||||
try {
|
||||
const response = await fetch('/api/watchlist/update-sound', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ callsign, playSound })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
dispatch('toast', { message: `Sound ${playSound ? 'enabled' : 'disabled'}`, type: 'success' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function startEditNotes(callsign, currentNotes) {
|
||||
editingNotes[callsign] = true;
|
||||
tempNotes[callsign] = currentNotes || '';
|
||||
}
|
||||
|
||||
async function saveNotes(callsign) {
|
||||
try {
|
||||
const response = await fetch('/api/watchlist/update-notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ callsign, notes: tempNotes[callsign] || '' })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
editingNotes[callsign] = false;
|
||||
dispatch('toast', { message: 'Notes saved', type: 'success' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving notes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditNotes(callsign) {
|
||||
editingNotes[callsign] = false;
|
||||
delete tempNotes[callsign];
|
||||
}
|
||||
|
||||
function handleKeyPress(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -162,7 +219,6 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// ✅ Fonction pour le toggle
|
||||
function toggleActiveOnly() {
|
||||
showOnlyActive = !showOnlyActive;
|
||||
}
|
||||
@@ -210,8 +266,8 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
<p class="text-xs mt-1">{showOnlyActive ? 'Click "Active Only" to see all entries' : 'Add callsigns or prefixes to monitor'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each displayList as callsign}
|
||||
{@const matchingSpots = getMatchingSpotsForCallsign(callsign)}
|
||||
{#each displayList as entry}
|
||||
{@const matchingSpots = getMatchingSpotsForCallsign(entry.callsign)}
|
||||
{@const count = matchingSpots.length}
|
||||
{@const neededCount = matchingSpots.filter(s => !s.workedBandMode).length}
|
||||
{@const borderClass = neededCount > 0 ? 'border-orange-500/30' : 'border-slate-700/50'}
|
||||
@@ -220,7 +276,12 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<div class="font-bold text-pink-400 text-lg">{callsign}</div>
|
||||
<div class="font-bold text-pink-400 text-lg">{entry.callsign}</div>
|
||||
|
||||
{#if entry.playSound}
|
||||
<span class="text-xs" title="Sound enabled">🔊</span>
|
||||
{/if}
|
||||
|
||||
{#if count > 0}
|
||||
<span class="text-xs text-slate-400">{count} active spot{count !== 1 ? 's' : ''}</span>
|
||||
{#if neededCount > 0}
|
||||
@@ -231,14 +292,62 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
{:else}
|
||||
<span class="text-xs text-slate-500">No active spots</span>
|
||||
{/if}
|
||||
|
||||
{#if entry.lastSeenStr && entry.lastSeenStr !== 'Never'}
|
||||
<span class="text-xs text-slate-500">Last seen: {entry.lastSeenStr}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
on:click={() => removeFromWatchlist(callsign)}
|
||||
title="Remove from watchlist"
|
||||
class="px-2 py-1 text-xs bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded transition-colors">
|
||||
Remove
|
||||
</button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
on:click={() => updateSound(entry.callsign, !entry.playSound)}
|
||||
class="px-2 py-1 text-xs rounded transition-colors {entry.playSound ? 'bg-blue-600/20 text-blue-400' : 'bg-slate-700/50 text-slate-400'}"
|
||||
title="{entry.playSound ? 'Disable' : 'Enable'} sound">
|
||||
{entry.playSound ? '🔊' : '🔇'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
on:click={() => removeFromWatchlist(entry.callsign)}
|
||||
title="Remove from watchlist"
|
||||
class="px-2 py-1 text-xs bg-red-600/20 hover:bg-red-600/40 text-red-400 rounded transition-colors">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes section -->
|
||||
<div class="mt-2 mb-2">
|
||||
{#if editingNotes[entry.callsign]}
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tempNotes[entry.callsign]}
|
||||
placeholder="Add notes..."
|
||||
class="flex-1 px-2 py-1 bg-slate-700/50 border border-slate-600 rounded text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
on:click={() => saveNotes(entry.callsign)}
|
||||
class="px-2 py-1 text-xs bg-green-600 hover:bg-green-700 rounded">
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
on:click={() => cancelEditNotes(entry.callsign)}
|
||||
class="px-2 py-1 text-xs bg-slate-600 hover:bg-slate-700 rounded">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
on:click={() => startEditNotes(entry.callsign, entry.notes)}
|
||||
class="w-full text-left px-2 py-1 bg-slate-800/30 rounded text-xs text-slate-400 hover:bg-slate-700/30 hover:text-slate-300 transition-colors">
|
||||
{#if entry.notes}
|
||||
📝 {entry.notes}
|
||||
{:else}
|
||||
+ Add notes...
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if count > 0}
|
||||
@@ -259,6 +368,7 @@ function getMatchingSpotsForCallsign(callsign) {
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="font-bold text-blue-400">{spot.dx}</span>
|
||||
<span class="text-slate-400 text-xs truncate" style="max-width: 120px;" title="{spot.countryName || 'Unknown'}">{spot.countryName || 'Unknown'}</span>
|
||||
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded flex-shrink-0">{spot.band}</span>
|
||||
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded flex-shrink-0">{spot.mode}</span>
|
||||
<span class="text-slate-400 font-mono truncate">{spot.frequencyMhz}</span>
|
||||
|
||||
74
frontend/src/lib/soundManager.js
Normal file
74
frontend/src/lib/soundManager.js
Normal file
@@ -0,0 +1,74 @@
|
||||
class SoundManager {
|
||||
constructor() {
|
||||
this.enabled = true;
|
||||
this.audioContext = null;
|
||||
this.sounds = {};
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize Web Audio API on user interaction
|
||||
if (typeof window !== 'undefined') {
|
||||
document.addEventListener('click', () => this.initAudioContext(), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
initAudioContext() {
|
||||
if (!this.audioContext) {
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a beep sound for watchlist alerts
|
||||
generateBeep(priority = 'medium') {
|
||||
if (!this.enabled || !this.audioContext) return;
|
||||
|
||||
const ctx = this.audioContext;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(ctx.destination);
|
||||
|
||||
// Different frequencies for different priorities
|
||||
const frequencies = {
|
||||
high: 1200, // High pitch
|
||||
medium: 800, // Medium pitch
|
||||
low: 500 // Low pitch
|
||||
};
|
||||
|
||||
oscillator.frequency.value = frequencies[priority] || frequencies.medium;
|
||||
oscillator.type = 'sine';
|
||||
|
||||
// Volume and duration
|
||||
gainNode.gain.setValueAtTime(0.3, ctx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.5);
|
||||
|
||||
oscillator.start(ctx.currentTime);
|
||||
oscillator.stop(ctx.currentTime + 0.5);
|
||||
}
|
||||
|
||||
// Play double beep for high priority
|
||||
playWatchlistAlert(priority = 'medium') {
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.initAudioContext();
|
||||
|
||||
this.generateBeep(priority);
|
||||
|
||||
if (priority === 'high') {
|
||||
// Double beep for high priority
|
||||
setTimeout(() => this.generateBeep(priority), 200);
|
||||
}
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
}
|
||||
|
||||
export const soundManager = new SoundManager();
|
||||
Reference in New Issue
Block a user