Initial hop. implementation and UGREEN NAS deployment guide

This commit is contained in:
2026-10-12 13:37:50 +09:00
commit 439c2ae61b
93 changed files with 42434 additions and 0 deletions

View File

@@ -0,0 +1,753 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
ArrowRight,
Inbox,
Send,
LogOut,
Copy,
Download,
Trash2,
Settings,
Check,
X,
RotateCcw,
Plus,
Link,
File as FileIcon,
Pencil,
} from '@lucide/vue';
import SendComposer from './SendComposer.vue';
import { createRefreshQueue } from './refreshQueue';
import {
api,
account,
registrationOpen,
passwordMinLength,
refreshAccount,
reconnect,
showError,
refresh,
uploadFile,
clearAccountUploads,
cancelTransfer,
uploadProgress,
} from './client';
const props = defineProps({ initialView: { type: String, default: 'vault' } });
const emit = defineEmits(['devices', 'send-item']);
const view = ref(props.initialView === 'inbox' ? 'inbox' : 'vault');
const authMode = ref('login'),
email = ref(''),
password = ref(''),
confirmation = ref(''),
displayName = ref(''),
token = ref(''),
authMessage = ref('');
const busy = ref(false),
notice = ref(''),
items = ref([]),
nextCursor = ref(null),
search = ref(''),
selected = ref([]),
undo = ref([]),
now = ref(Date.now());
const compose = ref(false),
saveKind = ref(null),
note = ref(''),
saveTransfer = ref(null),
preferencesOpen = ref(false),
editing = ref(null),
noteDraft = ref('');
const storage = ref({ usedBytes: 0, limitBytes: 0 });
const securityAction = ref('none'),
currentPassword = ref(''),
newPassword = ref(''),
newConfirmation = ref(''),
newEmail = ref('');
let timer;
function message(text) {
notice.value = text;
setTimeout(() => {
if (notice.value === text) notice.value = '';
}, 4000);
}
async function act(fn) {
if (busy.value) return;
busy.value = true;
try {
await fn();
} catch (error) {
showError(error);
} finally {
busy.value = false;
}
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
}
function date(value) {
return new Intl.DateTimeFormat('ko-KR', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(value);
}
const groups = computed(() => {
const grouped = new Map();
for (const item of items.value) {
const day = new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(item.createdAt);
if (!grouped.has(day)) grouped.set(day, []);
grouped.get(day).push(item);
}
return [...grouped].map(([date, items]) => ({ date, items }));
});
function clearPrivate() {
items.value = [];
nextCursor.value = null;
selected.value = [];
search.value = '';
undo.value = [];
compose.value = false;
saveTransfer.value = null;
note.value = '';
editing.value = null;
preferencesOpen.value = false;
password.value = '';
confirmation.value = '';
currentPassword.value = '';
newPassword.value = '';
newConfirmation.value = '';
newEmail.value = '';
securityAction.value = 'none';
}
const refreshLists = createRefreshQueue(async () => {
if (!account.value?.verified) return;
const owner = account.value.id;
const query = new URLSearchParams();
if (search.value) query.set('search', search.value);
const [list, quota] = await Promise.all([
api(`/${view.value}?${query}`),
api('/account/storage'),
]);
if (account.value?.id !== owner) return;
items.value = list.items;
nextCursor.value = list.nextCursor;
storage.value = quota;
});
watch(view, () => {
selected.value = [];
search.value = '';
void refreshLists().catch(showError);
});
watch(
() => account.value?.id,
(id, previous) => {
if (id !== previous) clearPrivate();
},
);
function tokenFromUrl() {
const params = new URLSearchParams(location.hash.slice(1));
for (const [key, mode] of [
['verify', 'verify'],
['reset-password', 'reset'],
['verify-email-change', 'email-change'],
]) {
const value = params.get(key);
if (value) {
token.value = value;
authMode.value = mode;
history.replaceState(null, '', location.pathname);
break;
}
}
}
async function authSubmit() {
await act(async () => {
authMessage.value = '';
if (authMode.value === 'login') {
await api('/auth/login', 'POST', { email: email.value, password: password.value });
clearPrivate();
await refreshAccount();
reconnect();
await refreshLists();
return;
}
if (authMode.value === 'register') {
const result = await api('/auth/register', 'POST', {
email: email.value,
password: password.value,
passwordConfirmation: confirmation.value,
displayName: displayName.value,
});
authMessage.value = result.message;
password.value = '';
confirmation.value = '';
return;
}
if (authMode.value === 'forgot') {
const result = await api('/auth/forgot-password', 'POST', {
email: email.value,
});
authMessage.value = result.message;
return;
}
if (authMode.value === 'verify' || authMode.value === 'email-change') {
await api(
authMode.value === 'verify' ? '/auth/verify-email' : '/auth/verify-email-change',
'POST',
{ token: token.value },
);
token.value = '';
authMode.value = 'login';
authMessage.value = '이메일 인증을 완료했습니다. 로그인해 주세요.';
await refreshAccount();
reconnect();
await refreshLists();
return;
}
await api('/auth/reset-password', 'POST', {
token: token.value,
password: password.value,
passwordConfirmation: confirmation.value,
});
token.value = '';
authMode.value = 'login';
authMessage.value = '비밀번호를 변경했습니다. 다시 로그인해 주세요.';
password.value = '';
confirmation.value = '';
});
}
async function save(values) {
if (busy.value || saveTransfer.value) return;
busy.value = true;
let id;
try {
const result = await api(
'/vault',
'POST',
{
kind: values.files ? 'file' : 'url',
...(values.files
? {
files: values.files.map((file) => ({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
})),
}
: { urls: values.urls }),
note: note.value,
},
crypto.randomUUID(),
);
id = result.requestId;
if (values.files) {
saveTransfer.value = { id, kind: 'file', status: 'accepted' };
busy.value = false;
for (const [index, file] of values.files.entries()) {
if (saveTransfer.value?.id !== id) return;
await uploadFile(id, index, file, account.value.id);
}
if (saveTransfer.value?.id !== id) return;
}
await refreshLists();
compose.value = false;
note.value = '';
message('임시 보관함에 저장했습니다.');
} catch (error) {
if (!id || saveTransfer.value?.id === id || !values.files) showError(error);
} finally {
busy.value = false;
if (saveTransfer.value?.id === id) saveTransfer.value = null;
}
}
async function cancelSave() {
const id = saveTransfer.value?.id;
if (!id) return;
await act(async () => {
saveTransfer.value = null;
await cancelTransfer(id);
await refreshLists();
});
}
async function read(item, value = true) {
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
await refreshLists();
}
function recordLinkOpen(item) {
void read(item).catch(showError);
}
async function copy(item) {
await act(async () => {
await navigator.clipboard.writeText(item.url);
await read(item);
message('복사했습니다.');
});
}
async function download(item) {
const a = document.createElement('a');
a.href = `/api/access/${item.accessId}/download`;
a.download = item.file.name;
document.body.append(a);
a.click();
a.remove();
await read(item);
}
async function remove(ids) {
await act(async () => {
const operation = await api('/access/delete-batch', 'POST', { ids });
undo.value.push(operation);
selected.value = [];
await refreshLists();
});
}
async function restore(operation) {
await act(async () => {
await api(`/deletion-operations/${operation.operationId}/undo`, 'POST', {});
undo.value = undo.value.filter((v) => v !== operation);
await refreshLists();
});
}
async function loadMoreAccount() {
await act(async () => {
if (!nextCursor.value) return;
const result = await api(
`/${view.value}?cursor=${encodeURIComponent(nextCursor.value)}&search=${encodeURIComponent(search.value)}`,
);
items.value.push(...result.items);
nextCursor.value = result.nextCursor;
});
}
async function saveNote(item) {
await act(async () => {
await api(`/vault/${item.accessId}/note`, 'PATCH', { note: noteDraft.value });
editing.value = null;
await refreshLists();
});
}
async function securitySubmit() {
await act(async () => {
if (securityAction.value === 'password') {
await api('/auth/change-password', 'POST', {
currentPassword: currentPassword.value,
password: newPassword.value,
passwordConfirmation: newConfirmation.value,
});
clearPrivate();
await refreshAccount();
reconnect();
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
} else {
await api('/auth/change-email', 'POST', {
email: newEmail.value,
password: currentPassword.value,
});
currentPassword.value = '';
newEmail.value = '';
securityAction.value = 'none';
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
}
});
}
async function logout() {
await act(async () => {
clearAccountUploads(account.value?.id);
await api('/auth/logout', 'POST', {});
clearPrivate();
await refreshAccount();
reconnect();
await refresh();
});
}
const revoked = () => {
clearPrivate();
void refreshAccount().catch(showError);
};
const updated = () => void refreshLists().catch(showError);
onMounted(async () => {
tokenFromUrl();
timer = setInterval(() => {
now.value = Date.now();
}, 1000);
window.addEventListener('hop-account-updated', updated);
window.addEventListener('hop-account-revoked', revoked);
window.addEventListener('hashchange', tokenFromUrl);
await act(async () => {
await refreshAccount();
await refreshLists();
});
});
onUnmounted(() => {
clearInterval(timer);
window.removeEventListener('hop-account-updated', updated);
window.removeEventListener('hop-account-revoked', revoked);
window.removeEventListener('hashchange', tokenFromUrl);
clearPrivate();
});
async function resendVerification() {
await act(async () => {
const response = await api('/auth/resend-verification', 'POST', { email: account.value.email });
message(response.message);
});
}
</script>
<template>
<section v-if="!account || token" class="account-auth">
<div class="eyebrow">ACROSS YOUR DEVICES</div>
<h1>
{{
{
login: '다시 가볍게.',
register: '이메일로 시작하기.',
forgot: '비밀번호 찾기.',
verify: '이메일 인증.',
reset: '새 비밀번호.',
'email-change': '이메일 변경 인증.',
}[authMode]
}}
</h1>
<p>계정으로 저장하고, 다른 기기에서도 꺼내 쓰세요.</p>
<form @submit.prevent="authSubmit">
<label v-if="['login', 'register', 'forgot'].includes(authMode)"
>이메일<input
type="email"
v-model="email"
required
autocomplete="email"
placeholder="you@example.com" /></label
><label v-if="authMode === 'register'"
>표시 이름<input
v-model="displayName"
required
maxlength="40"
autocomplete="nickname" /></label
><label v-if="['login', 'register', 'reset'].includes(authMode)"
>비밀번호<input
type="password"
v-model="password"
required
:minlength="authMode === 'login' ? 1 : passwordMinLength"
maxlength="1024"
:autocomplete="authMode === 'login' ? 'current-password' : 'new-password'"
:placeholder="authMode === 'login' ? '비밀번호' : `${passwordMinLength}자 이상`" /></label
><label v-if="['register', 'reset'].includes(authMode)"
>비밀번호 확인<input
type="password"
v-model="confirmation"
required
:minlength="passwordMinLength"
maxlength="1024"
autocomplete="new-password"
/></label>
<p v-if="authMessage" class="auth-message" role="status">{{ authMessage }}</p>
<button class="button primary" type="submit" :disabled="busy">
{{
{
login: '로그인',
register: '가입하고 인증 메일 받기',
forgot: '안내 메일 받기',
verify: '이메일 인증 완료하기',
reset: '비밀번호 변경하기',
'email-change': '이메일 변경 확정하기',
}[authMode]
}}<ArrowRight :size="17" />
</button>
</form>
<div class="auth-links">
<button v-if="authMode === 'login'" class="text-button" @click="authMode = 'forgot'">
비밀번호 찾기</button
><button
v-if="authMode === 'login' && registrationOpen"
class="text-button"
@click="authMode = 'register'"
>
이메일로 가입</button
><button
v-if="authMode !== 'login'"
class="text-button"
@click="
authMode = 'login';
token = '';
"
>
로그인으로 돌아가기
</button>
</div>
<p v-if="!registrationOpen && authMode === 'login'" class="muted">
현재 공개 가입은 닫혀 있습니다.
</p>
<button class="text-button" @click="emit('devices')">
가입 없이 기기로 보내기 <ArrowUpRight :size="15" />
</button>
</section>
<section v-else-if="!account.verified" class="account-auth">
<h1>이메일을 확인해 주세요<span>.</span></h1>
<p>{{ account.email }} 인증 후 이메일 전송과 계정 보관함을 사용할 수 있어요.</p>
<button class="button" @click="resendVerification">인증 메일 다시 받기</button
><button class="text-button" @click="logout">로그아웃</button>
</section>
<section v-else class="account-workspace compact-workspace">
<div class="account-heading compact-heading">
<div>
<h1>보관함</h1>
<p>
{{
view === 'inbox'
? '로그인 중 받은 링크와 파일을 날짜별로 모았어요.'
: '나중에 보내거나 꺼내 쓸 링크와 파일을 담아두세요.'
}}
· 최대 7일
</p>
</div>
<div>
<button class="icon-button" @click="preferencesOpen = true" aria-label="계정 설정">
<Settings :size="18" /></button
><button class="icon-button" @click="logout" aria-label="로그아웃">
<LogOut :size="18" /></button
><button
v-if="view === 'vault'"
class="button primary"
@click="
compose = true;
saveKind = null;
note = '';
"
>
<Plus :size="17" />보관함에 담기
</button>
</div>
</div>
<div class="account-tabs" role="tablist" aria-label="보관함 목록">
<button
role="tab"
:aria-selected="view === 'vault'"
:class="{ active: view === 'vault' }"
@click="view = 'vault'"
>
임시 저장</button
><button
role="tab"
:aria-selected="view === 'inbox'"
:class="{ active: view === 'inbox' }"
@click="view = 'inbox'"
>
받은 항목
</button>
</div>
<form class="compact-search" @submit.prevent="act(refreshLists)">
<input
type="search"
v-model="search"
placeholder="링크, 파일, 메모 검색"
aria-label="계정 자료 검색"
/><button class="button small">검색</button>
</form>
<button v-if="selected.length" class="button bulk-delete" @click="remove(selected)">
<Trash2 :size="16" />선택 삭제
</button>
<section v-for="group in groups" :key="group.date" class="dated-items">
<h2>{{ group.date }} {{ view === 'inbox' ? '받은 항목' : '저장한 항목' }}</h2>
<article v-for="item in group.items" :key="item.accessId" class="item-card compact-item">
<div class="item-heading">
<input
type="checkbox"
v-model="selected"
:value="item.accessId"
:aria-label="`${item.file?.name ?? item.url} 선택`"
/><span>{{ item.file ? '파일' : '링크' }}</span
><span v-if="view === 'inbox'">{{ item.peerName }}</span
><time>{{ date(item.createdAt) }}</time>
</div>
<strong v-if="item.note" class="item-note">{{ item.note }}</strong>
<div v-if="item.file" class="file-item-body">
<FileIcon :size="24" />
<div>
<strong>{{ item.file.name }}</strong
><span>{{ size(item.file.size) }}</span>
</div>
</div>
<p v-else class="item-body">{{ item.url }}</p>
<form v-if="editing === item.accessId" class="note-editor" @submit.prevent="saveNote(item)">
<input v-model="noteDraft" maxlength="200" aria-label="한 줄 메모 수정" /><button
class="button small"
:disabled="busy"
>
저장</button
><button type="button" class="text-button" @click="editing = null">취소</button>
</form>
<div class="item-footer">
<span>{{ date(item.expiresAt) }} 삭제</span>
<div>
<button class="button small" @click="emit('send-item', item)">
<Send :size="14" />기기로 보내기</button
><button v-if="item.file" class="button small" @click="act(() => download(item))">
<Download :size="14" />다운로드</button
><template v-else
><button class="button small" @click="copy(item)"><Copy :size="14" />복사</button
><a
class="button small"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
@click="recordLinkOpen(item)"
><ArrowUpRight :size="14" />열기</a
></template
><button
v-if="view === 'vault'"
class="icon-button"
aria-label="메모 수정"
@click="
editing = item.accessId;
noteDraft = item.note ?? '';
"
>
<Pencil :size="15" /></button
><button
class="icon-button"
aria-label="계정 목록에서 삭제"
@click="remove([item.accessId])"
>
<Trash2 :size="15" />
</button>
</div>
</div>
</article>
</section>
<div v-if="!items.length" class="list-empty">
<Inbox :size="28" />
<h3>{{ view === 'inbox' ? '아직 받은 항목이 없어요.' : '임시 보관함이 비어 있어요.' }}</h3>
<p>
{{
view === 'inbox'
? '로그인한 상태로 기기에서 받으면 여기에 남습니다.'
: '링크나 파일에 한 줄 메모를 더해 담아두세요.'
}}
</p>
</div>
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
<p class="scope-note">
저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }}
</p>
</section>
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
<section
class="modal vault-save-dialog"
role="dialog"
aria-modal="true"
aria-label="보관함에 담기"
>
<header>
<h2>보관함에 담기</h2>
<button
class="icon-button"
:disabled="Boolean(saveTransfer) || busy"
@click="compose = false"
aria-label="보관함 저장 닫기"
>
<X :size="20" />
</button>
</header>
<div v-if="!saveKind" class="save-kind-choices">
<button class="button" @click="saveKind = 'url'"><Link :size="22" />링크 저장</button
><button class="button" @click="saveKind = 'file'"><FileIcon :size="22" />파일 저장</button>
</div>
<template v-else>
<label class="vault-note-label"
>제목 / 한 줄 메모 (선택)<input
v-model="note"
maxlength="200"
aria-label="한 줄 메모"
placeholder="예: 나중에 읽을 글"
:disabled="Boolean(saveTransfer) || busy" /></label
><SendComposer
target-name="임시 보관함"
target-key="vault-save"
:save-mode="true"
:content-mode="saveKind"
:read-clipboard-on-selection="true"
:busy="busy"
:transfer="saveTransfer"
:progress="
Object.entries(uploadProgress)
.filter(([key]) => key.startsWith(saveTransfer?.id + ':'))
.map(([, value]) => value)
"
@links="save({ urls: $event })"
@files="save({ files: $event })"
@cancel="cancelSave"
/>
<button
class="text-button"
:disabled="Boolean(saveTransfer) || busy"
@click="saveKind = null"
>
다른 종류 선택
</button></template
>
</section>
</div>
<div v-if="preferencesOpen" class="modal-backdrop" @click.self="preferencesOpen = false">
<section class="modal" role="dialog" aria-modal="true" aria-label="계정 설정">
<header>
<h2>계정 설정</h2>
<button class="icon-button" @click="preferencesOpen = false" aria-label="계정 설정 닫기">
<X :size="20" />
</button>
</header>
<p>{{ account.email }}</p>
<select v-model="securityAction" aria-label="계정 정보 변경">
<option value="none">변경할 항목 선택</option>
<option value="password">비밀번호 변경</option>
<option value="email">이메일 변경</option>
</select>
<form v-if="securityAction !== 'none'" @submit.prevent="securitySubmit">
<label
>현재 비밀번호<input
type="password"
v-model="currentPassword"
required
autocomplete="current-password" /></label
><template v-if="securityAction === 'password'"
><label
>새 비밀번호<input
type="password"
v-model="newPassword"
:minlength="passwordMinLength"
required
autocomplete="new-password" /></label
><label
>새 비밀번호 확인<input
type="password"
v-model="newConfirmation"
:minlength="passwordMinLength"
required
autocomplete="new-password" /></label></template
><label v-else>새 이메일<input type="email" v-model="newEmail" required /></label
><button class="button primary" :disabled="busy">변경</button>
</form>
</section>
</div>
<div v-if="notice" class="toast" role="status"><Check :size="16" />{{ notice }}</div>
<div class="undo-stack">
<div
v-for="operation in undo.filter((v) => v.undoUntil > now)"
:key="operation.operationId"
class="undo-toast"
>
<span>삭제됨</span
><button @click="restore(operation)"><RotateCcw :size="16" />실행 취소</button>
</div>
</div>
</template>

942
apps/web/src/App.vue Normal file
View 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>

View File

@@ -0,0 +1,426 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue';
import {
FileUp,
File as FileIcon,
Link,
Send,
X,
ClipboardPaste,
Inbox,
Check,
ArrowUpRight,
} from '@lucide/vue';
import { parseUrls } from '@hop/shared';
const props = defineProps({
targetName: String,
targetKey: String,
clipboardKey: Number,
busy: Boolean,
disabled: Boolean,
transfer: Object,
progress: Array,
readClipboardOnSelection: Boolean,
saveMode: Boolean,
contentMode: String,
transferSource: Object,
vaultEnabled: Boolean,
vaultItems: Array,
vaultCursor: String,
initialItem: Object,
vaultOwner: String,
});
const emit = defineEmits(['files', 'links', 'vault', 'vault-open', 'vault-more', 'cancel']);
const source = ref('file'),
files = ref([]),
urlInput = ref(''),
clipboardManaged = ref(false),
reading = ref(false),
feedback = ref(''),
selectedVault = ref(null),
fileInput = ref(null);
let generation = 0;
const parsed = computed(() => parseUrls(urlInput.value));
const validUrl = computed(
() =>
parsed.value.urls.length > 0 && parsed.value.urls.length <= 20 && !parsed.value.invalid.length,
);
const mutable = computed(() => !props.busy && !props.disabled && !props.transfer);
const ready = computed(() =>
source.value === 'url'
? validUrl.value && !reading.value
: source.value === 'file'
? files.value.length > 0
: Boolean(selectedVault.value),
);
async function loadClipboard(force = false) {
if (
!props.targetKey ||
!props.readClipboardOnSelection ||
props.contentMode === 'file' ||
!mutable.value ||
document.visibilityState === 'hidden'
)
return;
if (
!force &&
(source.value === 'vault' || files.value.length || (urlInput.value && !clipboardManaged.value))
)
return;
const current = ++generation;
reading.value = true;
feedback.value = '';
try {
if (!navigator.clipboard?.readText) throw Error('Unavailable');
const result = parseUrls(await navigator.clipboard.readText());
if (current !== generation) return;
if (result.urls.length && result.urls.length <= 20 && !result.invalid.length) {
source.value = 'url';
files.value = [];
selectedVault.value = null;
urlInput.value = result.urls.join('\n');
clipboardManaged.value = true;
} else if (force || clipboardManaged.value) {
urlInput.value = '';
clipboardManaged.value = false;
if (source.value === 'url') feedback.value = '클립보드에 링크가 없어요. 직접 입력해 주세요.';
}
} catch {
if (current === generation && source.value === 'url')
feedback.value = '링크를 직접 붙여넣어 주세요.';
} finally {
if (current === generation) reading.value = false;
}
}
watch(
() => [props.targetKey, props.contentMode, props.clipboardKey],
(current, previous) => {
if (current[0] !== previous?.[0] || current[1] !== previous?.[1]) {
generation++;
reading.value = false;
files.value = [];
urlInput.value = '';
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
source.value = props.contentMode === 'url' ? 'url' : 'file';
}
if (props.initialItem) {
source.value = 'vault';
selectedVault.value = props.initialItem;
} else void loadClipboard(true);
},
{ immediate: true },
);
watch(
() => props.transferSource,
(item) => {
if (item && props.transfer) {
source.value = 'vault';
selectedVault.value = item;
files.value = [];
urlInput.value = '';
}
},
);
watch(
() => props.transfer,
(transfer) => {
if (transfer && props.transferSource) {
source.value = 'vault';
selectedVault.value = props.transferSource;
}
},
);
watch(
() => props.vaultEnabled,
(enabled) => {
if (!enabled && source.value === 'vault') {
source.value = 'file';
selectedVault.value = null;
}
},
);
watch(
() => props.vaultItems,
(items) => {
if (
selectedVault.value &&
!props.initialItem &&
!props.transfer &&
!items?.some((item) => item.accessId === selectedVault.value.accessId)
)
selectedVault.value = null;
},
);
watch(
() => props.vaultOwner,
(owner, previous) => {
if (owner !== previous && source.value === 'vault') {
selectedVault.value = null;
source.value = 'file';
}
},
);
watch(
() => props.disabled,
(disabled) => {
if (!disabled) void loadClipboard();
},
);
const focus = () => void loadClipboard();
const visible = () => {
if (document.visibilityState === 'visible') focus();
};
window.addEventListener('focus', focus);
document.addEventListener('visibilitychange', visible);
onUnmounted(() => {
generation++;
window.removeEventListener('focus', focus);
document.removeEventListener('visibilitychange', visible);
});
function editUrl() {
generation++;
reading.value = false;
clipboardManaged.value = false;
feedback.value = '';
}
function choose(list) {
if (!mutable.value || props.contentMode === 'url' || !list?.length) return;
generation++;
reading.value = false;
files.value = Array.from(list);
source.value = 'file';
urlInput.value = '';
selectedVault.value = null;
clipboardManaged.value = false;
feedback.value = '';
}
function pickFiles() {
if (mutable.value && fileInput.value) {
fileInput.value.value = '';
fileInput.value.click();
}
}
function toLink() {
if (!mutable.value) return;
generation++;
reading.value = false;
files.value = [];
selectedVault.value = null;
urlInput.value = '';
source.value = 'url';
clipboardManaged.value = false;
void loadClipboard(true);
}
function toVault() {
if (!mutable.value || !props.vaultEnabled) return;
generation++;
reading.value = false;
files.value = [];
urlInput.value = '';
selectedVault.value = null;
source.value = 'vault';
emit('vault-open');
}
function selectVault(item) {
if (mutable.value) selectedVault.value = item;
}
function pasted(event) {
if (!mutable.value) return;
if (event.clipboardData?.files.length) {
event.preventDefault();
choose(event.clipboardData.files);
return;
}
if (props.contentMode === 'file') return;
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
if (result.urls.length && !result.invalid.length) {
event.preventDefault();
editUrl();
files.value = [];
selectedVault.value = null;
source.value = 'url';
urlInput.value = result.urls.join('\n');
}
}
function submit() {
if (props.transfer) {
emit('cancel');
return;
}
if (!ready.value || !mutable.value) return;
if (source.value === 'url') emit('links', parsed.value.urls);
else if (source.value === 'file') emit('files', [...files.value]);
else emit('vault', selectedVault.value);
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
}
</script>
<template>
<section
class="unified-composer single-composer"
@paste="pasted"
@dragover.prevent
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<input
v-if="contentMode !== 'url'"
ref="fileInput"
type="file"
multiple
class="sr-only"
aria-label="보낼 파일 선택"
:disabled="!mutable"
@change="choose($event.target.files)"
/>
<div
class="content-pane"
@dragover.prevent
@drop.stop.prevent="choose($event.dataTransfer?.files)"
>
<template v-if="source === 'url'">
<div class="content-heading">
<strong><Link :size="18" />보낼 링크</strong
><button
class="text-button clipboard-load"
:disabled="!mutable || reading"
@click="loadClipboard(true)"
>
<ClipboardPaste :size="15" />{{ reading ? '불러오는 중' : '클립보드 가져오기' }}
</button>
</div>
<textarea
v-model="urlInput"
aria-label="보낼 URL"
placeholder="https://example.com"
rows="3"
:disabled="!mutable"
@input="editUrl"
class="current-url"
/>
<p v-if="feedback" class="content-feedback" role="status">{{ feedback }}</p>
<p v-else-if="urlInput.trim() && !validUrl" class="content-feedback" role="status">
HTTP 또는 HTTPS 링크를 입력해 주세요.
</p>
</template>
<template v-else-if="source === 'file'">
<div v-if="!files.length" class="current-file-empty">
<FileUp :size="32" /><strong>보낼 파일을 선택하세요.</strong
><span>파일을 여기에 놓거나 클릭해 선택</span
><button class="button" :disabled="!mutable" @click="pickFiles">파일 선택</button>
</div>
<template v-else
><div class="content-heading">
<strong><FileIcon :size="18" />보낼 파일 {{ files.length }}개</strong
><button class="text-button" :disabled="!mutable" @click="pickFiles">다시 선택</button>
</div>
<ul class="selected-files">
<li v-for="(file, index) in files" :key="index">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><button
class="icon-button"
:disabled="!mutable"
:aria-label="`${file.name} 선택 해제`"
@click="files.splice(index, 1)"
>
<X :size="15" />
</button>
</li></ul
></template>
</template>
<template v-else>
<div class="content-heading">
<strong><Inbox :size="18" />보관함에서 가져오기</strong>
</div>
<div v-if="selectedVault" class="current-vault">
<FileIcon v-if="selectedVault.file" :size="28" /><Link v-else :size="28" /><strong>{{
selectedVault.note || selectedVault.file?.name || selectedVault.url
}}</strong
><small v-if="selectedVault.note">{{
selectedVault.file?.name || selectedVault.url
}}</small
><button
v-if="!transfer"
class="text-button"
:disabled="!mutable"
@click="selectedVault = null"
>
다른 자료 선택
</button>
</div>
<template v-else
><button
v-for="item in vaultItems"
:key="item.accessId"
class="vault-choice"
:class="{ chosen: selectedVault?.accessId === item.accessId }"
:aria-pressed="selectedVault?.accessId === item.accessId"
:disabled="!mutable"
@click="selectVault(item)"
>
<FileIcon v-if="item.file" :size="18" /><Link v-else :size="18" /><span
><strong>{{ item.note || item.file?.name || item.url }}</strong
><small
>{{ item.role === 'self' ? '임시 저장' : '받은 항목' }} ·
{{ item.file ? '파일' : '링크' }}</small
></span
><Check v-if="selectedVault?.accessId === item.accessId" :size="16" />
</button>
<p v-if="!vaultItems?.length" class="content-feedback">
{{ busy ? '보관함을 불러오고 있어요.' : '보관함이 비어 있어요.' }}
</p>
<button
v-if="vaultCursor"
class="text-button"
:disabled="busy"
@click="emit('vault-more')"
>
더 보기
</button></template
>
</template>
<p v-if="transfer" class="composer-transfer-state" role="status">
{{ transfer.status === 'pending' ? '상대 기기의 수락을 기다립니다.' : '전송 중입니다.' }}
</p>
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
<span
>{{ upload.name }} ·
{{ upload.status === 'failed' ? upload.message : `${upload.progress}%` }}</span
><progress :value="upload.progress" max="100" />
</div>
</div>
<button
class="button primary content-submit"
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
:disabled="busy || disabled || (!transfer && !ready)"
@click="submit"
>
{{ transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
}}<X v-if="transfer" :size="16" /><ArrowUpRight v-else :size="16" />
</button>
<div v-if="!contentMode" class="content-switches">
<button v-if="source !== 'file'" class="text-button" :disabled="!mutable" @click="pickFiles">
<FileIcon :size="14" />대신 파일 선택하기</button
><button v-if="source !== 'url'" class="text-button" :disabled="!mutable" @click="toLink">
<Link :size="14" />대신 링크 붙여넣기</button
><button
v-if="vaultEnabled && source !== 'vault'"
class="text-button"
:disabled="!mutable"
@click="toVault"
>
<Inbox :size="14" />보관함에서 가져오기
</button>
</div>
<p class="consent-note">
{{ saveMode ? '저장한 자료는 최대 7일 후 삭제됩니다.' : '상대가 수락한 후에만 전달됩니다.' }}
</p>
</section>
</template>

409
apps/web/src/client.js Normal file
View File

@@ -0,0 +1,409 @@
import { createRefreshQueue } from './refreshQueue';
import { ref } from 'vue';
import { io } from 'socket.io-client';
export const account = ref(null),
registrationOpen = ref(false),
passwordMinLength = ref(12);
export async function refreshAccount() {
const result = await api('/auth/me');
account.value = result.account;
registrationOpen.value = result.registrationOpen;
passwordMinLength.value = result.passwordMinLength ?? 12;
}
export function reconnect() {
connect();
}
export const device = ref(null),
csrfToken = ref(''),
sessionExpiresAt = ref(0),
connected = ref(false),
peers = ref([]),
requests = ref([]),
items = ref([]),
spaces = ref([]),
discoveryEnabled = ref(false),
error = ref('');
export const nextCursor = ref(null);
let socket;
let reauthorizing;
const drafts = new Map();
const completing = new Set();
export async function api(path, method = 'GET', body, key) {
const response = await fetch(`/api${path}`, {
method,
credentials: 'same-origin',
headers: {
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(method !== 'GET' ? { 'X-CSRF-Token': csrfToken.value } : {}),
...(key ? { 'Idempotency-Key': key } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
return payload;
}
export async function bootstrap() {
const init = async () => {
const s = await api('/guest/session', 'POST', {});
device.value = s.device;
csrfToken.value = s.csrfToken;
sessionExpiresAt.value = s.expiresAt;
};
if (navigator.locks) await navigator.locks.request('hop-session-bootstrap', init);
else await init();
await refreshAccount();
connect();
await refresh();
startReconciliation();
}
function connect() {
socket?.disconnect();
socket = io({ auth: { csrfToken: csrfToken.value }, reconnection: true });
socket.on('connect', () => {
connected.value = true;
void refresh().catch(showError);
});
socket.on('disconnect', () => {
connected.value = false;
});
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
socket.on('account:revoked', () => {
clearAccountUploads(account.value?.id);
account.value = null;
window.dispatchEvent(new Event('hop-account-revoked'));
});
socket.on('connect_error', (err) => {
connected.value = false;
if (err.message !== 'SESSION_EXPIRED' || reauthorizing) return;
reauthorizing = (async () => {
const session = await api('/guest/session', 'POST', {});
device.value = session.device;
csrfToken.value = session.csrfToken;
sessionExpiresAt.value = session.expiresAt;
if (socket) {
socket.auth = { csrfToken: csrfToken.value };
socket.connect();
}
})()
.catch(showError)
.finally(() => {
reauthorizing = undefined;
});
});
for (const event of [
'device:online',
'device:offline',
'device:updated',
'space:updated',
'request:created',
'request:updated',
'transfer:updated',
'access:updated',
])
socket.on(event, () => void refresh().catch(showError));
}
export function showError(err) {
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
}
export const refresh = createRefreshQueue(async () => {
const snapshot = await api('/device/snapshot');
device.value = snapshot.device;
peers.value = snapshot.devices;
discoveryEnabled.value = snapshot.sameNetworkDiscoveryEnabled;
requests.value = snapshot.requests;
items.value = snapshot.items;
nextCursor.value = snapshot.nextCursor;
spaces.value = snapshot.spaces;
for (const req of requests.value) {
if (
req.kind === 'file' &&
req.status === 'accepted' &&
req.transferStatus === 'waiting' &&
fileDrafts.has(req.id) &&
!Object.keys(uploadProgress.value).some(
(k) => k.startsWith(req.id) && uploadProgress.value[k]?.status === 'failed',
)
)
void deliverFiles(req.id);
if (req.status === 'accepted' && req.transferStatus === 'waiting' && drafts.has(req.id))
void deliver(req.id);
if (
['rejected', 'expired', 'cancelled'].includes(req.status) ||
req.transferStatus === 'stored'
)
drafts.delete(req.id);
}
});
export async function send(targetDeviceId, kind, values) {
const content = values.urls.join('\n');
const { requestId } = await api(
'/transfer-requests',
'POST',
{
targetDeviceId,
kind,
byteSize: new TextEncoder().encode(content).length,
count: values.urls.length,
},
crypto.randomUUID(),
);
drafts.set(requestId, { ...values, key: crypto.randomUUID() });
await refresh();
return requestId;
}
async function deliver(id) {
if (completing.has(id)) return;
const draft = drafts.get(id);
if (!draft) return;
completing.add(id);
try {
await api(`/transfers/${id}/complete`, 'POST', { urls: draft.urls }, draft.key);
drafts.delete(id);
await refresh();
return true;
} catch (err) {
showError(err);
return false;
} finally {
completing.delete(id);
}
}
export async function retryDelivery(id, values) {
drafts.set(id, { ...values, key: crypto.randomUUID() });
if (!(await deliver(id))) throw new Error(error.value || '전송을 완료하지 못했습니다.');
}
export async function requestAction(id, action) {
await api(
`/transfer-requests/${id}/${action}`,
'POST',
{},
action === 'accept' ? crypto.randomUUID() : undefined,
);
await refresh();
}
export async function patchDevice(input) {
await api('/device/me', 'PATCH', input);
await refresh();
}
export async function markRead(item, value = true) {
await api(`/access/${item.accessId}/read`, 'PATCH', { read: value });
await refresh();
}
export async function loadMore() {
if (!nextCursor.value) return;
const result = await api(`/device/items?cursor=${encodeURIComponent(nextCursor.value)}`);
items.value.push(...result.items);
nextCursor.value = result.nextCursor;
}
export async function reset() {
const result = await api('/device/reset', 'POST', {});
drafts.clear();
fileDrafts.clear();
for (const [key, xhr] of uploadRequests) if (!uploadProgress.value[key]?.accountId) xhr.abort();
uploadProgress.value = Object.fromEntries(
Object.entries(uploadProgress.value).filter(([, p]) => p.accountId),
);
device.value = result.device;
csrfToken.value = result.csrfToken;
sessionExpiresAt.value = result.expiresAt;
connect();
await refresh();
}
export const uploadProgress = ref({});
const fileDrafts = new Map();
const fileSending = new Set();
const uploadRequests = new Map();
const cancelledTransfers = new Set();
export async function sendFiles(targetDeviceId, files) {
const input = {
targetDeviceId,
kind: 'file',
byteSize: files.reduce((n, f) => n + f.size, 0),
count: files.length,
files: files.map((f) => ({
name: f.name,
size: f.size,
type: f.type || 'application/octet-stream',
})),
};
const { requestId } = await api('/transfer-requests', 'POST', input, crypto.randomUUID());
fileDrafts.set(requestId, files);
await refresh();
return requestId;
}
export function uploadFile(id, index, file, accountId) {
const progressKey = `${id}:${index}`;
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'uploading',
accountId,
};
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
uploadRequests.set(progressKey, xhr);
xhr.open('POST', `/api/uploads?requestId=${id}&fileIndex=${index}`);
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
xhr.setRequestHeader('Idempotency-Key', crypto.randomUUID());
xhr.upload.onprogress = (e) => {
if (e.lengthComputable)
uploadProgress.value[progressKey].progress = Math.min(
100,
Math.round((e.loaded / e.total) * 100),
);
};
const finish = () => uploadRequests.delete(progressKey);
xhr.onload = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
if (xhr.status >= 200 && xhr.status < 300) {
uploadProgress.value[progressKey] = { name: file.name, progress: 100, status: 'stored' };
resolve();
} else {
let message = '업로드하지 못했습니다.';
try {
message = JSON.parse(xhr.responseText).message;
} catch {}
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message,
};
reject(Error(message));
}
};
xhr.onerror = xhr.onabort = () => {
finish();
if (accountId && account.value?.id !== accountId) {
delete uploadProgress.value[progressKey];
reject(Error('계정 세션이 종료되었습니다.'));
return;
}
uploadProgress.value[progressKey] = {
name: file.name,
progress: 0,
status: 'failed',
accountId,
message: '업로드가 중단되었습니다.',
};
reject(Error('업로드가 중단되었습니다.'));
};
const form = new FormData();
form.append('file', file);
xhr.send(form);
});
}
async function deliverFiles(id) {
if (fileSending.has(id)) return;
const files = fileDrafts.get(id),
request = requests.value.find((r) => r.id === id);
if (!files || !request) return;
fileSending.add(id);
try {
for (const [index, file] of files.entries()) {
if (cancelledTransfers.has(id)) break;
if (request.files?.[index]?.status === 'stored') continue;
try {
await uploadFile(id, index, file);
} catch (err) {
if (!cancelledTransfers.has(id)) showError(err);
}
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
}
await refresh();
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
} finally {
fileSending.delete(id);
}
}
export async function retryFiles(id, files) {
const r = requests.value.find((r) => r.id === id);
if (
!r?.files ||
r.files.length !== files.length ||
files.some((f, i) => f.name !== r.files[i].name || f.size !== r.files[i].size)
)
throw Error('수락한 파일 목록과 이름·크기가 같은 파일을 선택해 주세요.');
fileDrafts.set(id, files);
await deliverFiles(id);
}
export function cancelUpload(key) {
uploadRequests.get(key)?.abort();
}
export async function cancelTransfer(id) {
// Cancel on the server before aborting uploads so a failed upload cannot restart delivery.
cancelledTransfers.add(id);
try {
await api(`/transfer-requests/${id}/cancel`, 'POST', {});
} catch (error) {
cancelledTransfers.delete(id);
throw error;
}
drafts.delete(id);
fileDrafts.delete(id);
for (const key of uploadRequests.keys()) {
if (key.startsWith(`${id}:`)) uploadRequests.get(key)?.abort();
}
for (const key of Object.keys(uploadProgress.value)) {
if (key.startsWith(`${id}:`)) delete uploadProgress.value[key];
}
await refresh();
}
export async function deleteItems(ids) {
const original = [...items.value];
items.value = items.value.filter((i) => !ids.includes(i.accessId));
try {
const result = await api('/access/delete-batch', 'POST', { ids });
await refresh();
return result;
} catch (err) {
items.value = original;
throw err;
}
}
export async function undoDelete(id) {
await api(`/deletion-operations/${id}/undo`, 'POST', {});
await refresh();
}
export async function download(item) {
const a = document.createElement('a');
a.href = `/api/access/${item.accessId}/download`;
a.download = item.file?.name ?? '';
document.body.append(a);
a.click();
a.remove();
await markRead(item);
}
export function clearAccountUploads(accountId) {
for (const [key, progress] of Object.entries(uploadProgress.value)) {
if (!progress.accountId || (accountId && progress.accountId !== accountId)) continue;
uploadRequests.get(key)?.abort();
delete uploadProgress.value[key];
}
}
let reconciliation;
function startReconciliation() {
if (reconciliation) return;
reconciliation = setInterval(() => {
if (
document.visibilityState !== 'visible' ||
!requests.value.some(
(r) => r.status === 'pending' || (r.status === 'accepted' && r.transferStatus !== 'stored'),
)
)
return;
void api('/transfer-requests')
.then((result) => {
const summary = (rs) => JSON.stringify(rs.map((r) => [r.id, r.status, r.transferStatus]));
if (summary(result.requests) !== summary(requests.value)) return refresh();
})
.catch(showError);
}, 5000);
}

View File

@@ -0,0 +1,83 @@
import { ref } from 'vue';
// Reserve tabs during the acceptance click; later socket updates have no user activation.
// Only the tab that accepts a request performs its delivery actions.
export function createDeliveryReceiver({ download, markRead, onError }) {
const pending = new Map();
const blockedLinks = ref([]);
function reserve(request) {
if (pending.has(request.id)) return;
const tabs = [];
if (request.kind === 'url') {
for (let i = 0; i < request.count; i++) {
let tab = null;
try {
tab = window.open('about:blank', '_blank');
} catch {
/* Browser restriction. */
}
if (tab) {
tab.opener = null;
tab.document.title = 'hop. 링크를 받는 중';
tab.document.body.textContent = '수락한 링크를 받고 있어요. 잠시만 기다려 주세요.';
}
tabs.push(tab);
}
}
pending.set(request.id, { tabs, handled: new Set() });
}
function abort(id) {
const delivery = pending.get(id);
for (const tab of delivery?.tabs ?? []) tab?.close();
pending.delete(id);
}
function reconcile(requests, items) {
for (const [id, delivery] of pending) {
const request = requests.find((r) => r.id === id);
if (!request) continue;
if (
['rejected', 'cancelled', 'expired', 'blocked'].includes(request.status) ||
(request.status === 'pending' && request.expiresAt <= Date.now())
) {
abort(id);
continue;
}
for (const item of items.filter((i) => i.batchId === request.batchId)) {
if (delivery.handled.has(item.accessId)) continue;
delivery.handled.add(item.accessId);
if (item.file) {
void download(item).catch(onError);
} else if (item.url) {
const tab = delivery.tabs.shift();
if (tab && !tab.closed) {
try {
tab.location.replace(item.url);
void markRead(item).catch(onError);
} catch {
tab.close();
blockedLinks.value.push(item);
}
} else blockedLinks.value.push(item);
}
}
if (request.transferStatus === 'stored' && delivery.handled.size >= request.count) abort(id);
}
}
function opened(item) {
blockedLinks.value = blockedLinks.value.filter((i) => i.accessId !== item.accessId);
void markRead(item).catch(onError);
}
function dispose() {
for (const id of pending.keys()) abort(id);
blockedLinks.value = [];
}
return {
reserve,
abort,
reconcile,
blockedLinks,
opened,
dispose,
hasPending: () => pending.size > 0,
};
}

6
apps/web/src/main.js Normal file
View File

@@ -0,0 +1,6 @@
import { initialTheme, applyTheme } from './theme';
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
applyTheme(initialTheme());
createApp(App).mount('#app');

View File

@@ -0,0 +1,19 @@
/** Keep invalidations arriving during an in-flight snapshot. Returning the existing promise
* alone discards later changes and can strand an accepted transfer in its pending snapshot. */
export function createRefreshQueue(load) {
let requested = false;
let running;
return function refresh() {
requested = true;
if (running) return running;
running = (async () => {
do {
requested = false;
await load();
} while (requested);
})().finally(() => {
running = undefined;
});
return running;
};
}

3116
apps/web/src/style.css Normal file

File diff suppressed because it is too large Load Diff

23
apps/web/src/theme.js Normal file
View File

@@ -0,0 +1,23 @@
export function initialTheme() {
const saved = localStorage.getItem('hop-theme');
const current = localStorage.getItem('hop-theme-version') === '2';
const theme =
saved === 'light' || saved === 'dark'
? saved
: saved === 'system' && current
? 'system'
: 'dark';
localStorage.setItem('hop-theme', theme);
localStorage.setItem('hop-theme-version', '2');
return theme;
}
export function applyTheme(theme) {
const dark =
theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', dark ? '#000000' : '#fafafa');
localStorage.setItem('hop-theme', theme);
localStorage.setItem('hop-theme-version', '2');
}