From 76b75624b8b45544cddbf8631f2b48c81d4d90a8 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 7 Aug 2026 13:48:51 +0300 Subject: [PATCH] Add animated relative last-seen device timestamps --- src/shared/versions.js | 4 +- src/web/components/DevicesPanel.jsx | 27 +++++++++-- src/web/styles.css | 54 ++++++++++++++++++++-- src/web/utils/format.js | 4 +- test/web/device-inventory-contract.test.js | 8 +++- 5 files changed, 84 insertions(+), 13 deletions(-) diff --git a/src/shared/versions.js b/src/shared/versions.js index b5d9ee1..b3f70c1 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.9.1', - gatewayClient: '0.9.1', + macClient: '0.9.2', + gatewayClient: '0.9.2', gatewayBackend: '0.9.0', }); diff --git a/src/web/components/DevicesPanel.jsx b/src/web/components/DevicesPanel.jsx index 9582fd0..ff8692a 100644 --- a/src/web/components/DevicesPanel.jsx +++ b/src/web/components/DevicesPanel.jsx @@ -12,6 +12,15 @@ function Tooltip({ children }) { return {children}; } +function TextMorph({ from, to }) { + const anchor = from.length >= to.length ? from : to; + return ; +} + export function DevicesPanel({ open, panelRef, closeRef, onClose }) { const [snapshot, setSnapshot] = useState(null); const [status, setStatus] = useState('idle'); @@ -73,6 +82,17 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) { inert={!open ? true : undefined} >
+
- - - {seen.tooltip} + + {device.manufacturer &&

{device.manufacturer}

} diff --git a/src/web/styles.css b/src/web/styles.css index b2d22b0..979c09c 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -918,24 +918,69 @@ p { flex: 0 0 auto; margin-left: auto; color: var(--client-text); - cursor: help; + cursor: default; white-space: nowrap; } -.client-device-last-seen > .client-tooltip, .client-device-pin-wrap > .client-tooltip { right: 0; left: auto; transform: translate(0, 2px); } -.client-device-last-seen:hover > .client-tooltip, -.client-device-last-seen:focus-visible > .client-tooltip, .client-device-pin-wrap:hover > .client-tooltip, .client-device-pin-wrap:has(> :focus-visible) > .client-tooltip { transform: translate(0, 0); } +.client-text-morph-filter { + position: absolute; + width: 0; + height: 0; + pointer-events: none; +} + +.client-text-morph { + display: inline-grid; + filter: url('#client-text-morph-goo'); +} + +.client-text-morph-anchor, +.client-text-morph-value { + grid-area: 1 / 1; + justify-self: end; + white-space: nowrap; +} + +.client-text-morph-anchor { + visibility: hidden; +} + +.client-text-morph-value { + will-change: opacity, filter, transform; + transition: opacity 240ms ease, filter 280ms cubic-bezier(0.16, 1, 0.3, 1), transform 280ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-text-morph-value.is-relative { + opacity: 0; + filter: blur(4px); + transform: scale(0.9); +} + +.client-device-last-seen:hover .client-text-morph-value.is-date, +.client-device-last-seen:focus-visible .client-text-morph-value.is-date { + opacity: 0; + filter: blur(4px); + transform: scale(1.08); +} + +.client-device-last-seen:hover .client-text-morph-value.is-relative, +.client-device-last-seen:focus-visible .client-text-morph-value.is-relative { + opacity: 1; + filter: blur(0); + transform: scale(1); +} + .client-device-last-seen:focus-visible, .client-device-identity:focus-visible { border-radius: 3px; @@ -3947,6 +3992,7 @@ p { .client-device-edit, .client-device-edit svg, .client-device-edit-wrap, + .client-text-morph-value, .client-subscription-edit, .client-subscription-edit::after, .client-subscription-submit, diff --git a/src/web/utils/format.js b/src/web/utils/format.js index b2d3291..3fda5dc 100644 --- a/src/web/utils/format.js +++ b/src/web/utils/format.js @@ -32,7 +32,7 @@ export function formatTime(iso) { export function formatLastSeen(iso, now = new Date()) { const date = new Date(iso); - if (Number.isNaN(date.getTime())) return { label: "Нет данных", tooltip: "Нет данных" }; + if (Number.isNaN(date.getTime())) return { label: "Нет данных", relative: "Нет данных", tooltip: "Нет данных" }; const current = new Date(now); const day = (value) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate()); const daysAgo = Math.round((day(current) - day(date)) / 86_400_000); @@ -62,5 +62,5 @@ export function formatLastSeen(iso, now = new Date()) { hour: "2-digit", minute: "2-digit", }); - return { label: `${dateLabel}, ${time}`, tooltip: `${full} (${relative})` }; + return { label: `${dateLabel}, ${time}`, relative, tooltip: `${full} (${relative})` }; } diff --git a/test/web/device-inventory-contract.test.js b/test/web/device-inventory-contract.test.js index a085e7c..0ad4c96 100644 --- a/test/web/device-inventory-contract.test.js +++ b/test/web/device-inventory-contract.test.js @@ -15,21 +15,25 @@ test('Gateway device inventory uses the existing accessible responsive drawer', assert.match(panel, /api\.devices\.list\(\)/); assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/); assert.match(panel, /Изменить название<\/Tooltip>/); + assert.match(panel, //); + assert.match(panel, /filter id="client-text-morph-goo"/); assert.match(panel, /client-device-addresses/); assert.doesNotMatch(panel, /точная MAC|частная MAC|
Источник<\/dt>/); assert.match(panel, /aria-pressed=\{device\.pinned\}/); assert.match(panel, /maxLength="64"[\s\S]*autoFocus/); assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/); - assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-device-pin/); + assert.match(styles, /\.client-device-last-seen:hover \.client-text-morph-value\.is-relative[\s\S]*opacity: 1/); + assert.match(styles, /@media \(prefers-reduced-motion: reduce\)[\s\S]*\.client-text-morph-value/); }); -test('device last-seen copy is compact with a precise relative tooltip', () => { +test('device last-seen copy is compact with precise accessible and relative forms', () => { const lastSeen = new Date(2026, 7, 7, 13, 15); const now = new Date(2026, 7, 7, 13, 27); assert.deepEqual( formatLastSeen(lastSeen.toISOString(), now), { label: 'Сегодня, 13:15', + relative: '12 минут назад', tooltip: '7 августа 2026 г. в 13:15 (12 минут назад)', }, );