This commit is contained in:
2025-10-18 18:51:44 +02:00
parent 30cde5052b
commit 26bfd17683
17 changed files with 1133 additions and 288 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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();