fix(devices): signal glyph polish, fix invisible copy button, even firmware row

Follow-up on 529e866 after review:

- SignalIndicator: offline now renders a diagonal slash (standard
  "no signal" convention) instead of an X. Added a distinct amber "?"
  state for devices that are online but whose firmware never reports rssi
  at all (legacy v1 heartbeats predate the rssi/state/ok fields — see API
  Reference's v2 migration notes) — previously this looked identical to
  "not loaded yet". DeviceListCardView's online block now opts into this
  by passing isOnline explicitly.

- Copy-to-clipboard for the serial number was invisible: the icon button
  had opacity:0 as an inline style, which beats a CSS hover rule at equal
  specificity, so the reveal-on-hover code paths never fired. Replaced
  with a shared CopyableSerial component (components/shared/) where the
  whole serial text is clickable, not just a trailing icon, and the icon
  sits at partial opacity at rest instead of fully hidden. Wired into both
  DeviceDetail's header and OverviewTab's hero Serial Number field.

- GeneralTab firmware hero: all stat columns are now equal-width via a
  grid instead of ad-hoc flex gaps, and backup_version's "Unknown" sentinel
  (the device's own placeholder when no second OTA slot has ever been
  flashed) is no longer rendered as the literal string "vUnknown".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 12:45:01 +03:00
co-authored by Claude Sonnet 5
parent 529e866fff
commit 0c0dd9d0e9
6 changed files with 228 additions and 152 deletions
@@ -0,0 +1,71 @@
// frontend/src/components/shared/CopyableSerial.jsx
// Serial number display with click-to-copy — the whole text is clickable,
// not just the trailing icon, and the icon stays dimly visible at rest so
// its affordance isn't hover-only-discoverable (it was previously opacity:0
// at rest via an inline style, which beat the hover CSS rule and made the
// button invisible at all times).
import { useState } from 'react'
import Icon from '@/components/ui/Icon'
function copyText(text, onDone) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(onDone).catch(() => {
const ta = document.createElement('textarea')
ta.value = text; ta.style.cssText = 'position:fixed;opacity:0'
document.body.appendChild(ta); ta.select()
try { document.execCommand('copy'); onDone() } catch { /* clipboard unavailable */ }
document.body.removeChild(ta)
})
}
}
export default function CopyableSerial({ serial, className = '', style = {}, textStyle = {}, iconSize = 12 }) {
const [copied, setCopied] = useState(false)
if (!serial) return <span className={className} style={{ ...textStyle, ...style }}>—</span>
const handleCopy = () => {
copyText(serial, () => { setCopied(true); setTimeout(() => setCopied(false), 1500) })
}
return (
<button
type="button"
onClick={handleCopy}
title={copied ? 'Copied!' : 'Click to copy serial number'}
aria-label={copied ? 'Serial number copied' : 'Copy serial number'}
className={['copyable-serial', className].filter(Boolean).join(' ')}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
padding: 0,
border: 'none',
background: 'none',
cursor: 'pointer',
font: 'inherit',
color: 'inherit',
...style,
}}
>
<span style={textStyle}>{serial}</span>
<Icon
name={copied ? 'check' : 'copy'}
size={iconSize}
color={copied ? 'var(--color-success)' : 'currentColor'}
className="copyable-serial__icon"
/>
<style>{`
.copyable-serial .copyable-serial__icon {
opacity: 0.45;
transition: opacity 0.15s, color 0.15s;
}
.copyable-serial:hover .copyable-serial__icon,
.copyable-serial:focus-visible .copyable-serial__icon {
opacity: 1;
}
`}</style>
</button>
)
}