This commit is contained in:
2025-10-13 21:37:50 +05:30
parent f3fecfff26
commit cbaacb298f
22 changed files with 4535 additions and 2097 deletions

83
Makefile Normal file
View File

@@ -0,0 +1,83 @@
# Variables
BINARY_NAME=FlexDXCluster.exe
FRONTEND_DIR=frontend
DIST_DIR=$(FRONTEND_DIR)/dist
GO_FILES=$(shell find . -name '*.go' -not -path "./$(FRONTEND_DIR)/*")
.PHONY: all build frontend backend run clean dev help install-deps
# Commande par défaut
all: build
## help: Affiche cette aide
help:
@echo "FlexDXCluster - Makefile"
@echo ""
@echo "Commandes disponibles:"
@echo " make build - Build complet (frontend + backend)"
@echo " make frontend - Build uniquement le frontend"
@echo " make backend - Build uniquement le backend Go"
@echo " make run - Build et lance l'application"
@echo " make dev - Lance le frontend en mode dev"
@echo " make clean - Nettoie les fichiers générés"
@echo " make install-deps - Installe toutes les dépendances"
@echo " make help - Affiche cette aide"
## install-deps: Installe les dépendances npm
install-deps:
@echo "[1/2] Installation des dépendances npm..."
cd $(FRONTEND_DIR) && npm install
@echo "Dépendances installées"
@echo ""
@echo "[2/2] Vérification de Go..."
@go version
@echo "Go est installé"
## frontend: Build le frontend Svelte
frontend:
@echo "Building frontend..."
cd $(FRONTEND_DIR) && npm run build
@echo "Frontend built successfully"
## backend: Build le backend Go
backend: frontend
@echo "Building Go binary..."
go build -ldflags -H=windowsgui .
@echo "Backend built successfully"
## build: Build complet (frontend + backend)
build: install-deps frontend backend
@echo ""
@echo "====================================="
@echo " BUILD COMPLETE!"
@echo "====================================="
@echo ""
@echo "Run: ./$(BINARY_NAME)"
@echo ""
## run: Build et lance l'application
run: build
@echo "Starting FlexDXCluster..."
@echo ""
./$(BINARY_NAME)
## dev: Lance le frontend en mode développement (hot reload)
dev:
@echo "Starting frontend dev server..."
@echo "Frontend: http://localhost:3000"
@echo "Backend: http://localhost:8080"
@echo ""
cd $(FRONTEND_DIR) && npm run dev
## clean: Nettoie les fichiers générés
clean:
@echo "Cleaning build files..."
@if exist $(BINARY_NAME) del /f /q $(BINARY_NAME)
@if exist $(DIST_DIR) rmdir /s /q $(DIST_DIR)
@echo "Clean complete"
## watch: Build auto lors des changements (nécessite watchexec)
watch:
@echo "Watching for changes..."
@echo "Install watchexec: choco install watchexec"
watchexec -w . -e go -- make build

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlexDXCluster Dashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2787
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

18
frontend/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "flexdxcluster-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"svelte": "^4.2.8",
"tailwindcss": "^3.4.0",
"vite": "^5.0.8"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

448
frontend/src/App.svelte Normal file
View File

@@ -0,0 +1,448 @@
<script>
import { onMount, onDestroy } from 'svelte';
import Header from './components/Header.svelte';
import StatsCards from './components/StatsCards.svelte';
import FilterBar from './components/FilterBar.svelte';
import SpotsTable from './components/SpotsTable.svelte';
import Sidebar from './components/Sidebar.svelte';
import Toast from './components/Toast.svelte';
import ErrorBanner from './components/ErrorBanner.svelte';
// State
let spots = [];
let filteredSpots = [];
let stats = {
totalSpots: 0,
activeSpotters: 0,
newDXCC: 0,
connectedClients: 0,
totalContacts: 0,
clusterStatus: 'disconnected',
flexStatus: 'disconnected',
myCallsign: '',
filters: { skimmer: false, ft8: false, ft4: false }
};
let topSpotters = [];
let watchlist = [];
let recentQSOs = [];
let logStats = { today: 0, thisWeek: 0, thisMonth: 0, total: 0 };
let dxccProgress = { worked: 0, total: 340, percentage: 0 };
let solarData = { sfi: 'N/A', sunspots: 'N/A', aIndex: 'N/A', kIndex: 'N/A' };
let activeTab = 'stats';
let wsStatus = 'disconnected';
let errorMessage = '';
let toastMessage = '';
let toastType = 'info';
let spotFilters = {
showAll: true,
showNewDXCC: false,
showNewBand: false,
showNewMode: false,
showNewBandMode: false,
showNewSlot: false,
showWorked: false,
showWatchlist: false,
showDigital: false,
showSSB: false,
showCW: false,
band160M: false,
band80M: false,
band60M: false,
band40M: false,
band30M: false,
band20M: false,
band17M: false,
band15M: false,
band12M: false,
band10M: false,
band6M: false
};
// WebSocket
let ws;
let reconnectTimer;
let reconnectAttempts = 0;
let maxReconnectAttempts = 10;
// Reactive filtered spots
$: {
if (spotFilters.showAll) {
filteredSpots = spots;
} else {
filteredSpots = applyFilters(spots, spotFilters, watchlist);
}
}
function applyFilters(allSpots, filters, wl) {
const bandFiltersActive = filters.band160M || filters.band80M || filters.band60M ||
filters.band40M || filters.band30M || filters.band20M || filters.band17M ||
filters.band15M || filters.band12M || filters.band10M || filters.band6M;
const typeFiltersActive = filters.showNewDXCC || filters.showNewBand ||
filters.showNewMode || filters.showNewBandMode || filters.showNewSlot ||
filters.showWorked || filters.showWatchlist;
const modeFiltersActive = filters.showDigital || filters.showSSB || filters.showCW;
return allSpots.filter(spot => {
let matchesBand = false;
let matchesType = false;
let matchesMode = false;
if (bandFiltersActive) {
matchesBand = (
(filters.band160M && spot.Band === '160M') ||
(filters.band80M && spot.Band === '80M') ||
(filters.band60M && spot.Band === '60M') ||
(filters.band40M && spot.Band === '40M') ||
(filters.band30M && spot.Band === '30M') ||
(filters.band20M && spot.Band === '20M') ||
(filters.band17M && spot.Band === '17M') ||
(filters.band15M && spot.Band === '15M') ||
(filters.band12M && spot.Band === '12M') ||
(filters.band10M && spot.Band === '10M') ||
(filters.band6M && spot.Band === '6M')
);
}
if (typeFiltersActive) {
if (filters.showWatchlist) {
const inWatchlist = wl.some(pattern =>
spot.DX === pattern || spot.DX.startsWith(pattern)
);
if (inWatchlist) matchesType = true;
}
if (filters.showNewDXCC && spot.NewDXCC) matchesType = true;
else if (filters.showNewBandMode && spot.NewBand && spot.NewMode && !spot.NewDXCC) matchesType = true;
else if (filters.showNewBand && spot.NewBand && !spot.NewMode && !spot.NewDXCC) matchesType = true;
else if (filters.showNewMode && spot.NewMode && !spot.NewBand && !spot.NewDXCC) matchesType = true;
else if (filters.showNewSlot && spot.NewSlot && !spot.NewDXCC && !spot.NewBand && !spot.NewMode) matchesType = true;
else if (filters.showWorked && spot.Worked) matchesType = true;
}
if (modeFiltersActive) {
const mode = spot.Mode || '';
if (filters.showDigital && ['FT8', 'FT4', 'RTTY'].includes(mode)) matchesMode = true;
if (filters.showSSB && ['SSB', 'USB', 'LSB'].includes(mode)) matchesMode = true;
if (filters.showCW && mode === 'CW') matchesMode = true;
}
const numActiveFilterTypes = [bandFiltersActive, typeFiltersActive, modeFiltersActive].filter(Boolean).length;
if (numActiveFilterTypes === 0) return false;
if (numActiveFilterTypes === 1) {
if (bandFiltersActive) return matchesBand;
if (typeFiltersActive) return matchesType;
if (modeFiltersActive) return matchesMode;
}
if (numActiveFilterTypes === 2) {
if (bandFiltersActive && typeFiltersActive) return matchesBand && matchesType;
if (bandFiltersActive && modeFiltersActive) return matchesBand && matchesMode;
if (typeFiltersActive && modeFiltersActive) return matchesType && matchesMode;
}
if (numActiveFilterTypes === 3) {
return matchesBand && matchesType && matchesMode;
}
return false;
});
}
function toggleFilter(filterName) {
if (filterName === 'showAll') {
spotFilters = {
showAll: true,
showNewDXCC: false,
showNewBand: false,
showNewMode: false,
showNewBandMode: false,
showNewSlot: false,
showWorked: false,
showWatchlist: false,
showDigital: false,
showSSB: false,
showCW: false,
band160M: false,
band80M: false,
band60M: false,
band40M: false,
band30M: false,
band20M: false,
band17M: false,
band15M: false,
band12M: false,
band10M: false,
band6M: false
};
} else {
spotFilters.showAll = false;
spotFilters[filterName] = !spotFilters[filterName];
const anyActive = Object.keys(spotFilters).some(key =>
key !== 'showAll' && spotFilters[key]
);
if (!anyActive) {
spotFilters.showAll = true;
}
}
}
function connectWebSocket() {
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) {
return;
}
wsStatus = 'connecting';
try {
ws = new WebSocket('ws://localhost:8080/api/ws');
ws.onopen = () => {
console.log('WebSocket connected');
wsStatus = 'connected';
reconnectAttempts = 0;
errorMessage = '';
showToast('Connected to server', 'success');
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
handleWebSocketMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
wsStatus = 'disconnected';
};
ws.onclose = () => {
console.log('WebSocket closed');
wsStatus = 'disconnected';
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
errorMessage = `Connection lost. Reconnecting in ${delay/1000}s... (attempt ${reconnectAttempts}/${maxReconnectAttempts})`;
reconnectTimer = setTimeout(() => {
connectWebSocket();
}, delay);
} else {
errorMessage = 'Unable to connect to server. Please refresh the page.';
}
};
} catch (error) {
console.error('Error creating WebSocket:', error);
wsStatus = 'disconnected';
}
}
function handleWebSocketMessage(message) {
switch (message.type) {
case 'stats':
stats = message.data;
break;
case 'spots':
spots = message.data || [];
break;
case 'spotters':
topSpotters = message.data || [];
break;
case 'watchlist':
watchlist = message.data || [];
break;
case 'log':
recentQSOs = message.data || [];
break;
case 'logStats':
logStats = message.data || {};
break;
case 'dxccProgress':
dxccProgress = message.data || { worked: 0, total: 340, percentage: 0 };
break;
}
}
function showToast(message, type = 'info') {
toastMessage = message;
toastType = type;
setTimeout(() => {
toastMessage = '';
}, 3000);
}
async function fetchSolarData() {
try {
const response = await fetch('/api/solar');
const json = await response.json();
if (json.success) {
solarData = {
sfi: json.data.sfi || 'N/A',
sunspots: json.data.sunspots || 'N/A',
aIndex: json.data.aIndex || 'N/A',
kIndex: json.data.kIndex || 'N/A'
};
}
} catch (error) {
console.error('Error fetching solar data:', error);
}
}
async function sendCallsign(callsign, frequency, mode) {
try {
const response = await fetch('/api/send-callsign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign, frequency, mode })
});
const data = await response.json();
if (data.success) {
showToast(`${callsign} Sent - Radio tuned on ${frequency} in ${mode}`, 'success');
} else {
showToast('Failed to send', 'error');
}
} catch (error) {
console.error('Error sending callsign:', error);
showToast(`Error: ${error.message}`, 'error');
}
}
async function updateClusterFilter(filterName, value) {
try {
const response = await fetch('/api/filters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [filterName]: value })
});
const data = await response.json();
if (data.success) {
stats.filters[filterName] = value;
showToast(`Filter ${filterName} updated`, 'success');
}
} catch (error) {
console.error('Error updating filter:', error);
showToast(`Update error: ${error.message}`, 'error');
}
}
async function shutdownApp() {
try {
const response = await fetch('/api/shutdown', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
showToast('FlexDXCluster shutting down...', 'info');
// Fermer le WebSocket et arrêter les tentatives de reconnexion
if (ws) ws.close();
if (reconnectTimer) clearTimeout(reconnectTimer);
wsStatus = 'disconnected';
maxReconnectAttempts = 0; // Empêcher les reconnexions
// Afficher la page de shutdown après 1 seconde
setTimeout(() => {
document.body.innerHTML = `
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
<div class="text-center">
<svg class="w-24 h-24 mx-auto mb-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 class="text-4xl font-bold mb-4 bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
FlexDXCluster Stopped
</h1>
<p class="text-slate-400 text-lg">The application has been shut down successfully.</p>
<p class="text-slate-500 text-sm mt-4">You can close this window.</p>
</div>
</div>
`;
}, 1000);
}
} catch (error) {
console.error('Error shutting down:', error);
showToast(`Cannot shutdown: ${error.message}`, 'error');
}
}
onMount(() => {
connectWebSocket();
fetchSolarData();
// Update solar data every 15 minutes
const solarInterval = setInterval(fetchSolarData, 15 * 60 * 1000);
// Listen for sendSpot events from watchlist
const handleSendSpot = (e) => {
sendCallsign(e.detail.callsign, e.detail.frequency, e.detail.mode);
};
window.addEventListener('sendSpot', handleSendSpot);
return () => {
if (ws) ws.close();
if (reconnectTimer) clearTimeout(reconnectTimer);
clearInterval(solarInterval);
window.removeEventListener('sendSpot', handleSendSpot);
};
});
</script>
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-4">
{#if errorMessage}
<ErrorBanner message={errorMessage} on:close={() => errorMessage = ''} />
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
<Header
{stats}
{solarData}
{wsStatus}
on:shutdown={shutdownApp}
/>
<StatsCards
{stats}
on:filterChange={(e) => updateClusterFilter(e.detail.name, e.detail.value)}
/>
<FilterBar
{spotFilters}
{spots}
{watchlist}
on:toggleFilter={(e) => toggleFilter(e.detail)}
/>
<div class="grid grid-cols-4 gap-3" style="height: calc(100vh - 360px);">
<div class="col-span-3">
<SpotsTable
spots={filteredSpots}
{watchlist}
myCallsign={stats.myCallsign}
on:clickSpot={(e) => sendCallsign(e.detail.callsign, e.detail.frequency, e.detail.mode)}
/>
</div>
<Sidebar
bind:activeTab
{topSpotters}
{spots}
{watchlist}
{recentQSOs}
{logStats}
{dxccProgress}
on:toast={(e) => showToast(e.detail.message, e.detail.type)}
/>
</div>
</div>

38
frontend/src/app.css Normal file
View File

@@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body, html {
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
width: 100vw;
}
::-webkit-scrollbar {
width: 8px;
}
::-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);
}
.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);
}

View File

@@ -0,0 +1,23 @@
<script>
import { createEventDispatcher } from 'svelte';
export let message;
const dispatch = createEventDispatcher();
</script>
<div class="bg-gradient-to-r from-red-600 to-red-700 text-white p-3 text-center text-sm border-b-2 border-red-800">
<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
on:click={() => dispatch('close')}
class="hover:bg-red-700 px-3 py-1 rounded">
</button>
</div>
</div>

View File

@@ -0,0 +1,221 @@
<script>
import { createEventDispatcher } from 'svelte';
export let spotFilters;
export let spots;
export let watchlist;
const dispatch = createEventDispatcher();
$: watchlistCount = spots && watchlist ? countWatchlistSpots(spots, watchlist) : 0;
$: newDXCCCount = spots ? countSpotsByType(spots, 'newDXCC') : 0;
$: newBandModeCount = spots ? countSpotsByType(spots, 'newBandMode') : 0;
$: newBandCount = spots ? countSpotsByType(spots, 'newBand') : 0;
$: newModeCount = spots ? countSpotsByType(spots, 'newMode') : 0;
$: newSlotCount = spots ? countSpotsByType(spots, 'newSlot') : 0;
$: workedCount = spots ? countSpotsByType(spots, 'worked') : 0;
$: digitalCount = spots ? countSpotsByMode(spots, 'digital') : 0;
$: ssbCount = spots ? countSpotsByMode(spots, 'ssb') : 0;
$: cwCount = spots ? countSpotsByMode(spots, 'cw') : 0;
$: band160MCount = spots ? countSpotsByType(spots, '160M') : 0;
$: band80MCount = spots ? countSpotsByType(spots, '80M') : 0;
$: band60MCount = spots ? countSpotsByType(spots, '60M') : 0;
$: band40MCount = spots ? countSpotsByType(spots, '40M') : 0;
$: band30MCount = spots ? countSpotsByType(spots, '30M') : 0;
$: band20MCount = spots ? countSpotsByType(spots, '20M') : 0;
$: band17MCount = spots ? countSpotsByType(spots, '17M') : 0;
$: band15MCount = spots ? countSpotsByType(spots, '15M') : 0;
$: band12MCount = spots ? countSpotsByType(spots, '12M') : 0;
$: band10MCount = spots ? countSpotsByType(spots, '10M') : 0;
$: band6MCount = spots ? countSpotsByType(spots, '6M') : 0;
function countSpotsByType(spotsList, type) {
if (!spotsList || !Array.isArray(spotsList)) return 0;
switch(type) {
case 'newDXCC':
return spotsList.filter(s => s.NewDXCC).length;
case 'newBandMode':
return spotsList.filter(s => s.NewBand && s.NewMode && !s.NewDXCC).length;
case 'newBand':
return spotsList.filter(s => s.NewBand && !s.NewMode && !s.NewDXCC).length;
case 'newMode':
return spotsList.filter(s => s.NewMode && !s.NewBand && !s.NewDXCC).length;
case 'newSlot':
return spotsList.filter(s => s.NewSlot && !s.NewDXCC && !s.NewBand && !s.NewMode).length;
case 'worked':
return spotsList.filter(s => s.Worked).length;
default:
return spotsList.filter(s => s.Band === type).length;
}
}
function countSpotsByMode(spotsList, mode) {
if (!spotsList || !Array.isArray(spotsList)) return 0;
switch(mode) {
case 'digital':
return spotsList.filter(s => ['FT8', 'FT4', 'RTTY'].includes(s.Mode)).length;
case 'ssb':
return spotsList.filter(s => ['SSB', 'USB', 'LSB'].includes(s.Mode)).length;
case 'cw':
return spotsList.filter(s => s.Mode === 'CW').length;
default:
return 0;
}
}
function countWatchlistSpots(spotsList, wl) {
if (!spotsList || !Array.isArray(spotsList) || !wl || !Array.isArray(wl)) return 0;
return spotsList.filter(spot =>
wl.some(pattern => spot.DX === pattern || spot.DX.startsWith(pattern))
).length;
}
</script>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-2 border border-slate-700/50 mb-3">
<div class="flex items-center gap-1 flex-wrap">
<span class="text-xs font-bold text-slate-400 mr-2">TYPE:</span>
<button
on:click={() => dispatch('toggleFilter', 'showAll')}
class={spotFilters.showAll ? 'px-2 py-0.5 text-xs rounded transition-colors bg-blue-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
All ({spots.length})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showWatchlist')}
class={spotFilters.showWatchlist ? 'px-2 py-0.5 text-xs rounded transition-colors bg-pink-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
Watch ({watchlistCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showNewDXCC')}
class={spotFilters.showNewDXCC ? 'px-2 py-0.5 text-xs rounded transition-colors bg-green-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
DXCC ({newDXCCCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showNewBandMode')}
class={spotFilters.showNewBandMode ? 'px-2 py-0.5 text-xs rounded transition-colors bg-purple-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
B&M ({newBandModeCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showNewBand')}
class={spotFilters.showNewBand ? 'px-2 py-0.5 text-xs rounded transition-colors bg-yellow-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
Band ({newBandCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showNewMode')}
class={spotFilters.showNewMode ? 'px-2 py-0.5 text-xs rounded transition-colors bg-orange-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
Mode ({newModeCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showNewSlot')}
class={spotFilters.showNewSlot ? 'px-2 py-0.5 text-xs rounded transition-colors bg-sky-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
Slot ({newSlotCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showWorked')}
class={spotFilters.showWorked ? 'px-2 py-0.5 text-xs rounded transition-colors bg-cyan-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
Wkd ({workedCount})
</button>
<span class="text-slate-600 mx-1">|</span>
<span class="text-xs font-bold text-slate-400 mr-2">MODE:</span>
<button
on:click={() => dispatch('toggleFilter', 'showDigital')}
class={spotFilters.showDigital ? 'px-2 py-0.5 text-xs rounded transition-colors bg-teal-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
Digi ({digitalCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showSSB')}
class={spotFilters.showSSB ? 'px-2 py-0.5 text-xs rounded transition-colors bg-amber-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
SSB ({ssbCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'showCW')}
class={spotFilters.showCW ? 'px-2 py-0.5 text-xs rounded transition-colors bg-rose-600 text-white' : 'px-2 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
CW ({cwCount})
</button>
<span class="text-slate-600 mx-1">|</span>
<span class="text-xs font-bold text-slate-400 mr-2">BAND:</span>
<button
on:click={() => dispatch('toggleFilter', 'band160M')}
class={spotFilters.band160M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
160 ({band160MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band80M')}
class={spotFilters.band80M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
80 ({band80MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band60M')}
class={spotFilters.band60M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
60 ({band60MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band40M')}
class={spotFilters.band40M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
40 ({band40MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band30M')}
class={spotFilters.band30M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
30 ({band30MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band20M')}
class={spotFilters.band20M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
20 ({band20MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band17M')}
class={spotFilters.band17M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
17 ({band17MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band15M')}
class={spotFilters.band15M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
15 ({band15MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band12M')}
class={spotFilters.band12M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
12 ({band12MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band10M')}
class={spotFilters.band10M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
10 ({band10MCount})
</button>
<button
on:click={() => dispatch('toggleFilter', 'band6M')}
class={spotFilters.band6M ? 'px-1.5 py-0.5 text-xs rounded transition-colors bg-indigo-600 text-white' : 'px-1.5 py-0.5 text-xs rounded transition-colors bg-slate-700/50 text-slate-300 hover:bg-slate-700'}>
6 ({band6MCount})
</button>
</div>
</div>

View File

@@ -0,0 +1,112 @@
<script>
import { createEventDispatcher } from 'svelte';
export let stats;
export let solarData;
export let wsStatus;
const dispatch = createEventDispatcher();
function getSFIColor(sfi) {
const value = parseInt(sfi);
if (isNaN(value)) return 'text-slate-500';
if (value >= 150) return 'text-green-400 font-bold';
if (value >= 100) return 'text-yellow-400';
return 'text-red-400';
}
function getSunspotsColor(sunspots) {
const value = parseInt(sunspots);
if (isNaN(value)) return 'text-slate-500';
if (value >= 100) return 'text-green-400 font-bold';
if (value >= 50) return 'text-yellow-400';
return 'text-orange-400';
}
function getAIndexColor(aIndex) {
const value = parseInt(aIndex);
if (isNaN(value)) return 'text-slate-500';
if (value <= 7) return 'text-green-400 font-bold';
if (value <= 15) return 'text-yellow-400';
return 'text-red-400';
}
function getKIndexColor(kIndex) {
const value = parseInt(kIndex);
if (isNaN(value)) return 'text-slate-500';
if (value <= 2) return 'text-green-400 font-bold';
if (value <= 4) return 'text-yellow-400';
return 'text-red-400';
}
</script>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">
FlexDXCluster
</h1>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span>F4BPO • <span>{stats.totalContacts}</span> Contacts</span>
<span class="text-slate-600">|</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-amber-400">SFI:</span>
<span class={getSFIColor(solarData.sfi)}>{solarData.sfi}</span>
</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-yellow-400">SSN:</span>
<span class={getSunspotsColor(solarData.sunspots)}>{solarData.sunspots}</span>
</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-red-400">A:</span>
<span class={getAIndexColor(solarData.aIndex)}>{solarData.aIndex}</span>
</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-purple-400">K:</span>
<span class={getKIndexColor(solarData.kIndex)}>{solarData.kIndex}</span>
</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
{#if wsStatus === 'connected'}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold bg-green-500/20 text-green-400">
<span class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
WebSocket
</span>
{:else if wsStatus === 'connecting'}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold bg-orange-500/20 text-orange-400">
<span class="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></span>
Connecting...
</span>
{:else}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold bg-red-500/20 text-red-400">
<span class="w-2 h-2 bg-red-500 rounded-full"></span>
Disconnected
</span>
{/if}
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold {stats.clusterStatus === 'connected' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">
<span class="w-2 h-2 {stats.clusterStatus === 'connected' ? 'bg-green-500 animate-pulse' : 'bg-red-500'} rounded-full"></span>
Cluster
</span>
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-semibold {stats.flexStatus === 'connected' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'}">
<span class="w-2 h-2 {stats.flexStatus === 'connected' ? 'bg-green-500 animate-pulse' : 'bg-red-500'} rounded-full"></span>
Flex
</span>
<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">
<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>

View File

@@ -0,0 +1,98 @@
<script>
export let recentQSOs;
export let logStats;
export let dxccProgress;
</script>
<div class="h-full flex flex-col overflow-hidden">
<div class="p-3 border-b border-slate-700/50 flex-shrink-0">
<h2 class="text-lg font-bold mb-3">Station Log</h2>
<div class="grid grid-cols-4 gap-2 mb-3">
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">Today</div>
<div class="text-xl font-bold text-blue-400">{logStats.today || 0}</div>
</div>
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">This Week</div>
<div class="text-xl font-bold text-green-400">{logStats.thisWeek || 0}</div>
</div>
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">This Month</div>
<div class="text-xl font-bold text-purple-400">{logStats.thisMonth || 0}</div>
</div>
<div class="bg-slate-900/50 rounded p-2">
<div class="text-xs text-slate-400">Total</div>
<div class="text-xl font-bold text-orange-400">{logStats.total || 0}</div>
</div>
</div>
<!-- DXCC Progress Bar -->
<div class="bg-slate-900/50 rounded p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-slate-300">DXCC Progress</span>
<span class="text-sm font-bold text-green-400">{dxccProgress.worked || 0} / {dxccProgress.total || 340}</span>
</div>
<div class="w-full bg-slate-700/30 rounded-full h-3 overflow-hidden">
<div
class="h-full rounded-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all duration-500"
style="width: {dxccProgress.percentage || 0}%">
</div>
</div>
<div class="text-xs text-slate-400 text-right mt-1">{(dxccProgress.percentage || 0).toFixed(1)}% Complete</div>
</div>
</div>
<!-- Recent QSOs Table -->
<div class="flex-1 overflow-y-auto">
<div class="p-3">
<h3 class="text-sm font-bold text-slate-400 mb-2">Recent QSOs</h3>
{#if recentQSOs.length === 0}
<div class="text-center py-8 text-slate-400">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" 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>
<p class="text-sm">No QSOs in log</p>
</div>
{:else}
<table class="w-full text-xs">
<thead class="bg-slate-900/50 sticky top-0">
<tr class="text-left text-xs text-slate-400">
<th class="p-2 font-semibold">Date</th>
<th class="p-2 font-semibold">Time</th>
<th class="p-2 font-semibold">Callsign</th>
<th class="p-2 font-semibold">Band</th>
<th class="p-2 font-semibold">Mode</th>
<th class="p-2 font-semibold">RST S/R</th>
<th class="p-2 font-semibold">Country</th>
</tr>
</thead>
<tbody>
{#each recentQSOs as qso}
{@const date = qso.date ? new Date(qso.date.replace(' ', 'T')) : null}
{@const qsoDate = date ? date.toISOString().split('T')[0] : 'N/A'}
{@const qsoTime = date ? date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }) : 'N/A'}
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors">
<td class="p-2 text-slate-300">{qsoDate}</td>
<td class="p-2 text-slate-300">{qsoTime}</td>
<td class="p-2">
<span class="font-bold text-blue-400">{qso.callsign}</span>
</td>
<td class="p-2">
<span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">{qso.band || 'N/A'}</span>
</td>
<td class="p-2">
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">{qso.mode || 'N/A'}</span>
</td>
<td class="p-2 font-mono text-xs text-slate-400">{qso.rstSent || '---'} / {qso.rstRcvd || '---'}</td>
<td class="p-2 text-slate-400">{qso.country || 'N/A'}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,68 @@
<script>
import { createEventDispatcher } from 'svelte';
import StatsTab from './StatsTab.svelte';
import WatchlistTab from './WatchlistTab.svelte';
import LogTab from './LogTab.svelte';
export let activeTab;
export let topSpotters;
export let spots;
export let watchlist;
export let recentQSOs;
export let logStats;
export let dxccProgress;
const dispatch = createEventDispatcher();
</script>
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden h-full">
<!-- Tabs Header -->
<div class="flex border-b border-slate-700/50 bg-slate-900/30 flex-shrink-0">
<button
class="px-4 py-2 text-sm font-semibold transition-colors {activeTab === 'stats' ? 'bg-blue-500/20 text-blue-400 border-b-2 border-blue-500' : 'text-slate-400 hover:text-slate-300'}"
on:click={() => activeTab = 'stats'}>
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Stats
</button>
<button
class="px-4 py-2 text-sm font-semibold transition-colors {activeTab === 'watchlist' ? 'bg-blue-500/20 text-blue-400 border-b-2 border-blue-500' : 'text-slate-400 hover:text-slate-300'}"
on:click={() => activeTab = 'watchlist'}>
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Watchlist
</button>
<button
class="px-4 py-2 text-sm font-semibold transition-colors {activeTab === 'log' ? 'bg-blue-500/20 text-blue-400 border-b-2 border-blue-500' : 'text-slate-400 hover:text-slate-300'}"
on:click={() => activeTab = 'log'}>
<svg class="w-4 h-4 inline-block mr-1" 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>
Log
</button>
</div>
<!-- Tab Content -->
<div class="flex-1 overflow-hidden">
{#if activeTab === 'stats'}
<StatsTab {topSpotters} {spots} />
{:else if activeTab === 'watchlist'}
<WatchlistTab
{watchlist}
{spots}
on:toast={(e) => dispatch('toast', e.detail)}
/>
{:else if activeTab === 'log'}
<LogTab
{recentQSOs}
{logStats}
{dxccProgress}
/>
{/if}
</div>
</div>

View File

@@ -0,0 +1,102 @@
<script>
import { createEventDispatcher } from 'svelte';
export let spots;
export let watchlist;
export let myCallsign;
const dispatch = createEventDispatcher();
function handleSpotClick(spot) {
dispatch('clickSpot', {
callsign: spot.DX,
frequency: spot.FrequencyMhz,
mode: spot.Mode
});
}
function getPriorityColor(spot) {
const inWatchlist = watchlist.some(pattern =>
spot.DX === pattern || spot.DX.startsWith(pattern)
);
if (inWatchlist) return 'bg-pink-500/20 text-pink-400 border-pink-500/50';
if (spot.DX === 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';
if (spot.NewMode) return 'bg-orange-500/20 text-orange-400 border-orange-500/50';
if (spot.Worked) return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/50';
return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
}
function getStatusLabel(spot) {
const inWatchlist = watchlist.some(pattern =>
spot.DX === pattern || spot.DX.startsWith(pattern)
);
if (inWatchlist) return 'Watchlist';
if (spot.DX === myCallsign) return 'My Call';
if (spot.NewDXCC) return 'New DXCC';
if (spot.NewBand && spot.NewMode) return 'New B&M';
if (spot.NewBand) return 'New Band';
if (spot.NewMode) return 'New Mode';
if (spot.NewSlot) return 'New Slot';
if (spot.Worked) return 'Worked';
return '';
}
</script>
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden h-full">
<div class="p-3 border-b border-slate-700/50 flex items-center justify-between flex-shrink-0">
<h2 class="text-lg font-bold">Recent Spots (<span>{spots.length}</span>)</h2>
</div>
<div class="overflow-y-auto flex-1">
<table class="w-full text-sm">
<thead class="bg-slate-900/50 sticky top-0">
<tr class="text-left text-xs text-slate-400">
<th class="p-2 font-semibold">DX</th>
<th class="p-2 font-semibold">Freq</th>
<th class="p-2 font-semibold">Band</th>
<th class="p-2 font-semibold">Mode</th>
<th class="p-2 font-semibold">Spotter</th>
<th class="p-2 font-semibold">Time</th>
<th class="p-2 font-semibold">Country</th>
<th class="p-2 font-semibold">Status</th>
</tr>
</thead>
<tbody>
{#each spots as spot (spot.ID)}
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm">
<td class="p-2">
<button
class="font-bold text-blue-400 dx-callsign"
on:click={() => handleSpotClick(spot)}
title="Click to send to Log4OM and tune radio">
{spot.DX}
</button>
</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>
<td class="p-2 text-slate-300 text-xs">{spot.SpotterCallsign}</td>
<td class="p-2 text-slate-400 text-xs">{spot.UTCTime}</td>
<td class="p-2 text-slate-400 text-xs">{spot.CountryName || 'N/A'}</td>
<td class="p-2">
{#if getStatusLabel(spot)}
<span class="px-1.5 py-0.5 rounded text-xs font-semibold border {getPriorityColor(spot)}">
{getStatusLabel(spot)}
</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,90 @@
<script>
import { createEventDispatcher } from 'svelte';
export let stats;
const dispatch = createEventDispatcher();
function handleFilterChange(filterName, checked) {
dispatch('filterChange', { name: filterName, value: checked });
}
</script>
<div class="grid grid-cols-7 gap-3 mb-3">
<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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div class="text-xl font-bold text-blue-400">{stats.totalSpots}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Total Spots</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-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xl font-bold text-green-400">{stats.newDXCC}</div>
</div>
<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>
<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" />
</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">
<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>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<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>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<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>
</label>
</div>
</div>
</div>

View File

@@ -0,0 +1,75 @@
<script>
export let topSpotters;
export let spots;
const BANDS = ['160M', '80M', '60M', '40M', '30M', '20M', '17M', '15M', '12M', '10M', '6M'];
const BAND_COLORS = {
'160M': '#ef4444',
'80M': '#f97316',
'60M': '#f59e0b',
'40M': '#eab308',
'30M': '#84cc16',
'20M': '#22c55e',
'17M': '#10b981',
'15M': '#14b8a6',
'12M': '#06b6d4',
'10M': '#0ea5e9',
'6M': '#3b82f6'
};
$: bandStats = getBandStats(spots);
$: maxSpots = Math.max(...Object.values(bandStats), 1);
function getBandStats(allSpots) {
const stats = {};
BANDS.forEach(band => {
stats[band] = allSpots.filter(s => s.Band === band).length;
});
return stats;
}
</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>
<div class="px-3 pb-3 space-y-2">
{#each BANDS as band}
{@const count = bandStats[band]}
{@const percentage = maxSpots > 0 ? (count / maxSpots) * 100 : 0}
{@const color = BAND_COLORS[band]}
<div class="cursor-pointer hover:opacity-80 transition-opacity">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-semibold" style="color: {color}">{band}</span>
<span class="text-xs text-slate-400">{count} spots</span>
</div>
<div class="w-full bg-slate-700/30 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500"
style="width: {percentage}%; background-color: {color}">
</div>
</div>
</div>
{/each}
</div>
</div>

View File

@@ -0,0 +1,46 @@
<script>
export let message;
export let type = 'info'; // 'success', 'error', 'warning', 'info'
const icons = {
success: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>`,
error: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>`,
warning: `<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>`,
info: ''
};
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-orange-500',
info: 'bg-blue-500'
};
</script>
<div class="fixed bottom-5 right-5 {colors[type]} text-white px-5 py-3 rounded-lg shadow-lg z-50 animate-in slide-in-from-bottom-5 duration-300">
<div class="flex items-center gap-2">
{#if icons[type]}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html icons[type]}
</svg>
{/if}
<span>{message}</span>
</div>
</div>
<style>
@keyframes slide-in-from-bottom {
from {
transform: translateY(400px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-in {
animation: slide-in-from-bottom 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,236 @@
<script>
import { createEventDispatcher } from 'svelte';
export let watchlist;
export let spots;
const dispatch = createEventDispatcher();
let newCallsign = '';
let showOnlyActive = false;
let watchlistSpots = [];
$: matchingSpots = countWatchlistSpots(spots, watchlist);
$: displayList = showOnlyActive
? watchlist.filter(cs => getMatchingSpotsForCallsign(cs).length > 0)
: watchlist;
// Fetch watchlist spots with worked status from API
$: if (watchlist.length > 0) {
fetchWatchlistSpots();
}
async function fetchWatchlistSpots() {
try {
const response = await fetch('/api/watchlist/spots');
const json = await response.json();
if (json.success) {
watchlistSpots = json.data || [];
}
} catch (error) {
console.error('Error fetching watchlist spots:', error);
}
}
function countWatchlistSpots(allSpots, wl) {
return allSpots.filter(spot =>
wl.some(pattern => spot.DX === pattern || spot.DX.startsWith(pattern))
).length;
}
function getMatchingSpotsForCallsign(callsign) {
return watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
}
async function addToWatchlist() {
const callsign = newCallsign.trim().toUpperCase();
if (!callsign) {
dispatch('toast', { message: 'Please enter a callsign', type: 'warning' });
return;
}
try {
const response = await fetch('/api/watchlist/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign })
});
const data = await response.json();
if (data.success) {
newCallsign = '';
dispatch('toast', { message: `${callsign} added to watchlist`, type: 'success' });
await fetchWatchlistSpots();
} else {
dispatch('toast', { message: 'Failed to add callsign', type: 'error' });
}
} catch (error) {
console.error('Error adding to watchlist:', error);
dispatch('toast', { message: `Error: ${error.message}`, type: 'error' });
}
}
async function removeFromWatchlist(callsign) {
try {
const response = await fetch('/api/watchlist/remove', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ callsign })
});
const data = await response.json();
if (data.success) {
dispatch('toast', { message: `${callsign} removed from watchlist`, type: 'success' });
await fetchWatchlistSpots();
} else {
dispatch('toast', { message: 'Failed to remove callsign', type: 'error' });
}
} catch (error) {
console.error('Error removing from watchlist:', error);
dispatch('toast', { message: `Error: ${error.message}`, type: 'error' });
}
}
function handleKeyPress(e) {
if (e.key === 'Enter') {
e.preventDefault();
addToWatchlist();
}
}
function sendSpot(spot) {
const event = new CustomEvent('sendSpot', {
detail: {
callsign: spot.dx,
frequency: spot.frequencyMhz,
mode: spot.mode
}
});
window.dispatchEvent(event);
}
</script>
<div class="h-full flex flex-col overflow-hidden">
<div class="p-3 border-b border-slate-700/50 flex-shrink-0">
<div class="flex items-center justify-between mb-2">
<h2 class="text-lg font-bold">Watchlist</h2>
<button
on:click={() => showOnlyActive = !showOnlyActive}
class="px-3 py-1.5 text-xs rounded transition-colors flex items-center gap-2 {showOnlyActive ? 'bg-blue-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path>
</svg>
{showOnlyActive ? 'Show All' : 'Active Only'}
</button>
</div>
<p class="text-xs text-slate-400 mb-3">{matchingSpots} matching spots</p>
<div class="flex gap-2">
<input
type="text"
bind:value={newCallsign}
on:keypress={handleKeyPress}
placeholder="Enter callsign or prefix (e.g., VK9)"
class="flex-1 px-3 py-2 bg-slate-700/50 border border-slate-600 rounded text-sm text-white placeholder-slate-400 focus:outline-none focus:border-blue-500"
/>
<button
on:click={addToWatchlist}
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-sm font-medium transition-colors">
Add
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-3">
{#if displayList.length === 0}
<div class="text-center py-8 text-slate-400">
<svg class="w-12 h-12 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<p class="text-sm">{showOnlyActive ? 'No active spots for watchlist callsigns' : 'No callsigns in watchlist'}</p>
<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)}
{@const count = matchingSpots.length}
{@const neededCount = matchingSpots.filter(s => !s.workedBandMode).length}
{@const borderClass = neededCount > 0 ? 'border-orange-500/30' : 'border-slate-700/50'}
<div class="mb-3 p-3 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors border {borderClass}">
<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>
{#if count > 0}
<span class="text-xs text-slate-400">{count} active spot{count !== 1 ? 's' : ''}</span>
{#if neededCount > 0}
<span class="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded text-xs font-semibold">{neededCount} needed</span>
{:else}
<span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded text-xs font-semibold">All worked</span>
{/if}
{:else}
<span class="text-xs text-slate-500">No active spots</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>
{#if count > 0}
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
{#each matchingSpots.slice(0, 10) as spot}
<button
on:click={() => sendSpot(spot)}
class="w-full flex items-center justify-between p-2 bg-slate-800/50 rounded text-xs hover:bg-slate-700/50 transition-colors {!spot.workedBandMode ? 'border-l-2 border-orange-500' : ''}"
title="Click to send to Log4OM and tune radio">
<div class="flex items-center gap-2 flex-1 min-w-0">
{#if spot.workedBandMode}
<svg class="w-4 h-4 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>
{:else}
<svg class="w-4 h-4 text-orange-400 flex-shrink-0" 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>
{/if}
<span class="font-bold text-blue-400">{spot.dx}</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>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
{#if spot.workedBandMode}
<span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded text-xs font-semibold">Worked</span>
{:else if spot.newDXCC}
<span class="px-1.5 py-0.5 bg-green-500/20 text-green-400 rounded text-xs font-semibold">New DXCC!</span>
{:else if spot.newBand && spot.newMode}
<span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs font-semibold">New B&M!</span>
{:else if spot.newBand}
<span class="px-1.5 py-0.5 bg-yellow-500/20 text-yellow-400 rounded text-xs font-semibold">New Band!</span>
{:else if spot.newMode}
<span class="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded text-xs font-semibold">New Mode!</span>
{:else}
<span class="px-1.5 py-0.5 bg-orange-500/20 text-orange-400 rounded text-xs font-semibold">Needed!</span>
{/if}
<span class="text-slate-500">{spot.utcTime}</span>
</div>
</button>
{/each}
</div>
{:else}
<div class="mt-2 text-xs text-slate-500 text-center py-2 bg-slate-800/30 rounded">No active spots</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>

8
frontend/src/main.js Normal file
View File

@@ -0,0 +1,8 @@
import './app.css'
import App from './App.svelte'
const app = new App({
target: document.getElementById('app'),
})
export default app

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{svelte,js,ts}",
],
theme: {
extend: {},
},
plugins: [],
}

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
export default defineConfig({
plugins: [svelte()],
build: {
outDir: 'dist',
emptyOutDir: true,
},
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
}
}
}
})

View File

@@ -1,10 +1,12 @@
package main
import (
"embed"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
@@ -16,6 +18,9 @@ import (
log "github.com/sirupsen/logrus"
)
//go:embed frontend/dist/*
var frontendFiles embed.FS
type HTTPServer struct {
Router *mux.Router
FlexRepo *FlexDXClusterRepository
@@ -141,15 +146,35 @@ func (s *HTTPServer) setupRoutes() {
// WebSocket endpoint
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
// Serve static files (dashboard)
// s.Router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static")))
s.Router.HandleFunc("/", s.serveIndex).Methods("GET")
s.setupStaticFiles()
}
func (s *HTTPServer) serveIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(indexHTML)
func (s *HTTPServer) setupStaticFiles() {
// Obtenir le sous-système de fichiers depuis dist/
distFS, err := fs.Sub(frontendFiles, "frontend/dist")
if err != nil {
s.Log.Fatal("Cannot load frontend files:", err)
}
spaHandler := http.FileServer(http.FS(distFS))
s.Router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Vérifier si le fichier existe
if path != "/" {
file, err := distFS.Open(strings.TrimPrefix(path, "/"))
if err == nil {
file.Close()
spaHandler.ServeHTTP(w, r)
return
}
}
// Si pas trouvé ou racine, servir index.html
r.URL.Path = "/"
spaHandler.ServeHTTP(w, r)
}))
}
func (s *HTTPServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
@@ -214,7 +239,7 @@ func (s *HTTPServer) sendInitialData(conn *websocket.Conn) {
conn.WriteJSON(WSMessage{Type: "watchlist", Data: watchlist})
// Send initial log data
qsos := s.ContactRepo.GetRecentQSOs("10")
qsos := s.ContactRepo.GetRecentQSOs("8")
conn.WriteJSON(WSMessage{Type: "log", Data: qsos})
logStats := s.ContactRepo.GetQSOStats()
@@ -287,7 +312,7 @@ func (s *HTTPServer) broadcastUpdates() {
}
// Broadcast log data every 10 seconds
qsos := s.ContactRepo.GetRecentQSOs("10")
qsos := s.ContactRepo.GetRecentQSOs("8")
s.broadcast <- WSMessage{Type: "log", Data: qsos}
stats := s.ContactRepo.GetQSOStats()

File diff suppressed because it is too large Load Diff