Files
hop.noname.company/apps/web/src/App.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>