943 lines
32 KiB
Vue
943 lines
32 KiB
Vue
<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>
|