svelte
This commit is contained in:
83
Makefile
Normal file
83
Makefile
Normal 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
12
frontend/index.html
Normal 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
2787
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
frontend/package.json
Normal file
18
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
448
frontend/src/App.svelte
Normal file
448
frontend/src/App.svelte
Normal 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
38
frontend/src/app.css
Normal 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);
|
||||||
|
}
|
||||||
23
frontend/src/components/ErrorBanner.svelte
Normal file
23
frontend/src/components/ErrorBanner.svelte
Normal 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>
|
||||||
221
frontend/src/components/FilterBar.svelte
Normal file
221
frontend/src/components/FilterBar.svelte
Normal 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>
|
||||||
112
frontend/src/components/Header.svelte
Normal file
112
frontend/src/components/Header.svelte
Normal 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>
|
||||||
98
frontend/src/components/LogTab.svelte
Normal file
98
frontend/src/components/LogTab.svelte
Normal 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>
|
||||||
68
frontend/src/components/Sidebar.svelte
Normal file
68
frontend/src/components/Sidebar.svelte
Normal 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>
|
||||||
102
frontend/src/components/SpotsTable.svelte
Normal file
102
frontend/src/components/SpotsTable.svelte
Normal 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>
|
||||||
90
frontend/src/components/StatsCards.svelte
Normal file
90
frontend/src/components/StatsCards.svelte
Normal 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>
|
||||||
75
frontend/src/components/StatsTab.svelte
Normal file
75
frontend/src/components/StatsTab.svelte
Normal 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>
|
||||||
46
frontend/src/components/Toast.svelte
Normal file
46
frontend/src/components/Toast.svelte
Normal 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>
|
||||||
236
frontend/src/components/WatchlistTab.svelte
Normal file
236
frontend/src/components/WatchlistTab.svelte
Normal 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
8
frontend/src/main.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import './app.css'
|
||||||
|
import App from './App.svelte'
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default app
|
||||||
11
frontend/tailwind.config.js
Normal file
11
frontend/tailwind.config.js
Normal 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
19
frontend/vite.config.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,6 +18,9 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//go:embed frontend/dist/*
|
||||||
|
var frontendFiles embed.FS
|
||||||
|
|
||||||
type HTTPServer struct {
|
type HTTPServer struct {
|
||||||
Router *mux.Router
|
Router *mux.Router
|
||||||
FlexRepo *FlexDXClusterRepository
|
FlexRepo *FlexDXClusterRepository
|
||||||
@@ -141,15 +146,35 @@ func (s *HTTPServer) setupRoutes() {
|
|||||||
// WebSocket endpoint
|
// WebSocket endpoint
|
||||||
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
|
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
|
||||||
|
|
||||||
// Serve static files (dashboard)
|
s.setupStaticFiles()
|
||||||
// s.Router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static")))
|
|
||||||
s.Router.HandleFunc("/", s.serveIndex).Methods("GET")
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HTTPServer) serveIndex(w http.ResponseWriter, r *http.Request) {
|
func (s *HTTPServer) setupStaticFiles() {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
// Obtenir le sous-système de fichiers depuis dist/
|
||||||
w.Write(indexHTML)
|
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) {
|
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})
|
conn.WriteJSON(WSMessage{Type: "watchlist", Data: watchlist})
|
||||||
|
|
||||||
// Send initial log data
|
// Send initial log data
|
||||||
qsos := s.ContactRepo.GetRecentQSOs("10")
|
qsos := s.ContactRepo.GetRecentQSOs("8")
|
||||||
conn.WriteJSON(WSMessage{Type: "log", Data: qsos})
|
conn.WriteJSON(WSMessage{Type: "log", Data: qsos})
|
||||||
|
|
||||||
logStats := s.ContactRepo.GetQSOStats()
|
logStats := s.ContactRepo.GetQSOStats()
|
||||||
@@ -287,7 +312,7 @@ func (s *HTTPServer) broadcastUpdates() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast log data every 10 seconds
|
// Broadcast log data every 10 seconds
|
||||||
qsos := s.ContactRepo.GetRecentQSOs("10")
|
qsos := s.ContactRepo.GetRecentQSOs("8")
|
||||||
s.broadcast <- WSMessage{Type: "log", Data: qsos}
|
s.broadcast <- WSMessage{Type: "log", Data: qsos}
|
||||||
|
|
||||||
stats := s.ContactRepo.GetQSOStats()
|
stats := s.ContactRepo.GetQSOStats()
|
||||||
|
|||||||
2088
static/index.html
2088
static/index.html
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user