You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
openhamclock/src/components/DXFilterManager.jsx

483 lines
19 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* DXFilterManager Component
* Filter modal with tabs for Zones, Bands, Modes, Watchlist, Exclude, Settings
*/
import React, { useState } from 'react';
export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState('zones');
const [newWatchlistCall, setNewWatchlistCall] = useState('');
const [newExcludeCall, setNewExcludeCall] = useState('');
if (!isOpen) return null;
const continents = [
{ code: 'NA', name: 'North America' },
{ code: 'SA', name: 'South America' },
{ code: 'EU', name: 'Europe' },
{ code: 'AF', name: 'Africa' },
{ code: 'AS', name: 'Asia' },
{ code: 'OC', name: 'Oceania' },
{ code: 'AN', name: 'Antarctica' }
];
const bands = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m', '6m', '2m', '70cm'];
const modes = ['CW', 'SSB', 'FT8', 'FT4', 'RTTY', 'PSK', 'JT65', 'JS8', 'SSTV', 'AM', 'FM'];
const toggleArrayItem = (key, item) => {
const current = filters[key] || [];
const newArray = current.includes(item)
? current.filter(x => x !== item)
: [...current, item];
onFilterChange({ ...filters, [key]: newArray.length ? newArray : undefined });
};
const selectAll = (key, items) => {
onFilterChange({ ...filters, [key]: [...items] });
};
const clearFilter = (key) => {
const newFilters = { ...filters };
delete newFilters[key];
onFilterChange(newFilters);
};
const clearAllFilters = () => {
onFilterChange({});
};
const getActiveFilterCount = () => {
let count = 0;
if (filters?.continents?.length) count += filters.continents.length;
if (filters?.cqZones?.length) count += filters.cqZones.length;
if (filters?.ituZones?.length) count += filters.ituZones.length;
if (filters?.bands?.length) count += filters.bands.length;
if (filters?.modes?.length) count += filters.modes.length;
if (filters?.watchlist?.length) count += filters.watchlist.length;
if (filters?.excludeList?.length) count += filters.excludeList.length;
return count;
};
const tabStyle = (active) => ({
padding: '8px 16px',
background: active ? 'var(--bg-tertiary)' : 'transparent',
border: 'none',
borderBottom: active ? '2px solid var(--accent-cyan)' : '2px solid transparent',
color: active ? 'var(--accent-cyan)' : 'var(--text-muted)',
fontSize: '13px',
cursor: 'pointer',
fontFamily: 'inherit'
});
const chipStyle = (selected) => ({
padding: '6px 12px',
background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
borderRadius: '4px',
color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
fontSize: '12px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace'
});
const zoneButtonStyle = (selected) => ({
width: '36px',
height: '32px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: selected ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
border: `1px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
borderRadius: '4px',
color: selected ? 'var(--accent-cyan)' : 'var(--text-secondary)',
fontSize: '12px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace'
});
const addToWatchlist = () => {
if (newWatchlistCall.trim()) {
const current = filters?.watchlist || [];
if (!current.includes(newWatchlistCall.toUpperCase())) {
onFilterChange({ ...filters, watchlist: [...current, newWatchlistCall.toUpperCase()] });
}
setNewWatchlistCall('');
}
};
const addToExclude = () => {
if (newExcludeCall.trim()) {
const current = filters?.excludeList || [];
if (!current.includes(newExcludeCall.toUpperCase())) {
onFilterChange({ ...filters, excludeList: [...current, newExcludeCall.toUpperCase()] });
}
setNewExcludeCall('');
}
};
const renderZonesTab = () => (
<div>
{/* Continents */}
<div style={{ marginBottom: '20px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '10px' }}>
Continents
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{continents.map(c => (
<button
key={c.code}
onClick={() => toggleArrayItem('continents', c.code)}
style={chipStyle(filters?.continents?.includes(c.code))}
>
{c.code} - {c.name}
</button>
))}
</div>
</div>
{/* CQ Zones */}
<div style={{ marginBottom: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>CQ Zones</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => selectAll('cqZones', Array.from({length: 40}, (_, i) => i + 1))} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All</button>
<button onClick={() => clearFilter('cqZones')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(15, 1fr)', gap: '4px' }}>
{Array.from({ length: 40 }, (_, i) => i + 1).map(zone => (
<button
key={zone}
onClick={() => toggleArrayItem('cqZones', zone)}
style={zoneButtonStyle(filters?.cqZones?.includes(zone))}
>
{zone}
</button>
))}
</div>
</div>
{/* ITU Zones */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>ITU Zones</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => selectAll('ituZones', Array.from({length: 90}, (_, i) => i + 1))} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All</button>
<button onClick={() => clearFilter('ituZones')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear</button>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(15, 1fr)', gap: '4px' }}>
{Array.from({ length: 90 }, (_, i) => i + 1).map(zone => (
<button
key={zone}
onClick={() => toggleArrayItem('ituZones', zone)}
style={zoneButtonStyle(filters?.ituZones?.includes(zone))}
>
{zone}
</button>
))}
</div>
</div>
</div>
);
const renderBandsTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>HF/VHF/UHF Bands</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => selectAll('bands', bands)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All</button>
<button onClick={() => clearFilter('bands')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{bands.map(band => (
<button
key={band}
onClick={() => toggleArrayItem('bands', band)}
style={chipStyle(filters?.bands?.includes(band))}
>
{band}
</button>
))}
</div>
</div>
);
const renderModesTab = () => (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '10px' }}>
<span style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)' }}>Operating Modes</span>
<div style={{ display: 'flex', gap: '12px' }}>
<button onClick={() => selectAll('modes', modes)} style={{ background: 'none', border: 'none', color: 'var(--accent-cyan)', fontSize: '12px', cursor: 'pointer' }}>Select All</button>
<button onClick={() => clearFilter('modes')} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', fontSize: '12px', cursor: 'pointer' }}>Clear</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{modes.map(mode => (
<button
key={mode}
onClick={() => toggleArrayItem('modes', mode)}
style={chipStyle(filters?.modes?.includes(mode))}
>
{mode}
</button>
))}
</div>
</div>
);
const renderWatchlistTab = () => (
<div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Watchlist - Highlight these callsigns
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={newWatchlistCall}
onChange={(e) => setNewWatchlistCall(e.target.value.toUpperCase())}
onKeyPress={(e) => e.key === 'Enter' && addToWatchlist()}
placeholder="Enter callsign..."
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
color: 'var(--text-primary)',
fontSize: '13px',
fontFamily: 'JetBrains Mono'
}}
/>
<button onClick={addToWatchlist} style={{ padding: '8px 16px', background: 'var(--accent-cyan)', border: 'none', borderRadius: '4px', color: '#000', fontWeight: '600', cursor: 'pointer' }}>Add</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{(filters?.watchlist || []).map(call => (
<div key={call} style={{ ...chipStyle(true), display: 'flex', alignItems: 'center', gap: '8px' }}>
{call}
<button onClick={() => toggleArrayItem('watchlist', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>×</button>
</div>
))}
</div>
<div style={{ marginTop: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', fontSize: '12px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={filters?.watchlistOnly || false}
onChange={(e) => onFilterChange({ ...filters, watchlistOnly: e.target.checked || undefined })}
/>
Show only watchlist callsigns
</label>
</div>
</div>
);
const renderExcludeTab = () => (
<div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Exclude List - Hide DX callsigns beginning with:
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={newExcludeCall}
onChange={(e) => setNewExcludeCall(e.target.value.toUpperCase())}
onKeyPress={(e) => e.key === 'Enter' && addToExclude()}
placeholder="Enter callsign..."
style={{
flex: 1,
padding: '8px 12px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '4px',
color: 'var(--text-primary)',
fontSize: '13px',
fontFamily: 'JetBrains Mono'
}}
/>
<button onClick={addToExclude} style={{ padding: '8px 16px', background: 'var(--accent-red)', border: 'none', borderRadius: '4px', color: '#fff', fontWeight: '600', cursor: 'pointer' }}>Add</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{(filters?.excludeList || []).map(call => (
<div key={call} style={{ ...chipStyle(false), background: 'rgba(255, 68, 68, 0.2)', borderColor: 'var(--accent-red)', color: 'var(--accent-red)', display: 'flex', alignItems: 'center', gap: '8px' }}>
{call}
<button onClick={() => toggleArrayItem('excludeList', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>×</button>
</div>
))}
</div>
</div>
);
const renderSettingsTab = () => {
const retentionMinutes = filters?.spotRetentionMinutes || 30;
return (
<div>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '12px' }}>
Spot Retention Time
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '12px' }}>
How long to keep DX spots on the map before they expire. Shorter times show only the most recent activity.
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<input
type="range"
min="5"
max="30"
step="5"
value={retentionMinutes}
onChange={(e) => onFilterChange({ ...filters, spotRetentionMinutes: parseInt(e.target.value) })}
style={{ flex: 1, cursor: 'pointer' }}
/>
<div style={{
minWidth: '80px',
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontFamily: 'JetBrains Mono',
fontSize: '14px',
fontWeight: '600',
color: 'var(--accent-cyan)'
}}>
{retentionMinutes} min
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '8px', fontSize: '11px', color: 'var(--text-muted)' }}>
<span>5 min (freshest)</span>
<span>30 min (default)</span>
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '12px' }}>
Quick Presets
</div>
<div style={{ display: 'flex', gap: '8px' }}>
{[5, 10, 15, 20, 30].map(mins => (
<button
key={mins}
onClick={() => onFilterChange({ ...filters, spotRetentionMinutes: mins })}
style={{
padding: '8px 16px',
background: retentionMinutes === mins ? 'rgba(0, 221, 255, 0.2)' : 'var(--bg-tertiary)',
border: `1px solid ${retentionMinutes === mins ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
borderRadius: '4px',
color: retentionMinutes === mins ? 'var(--accent-cyan)' : 'var(--text-secondary)',
fontSize: '13px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono'
}}
>
{mins} min
</button>
))}
</div>
</div>
</div>
);
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000
}}>
<div style={{
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '12px',
width: '700px',
maxHeight: '85vh',
display: 'flex',
flexDirection: 'column'
}}>
{/* Header */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 20px',
borderBottom: '1px solid var(--border-color)'
}}>
<div>
<div style={{ fontSize: '18px', fontWeight: '700', color: 'var(--accent-cyan)' }}>
DX Cluster Filters
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: '2px' }}>
{getActiveFilterCount()} filters active
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
onClick={clearAllFilters}
style={{
padding: '8px 16px',
background: 'rgba(255, 68, 102, 0.2)',
border: '1px solid var(--accent-red)',
borderRadius: '6px',
color: 'var(--accent-red)',
fontSize: '13px',
cursor: 'pointer'
}}
>
Clear All
</button>
<button
onClick={onClose}
style={{
padding: '8px 20px',
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '13px',
fontWeight: '600',
cursor: 'pointer'
}}
>
Done
</button>
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)' }}>
<button onClick={() => setActiveTab('zones')} style={tabStyle(activeTab === 'zones')}>Zones</button>
<button onClick={() => setActiveTab('bands')} style={tabStyle(activeTab === 'bands')}>Bands</button>
<button onClick={() => setActiveTab('modes')} style={tabStyle(activeTab === 'modes')}>Modes</button>
<button onClick={() => setActiveTab('watchlist')} style={tabStyle(activeTab === 'watchlist')}>Watchlist</button>
<button onClick={() => setActiveTab('exclude')} style={tabStyle(activeTab === 'exclude')}>Exclude</button>
<button onClick={() => setActiveTab('settings')} style={tabStyle(activeTab === 'settings')}> Settings</button>
</div>
{/* Tab Content */}
<div style={{ padding: '20px', overflowY: 'auto', flex: 1 }}>
{activeTab === 'zones' && renderZonesTab()}
{activeTab === 'bands' && renderBandsTab()}
{activeTab === 'modes' && renderModesTab()}
{activeTab === 'watchlist' && renderWatchlistTab()}
{activeTab === 'exclude' && renderExcludeTab()}
{activeTab === 'settings' && renderSettingsTab()}
</div>
</div>
</div>
);
};
export default DXFilterManager;

Powered by TurnKey Linux.