diff --git a/static/css/index.css b/static/css/index.css index 13c0e01..44fcfa1 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -4799,8 +4799,8 @@ header h1 .tagline { /* Selected device highlight */ .bt-device-row.selected { - background: rgba(0, 212, 255, 0.1); - border-color: var(--accent-cyan); + background: rgba(74, 163, 255, 0.07); + border-left-color: var(--accent-cyan) !important; } .bt-device-list { @@ -5212,57 +5212,107 @@ header h1 .tagline { background: linear-gradient(90deg, #ef4444, #dc2626); } -/* Bluetooth Device Row - Compact Design */ +/* Bluetooth Device Row - WiFi-style 2-line layout */ .bt-device-row { display: flex; flex-direction: column; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-left: 4px solid #666; - border-radius: 6px; - padding: 10px 12px; - margin-bottom: 6px; + border-left: 3px solid transparent; + padding: 9px 12px; cursor: pointer; - transition: all 0.15s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); + transition: background 0.12s; } .bt-device-row:last-child { - margin-bottom: 0; + border-bottom: none; } -.bt-device-row:hover { - background: rgba(0, 212, 255, 0.05); - border-color: var(--accent-cyan); -} +.bt-device-row:hover { background: var(--bg-tertiary); } .bt-device-row:focus-visible { outline: 1px solid var(--accent-cyan); - outline-offset: 1px; + outline-offset: -1px; } -.bt-row-main { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 12px; -} - -.bt-row-left { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 4px 8px; - min-width: 0; - flex: 1; -} - -.bt-row-right { +/* Bluetooth device row — 2-line WiFi-style layout */ +.bt-row-top { display: flex; align-items: center; - gap: 10px; + justify-content: space-between; + gap: 6px; + margin-bottom: 7px; +} + +.bt-row-top-left { + display: flex; + align-items: center; + gap: 5px; + min-width: 0; + flex: 1; + overflow: hidden; +} + +.bt-row-top-right { + display: flex; + align-items: center; + gap: 4px; flex-shrink: 0; } +.bt-row-name { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bt-row-name.bt-unnamed { + color: var(--text-dim); + font-style: italic; +} + +.bt-row-bottom { + display: flex; + align-items: center; + gap: 8px; +} + +.bt-signal-bar-wrap { flex: 1; } + +.bt-signal-track { + height: 4px; + background: var(--border-color); + border-radius: 2px; + overflow: hidden; +} + +.bt-signal-fill { + height: 100%; + border-radius: 2px; + transition: width 0.4s ease; +} + +.bt-signal-fill.strong { background: linear-gradient(90deg, var(--accent-green), #88d49b); } +.bt-signal-fill.medium { background: linear-gradient(90deg, var(--accent-green), var(--accent-orange)); } +.bt-signal-fill.weak { background: linear-gradient(90deg, var(--accent-orange), var(--accent-red)); } + +.bt-row-meta { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + font-size: 10px; + color: var(--text-dim); + white-space: nowrap; +} + +.bt-row-rssi { font-family: var(--font-mono); font-size: 10px; } +.bt-row-rssi.strong { color: var(--accent-green); } +.bt-row-rssi.medium { color: var(--accent-amber, #eab308); } +.bt-row-rssi.weak { color: var(--accent-red); } + .bt-proto-badge { display: inline-block; padding: 2px 6px; @@ -5308,43 +5358,6 @@ header h1 .tagline { border: 1px solid rgba(168, 85, 247, 0.3); } -.bt-device-name { - font-size: 13px; - font-weight: 600; - color: var(--text-primary); - overflow-wrap: break-word; - word-break: break-word; - min-width: 0; -} - -.bt-rssi-container { - display: flex; - align-items: center; - gap: 6px; -} - -.bt-rssi-bar-bg { - width: 50px; - height: 8px; - background: var(--bg-secondary); - border-radius: 4px; - overflow: hidden; -} - -.bt-rssi-bar { - height: 100%; - border-radius: 4px; - transition: width 0.3s ease; -} - -.bt-rssi-value { - font-family: var(--font-mono); - font-size: 11px; - font-weight: 600; - min-width: 28px; - text-align: right; -} - .bt-status-dot { width: 8px; height: 8px; @@ -5358,63 +5371,7 @@ header h1 .tagline { } .bt-status-dot.known { - background: #22c55e; -} - -.bt-row-secondary { - font-size: 10px; - color: var(--text-dim); - margin-top: 4px; - padding-left: 42px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.bt-row-actions { - display: flex; - justify-content: flex-end; - padding: 4px 4px 0 42px; -} - -/* Locate action on Bluetooth device rows (must be in index.css so it styles in scanner mode) */ -.bt-row-actions .bt-locate-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - min-height: 28px; - padding: 5px 10px; - font-size: 10px; - line-height: 1; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.07em; - color: var(--accent-green, #38c180); - background: linear-gradient(180deg, rgba(56, 193, 128, 0.2), rgba(56, 193, 128, 0.12)); - border: 1px solid rgba(56, 193, 128, 0.42); - border-radius: 999px; - cursor: pointer; - white-space: nowrap; - transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; -} - -.bt-row-actions .bt-locate-btn:hover { - background: linear-gradient(180deg, rgba(56, 193, 128, 0.28), rgba(56, 193, 128, 0.18)); - border-color: rgba(56, 193, 128, 0.72); - box-shadow: 0 0 0 1px rgba(56, 193, 128, 0.2), 0 6px 16px rgba(20, 80, 54, 0.35); - transform: translateY(-1px); -} - -.bt-row-actions .bt-locate-btn:active { - transform: translateY(0); -} - -.bt-row-actions .bt-locate-btn svg { - width: 12px; - height: 12px; - stroke: currentColor; - flex-shrink: 0; + background: #484f58; } .bt-device-filter-state { diff --git a/static/js/modes/bluetooth.js b/static/js/modes/bluetooth.js index 1ce4315..cd7c563 100644 --- a/static/js/modes/bluetooth.js +++ b/static/js/modes/bluetooth.js @@ -37,6 +37,7 @@ const BluetoothMode = (function() { // Device list filter let currentDeviceFilter = 'all'; let sortBy = 'rssi'; + let sortListenersBound = false; let currentSearchTerm = ''; let visibleDeviceCount = 0; let pendingDeviceFlush = false; @@ -161,6 +162,8 @@ const BluetoothMode = (function() { } function initSortControls() { + if (sortListenersBound) return; + sortListenersBound = true; const sortGroup = document.getElementById('btSortGroup'); if (!sortGroup) return; sortGroup.addEventListener('click', (e) => { @@ -1390,6 +1393,7 @@ const BluetoothMode = (function() { */ function renderAllDevices() { if (!deviceContainer) return; + if (devices.size === 0) return; deviceContainer.innerHTML = ''; const sorted = [...devices.values()].sort((a, b) => { @@ -1408,7 +1412,6 @@ const BluetoothMode = (function() { function createSimpleDeviceCard(device) { const protocol = device.protocol || 'ble'; const rssi = device.rssi_current; - const rssiColor = getRssiColor(rssi); const inBaseline = device.in_baseline || false; const isNew = !inBaseline; const hasName = !!device.name; @@ -1419,58 +1422,69 @@ const BluetoothMode = (function() { const agentName = device._agent || 'Local'; const seenBefore = device.seen_before === true; - // Calculate RSSI bar width (0-100%) - // RSSI typically ranges from -100 (weak) to -30 (very strong) + // Signal bar const rssiPercent = rssi != null ? Math.max(0, Math.min(100, ((rssi + 100) / 70) * 100)) : 0; + const fillClass = rssi == null ? 'weak' + : rssi >= -60 ? 'strong' + : rssi >= -75 ? 'medium' : 'weak'; const displayName = device.name || formatDeviceId(device.address); const name = escapeHtml(displayName); const addr = escapeHtml(isUuidAddress(device) ? formatAddress(device) : (device.address || 'Unknown')); const mfr = device.manufacturer_name ? escapeHtml(device.manufacturer_name) : ''; - const seenCount = device.seen_count || 0; const searchIndex = [ - displayName, - device.address, - device.manufacturer_name, - device.tracker_name, - device.tracker_type, - agentName + displayName, device.address, device.manufacturer_name, + device.tracker_name, device.tracker_type, agentName ].filter(Boolean).join(' ').toLowerCase(); - // Protocol badge - compact + // Protocol badge const protoBadge = protocol === 'ble' ? 'BLE' : 'CLASSIC'; - // Tracker badge - show if device is detected as tracker + // Tracker badge let trackerBadge = ''; if (isTracker) { - const confColor = trackerConfidence === 'high' ? '#ef4444' : - trackerConfidence === 'medium' ? '#f97316' : '#eab308'; - const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' : - trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)'; - const typeLabel = trackerType === 'airtag' ? 'AirTag' : - trackerType === 'tile' ? 'Tile' : - trackerType === 'samsung_smarttag' ? 'SmartTag' : - trackerType === 'findmy_accessory' ? 'FindMy' : - trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER'; - trackerBadge = '' + typeLabel + ''; + const confColor = trackerConfidence === 'high' ? '#ef4444' + : trackerConfidence === 'medium' ? '#f97316' : '#eab308'; + const confBg = trackerConfidence === 'high' ? 'rgba(239,68,68,0.15)' + : trackerConfidence === 'medium' ? 'rgba(249,115,22,0.15)' : 'rgba(234,179,8,0.15)'; + const typeLabel = trackerType === 'airtag' ? 'AirTag' + : trackerType === 'tile' ? 'Tile' + : trackerType === 'samsung_smarttag' ? 'SmartTag' + : trackerType === 'findmy_accessory' ? 'FindMy' + : trackerType === 'chipolo' ? 'Chipolo' : 'TRACKER'; + trackerBadge = '' + typeLabel + ''; } - // IRK badge - show if paired IRK is available - let irkBadge = ''; - if (device.has_irk) { - irkBadge = 'IRK'; - } + // IRK badge + const irkBadge = device.has_irk ? 'IRK' : ''; - // Risk badge - show if risk score is significant + // Risk badge let riskBadge = ''; if (riskScore >= 0.3) { const riskColor = riskScore >= 0.5 ? '#ef4444' : '#f97316'; - riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; + riskBadge = '' + Math.round(riskScore * 100) + '% RISK'; } - // Status indicator + // MAC cluster badge + const clusterBadge = device.mac_cluster_count > 1 + ? '' + device.mac_cluster_count + ' MACs' + : ''; + + // Flag badges (top-right, before status dot) + const hFlags = device.heuristic_flags || []; + let flagBadges = ''; + if (device.is_persistent || hFlags.includes('persistent')) + flagBadges += 'PERSIST'; + if (device.is_beacon_like || hFlags.includes('beacon_like')) + flagBadges += 'BEACON'; + if (device.is_strong_stable || hFlags.includes('strong_stable')) + flagBadges += 'STABLE'; + + // Status dot let statusDot; if (isTracker && trackerConfidence === 'high') { statusDot = ''; @@ -1480,74 +1494,55 @@ const BluetoothMode = (function() { statusDot = ''; } - // Distance display + // Bottom meta + const metaLabel = mfr || addr; // already HTML-escaped const distM = device.estimated_distance_m; - let distStr = ''; - if (distM != null) { - distStr = '~' + distM.toFixed(1) + 'm'; - } + const distStr = distM != null ? '~' + distM.toFixed(1) + 'm' : ''; + let metaHtml = '' + metaLabel + ''; + if (distStr) metaHtml += '' + distStr + ''; + metaHtml += '' + (rssi != null ? rssi : '—') + ''; + if (seenBefore) metaHtml += 'SEEN'; + if (agentName !== 'Local') + metaHtml += '' + + escapeHtml(agentName) + ''; - // Behavioral flag badges - const hFlags = device.heuristic_flags || []; - let flagBadges = ''; - if (device.is_persistent || hFlags.includes('persistent')) { - flagBadges += 'PERSIST'; - } - if (device.is_beacon_like || hFlags.includes('beacon_like')) { - flagBadges += 'BEACON'; - } - if (device.is_strong_stable || hFlags.includes('strong_stable')) { - flagBadges += 'STABLE'; - } + // Left border colour + const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' + : isTracker ? '#f97316' + : rssi != null && rssi >= -60 ? 'var(--accent-green)' + : rssi != null && rssi >= -75 ? 'var(--accent-amber, #eab308)' + : 'var(--accent-red)'; - // MAC cluster badge - let clusterBadge = ''; - if (device.mac_cluster_count > 1) { - clusterBadge = '' + device.mac_cluster_count + ' MACs'; - } - - // Build secondary info line - let secondaryParts = [addr]; - if (mfr) secondaryParts.push(mfr); - if (distStr) secondaryParts.push(distStr); - secondaryParts.push('Seen ' + seenCount + '×'); - if (seenBefore) secondaryParts.push('SEEN BEFORE'); - // Add agent name if not Local - if (agentName !== 'Local') { - secondaryParts.push('' + escapeHtml(agentName) + ''); - } - const secondaryInfo = secondaryParts.join(' · '); - - // Row border color - highlight trackers in red/orange - const borderColor = isTracker && trackerConfidence === 'high' ? '#ef4444' : - isTracker ? '#f97316' : rssiColor; - - return '
' + - '
' + - '
' + - protoBadge + - '' + name + '' + - trackerBadge + - irkBadge + - riskBadge + - flagBadges + - clusterBadge + - '
' + - '
' + - '
' + - '
' + - '' + (rssi != null ? rssi : '--') + '' + - '
' + - statusDot + - '
' + - '
' + - '
' + secondaryInfo + '
' + - '
' + - '' + - '
' + - '
'; + return '
' + // Top line + + '
' + + '
' + + protoBadge + + '' + name + '' + + trackerBadge + irkBadge + riskBadge + clusterBadge + + '
' + + '
' + + flagBadges + statusDot + + '
' + + '
' + // Bottom line + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + metaHtml + '
' + + '
' + + '
'; } function getRssiColor(rssi) {