Add automatic nearby discovery and simplify device presence UI
This commit is contained in:
@@ -34,6 +34,7 @@ import QRCode from 'qrcode';
|
||||
import AccountPanel from './AccountPanel.vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import InstallButton from './InstallButton.vue';
|
||||
import { createNearbySearch } from './nearbySearch';
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
@@ -41,7 +42,12 @@ import {
|
||||
account,
|
||||
connected,
|
||||
discoveryEnabled,
|
||||
locationSettings,
|
||||
publishLocation,
|
||||
clearLocation,
|
||||
reserveLocationAcceptance,
|
||||
peers,
|
||||
pendingPeers,
|
||||
requests,
|
||||
items,
|
||||
spaces,
|
||||
@@ -97,6 +103,14 @@ const receiving = computed(() =>
|
||||
r.transferStatus !== 'failed',
|
||||
),
|
||||
);
|
||||
createNearbySearch({
|
||||
connected,
|
||||
device,
|
||||
spaces,
|
||||
settings: locationSettings,
|
||||
publish: publishLocation,
|
||||
clear: clearLocation,
|
||||
});
|
||||
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
|
||||
const blockedLinks = receiver.blockedLinks;
|
||||
watch([requests, items], ([currentRequests, currentItems]) => {
|
||||
@@ -105,7 +119,10 @@ watch([requests, items], ([currentRequests, currentItems]) => {
|
||||
onUnmounted(receiver.dispose);
|
||||
async function receiveAction(r, action) {
|
||||
if (busy.value) return;
|
||||
if (action === 'accept') receiver.reserve(r);
|
||||
if (action === 'accept') {
|
||||
reserveLocationAcceptance(r.id);
|
||||
receiver.reserve(r);
|
||||
}
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestAction(r.id, action);
|
||||
@@ -136,8 +153,29 @@ const connectionCards = computed(() => {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const id of pendingPeers.value)
|
||||
if (!cards.has(id)) cards.set(id, { peer: { id }, skeleton: true });
|
||||
return [...cards.values()];
|
||||
});
|
||||
function leaveDevice(el) {
|
||||
const { width, height } = el.getBoundingClientRect();
|
||||
Object.assign(el.style, {
|
||||
left: `${el.offsetLeft}px`,
|
||||
top: `${el.offsetTop}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
});
|
||||
}
|
||||
function deviceLabel(peer) {
|
||||
return /^(Android|iPhone|iPad|Mac|Windows|Linux|기기)$/i.test(peer.name) && peer.info?.model
|
||||
? peer.info.model
|
||||
: peer.name;
|
||||
}
|
||||
function deviceDetail(peer) {
|
||||
return [peer.info?.manufacturer, peer.info?.model !== deviceLabel(peer) ? peer.info?.model : '']
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
async function toggleDeviceMenu(id) {
|
||||
if (deviceMenu.value === id) {
|
||||
deviceMenu.value = null;
|
||||
@@ -534,9 +572,14 @@ async function spaceAction(space, action, memberId) {
|
||||
<p v-if="!connected && !loading" class="reconnect-note" role="status">
|
||||
연결을 복구하고 있어요.
|
||||
</p>
|
||||
<div v-if="loading" class="loading-state">
|
||||
<LoaderCircle class="spin" :size="24" />
|
||||
<p>기기를 연결하고 있어요.</p>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="initial-device-loading"
|
||||
role="status"
|
||||
aria-label="연결 정보 확인 중"
|
||||
>
|
||||
<div class="peer-card peer-skeleton"><i></i><span></span><small></small></div>
|
||||
<p>연결 정보를 확인하고 있어요.</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Teleport to="body">
|
||||
@@ -638,47 +681,32 @@ async function spaceAction(space, action, memberId) {
|
||||
</Teleport>
|
||||
<template v-if="tab === 'devices'">
|
||||
<section class="devices-stage">
|
||||
<div class="stage-heading">
|
||||
<h1>어디로 보낼까요?</h1>
|
||||
<p class="discovery-status" role="status">
|
||||
<LoaderCircle
|
||||
v-if="connected && device?.discoverable && (spaces.length || discoveryEnabled)"
|
||||
class="spin"
|
||||
:size="13"
|
||||
/>
|
||||
{{
|
||||
!connected
|
||||
? '연결을 확인하고 있어요.'
|
||||
: spaces.length
|
||||
? '방 안의 기기를 확인하고 있어요.'
|
||||
: !device?.discoverable
|
||||
? '발견 허용이 꺼져 있어요.'
|
||||
: discoveryEnabled
|
||||
? '주변 기기를 계속 찾고 있어요.'
|
||||
: '주변 검색 설정이 필요해요. QR로 연결할 수 있어요.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<section class="nearby-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<h2>
|
||||
{{ spaces.length ? '방 안의 기기' : '주변 기기' }}
|
||||
<span>{{ connectionCards.length.toString().padStart(2, '0') }}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="act(refresh)" aria-label="기기 목록 새로고침">
|
||||
<RefreshCw :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!connectionCards.length" class="empty-device-hint">
|
||||
{{
|
||||
device?.discoverable
|
||||
? '상대도 hop.을 열거나 QR로 연결해 주세요.'
|
||||
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
<div v-if="!connectionCards.length" class="empty-devices">
|
||||
<Monitor :size="30" stroke-width="1.4" />
|
||||
<h3>아직 연결된 기기가 없어요.</h3>
|
||||
<p>
|
||||
{{
|
||||
device?.discoverable
|
||||
? '다른 기기에서 hop.을 열거나 QR로 연결해 보세요.'
|
||||
: '아래 내 기기 설정에서 발견 허용을 켜 주세요.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="device"
|
||||
@before-leave="leaveDevice"
|
||||
class="peer-grid"
|
||||
:class="`device-count-${connectionCards.length + (canInvite ? 1 : 0)}`"
|
||||
v-if="connectionCards.length || canInvite"
|
||||
@@ -689,7 +717,16 @@ async function spaceAction(space, action, memberId) {
|
||||
class="peer-entry"
|
||||
data-device-menu
|
||||
>
|
||||
<div
|
||||
v-if="card.skeleton"
|
||||
class="peer-card peer-skeleton"
|
||||
role="status"
|
||||
aria-label="연결 정보 확인 중"
|
||||
>
|
||||
<i></i><span></span><small></small>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
class="peer-card"
|
||||
:class="{ selected: selected?.id === card.peer.id, pending: card.pending }"
|
||||
:disabled="card.pending"
|
||||
@@ -702,11 +739,20 @@ async function spaceAction(space, action, memberId) {
|
||||
><template v-if="card.pending">승인 대기</template
|
||||
><template v-else><i></i>온라인</template></span
|
||||
></span
|
||||
><strong>{{ card.peer.name }}</strong
|
||||
><strong>{{ deviceLabel(card.peer) }}</strong
|
||||
><span v-if="deviceDetail(card.peer)" class="peer-model">{{
|
||||
deviceDetail(card.peer)
|
||||
}}</span
|
||||
><span class="peer-code">#{{ card.peer.code }}</span
|
||||
><span class="peer-bottom"
|
||||
><span
|
||||
>{{ card.peer.sources.includes('network') ? '같은 네트워크' : '연결 공간' }}
|
||||
>{{
|
||||
card.peer.sources.includes('network')
|
||||
? '같은 네트워크'
|
||||
: card.peer.sources.includes('nearby')
|
||||
? '근처 기기'
|
||||
: '연결 공간'
|
||||
}}
|
||||
<span v-if="isHost(card.peer.id)" class="host-badge">HOST</span></span
|
||||
><span v-if="!card.pending" class="peer-select"
|
||||
><Check v-if="selected?.id === card.peer.id" :size="14" /><ArrowUpRight
|
||||
@@ -714,7 +760,7 @@ async function spaceAction(space, action, memberId) {
|
||||
:size="14" /></span
|
||||
></span></button
|
||||
><button
|
||||
v-if="managedSpaces(card.peer.id).length"
|
||||
v-if="!card.skeleton && managedSpaces(card.peer.id).length"
|
||||
:id="`device-menu-toggle-${card.peer.id}`"
|
||||
class="icon-button device-menu-toggle"
|
||||
:aria-label="`${card.peer.name} 기기 더보기`"
|
||||
@@ -759,6 +805,7 @@ async function spaceAction(space, action, memberId) {
|
||||
</div>
|
||||
<button
|
||||
v-if="canInvite"
|
||||
key="qr-connect"
|
||||
class="qr-connect-card"
|
||||
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
|
||||
@click="modal = 'connect'"
|
||||
@@ -768,24 +815,7 @@ async function spaceAction(space, action, memberId) {
|
||||
}}</strong
|
||||
><span>다른 기기 연결하기</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="empty-peers">
|
||||
<div class="orbit-icon"><Radio :size="35" stroke-width="1.2" /></div>
|
||||
<h3>
|
||||
{{ spaces.length ? '기기 참여를 기다리고 있어요.' : '상대도 hop.을 열어주세요.' }}
|
||||
</h3>
|
||||
<p v-if="!spaces.length">다른 네트워크라면 QR이나 코드로 연결하세요.</p>
|
||||
<button
|
||||
v-if="
|
||||
!spaces.length ||
|
||||
(invite?.space.id === spaces[0]?.id && invite.space.expiresAt > now)
|
||||
"
|
||||
class="button room-connect"
|
||||
@click="modal = 'connect'"
|
||||
>
|
||||
<QrCode :size="17" />{{ spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결' }}
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</section>
|
||||
<div v-if="spaces.length" class="connection-actions">
|
||||
<button
|
||||
@@ -840,11 +870,11 @@ async function spaceAction(space, action, memberId) {
|
||||
class="modal send-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`${selected.name}에 보내기`"
|
||||
:aria-label="`${deviceLabel(selected)}에 보내기`"
|
||||
@keydown.tab="trapSendFocus"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ selected.name }}에 보내기</h2>
|
||||
<h2>{{ deviceLabel(selected) }}에 보내기</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="busy"
|
||||
@@ -908,9 +938,7 @@ async function spaceAction(space, action, memberId) {
|
||||
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
|
||||
</p>
|
||||
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
|
||||
<p class="discovery-status">
|
||||
<LoaderCircle class="spin" :size="14" />기기 참여를 기다리고 있어요.
|
||||
</p>
|
||||
<p class="discovery-status">기기 참여를 기다리고 있어요.</p>
|
||||
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
|
||||
<p v-if="localOnlyQr" class="local-qr-note">
|
||||
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readDeviceInfo } from './deviceInfo';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { ref } from 'vue';
|
||||
import { io } from 'socket.io-client';
|
||||
@@ -23,7 +24,22 @@ export const device = ref(null),
|
||||
spaces = ref([]),
|
||||
discoveryEnabled = ref(false),
|
||||
error = ref('');
|
||||
export const pendingPeers = ref([]);
|
||||
let pendingTimer;
|
||||
let deviceInfo = {};
|
||||
export const nextCursor = ref(null);
|
||||
export const locationSettings = ref({ enabled: false, radiusMeters: 100, refreshMs: 120000 });
|
||||
export async function publishLocation(position) {
|
||||
if (!socket?.connected) throw Error('서버 연결을 확인해 주세요.');
|
||||
const result = await socket.timeout(5000).volatile.emitWithAck('location:update', position);
|
||||
if (!result?.ok) throw Error(result?.message ?? '위치를 확인하지 못했습니다.');
|
||||
}
|
||||
export function clearLocation() {
|
||||
if (socket?.connected) socket.emit('location:clear');
|
||||
}
|
||||
export function reserveLocationAcceptance(requestId) {
|
||||
if (socket?.connected) socket.emit('location:accept', { requestId });
|
||||
}
|
||||
let socket;
|
||||
let reauthorizing;
|
||||
const drafts = new Map();
|
||||
@@ -58,13 +74,14 @@ export async function bootstrap() {
|
||||
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
|
||||
else await init();
|
||||
await refreshAccount();
|
||||
deviceInfo = await readDeviceInfo();
|
||||
connect();
|
||||
await refresh();
|
||||
startReconciliation();
|
||||
}
|
||||
function connect() {
|
||||
socket?.disconnect();
|
||||
socket = io({ auth: { csrfToken: csrfToken.value }, reconnection: true });
|
||||
socket = io({ auth: { csrfToken: csrfToken.value, deviceInfo }, reconnection: true });
|
||||
socket.on('connect', () => {
|
||||
connected.value = true;
|
||||
void refresh().catch(showError);
|
||||
@@ -91,7 +108,7 @@ function connect() {
|
||||
csrfToken.value = session.csrfToken;
|
||||
sessionExpiresAt.value = session.expiresAt;
|
||||
if (socket) {
|
||||
socket.auth = { csrfToken: csrfToken.value };
|
||||
socket.auth = { csrfToken: csrfToken.value, deviceInfo };
|
||||
socket.connect();
|
||||
}
|
||||
})()
|
||||
@@ -100,10 +117,20 @@ function connect() {
|
||||
reauthorizing = undefined;
|
||||
});
|
||||
});
|
||||
socket.on('device:updated', (event) => {
|
||||
const known = new Set(peers.value.map((peer) => peer.id));
|
||||
pendingPeers.value = (event?.devices ?? [])
|
||||
.filter((peer) => !known.has(peer.id))
|
||||
.map((peer) => peer.id);
|
||||
clearTimeout(pendingTimer);
|
||||
pendingTimer = setTimeout(() => {
|
||||
pendingPeers.value = [];
|
||||
}, 5000);
|
||||
void refresh().catch(showError);
|
||||
});
|
||||
for (const event of [
|
||||
'device:online',
|
||||
'device:offline',
|
||||
'device:updated',
|
||||
'space:updated',
|
||||
'request:created',
|
||||
'request:updated',
|
||||
@@ -119,7 +146,15 @@ export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
device.value = snapshot.device;
|
||||
peers.value = snapshot.devices;
|
||||
pendingPeers.value = pendingPeers.value.filter(
|
||||
(id) => !snapshot.devices.some((peer) => peer.id === id),
|
||||
);
|
||||
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
|
||||
locationSettings.value = snapshot.locationDiscovery ?? {
|
||||
enabled: false,
|
||||
radiusMeters: 100,
|
||||
refreshMs: 120000,
|
||||
};
|
||||
requests.value = snapshot.requests;
|
||||
items.value = snapshot.items;
|
||||
nextCursor.value = snapshot.nextCursor;
|
||||
|
||||
24
apps/web/src/deviceInfo.js
Normal file
24
apps/web/src/deviceInfo.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// Model hints are optional; user-defined names always remain intact.
|
||||
export async function readDeviceInfo(nav = navigator) {
|
||||
try {
|
||||
const hint = await Promise.race([
|
||||
nav.userAgentData?.getHighEntropyValues(['model']),
|
||||
new Promise((resolve) => setTimeout(resolve, 1000)),
|
||||
]);
|
||||
const model = String(hint?.model ?? '')
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
const platform = String(hint?.platform ?? nav.userAgentData?.platform ?? '')
|
||||
.trim()
|
||||
.slice(0, 40);
|
||||
// Conservative manufacturer inference, not a browser-provided manufacturer field.
|
||||
const manufacturer = /^SM-|^GT-/i.test(model)
|
||||
? 'Samsung'
|
||||
: /^Pixel(?: |$)/i.test(model)
|
||||
? 'Google'
|
||||
: '';
|
||||
return { model, platform, manufacturer };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
123
apps/web/src/nearbySearch.js
Normal file
123
apps/web/src/nearbySearch.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import { ref, watch, onUnmounted } from 'vue';
|
||||
|
||||
// Discovery is automatic. The browser owns the location permission prompt.
|
||||
export function createNearbySearch({ connected, device, spaces, settings, publish, clear }) {
|
||||
const active = ref(false),
|
||||
locating = ref(false);
|
||||
let visible = document.visibilityState === 'visible';
|
||||
let revision = 0,
|
||||
timer,
|
||||
permission,
|
||||
permissionQuery,
|
||||
disposed = false,
|
||||
blocked = false;
|
||||
const ready = () =>
|
||||
visible &&
|
||||
connected.value &&
|
||||
settings.value.enabled &&
|
||||
device.value?.discoverable &&
|
||||
!spaces.value.length;
|
||||
function pause() {
|
||||
const hadPosition = active.value || locating.value;
|
||||
revision++;
|
||||
clearTimeout(timer);
|
||||
active.value = false;
|
||||
locating.value = false;
|
||||
if (hadPosition) clear();
|
||||
}
|
||||
async function readPermission() {
|
||||
if (permission) return permission.state;
|
||||
if (!navigator.permissions?.query) return null;
|
||||
if (!permissionQuery)
|
||||
permissionQuery = navigator.permissions
|
||||
.query({ name: 'geolocation' })
|
||||
.then((status) => {
|
||||
if (disposed) return null;
|
||||
permission = status;
|
||||
status.onchange = () => {
|
||||
blocked = status.state !== 'granted';
|
||||
if (blocked) pause();
|
||||
else void resume();
|
||||
};
|
||||
return status.state;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
permissionQuery = undefined;
|
||||
});
|
||||
return permissionQuery;
|
||||
}
|
||||
async function locate() {
|
||||
if (!ready() || blocked || locating.value || disposed) return;
|
||||
const current = ++revision;
|
||||
locating.value = true;
|
||||
clearTimeout(timer);
|
||||
try {
|
||||
if (!window.isSecureContext || !navigator.geolocation) return;
|
||||
const position = await new Promise((resolve, reject) =>
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 15000,
|
||||
maximumAge: 0,
|
||||
}),
|
||||
);
|
||||
if (current !== revision || !ready() || disposed) return;
|
||||
await publish({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
});
|
||||
if (current !== revision || !ready() || disposed) return;
|
||||
active.value = true;
|
||||
} catch (error) {
|
||||
if (current !== revision) return;
|
||||
active.value = false;
|
||||
clear();
|
||||
// Denied permissions must not trigger repeated prompts or affect file transfers.
|
||||
if (error.code === 1) blocked = true;
|
||||
} finally {
|
||||
if (current === revision) {
|
||||
locating.value = false;
|
||||
if (!blocked && ready() && !disposed && window.isSecureContext && navigator.geolocation)
|
||||
timer = setTimeout(locate, settings.value.refreshMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
async function resume() {
|
||||
if (!ready() || blocked || active.value || locating.value || disposed) return;
|
||||
const current = revision;
|
||||
const state = await readPermission();
|
||||
if (disposed || current !== revision || !ready()) return;
|
||||
if (state === 'denied') {
|
||||
blocked = true;
|
||||
return;
|
||||
}
|
||||
void locate();
|
||||
}
|
||||
function visibility() {
|
||||
visible = document.visibilityState === 'visible';
|
||||
if (visible) void resume();
|
||||
else pause();
|
||||
}
|
||||
document.addEventListener('visibilitychange', visibility);
|
||||
watch(
|
||||
[
|
||||
connected,
|
||||
() => device.value?.discoverable,
|
||||
() => spaces.value.length,
|
||||
() => settings.value.enabled,
|
||||
],
|
||||
() => {
|
||||
if (ready()) void resume();
|
||||
else pause();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
onUnmounted(() => {
|
||||
disposed = true;
|
||||
pause();
|
||||
document.removeEventListener('visibilitychange', visibility);
|
||||
if (permission) permission.onchange = null;
|
||||
});
|
||||
return { active, locating };
|
||||
}
|
||||
@@ -2604,15 +2604,6 @@ a.button {
|
||||
.devices-stage {
|
||||
margin: 34px auto 0;
|
||||
}
|
||||
.stage-heading {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
.stage-heading h1 {
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.04em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.devices-stage .section-heading h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
@@ -2725,12 +2716,6 @@ a.button {
|
||||
.devices-stage {
|
||||
margin-top: 26px;
|
||||
}
|
||||
.stage-heading {
|
||||
margin-bottom: 26px;
|
||||
}
|
||||
.stage-heading h1 {
|
||||
font-size: 23px;
|
||||
}
|
||||
.devices-stage .peer-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
@@ -2780,7 +2765,7 @@ a.button {
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.devices-stage .peer-grid.device-count-1 {
|
||||
grid-template-columns: minmax(0, 190px);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
.devices-stage .empty-peers {
|
||||
@@ -2983,12 +2968,6 @@ a.button {
|
||||
.devices-stage {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.stage-heading {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stage-heading h1 {
|
||||
font-size: 21px;
|
||||
}
|
||||
.device-footer {
|
||||
margin-top: 38px;
|
||||
padding: 20px 0;
|
||||
@@ -3090,9 +3069,6 @@ a.button {
|
||||
min-height: 166px;
|
||||
padding: 18px 12px;
|
||||
}
|
||||
.stage-heading h1 {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
.footer-device-summary .footer-device-code {
|
||||
font-size: 10px;
|
||||
@@ -3154,3 +3130,103 @@ a.button {
|
||||
height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Presence changes animate once; idle discovery has no running animation. */
|
||||
.devices-stage .peer-grid {
|
||||
position: relative;
|
||||
}
|
||||
.devices-stage .peer-model {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
margin-top: 3px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.devices-stage .empty-devices {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.devices-stage .empty-devices h3 {
|
||||
font-size: 17px;
|
||||
margin: 0;
|
||||
}
|
||||
.devices-stage .empty-devices p {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
.initial-device-loading {
|
||||
padding: 32px 0;
|
||||
max-width: 360px;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.peer-skeleton {
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.peer-skeleton i,
|
||||
.peer-skeleton span,
|
||||
.peer-skeleton small {
|
||||
display: block;
|
||||
background: var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.peer-skeleton i {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
.peer-skeleton span {
|
||||
width: 65%;
|
||||
height: 16px;
|
||||
}
|
||||
.peer-skeleton small {
|
||||
width: 42%;
|
||||
height: 10px;
|
||||
}
|
||||
.device-enter-active {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 180ms ease;
|
||||
}
|
||||
.device-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.96);
|
||||
}
|
||||
.device-move {
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
.device-leave-active {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
animation: device-pop-away 240ms ease-in forwards;
|
||||
}
|
||||
@keyframes device-pop-away {
|
||||
25% {
|
||||
transform: scale(1.035);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px) scale(0.82);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.device-enter-active,
|
||||
.device-move {
|
||||
transition: none;
|
||||
}
|
||||
.device-leave-active {
|
||||
animation: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user