Animate device pin collapse and traffic chart updates
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 7s

This commit is contained in:
2026-08-09 23:58:02 +03:00
parent d349ca5e29
commit 3a4173db43
7 changed files with 192 additions and 102 deletions
+49 -12
View File
@@ -29,6 +29,11 @@ interface TrafficDelta {
total?: string;
}
interface PinCollapse {
animationDone: boolean;
result: 'pending' | 'saved' | 'failed';
}
function requestMessage(value: unknown) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
const message: unknown = Reflect.get(value, 'message');
@@ -79,9 +84,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
const [copyAnnouncement, setCopyAnnouncement] = useState<{ id: string; message: string } | null>(null);
const [pencilAnimationId, setPencilAnimationId] = useState('');
const [trafficDeltas, setTrafficDeltas] = useState<Record<string, TrafficDelta>>({});
const [pinCollapses, setPinCollapses] = useState<Record<string, PinCollapse>>({});
const deviceNodes = useRef(new Map<string, HTMLElement>());
const previousPositions = useRef(new Map<string, DOMRect>());
const previousOrder = useRef<string[]>([]);
const previousScrollTop = useRef(0);
const movementAnimations = useRef(new Map<string, Animation>());
const previousTraffic = useRef(new Map<string, { gateway: bigint; proxy: bigint }>());
@@ -144,7 +149,6 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
useLayoutEffect(() => {
if (!open) {
previousPositions.current.clear();
previousOrder.current = [];
previousScrollTop.current = 0;
for (const animation of movementAnimations.current.values()) animation.cancel();
movementAnimations.current.clear();
@@ -155,12 +159,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
movementAnimations.current.get(id)?.cancel();
positions.set(id, node.getBoundingClientRect());
}
const order = devices.map(({ id }) => id);
const orderChanged = previousOrder.current.length > 0
&& (order.length !== previousOrder.current.length
|| order.some((id, index) => id !== previousOrder.current[index]));
const currentScrollTop = panelRef.current?.scrollTop || 0;
if (orderChanged && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (previousPositions.current.size > 0
&& !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
for (const [id, after] of positions) {
const before = previousPositions.current.get(id);
const deltaY = before
@@ -178,9 +179,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
}
}
previousPositions.current = positions;
previousOrder.current = order;
previousScrollTop.current = currentScrollTop;
}, [devices, open, panelRef]);
}, [devices, open, panelRef, pinCollapses]);
async function saveAlias(device: Device) {
const nextAlias = alias.trim();
@@ -233,6 +233,39 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
setAlias(value);
}
async function togglePin(device: Device) {
if (!device.pinned || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
await updateDevice(device, { pinned: !device.pinned });
return;
}
setPinCollapses((current) => ({
...current,
[device.id]: { animationDone: false, result: 'pending' },
}));
const saved = await updateDevice(device, { pinned: false });
setPinCollapses((current) => {
const phase = current[device.id];
if (!phase) return current;
if (!phase.animationDone) {
return { ...current, [device.id]: { ...phase, result: saved ? 'saved' : 'failed' } };
}
const next = { ...current };
delete next[device.id];
return next;
});
}
function finishPinCollapse(deviceId: string) {
setPinCollapses((current) => {
const phase = current[deviceId];
if (!phase) return current;
if (phase.result === 'pending') return { ...current, [deviceId]: { ...phase, animationDone: true } };
const next = { ...current };
delete next[deviceId];
return next;
});
}
return (
<aside
ref={panelRef}
@@ -333,6 +366,8 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
</div>
<div className="client-devices-list" aria-live="polite" aria-busy={status === 'loading' || status === 'refreshing'}>
{devices.map((device) => {
const collapsing = Boolean(pinCollapses[device.id]);
const displayPinned = device.pinned || collapsing;
const hasName = Boolean(device.alias || device.hostname);
const title = device.alias || device.hostname || device.ip || 'Неизвестное устройство';
const editing = editingId === device.id;
@@ -374,7 +409,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
if (node) deviceNodes.current.set(device.id, node);
else deviceNodes.current.delete(device.id);
}}
className={`client-device is-${device.status}${device.pinned ? ' is-pinned' : ''}`}
className={`client-device is-${device.status}${displayPinned ? ' is-pinned' : ''}${collapsing ? ' is-collapsing' : ''}`}
key={device.id}
>
<span className="client-device-pin-wrap client-tooltip-anchor">
@@ -384,7 +419,7 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
aria-pressed={device.pinned}
aria-label={device.pinned ? `Открепить ${title}` : `Закрепить ${title}`}
disabled={saving}
onClick={() => updateDevice(device, { pinned: !device.pinned })}
onClick={() => togglePin(device)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 3h6l-1 5 3 3v2H7v-2l3-3-1-5ZM12 13v8" />
@@ -493,7 +528,9 @@ export function DevicesPanel({ feature }: { feature: DevicesFeature }) {
scale={trafficScale}
capacity={snapshot?.trafficHistoryCapacity || device.trafficHistory?.length || 1}
routeLabel={device.appliedPolicy === 'direct' ? 'Напрямую' : 'Gateway'}
pinned={device.pinned}
pinned={displayPinned}
collapsing={collapsing}
onCollapseEnd={() => finishPinCollapse(device.id)}
/>
</article>;
})}