mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-07-13 13:21:37 +02:00
741 lines
26 KiB
HTML
741 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>pyMC Repeater - Neighbors</title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<div class="layout">
|
|
<!-- Navigation Component -->
|
|
<!-- NAVIGATION_PLACEHOLDER -->
|
|
|
|
<!-- Main Content -->
|
|
<main class="content">
|
|
<header>
|
|
<h1>Neighbor Repeaters</h1>
|
|
<div class="header-info">
|
|
<span>Tracking: <strong id="neighbor-count">0</strong> repeaters</span>
|
|
<span>Updated: <strong id="update-time">{{ last_updated }}</strong></span>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Neighbors Map -->
|
|
<div class="map-card">
|
|
<h2>Repeater Network Map</h2>
|
|
<div id="map-container" class="map-container">
|
|
<div id="map"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Neighbors Table -->
|
|
<div class="table-card">
|
|
<h2>Discovered Repeaters</h2>
|
|
<div class="table-container">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Node Name</th>
|
|
<th>Public Key</th>
|
|
<th>Contact Type</th>
|
|
<th>Location</th>
|
|
<th>RSSI</th>
|
|
<th>SNR</th>
|
|
<th>Last Seen</th>
|
|
<th>First Seen</th>
|
|
<th>Advert Count</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="neighbors-table">
|
|
<tr>
|
|
<td colspan="9" class="empty-message">
|
|
No repeaters discovered yet - waiting for adverts...
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
|
|
<script>
|
|
let updateInterval;
|
|
let map = null;
|
|
let centerMarker = null;
|
|
let neighborMarkers = [];
|
|
let connectionLines = [];
|
|
let configLat = null;
|
|
let configLng = null;
|
|
|
|
// Initialize map
|
|
function initMap() {
|
|
if (map || !configLat || !configLng) return; // Already initialized or no coords yet
|
|
|
|
map = L.map('map').setView([configLat, configLng], 10);
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© OpenStreetMap contributors',
|
|
maxZoom: 19,
|
|
tileSize: 256,
|
|
className: 'map-tiles'
|
|
}).addTo(map);
|
|
|
|
// Add center marker (your repeater)
|
|
centerMarker = L.circleMarker([configLat, configLng], {
|
|
radius: 8,
|
|
fillColor: '#6a9955',
|
|
color: '#4ade80',
|
|
weight: 3,
|
|
opacity: 1,
|
|
fillOpacity: 0.8
|
|
}).addTo(map);
|
|
|
|
centerMarker.bindPopup('<strong>Your Repeater</strong><br>(' + configLat.toFixed(4) + ', ' + configLng.toFixed(4) + ')');
|
|
|
|
// Force map to recalculate size after container is visible
|
|
setTimeout(() => {
|
|
map.invalidateSize();
|
|
}, 100);
|
|
}
|
|
|
|
function updateMapData(stats) {
|
|
// Get lat/lng from API
|
|
if (stats.config && stats.config.repeater) {
|
|
configLat = stats.config.repeater.latitude;
|
|
configLng = stats.config.repeater.longitude;
|
|
}
|
|
|
|
// Initialize map if needed (after we have coords from API)
|
|
if (!map) {
|
|
initMap();
|
|
}
|
|
|
|
if (!map) return; // Still no valid coords
|
|
|
|
// Update center marker position
|
|
if (centerMarker) {
|
|
centerMarker.setLatLng([configLat, configLng]);
|
|
}
|
|
|
|
// Clear existing neighbor markers and lines
|
|
neighborMarkers.forEach(m => map.removeLayer(m));
|
|
connectionLines.forEach(l => map.removeLayer(l));
|
|
neighborMarkers = [];
|
|
connectionLines = [];
|
|
|
|
// Add neighbor markers and connection lines
|
|
const neighbors = stats.neighbors || {};
|
|
const neighborsArray = Object.entries(neighbors);
|
|
|
|
if (neighborsArray.length === 0) return;
|
|
|
|
neighborsArray.forEach(([pubkey, neighbor]) => {
|
|
if (!neighbor.latitude || !neighbor.longitude) return;
|
|
if (neighbor.latitude === 0.0 && neighbor.longitude === 0.0) return;
|
|
|
|
const lat = neighbor.latitude;
|
|
const lng = neighbor.longitude;
|
|
const name = neighbor.node_name || 'Unknown';
|
|
|
|
// Add neighbor marker
|
|
const marker = L.circleMarker([lat, lng], {
|
|
radius: 6,
|
|
fillColor: '#4ec9b0',
|
|
color: '#ce9178',
|
|
weight: 2,
|
|
opacity: 1,
|
|
fillOpacity: 0.7
|
|
}).addTo(map);
|
|
|
|
const popupText = `<strong>${name}</strong><br>
|
|
RSSI: ${neighbor.rssi || 'N/A'}<br>
|
|
SNR: ${neighbor.snr ? neighbor.snr.toFixed(1) + ' dB' : 'N/A'}<br>
|
|
Adverts: ${neighbor.advert_count || 0}<br>
|
|
(${lat.toFixed(4)}, ${lng.toFixed(4)})`;
|
|
|
|
marker.bindPopup(popupText);
|
|
neighborMarkers.push(marker);
|
|
|
|
// Draw connection line from center to neighbor
|
|
const line = L.polyline([
|
|
[configLat, configLng],
|
|
[lat, lng]
|
|
], {
|
|
color: '#4ec9b0',
|
|
weight: 2,
|
|
opacity: 0.6,
|
|
dashArray: '5, 5'
|
|
}).addTo(map);
|
|
|
|
connectionLines.push(line);
|
|
});
|
|
|
|
// Fit map to show all markers
|
|
if (neighborsArray.length > 0) {
|
|
const group = new L.featureGroup([centerMarker, ...neighborMarkers]);
|
|
map.fitBounds(group.getBounds(), { padding: [50, 50] });
|
|
}
|
|
}
|
|
|
|
// Handle Send Advert button
|
|
function sendAdvert() {
|
|
const btn = document.getElementById('send-advert-btn');
|
|
if (!btn) return;
|
|
|
|
const icon = btn.querySelector('.icon');
|
|
const iconHTML = icon ? icon.outerHTML : '';
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = iconHTML + 'Sending...';
|
|
|
|
fetch('/api/send_advert', {
|
|
method: 'POST'
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
btn.innerHTML = iconHTML + 'Sent!';
|
|
setTimeout(() => {
|
|
btn.innerHTML = iconHTML + 'Send Advert';
|
|
btn.disabled = false;
|
|
}, 2000);
|
|
} else {
|
|
btn.innerHTML = iconHTML + 'Error';
|
|
console.error('Failed to send advert:', data.error);
|
|
setTimeout(() => {
|
|
btn.innerHTML = iconHTML + 'Send Advert';
|
|
btn.disabled = false;
|
|
}, 2000);
|
|
}
|
|
})
|
|
.catch(e => {
|
|
console.error('Error sending advert:', e);
|
|
btn.innerHTML = iconHTML + 'Error';
|
|
setTimeout(() => {
|
|
btn.innerHTML = iconHTML + 'Send Advert';
|
|
btn.disabled = false;
|
|
}, 2000);
|
|
});
|
|
}
|
|
|
|
function updateNeighbors() {
|
|
fetch('/api/stats')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const neighbors = data.neighbors || {};
|
|
const neighborCount = Object.keys(neighbors).length;
|
|
|
|
document.getElementById('neighbor-count').textContent = neighborCount;
|
|
document.getElementById('update-time').textContent = new Date().toLocaleTimeString();
|
|
|
|
updateNeighborsTable(neighbors);
|
|
updateMapData(data);
|
|
})
|
|
.catch(e => console.error('Error fetching neighbors:', e));
|
|
}
|
|
|
|
// Mock test data function
|
|
function updateNeighborsTable(neighbors) {
|
|
// Mock data for testing
|
|
const mockNeighbors = {
|
|
"abc123def456abc123def456abc123def456abc123def456abc123def456abc1": {
|
|
node_name: "Test Repeater 1",
|
|
contact_type: "Repeater",
|
|
latitude: 50.7185,
|
|
longitude: -1.5339,
|
|
rssi: -75,
|
|
snr: 8.5,
|
|
last_seen: Math.floor(Date.now() / 1000) - 30,
|
|
first_seen: Math.floor(Date.now() / 1000) - 3600,
|
|
advert_count: 5
|
|
},
|
|
"def456abc123def456abc123def456abc123def456abc123def456abc123def4": {
|
|
node_name: "Test Repeater 2",
|
|
contact_type: "Repeater",
|
|
latitude: 50.5185,
|
|
longitude: -1.9339,
|
|
rssi: -85,
|
|
snr: 5.2,
|
|
last_seen: Math.floor(Date.now() / 1000) - 60,
|
|
first_seen: Math.floor(Date.now() / 1000) - 7200,
|
|
advert_count: 12
|
|
},
|
|
"ghi789jkl012ghi789jkl012ghi789jkl012ghi789jkl012ghi789jkl012ghi7": {
|
|
node_name: "Test Repeater 3",
|
|
contact_type: "Repeater",
|
|
latitude: 50.6185,
|
|
longitude: -1.6339,
|
|
rssi: -65,
|
|
snr: 12.3,
|
|
last_seen: Math.floor(Date.now() / 1000) - 5,
|
|
first_seen: Math.floor(Date.now() / 1000) - 1800,
|
|
advert_count: 3
|
|
}
|
|
};
|
|
|
|
// Use mock data instead of real neighbors
|
|
neighbors = mockNeighbors;
|
|
|
|
const tbody = document.getElementById('neighbors-table');
|
|
|
|
if (!neighbors || Object.keys(neighbors).length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9" class="empty-message">
|
|
No repeaters discovered yet - waiting for adverts...
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Sort by last_seen (most recent first)
|
|
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
|
|
return b[1].last_seen - a[1].last_seen;
|
|
});
|
|
|
|
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
|
|
const name = neighbor.node_name || 'Unknown';
|
|
// Format pubkey properly - it's a 64-char hex string
|
|
const pubkeyShort = pubkey.length >= 16
|
|
? `<${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}>`
|
|
: `<${pubkey}>`;
|
|
const contactType = neighbor.contact_type || 'Repeater';
|
|
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
|
|
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
|
|
: 'N/A';
|
|
const rssi = neighbor.rssi || 'N/A';
|
|
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
|
|
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
|
|
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
|
|
const advertCount = neighbor.advert_count || 0;
|
|
|
|
// Color code RSSI
|
|
let rssiClass = 'rssi-poor';
|
|
if (rssi !== 'N/A') {
|
|
if (rssi > -80) rssiClass = 'rssi-excellent';
|
|
else if (rssi > -90) rssiClass = 'rssi-good';
|
|
else if (rssi > -100) rssiClass = 'rssi-fair';
|
|
}
|
|
|
|
return `
|
|
<tr>
|
|
<td data-label="Node Name"><strong>${name}</strong></td>
|
|
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
|
|
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
|
|
<td data-label="Location">${location}</td>
|
|
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
|
|
<td data-label="SNR">${snr}</td>
|
|
<td data-label="Last Seen">${lastSeen}</td>
|
|
<td data-label="First Seen">${firstSeen}</td>
|
|
<td data-label="Advert Count">${advertCount}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
/* ORIGINAL FUNCTION - COMMENTED OUT
|
|
function updateNeighborsTable(neighbors) {
|
|
const tbody = document.getElementById('neighbors-table');
|
|
|
|
if (!neighbors || Object.keys(neighbors).length === 0) {
|
|
tbody.innerHTML = `
|
|
<tr>
|
|
<td colspan="9" class="empty-message">
|
|
No repeaters discovered yet - waiting for adverts...
|
|
</td>
|
|
</tr>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Sort by last_seen (most recent first)
|
|
const sortedNeighbors = Object.entries(neighbors).sort((a, b) => {
|
|
return b[1].last_seen - a[1].last_seen;
|
|
});
|
|
|
|
tbody.innerHTML = sortedNeighbors.map(([pubkey, neighbor]) => {
|
|
const name = neighbor.node_name || 'Unknown';
|
|
// Format pubkey properly - it's a 64-char hex string
|
|
const pubkeyShort = pubkey.length >= 16
|
|
? `<${pubkey.substring(0, 8)}...${pubkey.substring(pubkey.length - 8)}>`
|
|
: `<${pubkey}>`;
|
|
const contactType = neighbor.contact_type || 'Repeater';
|
|
const location = neighbor.latitude && neighbor.longitude && (neighbor.latitude !== 0.0 || neighbor.longitude !== 0.0)
|
|
? `${neighbor.latitude.toFixed(6)}, ${neighbor.longitude.toFixed(6)}`
|
|
: 'N/A';
|
|
const rssi = neighbor.rssi || 'N/A';
|
|
const snr = neighbor.snr !== undefined ? neighbor.snr.toFixed(1) + ' dB' : 'N/A';
|
|
const lastSeen = new Date(neighbor.last_seen * 1000).toLocaleString();
|
|
const firstSeen = new Date(neighbor.first_seen * 1000).toLocaleString();
|
|
const advertCount = neighbor.advert_count || 0;
|
|
|
|
// Color code RSSI
|
|
let rssiClass = 'rssi-poor';
|
|
if (rssi !== 'N/A') {
|
|
if (rssi > -80) rssiClass = 'rssi-excellent';
|
|
else if (rssi > -90) rssiClass = 'rssi-good';
|
|
else if (rssi > -100) rssiClass = 'rssi-fair';
|
|
}
|
|
|
|
return `
|
|
<tr>
|
|
<td data-label="Node Name"><strong>${name}</strong></td>
|
|
<td data-label="Public Key"><code class="pubkey">${pubkeyShort}</code></td>
|
|
<td data-label="Contact Type"><span class="contact-type-badge">${contactType}</span></td>
|
|
<td data-label="Location">${location}</td>
|
|
<td data-label="RSSI"><span class="${rssiClass}">${rssi}</span></td>
|
|
<td data-label="SNR">${snr}</td>
|
|
<td data-label="Last Seen">${lastSeen}</td>
|
|
<td data-label="First Seen">${firstSeen}</td>
|
|
<td data-label="Advert Count">${advertCount}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
*/
|
|
|
|
// Initialize on load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
updateNeighbors();
|
|
|
|
// Auto-update every 10 seconds
|
|
updateInterval = setInterval(updateNeighbors, 10000);
|
|
|
|
// Attach send advert button handler
|
|
const sendAdvertBtn = document.getElementById('send-advert-btn');
|
|
if (sendAdvertBtn) {
|
|
sendAdvertBtn.addEventListener('click', sendAdvert);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
.map-card {
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
padding: var(--spacing-lg);
|
|
margin-bottom: var(--spacing-lg);
|
|
}
|
|
|
|
.map-card h2 {
|
|
margin-top: 0;
|
|
margin-bottom: var(--spacing-md);
|
|
color: var(--color-text-primary);
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.map-container {
|
|
border-radius: var(--radius-md);
|
|
overflow: hidden;
|
|
border: 1px solid var(--color-border);
|
|
background: var(--color-bg-tertiary);
|
|
}
|
|
|
|
#map {
|
|
height: 400px;
|
|
width: 100%;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Dark theme for leaflet */
|
|
.map-tiles {
|
|
filter: invert(0.93) hue-rotate(200deg) saturate(0.5);
|
|
}
|
|
|
|
.leaflet-control-attribution {
|
|
background: rgba(30, 30, 30, 0.7) !important;
|
|
color: #999 !important;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.leaflet-control-attribution a {
|
|
color: #4ec9b0 !important;
|
|
}
|
|
|
|
.leaflet-control {
|
|
background: var(--color-bg-secondary) !important;
|
|
border: 1px solid var(--color-border) !important;
|
|
color: var(--color-text-primary) !important;
|
|
}
|
|
|
|
.leaflet-control-zoom-in,
|
|
.leaflet-control-zoom-out {
|
|
color: var(--color-text-primary) !important;
|
|
background: var(--color-bg-secondary) !important;
|
|
}
|
|
|
|
.leaflet-control-zoom-in:hover,
|
|
.leaflet-control-zoom-out:hover {
|
|
background: var(--color-bg-tertiary) !important;
|
|
}
|
|
|
|
.leaflet-popup-content-wrapper {
|
|
background: var(--color-bg-secondary) !important;
|
|
border: 1px solid var(--color-border) !important;
|
|
border-radius: var(--radius-md) !important;
|
|
}
|
|
|
|
.leaflet-popup-content {
|
|
color: var(--color-text-primary) !important;
|
|
margin: 0 !important;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.leaflet-popup-content strong {
|
|
color: #4ec9b0 !important;
|
|
}
|
|
|
|
.leaflet-popup-tip {
|
|
background: var(--color-bg-secondary) !important;
|
|
border: 1px solid var(--color-border) !important;
|
|
}
|
|
|
|
@media (max-width: 768px) {
|
|
#map {
|
|
height: 300px;
|
|
}
|
|
|
|
.map-card {
|
|
padding: var(--spacing-md);
|
|
}
|
|
|
|
.map-card h2 {
|
|
font-size: 1.25rem;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
#map {
|
|
height: 250px;
|
|
}
|
|
|
|
.map-card {
|
|
padding: var(--spacing-sm);
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.map-card h2 {
|
|
font-size: 1.1rem;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
|
|
.leaflet-popup-content {
|
|
font-size: 0.75rem;
|
|
}
|
|
}
|
|
|
|
.pubkey {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.85em;
|
|
color: #4ec9b0;
|
|
}
|
|
|
|
.contact-type-badge {
|
|
display: inline-block;
|
|
padding: 4px 8px;
|
|
background-color: rgba(59, 130, 246, 0.2);
|
|
border: 1px solid rgba(59, 130, 246, 0.4);
|
|
border-radius: 4px;
|
|
font-size: 0.85em;
|
|
color: #60a5fa;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.rssi-excellent {
|
|
color: #4ade80;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.rssi-good {
|
|
color: #4ec9b0;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.rssi-fair {
|
|
color: #dcdcaa;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.rssi-poor {
|
|
color: #f48771;
|
|
font-weight: 500;
|
|
}
|
|
|
|
/* Mobile responsive table styling */
|
|
@media (max-width: 768px) {
|
|
.data-table {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.data-table thead {
|
|
display: none;
|
|
}
|
|
|
|
.data-table tbody {
|
|
display: block;
|
|
width: 100%;
|
|
}
|
|
|
|
.data-table tbody tr {
|
|
display: block;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-lg);
|
|
margin-bottom: var(--spacing-md);
|
|
padding: var(--spacing-md);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.data-table tbody tr:hover {
|
|
background: var(--color-bg-tertiary);
|
|
border-color: var(--color-accent-primary);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.data-table td {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--spacing-sm);
|
|
margin-bottom: var(--spacing-sm);
|
|
margin-right: var(--spacing-lg);
|
|
padding: 0;
|
|
border: none;
|
|
text-align: left;
|
|
font-size: 0.75rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.data-table td::before {
|
|
content: attr(data-label);
|
|
font-weight: var(--font-weight-bold);
|
|
color: var(--color-text-tertiary);
|
|
text-transform: uppercase;
|
|
font-size: 0.6rem;
|
|
letter-spacing: 0.5px;
|
|
min-width: fit-content;
|
|
padding: 2px 6px;
|
|
background: var(--color-bg-tertiary);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
/* Node Name and Public Key get full width */
|
|
.data-table td:nth-child(1),
|
|
.data-table td:nth-child(2) {
|
|
display: block;
|
|
width: 100%;
|
|
margin-right: 0;
|
|
margin-bottom: var(--spacing-md);
|
|
}
|
|
|
|
.data-table td:nth-child(1)::before { content: "Node Name"; }
|
|
.data-table td:nth-child(2)::before { content: "Public Key"; }
|
|
.data-table td:nth-child(3)::before { content: "Contact Type"; }
|
|
.data-table td:nth-child(4)::before { content: "Location"; }
|
|
.data-table td:nth-child(5)::before { content: "RSSI"; }
|
|
.data-table td:nth-child(6)::before { content: "SNR"; }
|
|
.data-table td:nth-child(7)::before { content: "Last Seen"; }
|
|
.data-table td:nth-child(8)::before { content: "First Seen"; }
|
|
.data-table td:nth-child(9)::before { content: "Advert Count"; }
|
|
|
|
/* Location and timestamps wrap to next line */
|
|
.data-table td:nth-child(4),
|
|
.data-table td:nth-child(7),
|
|
.data-table td:nth-child(8) {
|
|
display: block;
|
|
width: 100%;
|
|
margin-right: 0;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
}
|
|
|
|
@media (max-width: 600px) {
|
|
.data-table tbody tr {
|
|
padding: var(--spacing-md);
|
|
}
|
|
|
|
.data-table td {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
margin-bottom: var(--spacing-xs);
|
|
margin-right: var(--spacing-md);
|
|
font-size: 0.7rem;
|
|
}
|
|
|
|
.data-table td::before {
|
|
font-size: 0.55rem;
|
|
padding: 1px 4px;
|
|
}
|
|
|
|
/* Full width items */
|
|
.data-table td:nth-child(1),
|
|
.data-table td:nth-child(2),
|
|
.data-table td:nth-child(4),
|
|
.data-table td:nth-child(7),
|
|
.data-table td:nth-child(8) {
|
|
display: block;
|
|
width: 100%;
|
|
margin-right: 0;
|
|
margin-bottom: var(--spacing-sm);
|
|
}
|
|
|
|
.pubkey {
|
|
font-size: 0.7rem;
|
|
word-break: break-all;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.data-table tbody tr {
|
|
padding: var(--spacing-sm);
|
|
}
|
|
|
|
.data-table td {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: var(--spacing-xs);
|
|
margin-bottom: var(--spacing-xs);
|
|
margin-right: var(--spacing-sm);
|
|
font-size: 0.65rem;
|
|
}
|
|
|
|
.data-table td::before {
|
|
font-size: 0.5rem;
|
|
padding: 1px 3px;
|
|
}
|
|
|
|
/* Full width items */
|
|
.data-table td:nth-child(1),
|
|
.data-table td:nth-child(2),
|
|
.data-table td:nth-child(4),
|
|
.data-table td:nth-child(7),
|
|
.data-table td:nth-child(8) {
|
|
display: block;
|
|
width: 100%;
|
|
margin-right: 0;
|
|
margin-bottom: var(--spacing-xs);
|
|
}
|
|
|
|
.pubkey {
|
|
font-size: 0.6rem;
|
|
word-break: break-all;
|
|
white-space: normal;
|
|
}
|
|
|
|
.contact-type-badge {
|
|
font-size: 0.7rem;
|
|
padding: 2px 4px;
|
|
}
|
|
}
|
|
</style>
|
|
</body>
|
|
</html>
|