Add animated relative last-seen device timestamps
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.9.1',
|
macClient: '0.9.2',
|
||||||
gatewayClient: '0.9.1',
|
gatewayClient: '0.9.2',
|
||||||
gatewayBackend: '0.9.0',
|
gatewayBackend: '0.9.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,15 @@ function Tooltip({ children }) {
|
|||||||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TextMorph({ from, to }) {
|
||||||
|
const anchor = from.length >= to.length ? from : to;
|
||||||
|
return <span className="client-text-morph" aria-hidden="true">
|
||||||
|
<span className="client-text-morph-anchor">{anchor}</span>
|
||||||
|
<span className="client-text-morph-value is-date">{from}</span>
|
||||||
|
<span className="client-text-morph-value is-relative">{to}</span>
|
||||||
|
</span>;
|
||||||
|
}
|
||||||
|
|
||||||
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
||||||
const [snapshot, setSnapshot] = useState(null);
|
const [snapshot, setSnapshot] = useState(null);
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
@@ -73,6 +82,17 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
inert={!open ? true : undefined}
|
inert={!open ? true : undefined}
|
||||||
>
|
>
|
||||||
<div className="client-instructions-sheet client-devices-sheet">
|
<div className="client-instructions-sheet client-devices-sheet">
|
||||||
|
<svg className="client-text-morph-filter" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<filter id="client-text-morph-goo">
|
||||||
|
<feColorMatrix
|
||||||
|
in="SourceGraphic"
|
||||||
|
type="matrix"
|
||||||
|
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 20 -8"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
<button
|
<button
|
||||||
ref={closeRef}
|
ref={closeRef}
|
||||||
className="client-drawer-close"
|
className="client-drawer-close"
|
||||||
@@ -178,9 +198,10 @@ export function DevicesPanel({ open, panelRef, closeRef, onClose }) {
|
|||||||
</span>}
|
</span>}
|
||||||
{device.interface && <span>{device.interface}</span>}
|
{device.interface && <span>{device.interface}</span>}
|
||||||
</div>
|
</div>
|
||||||
<span className="client-device-last-seen client-tooltip-anchor" tabIndex="0" aria-label={seen.tooltip}>
|
<span className="client-device-last-seen" tabIndex="0">
|
||||||
<time dateTime={device.lastSeenAt}>{seen.label}</time>
|
<time dateTime={device.lastSeenAt} aria-label={seen.tooltip}>
|
||||||
<Tooltip>{seen.tooltip}</Tooltip>
|
<TextMorph from={seen.label} to={seen.relative} />
|
||||||
|
</time>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{device.manufacturer && <p className="client-device-manufacturer">{device.manufacturer}</p>}
|
{device.manufacturer && <p className="client-device-manufacturer">{device.manufacturer}</p>}
|
||||||
|
|||||||
+50
-4
@@ -918,24 +918,69 @@ p {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
cursor: help;
|
cursor: default;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-device-last-seen > .client-tooltip,
|
|
||||||
.client-device-pin-wrap > .client-tooltip {
|
.client-device-pin-wrap > .client-tooltip {
|
||||||
right: 0;
|
right: 0;
|
||||||
left: auto;
|
left: auto;
|
||||||
transform: translate(0, 2px);
|
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:hover > .client-tooltip,
|
||||||
.client-device-pin-wrap:has(> :focus-visible) > .client-tooltip {
|
.client-device-pin-wrap:has(> :focus-visible) > .client-tooltip {
|
||||||
transform: translate(0, 0);
|
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-last-seen:focus-visible,
|
||||||
.client-device-identity:focus-visible {
|
.client-device-identity:focus-visible {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -3947,6 +3992,7 @@ p {
|
|||||||
.client-device-edit,
|
.client-device-edit,
|
||||||
.client-device-edit svg,
|
.client-device-edit svg,
|
||||||
.client-device-edit-wrap,
|
.client-device-edit-wrap,
|
||||||
|
.client-text-morph-value,
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
.client-subscription-submit,
|
.client-subscription-submit,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export function formatTime(iso) {
|
|||||||
|
|
||||||
export function formatLastSeen(iso, now = new Date()) {
|
export function formatLastSeen(iso, now = new Date()) {
|
||||||
const date = new Date(iso);
|
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 current = new Date(now);
|
||||||
const day = (value) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate());
|
const day = (value) => Date.UTC(value.getFullYear(), value.getMonth(), value.getDate());
|
||||||
const daysAgo = Math.round((day(current) - day(date)) / 86_400_000);
|
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",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
});
|
});
|
||||||
return { label: `${dateLabel}, ${time}`, tooltip: `${full} (${relative})` };
|
return { label: `${dateLabel}, ${time}`, relative, tooltip: `${full} (${relative})` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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\.list\(\)/);
|
||||||
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
assert.match(panel, /api\.devices\.update\(device\.id, patch, snapshot\.revision\)/);
|
||||||
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
assert.match(panel, /<Tooltip>Изменить название<\/Tooltip>/);
|
||||||
|
assert.match(panel, /<TextMorph from=\{seen\.label\} to=\{seen\.relative\} \/>/);
|
||||||
|
assert.match(panel, /filter id="client-text-morph-goo"/);
|
||||||
assert.match(panel, /client-device-addresses/);
|
assert.match(panel, /client-device-addresses/);
|
||||||
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
assert.doesNotMatch(panel, /точная MAC|частная MAC|<dt>Источник<\/dt>/);
|
||||||
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
assert.match(panel, /aria-pressed=\{device\.pinned\}/);
|
||||||
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
assert.match(panel, /maxLength="64"[\s\S]*autoFocus/);
|
||||||
assert.match(styles, /\.client-devices \{\s*width: min\(560px, 100vw\)/);
|
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 lastSeen = new Date(2026, 7, 7, 13, 15);
|
||||||
const now = new Date(2026, 7, 7, 13, 27);
|
const now = new Date(2026, 7, 7, 13, 27);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
formatLastSeen(lastSeen.toISOString(), now),
|
formatLastSeen(lastSeen.toISOString(), now),
|
||||||
{
|
{
|
||||||
label: 'Сегодня, 13:15',
|
label: 'Сегодня, 13:15',
|
||||||
|
relative: '12 минут назад',
|
||||||
tooltip: '7 августа 2026 г. в 13:15 (12 минут назад)',
|
tooltip: '7 августа 2026 г. в 13:15 (12 минут назад)',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user