sound
This commit is contained in:
8
Makefile
8
Makefile
@@ -25,13 +25,13 @@ help:
|
||||
|
||||
## install-deps: Installe les dépendances npm
|
||||
install-deps:
|
||||
@echo "[1/2] Installation des dépendances npm..."
|
||||
@echo "[1/2] Installation des dependances npm..."
|
||||
cd $(FRONTEND_DIR) && npm install
|
||||
@echo "Dépendances installées"
|
||||
@echo "Dependances installees"
|
||||
@echo ""
|
||||
@echo "[2/2] Vérification de Go..."
|
||||
@echo "[2/2] Verification de Go..."
|
||||
@go version
|
||||
@echo "Go est installé"
|
||||
@echo "Go est installe"
|
||||
|
||||
## frontend: Build le frontend Svelte
|
||||
frontend:
|
||||
|
||||
BIN
flex.sqlite-journal
Normal file
BIN
flex.sqlite-journal
Normal file
Binary file not shown.
@@ -7,9 +7,11 @@
|
||||
import Sidebar from './components/Sidebar.svelte';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import ErrorBanner from './components/ErrorBanner.svelte';
|
||||
import SoundManager from './components/SoundManager.svelte';
|
||||
|
||||
// State
|
||||
let spots = [];
|
||||
let previousSpots = []; // Pour détecter les nouveaux spots
|
||||
let filteredSpots = [];
|
||||
let stats = {
|
||||
totalSpots: 0,
|
||||
@@ -23,13 +25,14 @@
|
||||
filters: { skimmer: false, ft8: false, ft4: false }
|
||||
};
|
||||
let topSpotters = [];
|
||||
let watchlist = [];
|
||||
let watchlist = []; // ✅ Initialisé vide, sera rempli par WebSocket
|
||||
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 showOnlyActive = false; // ✅ État global pour persister entre les onglets
|
||||
let wsStatus = 'disconnected';
|
||||
let errorMessage = '';
|
||||
let toastMessage = '';
|
||||
@@ -58,13 +61,15 @@
|
||||
band12M: false,
|
||||
band10M: false,
|
||||
band6M: false
|
||||
};
|
||||
};
|
||||
|
||||
// WebSocket
|
||||
let ws;
|
||||
let reconnectTimer;
|
||||
let reconnectAttempts = 0;
|
||||
let maxReconnectAttempts = 10;
|
||||
let soundEnabled = true;
|
||||
let isShuttingDown = false; // ✅ Flag pour éviter les erreurs pendant le shutdown
|
||||
|
||||
// Reactive filtered spots
|
||||
$: {
|
||||
@@ -75,6 +80,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Détecter les nouveaux spots et jouer les sons appropriés
|
||||
$: if (spots.length > 0 && soundEnabled) {
|
||||
checkForNewSpots(spots, previousSpots, watchlist);
|
||||
previousSpots = [...spots];
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMÉ - La watchlist est gérée côté serveur via WebSocket
|
||||
// Les fonctions addToWatchlist et removeFromWatchlist ne sont plus nécessaires
|
||||
|
||||
function checkForNewSpots(currentSpots, prevSpots, wl) {
|
||||
// Ne pas jouer de sons au chargement initial
|
||||
if (prevSpots.length === 0) return;
|
||||
|
||||
// Créer un Set des IDs précédents pour une recherche rapide
|
||||
const previousIds = new Set(prevSpots.map(s => `${s.DX}-${s.Frequency}-${s.Time}`));
|
||||
|
||||
// Trouver les nouveaux spots
|
||||
const newSpots = currentSpots.filter(spot => {
|
||||
const spotId = `${spot.DX}-${spot.Frequency}-${spot.Time}`;
|
||||
return !previousIds.has(spotId);
|
||||
});
|
||||
|
||||
if (newSpots.length === 0) return;
|
||||
|
||||
// Vérifier s'il y a un nouveau DXCC (priorité maximale)
|
||||
const hasNewDXCC = newSpots.some(spot => spot.NewDXCC === true);
|
||||
if (hasNewDXCC) {
|
||||
playSound('newDXCC');
|
||||
return; // Ne jouer qu'un seul son
|
||||
}
|
||||
|
||||
// Vérifier s'il y a un spot de la watchlist
|
||||
const hasWatchlistSpot = newSpots.some(spot =>
|
||||
wl.some(pattern => spot.DX === pattern || spot.DX.startsWith(pattern))
|
||||
);
|
||||
if (hasWatchlistSpot) {
|
||||
playSound('watchlist');
|
||||
}
|
||||
}
|
||||
|
||||
function playSound(type) {
|
||||
window.dispatchEvent(new CustomEvent('playSound', {
|
||||
detail: { type }
|
||||
}));
|
||||
}
|
||||
|
||||
function applyFilters(allSpots, filters, wl) {
|
||||
const bandFiltersActive = filters.band160M || filters.band80M || filters.band60M ||
|
||||
filters.band40M || filters.band30M || filters.band20M || filters.band17M ||
|
||||
@@ -255,6 +306,7 @@
|
||||
topSpotters = message.data || [];
|
||||
break;
|
||||
case 'watchlist':
|
||||
// ✅ La watchlist est mise à jour par WebSocket
|
||||
watchlist = message.data || [];
|
||||
break;
|
||||
case 'log':
|
||||
@@ -344,13 +396,11 @@
|
||||
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
|
||||
maxReconnectAttempts = 0;
|
||||
|
||||
// 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">
|
||||
@@ -378,10 +428,8 @@
|
||||
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);
|
||||
};
|
||||
@@ -397,6 +445,9 @@
|
||||
</script>
|
||||
|
||||
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-4">
|
||||
<!-- Gestionnaire de sons -->
|
||||
<SoundManager bind:enabled={soundEnabled} />
|
||||
|
||||
{#if errorMessage}
|
||||
<ErrorBanner message={errorMessage} on:close={() => errorMessage = ''} />
|
||||
{/if}
|
||||
@@ -424,8 +475,8 @@
|
||||
on:toggleFilter={(e) => toggleFilter(e.detail)}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-4 gap-3" style="height: calc(100vh - 360px);">
|
||||
<div class="col-span-3">
|
||||
<div class="grid grid-cols-4 gap-3 overflow-hidden" style="height: calc(100vh - 360px); min-height: 500px;">
|
||||
<div class="col-span-3 overflow-hidden">
|
||||
<SpotsTable
|
||||
spots={filteredSpots}
|
||||
{watchlist}
|
||||
@@ -434,15 +485,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Sidebar
|
||||
bind:activeTab
|
||||
{topSpotters}
|
||||
{spots}
|
||||
{watchlist}
|
||||
{recentQSOs}
|
||||
{logStats}
|
||||
{dxccProgress}
|
||||
on:toast={(e) => showToast(e.detail.message, e.detail.type)}
|
||||
/>
|
||||
<div class="overflow-hidden">
|
||||
<Sidebar
|
||||
bind:activeTab
|
||||
bind:showOnlyActive
|
||||
{topSpotters}
|
||||
{spots}
|
||||
{watchlist}
|
||||
{recentQSOs}
|
||||
{logStats}
|
||||
{dxccProgress}
|
||||
on:toast={(e) => showToast(e.detail.message, e.detail.type)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -11,11 +11,17 @@
|
||||
export let recentQSOs;
|
||||
export let logStats;
|
||||
export let dxccProgress;
|
||||
export let showOnlyActive = false; // ✅ Export pour persister l'état
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// ✅ Propagation des évènements vers le parent
|
||||
function handleToast(event) {
|
||||
dispatch('toast', event.detail);
|
||||
}
|
||||
</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="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col h-full" style="height: 100%; max-height: 100%;">
|
||||
<!-- Tabs Header -->
|
||||
<div class="flex border-b border-slate-700/50 bg-slate-900/30 flex-shrink-0">
|
||||
<button
|
||||
@@ -48,14 +54,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div class="flex-1 overflow-hidden" style="min-height: 0;">
|
||||
{#if activeTab === 'stats'}
|
||||
<StatsTab {topSpotters} {spots} />
|
||||
{:else if activeTab === 'watchlist'}
|
||||
<WatchlistTab
|
||||
{watchlist}
|
||||
{spots}
|
||||
on:toast={(e) => dispatch('toast', e.detail)}
|
||||
{:else if activeTab === 'watchlist'}
|
||||
<WatchlistTab
|
||||
{watchlist}
|
||||
{spots}
|
||||
bind:showOnlyActive
|
||||
on:toast={handleToast}
|
||||
/>
|
||||
{:else if activeTab === 'log'}
|
||||
<LogTab
|
||||
|
||||
158
frontend/src/components/SoundManager.svelte
Normal file
158
frontend/src/components/SoundManager.svelte
Normal file
@@ -0,0 +1,158 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let enabled = true;
|
||||
export let volume = 0.3;
|
||||
export let showControls = false; // Masquer les contrôles par défaut
|
||||
|
||||
let audioContext;
|
||||
let isMuted = false;
|
||||
|
||||
onMount(() => {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
window.addEventListener('playSound', handlePlaySound);
|
||||
|
||||
// Charger les préférences depuis localStorage
|
||||
const savedMuted = localStorage.getItem('soundMuted');
|
||||
const savedVolume = localStorage.getItem('soundVolume');
|
||||
|
||||
if (savedMuted !== null) isMuted = savedMuted === 'true';
|
||||
if (savedVolume !== null) volume = parseFloat(savedVolume);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('playSound', handlePlaySound);
|
||||
if (audioContext) audioContext.close();
|
||||
};
|
||||
});
|
||||
|
||||
function handlePlaySound(event) {
|
||||
if (!enabled || isMuted) return;
|
||||
|
||||
const { type } = event.detail;
|
||||
|
||||
if (type === 'newDXCC') {
|
||||
playNewDXCCSound();
|
||||
} else if (type === 'watchlist') {
|
||||
playWatchlistSound();
|
||||
}
|
||||
}
|
||||
|
||||
function playNewDXCCSound() {
|
||||
if (!audioContext) return;
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.type = 'sine';
|
||||
|
||||
// Mélodie joyeuse : C5 -> E5 -> G5 -> C6
|
||||
oscillator.frequency.setValueAtTime(523.25, now);
|
||||
oscillator.frequency.setValueAtTime(659.25, now + 0.15);
|
||||
oscillator.frequency.setValueAtTime(783.99, now + 0.3);
|
||||
oscillator.frequency.setValueAtTime(1046.50, now + 0.45);
|
||||
|
||||
gainNode.gain.setValueAtTime(0, now);
|
||||
gainNode.gain.linearRampToValueAtTime(volume, now + 0.05);
|
||||
gainNode.gain.setValueAtTime(volume * 0.8, now + 0.5);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, now + 0.7);
|
||||
|
||||
oscillator.start(now);
|
||||
oscillator.stop(now + 0.7);
|
||||
}
|
||||
|
||||
function playWatchlistSound() {
|
||||
if (!audioContext) return;
|
||||
|
||||
const now = audioContext.currentTime;
|
||||
playBeep(now, 800, 0.1);
|
||||
playBeep(now + 0.15, 1000, 0.1);
|
||||
}
|
||||
|
||||
function playBeep(startTime, frequency, duration) {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
const gainNode = audioContext.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioContext.destination);
|
||||
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.setValueAtTime(frequency, startTime);
|
||||
|
||||
gainNode.gain.setValueAtTime(0, startTime);
|
||||
gainNode.gain.linearRampToValueAtTime(volume, startTime + 0.01);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
|
||||
|
||||
oscillator.start(startTime);
|
||||
oscillator.stop(startTime + duration);
|
||||
}
|
||||
|
||||
function toggleMute() {
|
||||
isMuted = !isMuted;
|
||||
localStorage.setItem('soundMuted', isMuted.toString());
|
||||
}
|
||||
|
||||
function updateVolume(newVolume) {
|
||||
volume = newVolume;
|
||||
localStorage.setItem('soundVolume', volume.toString());
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if showControls}
|
||||
<div class="fixed bottom-4 right-4 flex items-center gap-2 bg-slate-800/90 backdrop-blur rounded-lg border border-slate-700/50 p-2 shadow-lg z-50">
|
||||
<button
|
||||
on:click={toggleMute}
|
||||
class="p-2 rounded transition-colors {isMuted ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}"
|
||||
title={isMuted ? 'Unmute sounds' : 'Mute sounds'}>
|
||||
{#if isMuted}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clip-rule="evenodd"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"></path>
|
||||
</svg>
|
||||
{:else}
|
||||
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2 border-l border-slate-700 pl-2">
|
||||
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume * 100}
|
||||
on:input={(e) => updateVolume(e.target.value / 100)}
|
||||
class="w-20 h-1 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
title="Volume: {Math.round(volume * 100)}%"
|
||||
/>
|
||||
<span class="text-xs text-slate-400 w-8">{Math.round(volume * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
input[type="range"]::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input[type="range"]::-moz-range-thumb {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,25 +1,61 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
|
||||
export let watchlist;
|
||||
export let spots;
|
||||
export let showOnlyActive = false;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let newCallsign = '';
|
||||
let showOnlyActive = false;
|
||||
let watchlistSpots = [];
|
||||
let refreshInterval;
|
||||
|
||||
$: matchingSpots = countWatchlistSpots(spots, watchlist);
|
||||
$: displayList = showOnlyActive
|
||||
? watchlist.filter(cs => getMatchingSpotsForCallsign(cs).length > 0)
|
||||
: watchlist;
|
||||
|
||||
// Fetch watchlist spots with worked status from API
|
||||
// ✅ Tri alphanumérique simple : 0-9 puis A-Z
|
||||
$: displayList = getDisplayList(watchlist, watchlistSpots, showOnlyActive);
|
||||
|
||||
// ✅ Rafraîchir automatiquement les spots de la watchlist
|
||||
$: if (watchlist.length > 0) {
|
||||
fetchWatchlistSpots();
|
||||
}
|
||||
|
||||
// ✅ Rafraîchir aussi quand les spots changent (temps réel)
|
||||
$: if (spots.length > 0 && watchlist.length > 0) {
|
||||
fetchWatchlistSpots();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Rafraîchir toutes les 10 secondes pour être sûr
|
||||
refreshInterval = setInterval(() => {
|
||||
if (watchlist.length > 0) {
|
||||
fetchWatchlistSpots();
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
}
|
||||
});
|
||||
|
||||
function getDisplayList(wl, wlSpots, activeOnly) {
|
||||
let list = wl;
|
||||
|
||||
if (activeOnly) {
|
||||
// Filtrer pour ne montrer que les callsigns avec des spots actifs
|
||||
list = wl.filter(cs => {
|
||||
const spots = wlSpots.filter(s => s.dx === cs || s.dx.startsWith(cs));
|
||||
return spots.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Tri alphanumérique : 0-9 puis A-Z
|
||||
return [...list].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
|
||||
}
|
||||
|
||||
async function fetchWatchlistSpots() {
|
||||
try {
|
||||
const response = await fetch('/api/watchlist/spots');
|
||||
@@ -110,14 +146,19 @@
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// ✅ Fonction pour le toggle
|
||||
function toggleActiveOnly() {
|
||||
showOnlyActive = !showOnlyActive;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<div class="h-full flex flex-col" style="height: 100%; max-height: 100%;">
|
||||
<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}
|
||||
on:click={toggleActiveOnly}
|
||||
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>
|
||||
@@ -143,7 +184,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<div class="flex-1 p-3 overflow-y-auto" style="overflow-y: auto; min-height: 0; flex: 1 1 0;">
|
||||
{#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">
|
||||
@@ -186,7 +227,7 @@
|
||||
</div>
|
||||
|
||||
{#if count > 0}
|
||||
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto">
|
||||
<div class="mt-2 space-y-1 max-h-48 overflow-y-auto" style="overflow-y: auto;">
|
||||
{#each matchingSpots.slice(0, 10) as spot}
|
||||
<button
|
||||
on:click={() => sendSpot(spot)}
|
||||
|
||||
@@ -1 +1 @@
|
||||
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG"]
|
||||
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB"]
|
||||
Reference in New Issue
Block a user