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