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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user