Initial hop. implementation and UGREEN NAS deployment guide
This commit is contained in:
942
apps/web/src/App.vue
Normal file
942
apps/web/src/App.vue
Normal file
@@ -0,0 +1,942 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Monitor,
|
||||
Smartphone,
|
||||
Laptop,
|
||||
QrCode,
|
||||
Link,
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
Settings,
|
||||
Sun,
|
||||
Moon,
|
||||
Radio,
|
||||
Send,
|
||||
Inbox,
|
||||
ShieldCheck,
|
||||
ChevronDown,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
ArrowDownLeft,
|
||||
CircleHelp,
|
||||
File as FileIcon,
|
||||
Trash2,
|
||||
Download,
|
||||
RotateCcw,
|
||||
EllipsisVertical,
|
||||
} from '@lucide/vue';
|
||||
import QRCode from 'qrcode';
|
||||
import AccountPanel from './AccountPanel.vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
device,
|
||||
account,
|
||||
connected,
|
||||
peers,
|
||||
requests,
|
||||
items,
|
||||
spaces,
|
||||
error,
|
||||
bootstrap,
|
||||
api,
|
||||
refresh,
|
||||
send,
|
||||
showError,
|
||||
requestAction,
|
||||
patchDevice,
|
||||
markRead,
|
||||
uploadProgress,
|
||||
sendFiles,
|
||||
cancelTransfer,
|
||||
download,
|
||||
} from './client';
|
||||
const tab = ref('devices'),
|
||||
selected = ref(null),
|
||||
loading = ref(true),
|
||||
busy = ref(false),
|
||||
editName = ref(false),
|
||||
nameDraft = ref(''),
|
||||
notice = ref(''),
|
||||
modal = ref(null),
|
||||
joinCode = ref(''),
|
||||
approvalRequired = ref(false),
|
||||
invite = ref(null),
|
||||
qr = ref(''),
|
||||
now = ref(Date.now());
|
||||
const localOnlyQr = /^(localhost|127(?:\.\d+){3}|\[::1\])$/.test(location.hostname);
|
||||
const theme = ref(initialTheme());
|
||||
const media = matchMedia('(prefers-color-scheme: dark)');
|
||||
function applyTheme() {
|
||||
setTheme(theme.value);
|
||||
}
|
||||
function toggleTheme() {
|
||||
theme.value = document.documentElement.classList.contains('dark') ? 'light' : 'dark';
|
||||
applyTheme();
|
||||
}
|
||||
const incoming = computed(() =>
|
||||
requests.value.filter(
|
||||
(r) => r.direction === 'received' && r.status === 'pending' && r.expiresAt > now.value,
|
||||
),
|
||||
);
|
||||
const receiving = computed(() =>
|
||||
requests.value.filter(
|
||||
(r) =>
|
||||
r.direction === 'received' &&
|
||||
r.status === 'accepted' &&
|
||||
r.transferStatus !== 'stored' &&
|
||||
r.transferStatus !== 'failed',
|
||||
),
|
||||
);
|
||||
const receiver = createDeliveryReceiver({ download, markRead, onError: showError });
|
||||
const blockedLinks = receiver.blockedLinks;
|
||||
watch([requests, items], ([currentRequests, currentItems]) => {
|
||||
receiver.reconcile(currentRequests, currentItems);
|
||||
});
|
||||
onUnmounted(receiver.dispose);
|
||||
async function receiveAction(r, action) {
|
||||
if (busy.value) return;
|
||||
if (action === 'accept') receiver.reserve(r);
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestAction(r.id, action);
|
||||
} catch (error) {
|
||||
if (action === 'accept') receiver.abort(r.id);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
const deviceMenu = ref(null);
|
||||
function isHost(id) {
|
||||
return Boolean(id && spaces.value.some((s) => s.creatorDeviceId === id));
|
||||
}
|
||||
function managedSpaces(id) {
|
||||
return spaces.value.filter(
|
||||
(s) => s.creatorDeviceId === device.value?.id && s.members.some((m) => m.device.id === id),
|
||||
);
|
||||
}
|
||||
const connectionCards = computed(() => {
|
||||
const cards = new Map(peers.value.map((peer) => [peer.id, { peer, pending: false }]));
|
||||
for (const space of spaces.value) {
|
||||
if (space.creatorDeviceId !== device.value?.id) continue;
|
||||
for (const member of space.members) {
|
||||
if (member.status === 'pending' && !cards.has(member.device.id))
|
||||
cards.set(member.device.id, {
|
||||
peer: { ...member.device, sources: ['space'] },
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...cards.values()];
|
||||
});
|
||||
async function toggleDeviceMenu(id) {
|
||||
if (deviceMenu.value === id) {
|
||||
deviceMenu.value = null;
|
||||
return;
|
||||
}
|
||||
deviceMenu.value = id;
|
||||
await nextTick();
|
||||
document.getElementById(`device-menu-${id}`)?.querySelector('button')?.focus();
|
||||
}
|
||||
function closeDeviceMenu(restore = false) {
|
||||
const id = deviceMenu.value;
|
||||
deviceMenu.value = null;
|
||||
if (restore && id) document.getElementById(`device-menu-toggle-${id}`)?.focus();
|
||||
}
|
||||
function outsideDeviceMenu(event) {
|
||||
if (!event.target.closest('[data-device-menu]')) closeDeviceMenu();
|
||||
}
|
||||
function escapeDeviceMenu(event) {
|
||||
if (event.key === 'Escape' && deviceMenu.value) {
|
||||
event.preventDefault();
|
||||
closeDeviceMenu(true);
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointerdown', outsideDeviceMenu);
|
||||
document.addEventListener('keydown', escapeDeviceMenu);
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', outsideDeviceMenu);
|
||||
document.removeEventListener('keydown', escapeDeviceMenu);
|
||||
});
|
||||
const active = computed(() =>
|
||||
requests.value.filter(
|
||||
(r) =>
|
||||
r.direction === 'sent' &&
|
||||
(r.status === 'pending' ||
|
||||
(r.status === 'accepted' && ['waiting', 'uploading'].includes(r.transferStatus))),
|
||||
),
|
||||
);
|
||||
const currentTransfer = computed(() =>
|
||||
active.value.find((r) => r.recipientDevice?.id === selected.value?.id),
|
||||
);
|
||||
async function cancelCurrentTransfer() {
|
||||
const request = currentTransfer.value;
|
||||
if (!request) return;
|
||||
await act(() => cancelTransfer(request.id));
|
||||
}
|
||||
const visibleUploadProgress = computed(() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(uploadProgress.value).filter(
|
||||
([, p]) => !p.accountId || p.accountId === account.value?.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
const canInvite = computed(
|
||||
() =>
|
||||
!spaces.value.length ||
|
||||
(invite.value?.space.id === spaces.value[0]?.id && invite.value.space.expiresAt > now.value),
|
||||
);
|
||||
const sendOpen = ref(false),
|
||||
sendDialog = ref(null);
|
||||
let beforeSendFocus;
|
||||
watch(sendOpen, async (open) => {
|
||||
if (open) {
|
||||
beforeSendFocus = document.activeElement;
|
||||
await nextTick();
|
||||
sendDialog.value?.querySelector('button:not(:disabled)')?.focus();
|
||||
} else if (beforeSendFocus?.isConnected) beforeSendFocus.focus();
|
||||
});
|
||||
function trapSendFocus(event) {
|
||||
const buttons = [
|
||||
...sendDialog.value.querySelectorAll(
|
||||
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), a[href]',
|
||||
),
|
||||
].filter((node) => node.getClientRects().length);
|
||||
const first = buttons[0],
|
||||
last = buttons.at(-1);
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
}
|
||||
async function closeSend() {
|
||||
if (busy.value) return;
|
||||
const request = currentTransfer.value;
|
||||
if (request)
|
||||
await act(async () => {
|
||||
await cancelTransfer(request.id);
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
});
|
||||
else {
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
}
|
||||
}
|
||||
watch(requests, (current, previous) => {
|
||||
for (const request of current)
|
||||
if (
|
||||
request.direction === 'sent' &&
|
||||
request.transferStatus === 'stored' &&
|
||||
previous.find((old) => old.id === request.id)?.transferStatus !== 'stored' &&
|
||||
request.recipientDevice?.id === selected.value?.id
|
||||
) {
|
||||
sendOpen.value = false;
|
||||
selected.value = null;
|
||||
itemToSend.value = null;
|
||||
notify('전송했습니다.');
|
||||
}
|
||||
});
|
||||
const selectionEpoch = ref(0);
|
||||
watch(spaces, (current, previous) => {
|
||||
const hosted = current.find((space) => space.creatorDeviceId === device.value?.id);
|
||||
const old = previous.find((space) => space.id === hosted?.id);
|
||||
if (
|
||||
hosted &&
|
||||
old &&
|
||||
hosted.members.some(
|
||||
(member) =>
|
||||
member.device.id !== device.value?.id &&
|
||||
!old.members.some((before) => before.device.id === member.device.id),
|
||||
)
|
||||
) {
|
||||
if (modal.value === 'connect') modal.value = null;
|
||||
notify('기기가 방에 참여했습니다.');
|
||||
}
|
||||
if (!current.length && previous.length) {
|
||||
selected.value = null;
|
||||
invite.value = null;
|
||||
modal.value = null;
|
||||
notify('방에서 나왔습니다.');
|
||||
}
|
||||
});
|
||||
const vaultItems = ref([]),
|
||||
vaultCursor = ref(null),
|
||||
forwardedItem = ref(null),
|
||||
itemToSend = ref(null);
|
||||
async function loadVaultChoices() {
|
||||
if (!account.value) return;
|
||||
const owner = account.value.id;
|
||||
await act(async () => {
|
||||
const result = await api('/vault/contents');
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value = result.items;
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
}
|
||||
async function moreVault() {
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
const result = await api(`/vault/contents?cursor=${encodeURIComponent(vaultCursor.value)}`);
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value.push(...result.items);
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
}
|
||||
function prepareVault(item) {
|
||||
itemToSend.value = item;
|
||||
selected.value = null;
|
||||
sendOpen.value = false;
|
||||
forwardedItem.value = null;
|
||||
tab.value = 'devices';
|
||||
notify('받을 기기를 선택해 주세요.');
|
||||
}
|
||||
async function forwardVault(item) {
|
||||
if (!selected.value) {
|
||||
itemToSend.value = item;
|
||||
tab.value = 'devices';
|
||||
notify('받을 기기를 선택해 주세요.');
|
||||
return;
|
||||
}
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
await api(
|
||||
`/vault/${item.accessId}/send`,
|
||||
'POST',
|
||||
{ targetDeviceId: selected.value.id },
|
||||
crypto.randomUUID(),
|
||||
);
|
||||
if (account.value?.id !== owner) return;
|
||||
forwardedItem.value = item;
|
||||
sendOpen.value = true;
|
||||
itemToSend.value = null;
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
watch(
|
||||
() => account.value?.id,
|
||||
() => {
|
||||
vaultItems.value = [];
|
||||
vaultCursor.value = null;
|
||||
itemToSend.value = null;
|
||||
forwardedItem.value = null;
|
||||
},
|
||||
);
|
||||
let timer;
|
||||
onMounted(async () => {
|
||||
applyTheme();
|
||||
media.addEventListener('change', applyTheme);
|
||||
timer = setInterval(() => {
|
||||
now.value = Date.now();
|
||||
}, 1000);
|
||||
try {
|
||||
await bootstrap();
|
||||
if (/(?:^|&)(verify|reset-password|verify-email-change)=/.test(location.hash.slice(1)))
|
||||
tab.value = 'vault';
|
||||
const token = new URLSearchParams(location.hash.slice(1)).get('join');
|
||||
if (token) {
|
||||
history.replaceState(null, '', location.pathname);
|
||||
await api('/spaces/join', 'POST', { token });
|
||||
await refresh();
|
||||
notify('연결 공간에 참여했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
const onFocus = () => void refresh().catch(showError);
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') onFocus();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
clearInterval(timer);
|
||||
media.removeEventListener('change', applyTheme);
|
||||
});
|
||||
function notify(message) {
|
||||
notice.value = message;
|
||||
setTimeout(() => {
|
||||
if (notice.value === message) notice.value = '';
|
||||
}, 4000);
|
||||
}
|
||||
async function act(fn) {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await fn();
|
||||
} catch (e) {
|
||||
showError(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
async function openDeviceSettings() {
|
||||
nameDraft.value = device.value?.name ?? '';
|
||||
modal.value = 'device';
|
||||
await nextTick();
|
||||
document.querySelector('.device-settings-form input')?.focus();
|
||||
}
|
||||
async function saveName() {
|
||||
await act(async () => {
|
||||
await patchDevice({ name: nameDraft.value });
|
||||
editName.value = false;
|
||||
modal.value = null;
|
||||
notify('기기 이름을 변경했습니다.');
|
||||
});
|
||||
}
|
||||
function selectPeer(peer) {
|
||||
closeDeviceMenu();
|
||||
selected.value = peer;
|
||||
sendOpen.value = true;
|
||||
selectionEpoch.value++;
|
||||
forwardedItem.value = null;
|
||||
}
|
||||
async function submitLinks(urls) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
await send(selected.value.id, 'url', { urls });
|
||||
});
|
||||
}
|
||||
async function submitFiles(files) {
|
||||
await act(async () => {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
await sendFiles(selected.value.id, files);
|
||||
});
|
||||
}
|
||||
async function createSpace() {
|
||||
await act(async () => {
|
||||
invite.value = await api('/spaces', 'POST', { approvalRequired: approvalRequired.value });
|
||||
qr.value = await QRCode.toDataURL(`${location.origin}/#join=${invite.value.token}`, {
|
||||
width: 208,
|
||||
margin: 1,
|
||||
color: { dark: '#111111', light: '#ffffff' },
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
await refresh();
|
||||
});
|
||||
}
|
||||
async function joinSpace() {
|
||||
await act(async () => {
|
||||
await api('/spaces/join', 'POST', { code: joinCode.value.replace(/[\s-]/g, '').toUpperCase() });
|
||||
joinCode.value = '';
|
||||
await refresh();
|
||||
modal.value = null;
|
||||
notify('방에 참여했습니다.');
|
||||
});
|
||||
}
|
||||
async function copyValue(value, item) {
|
||||
await act(async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
throw Error('복사하지 못했습니다. 내용을 선택해 직접 복사해 주세요.');
|
||||
}
|
||||
if (item) await markRead(item);
|
||||
notify('복사했습니다.');
|
||||
});
|
||||
}
|
||||
function iconFor(name) {
|
||||
return /phone|android|galaxy/i.test(name)
|
||||
? Smartphone
|
||||
: /mac|laptop/i.test(name)
|
||||
? Laptop
|
||||
: Monitor;
|
||||
}
|
||||
function time(value) {
|
||||
return new Intl.DateTimeFormat('ko', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(value);
|
||||
}
|
||||
function size(bytes) {
|
||||
return bytes >= 1073741824
|
||||
? (bytes / 1073741824).toFixed(1) + ' GiB'
|
||||
: bytes >= 1048576
|
||||
? (bytes / 1048576).toFixed(1) + ' MiB'
|
||||
: bytes >= 1024
|
||||
? (bytes / 1024).toFixed(1) + ' KiB'
|
||||
: bytes + ' B';
|
||||
}
|
||||
async function spaceAction(space, action, memberId) {
|
||||
await act(async () => {
|
||||
await api(
|
||||
`/spaces/${space.id}${memberId ? `/members/${memberId}${action === 'approve' ? '/approve' : ''}` : ''}`,
|
||||
action === 'approve' ? 'POST' : 'DELETE',
|
||||
action === 'approve' ? {} : undefined,
|
||||
);
|
||||
if (invite.value?.space.id === space.id && !memberId) invite.value = null;
|
||||
await refresh();
|
||||
closeDeviceMenu();
|
||||
if (memberId && action === 'remove') {
|
||||
if (selected.value?.id === memberId && !peers.value.some((p) => p.id === memberId))
|
||||
selected.value = null;
|
||||
notify('연결 공간에서 내보냈습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell" :data-online="connected">
|
||||
<header class="site-header">
|
||||
<div class="header-brand">
|
||||
<button class="wordmark" @click="tab = 'devices'" aria-label="hop. 홈">
|
||||
hop<span>.</span>
|
||||
</button>
|
||||
<span class="header-tagline">기기 사이를 가볍게.</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="icon-button theme-toggle"
|
||||
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Moon v-if="theme === 'light'" :size="19" /><Sun v-else :size="19" /></button
|
||||
><button class="login-button" @click="tab = 'vault'">
|
||||
{{ account ? '보관함' : '로그인' }} <ArrowUpRight :size="15" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<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>
|
||||
<template v-else>
|
||||
<Teleport to="body">
|
||||
<section
|
||||
v-if="receiving.length || blockedLinks.length"
|
||||
class="incoming-section"
|
||||
aria-label="수신 알림"
|
||||
aria-live="polite"
|
||||
>
|
||||
<article
|
||||
v-for="r in receiving"
|
||||
:key="`receiving-${r.id}`"
|
||||
class="incoming-card receiving-card"
|
||||
>
|
||||
<span class="incoming-icon"><LoaderCircle class="spin" :size="20" /></span>
|
||||
<div class="incoming-info">
|
||||
<strong>{{ r.senderDevice?.name }}에서 받는 중</strong>
|
||||
<p>
|
||||
{{
|
||||
r.transferStatus === 'uploading'
|
||||
? '파일을 업로드하고 있어요.'
|
||||
: '수락했습니다. 발신 기기에서 전달을 준비하고 있어요.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
v-for="item in blockedLinks"
|
||||
:key="item.accessId"
|
||||
class="incoming-card delivery-blocked"
|
||||
>
|
||||
<div class="incoming-info">
|
||||
<strong>브라우저가 새 탭 열기를 제한했어요.</strong>
|
||||
<p>아래 링크를 누르면 바로 열립니다.</p>
|
||||
</div>
|
||||
<a
|
||||
class="button primary small"
|
||||
:href="item.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
@click="receiver.opened(item)"
|
||||
>링크 열기 <ArrowUpRight :size="15"
|
||||
/></a>
|
||||
<button
|
||||
class="icon-button"
|
||||
aria-label="안내 닫기"
|
||||
@click="blockedLinks = blockedLinks.filter((i) => i.accessId !== item.accessId)"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</article>
|
||||
</section>
|
||||
<div v-if="incoming.length" class="receive-backdrop">
|
||||
<article
|
||||
v-for="r in incoming.slice(0, 1)"
|
||||
:key="r.id"
|
||||
class="incoming-card consent-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="전송 수락 요청"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="incoming-icon"><ArrowDownLeft :size="22" /></div>
|
||||
<div class="incoming-info">
|
||||
<strong
|
||||
>{{ r.senderDevice?.name }} <small>#{{ r.senderDevice?.code }}</small></strong
|
||||
>
|
||||
<p>
|
||||
{{ r.kind === 'file' ? '파일' : '링크' }} · {{ r.count }}개 ·
|
||||
{{ r.byteSize.toLocaleString() }}바이트
|
||||
<span>· {{ Math.max(0, Math.ceil((r.expiresAt - now) / 1000)) }}초 남음</span>
|
||||
</p>
|
||||
</div>
|
||||
<ul v-if="r.files" class="request-files">
|
||||
<li v-for="file in r.files" :key="file.id">
|
||||
{{ file.name }} · {{ size(file.size) }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="incoming-actions">
|
||||
<button
|
||||
class="button primary small"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'accept')"
|
||||
>
|
||||
<Check :size="16" /> 수락</button
|
||||
><button class="button small" :disabled="busy" @click="receiveAction(r, 'reject')">
|
||||
거절</button
|
||||
><button
|
||||
class="text-button muted"
|
||||
:disabled="busy"
|
||||
@click="receiveAction(r, 'block')"
|
||||
>
|
||||
차단
|
||||
</button>
|
||||
</div>
|
||||
<p class="request-note">기기 이름은 신원이 인증된 표시가 아닙니다.</p>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
<template v-if="tab === 'devices'">
|
||||
<section class="devices-stage">
|
||||
<div class="stage-heading"><h1>어디로 보낼까요?</h1></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
|
||||
class="peer-grid"
|
||||
:class="`device-count-${connectionCards.length + (canInvite ? 1 : 0)}`"
|
||||
v-if="connectionCards.length || canInvite"
|
||||
>
|
||||
<div
|
||||
v-for="card in connectionCards"
|
||||
:key="card.peer.id"
|
||||
class="peer-entry"
|
||||
data-device-menu
|
||||
>
|
||||
<button
|
||||
class="peer-card"
|
||||
:class="{ selected: selected?.id === card.peer.id, pending: card.pending }"
|
||||
:disabled="card.pending"
|
||||
@click="selectPeer(card.peer)"
|
||||
>
|
||||
<span class="peer-top"
|
||||
><component :is="iconFor(card.peer.name)" :size="30" /><span
|
||||
class="peer-live"
|
||||
:class="{ hasMenu: managedSpaces(card.peer.id).length }"
|
||||
><template v-if="card.pending">승인 대기</template
|
||||
><template v-else><i></i>온라인</template></span
|
||||
></span
|
||||
><strong>{{ card.peer.name }}</strong
|
||||
><span class="peer-code">#{{ card.peer.code }}</span
|
||||
><span class="peer-bottom"
|
||||
><span
|
||||
>{{ card.peer.sources.includes('network') ? '같은 네트워크' : '연결 공간' }}
|
||||
<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
|
||||
v-else
|
||||
:size="14" /></span
|
||||
></span></button
|
||||
><button
|
||||
v-if="managedSpaces(card.peer.id).length"
|
||||
:id="`device-menu-toggle-${card.peer.id}`"
|
||||
class="icon-button device-menu-toggle"
|
||||
:aria-label="`${card.peer.name} 기기 더보기`"
|
||||
aria-haspopup="menu"
|
||||
:aria-expanded="deviceMenu === card.peer.id"
|
||||
:aria-controls="`device-menu-${card.peer.id}`"
|
||||
@click="toggleDeviceMenu(card.peer.id)"
|
||||
>
|
||||
<EllipsisVertical :size="18" />
|
||||
</button>
|
||||
<div
|
||||
v-if="deviceMenu === card.peer.id"
|
||||
:id="`device-menu-${card.peer.id}`"
|
||||
class="device-menu"
|
||||
role="menu"
|
||||
:aria-label="`${card.peer.name} 기기 관리`"
|
||||
>
|
||||
<template v-for="space in managedSpaces(card.peer.id)" :key="space.id"
|
||||
><button
|
||||
v-if="
|
||||
space.members.some(
|
||||
(m) => m.device.id === card.peer.id && m.status === 'pending',
|
||||
)
|
||||
"
|
||||
role="menuitem"
|
||||
:disabled="busy"
|
||||
@click="spaceAction(space, 'approve', card.peer.id)"
|
||||
>
|
||||
참여 승인</button
|
||||
><button
|
||||
role="menuitem"
|
||||
class="kick-action"
|
||||
:disabled="busy"
|
||||
@click="spaceAction(space, 'remove', card.peer.id)"
|
||||
>
|
||||
내보내기<span v-if="managedSpaces(card.peer.id).length > 1">
|
||||
· 연결 공간 {{ spaces.findIndex((s) => s.id === space.id) + 1 }}</span
|
||||
>
|
||||
</button></template
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="canInvite"
|
||||
class="qr-connect-card"
|
||||
:aria-label="spaces.length ? 'QR 초대 보기' : 'QR · 코드로 연결'"
|
||||
@click="modal = 'connect'"
|
||||
>
|
||||
<QrCode :size="32" /><strong>{{
|
||||
spaces.length ? 'QR 초대 보기' : 'QR로 연결'
|
||||
}}</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>
|
||||
</section>
|
||||
<div v-if="spaces.length" class="connection-actions">
|
||||
<button
|
||||
class="text-button room-leave"
|
||||
:disabled="busy"
|
||||
@click="
|
||||
spaceAction(
|
||||
spaces[0],
|
||||
'remove',
|
||||
spaces[0].creatorDeviceId === device?.id ? undefined : device?.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
방 나가기
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template v-else
|
||||
><AccountPanel
|
||||
:initial-view="tab === 'received' ? 'inbox' : 'vault'"
|
||||
@devices="tab = 'devices'"
|
||||
@send-item="prepareVault"
|
||||
/></template>
|
||||
</template>
|
||||
</main>
|
||||
<footer class="site-footer device-footer">
|
||||
<div class="my-device-info" v-if="device">
|
||||
<button
|
||||
class="device-name footer-device-summary"
|
||||
aria-label="내 기기 설정"
|
||||
@click="openDeviceSettings"
|
||||
>
|
||||
<i class="device-state-dot" :class="{ enabled: device.discoverable }"></i
|
||||
><span class="footer-device-name">{{ device.name }}</span
|
||||
><small>· {{ device.discoverable ? '발견 허용' : '발견 꺼짐' }}</small
|
||||
><span class="footer-device-code">#{{ device.code }}</span
|
||||
><span v-if="isHost(device.id)" class="host-badge">HOST</span><Settings :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<span class="footer-brand">by noname.company</span>
|
||||
</footer>
|
||||
<Teleport to="body"
|
||||
><div
|
||||
v-if="sendOpen && selected"
|
||||
class="modal-backdrop send-backdrop"
|
||||
@click.self="closeSend"
|
||||
@keydown.esc.stop.prevent="closeSend"
|
||||
>
|
||||
<section
|
||||
ref="sendDialog"
|
||||
class="modal send-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="`${selected.name}에 보내기`"
|
||||
@keydown.tab="trapSendFocus"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ selected.name }}에 보내기</h2>
|
||||
<button
|
||||
class="icon-button"
|
||||
:disabled="busy"
|
||||
@click="closeSend"
|
||||
aria-label="전송 화면 닫기"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<SendComposer
|
||||
:target-name="selected?.name"
|
||||
:target-key="selected?.id"
|
||||
:clipboard-key="selectionEpoch"
|
||||
:read-clipboard-on-selection="true"
|
||||
:busy="busy"
|
||||
:transfer="currentTransfer"
|
||||
:progress="
|
||||
Object.entries(visibleUploadProgress)
|
||||
.filter(([key]) => key.startsWith(currentTransfer?.id + ':'))
|
||||
.map(([, value]) => value)
|
||||
"
|
||||
:disabled="!connected"
|
||||
@cancel="cancelCurrentTransfer"
|
||||
@files="submitFiles"
|
||||
@links="submitLinks"
|
||||
:transfer-source="forwardedItem"
|
||||
:initial-item="itemToSend"
|
||||
:vault-enabled="Boolean(account)"
|
||||
:vault-owner="account?.id"
|
||||
:vault-items="vaultItems"
|
||||
:vault-cursor="vaultCursor"
|
||||
@vault-open="loadVaultChoices"
|
||||
@vault-more="moreVault"
|
||||
@vault="forwardVault"
|
||||
>
|
||||
</SendComposer>
|
||||
</section></div
|
||||
></Teleport>
|
||||
<div v-if="notice" class="toast" role="status"><Check :size="17" />{{ notice }}</div>
|
||||
<div v-if="error" class="error-toast" role="alert">
|
||||
<span>{{ error }}</span
|
||||
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
|
||||
<X :size="17" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
<section
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="modal === 'connect' ? 'QR·코드 연결' : '설정'"
|
||||
>
|
||||
<header>
|
||||
<h2>{{ modal === 'connect' ? 'QR · 코드로 연결' : '내 기기 설정' }}</h2>
|
||||
<button class="icon-button" @click="modal = null" aria-label="대화상자 닫기">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<template v-if="modal === 'connect'"
|
||||
><p class="modal-subtitle">
|
||||
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
|
||||
</p>
|
||||
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
|
||||
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
|
||||
<p v-if="localOnlyQr" class="local-qr-note">
|
||||
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
|
||||
다른 브라우저에서 아래 코드를 입력해 주세요.
|
||||
</p>
|
||||
<div class="invite-code">{{ invite.code.slice(0, 4) }} {{ invite.code.slice(4) }}</div>
|
||||
<button class="text-button" @click="copyValue(invite.code)">
|
||||
<Copy :size="14" />코드 복사
|
||||
</button>
|
||||
<p>
|
||||
{{ Math.max(0, Math.ceil((invite.space.expiresAt - now) / 60000)) }}분 동안 · 최대
|
||||
10대
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!spaces.length" class="create-space">
|
||||
<label class="checkbox-label"
|
||||
><input type="checkbox" v-model="approvalRequired" />참여할 때 내 승인 받기</label
|
||||
><button class="button primary" @click="createSpace" :disabled="busy">
|
||||
<Plus :size="18" />연결 공간 만들기
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="!spaces.length" class="modal-divider"><span>또는 코드로 참여</span></div>
|
||||
<form v-if="!spaces.length" @submit.prevent="joinSpace" class="join-form">
|
||||
<input
|
||||
v-model="joinCode"
|
||||
aria-label="연결 코드"
|
||||
placeholder="8자리 연결 코드"
|
||||
maxlength="10"
|
||||
autocomplete="off"
|
||||
autocapitalize="characters"
|
||||
/><button
|
||||
class="button"
|
||||
type="submit"
|
||||
:disabled="busy || joinCode.replace(/[\s-]/g, '').length !== 8"
|
||||
>
|
||||
참여 <ArrowRight :size="16" />
|
||||
</button></form
|
||||
></template>
|
||||
<template v-else
|
||||
><form class="name-form device-settings-form" @submit.prevent="saveName">
|
||||
<label
|
||||
>기기 이름<input
|
||||
v-model="nameDraft"
|
||||
aria-label="기기 이름"
|
||||
maxlength="40"
|
||||
required /></label
|
||||
><button class="button primary" :disabled="busy" aria-label="이름 저장">
|
||||
이름 저장
|
||||
</button>
|
||||
</form>
|
||||
<label class="settings-row discovery-setting"
|
||||
><span>다른 기기에서 발견 허용</span
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="device?.discoverable"
|
||||
:disabled="busy"
|
||||
@change="act(() => patchDevice({ discoverable: $event.target.checked }))" /></label
|
||||
></template>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user