Add resumable uploads, proxy discovery, PWA install and 2 GiB personal vaults
This commit is contained in:
@@ -6,6 +6,10 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>hop. — 기기 사이를 가볍게.</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="hop." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/icon-maskable.png
Normal file
BIN
apps/web/public/icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
17
apps/web/public/manifest.webmanifest
Normal file
17
apps/web/public/manifest.webmanifest
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "hop. — 기기 사이를 가볍게.",
|
||||
"short_name": "hop.",
|
||||
"description": "가입 없이 주변 기기에 파일과 링크를 보내세요.",
|
||||
"lang": "ko",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#000000",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
18
apps/web/public/sw.js
Normal file
18
apps/web/public/sw.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// Network-only: no account records, files, tokens or API responses enter offline caches.
|
||||
self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting()));
|
||||
self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET' || event.request.mode !== 'navigate') return;
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(
|
||||
() =>
|
||||
new Response(
|
||||
'<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>hop.</title><body style="background:#000;color:#f5f5f5;font:16px system-ui;text-align:center;padding:25vh 24px"><h1>hop.</h1><p>인터넷 연결이 필요해요.</p><p>연결한 뒤 다시 열어주세요.</p><a href="/" style="color:inherit">다시 시도</a></body></html>',
|
||||
{
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' },
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -84,11 +84,13 @@ async function act(fn) {
|
||||
}
|
||||
}
|
||||
function size(value) {
|
||||
return value >= 1048576
|
||||
? (value / 1048576).toFixed(1) + ' MiB'
|
||||
: value >= 1024
|
||||
? (value / 1024).toFixed(1) + ' KiB'
|
||||
: value + ' B';
|
||||
return value >= 1073741824
|
||||
? (value / 1073741824).toFixed(1).replace(/\.0$/, '') + ' GiB'
|
||||
: 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', {
|
||||
@@ -229,39 +231,65 @@ async function authSubmit() {
|
||||
}
|
||||
|
||||
async function save(values) {
|
||||
if (busy.value || saveTransfer.value) return;
|
||||
if (busy.value) return;
|
||||
if (
|
||||
saveTransfer.value &&
|
||||
!Object.entries(uploadProgress.value).some(
|
||||
([key, value]) => key.startsWith(saveTransfer.value.id + ':') && value.status === 'failed',
|
||||
)
|
||||
)
|
||||
return;
|
||||
busy.value = true;
|
||||
let id;
|
||||
let id = saveTransfer.value?.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(),
|
||||
);
|
||||
const result = id
|
||||
? { requestId: id }
|
||||
: 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' };
|
||||
if (
|
||||
saveTransfer.value?.files &&
|
||||
(values.files.length !== saveTransfer.value.files.length ||
|
||||
values.files.some(
|
||||
(file, index) =>
|
||||
file.name !== saveTransfer.value.files[index].name ||
|
||||
file.size !== saveTransfer.value.files[index].size,
|
||||
))
|
||||
)
|
||||
throw Error('처음 선택한 파일과 이름·크기가 같은 파일을 선택해 주세요.');
|
||||
saveTransfer.value = {
|
||||
id,
|
||||
kind: 'file',
|
||||
status: 'accepted',
|
||||
files: values.files.map((file) => ({ name: file.name, size: file.size })),
|
||||
};
|
||||
busy.value = false;
|
||||
for (const [index, file] of values.files.entries()) {
|
||||
if (saveTransfer.value?.id !== id) return;
|
||||
if (uploadProgress.value[`${id}:${index}`]?.status === 'stored') continue;
|
||||
await uploadFile(id, index, file, account.value.id);
|
||||
}
|
||||
if (saveTransfer.value?.id !== id) return;
|
||||
}
|
||||
await refreshLists();
|
||||
saveTransfer.value = null;
|
||||
compose.value = false;
|
||||
note.value = '';
|
||||
message('임시 보관함에 저장했습니다.');
|
||||
@@ -269,7 +297,6 @@ async function save(values) {
|
||||
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() {
|
||||
@@ -638,7 +665,7 @@ async function resendVerification() {
|
||||
</div>
|
||||
<button v-if="nextCursor" class="button load-more" @click="loadMoreAccount">더 보기</button>
|
||||
<p class="scope-note">
|
||||
저장 용량 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }}
|
||||
내 보관함 {{ size(storage.usedBytes) }} / {{ size(storage.limitBytes) }} · 최대 7일
|
||||
</p>
|
||||
</section>
|
||||
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">
|
||||
|
||||
@@ -33,12 +33,14 @@ import {
|
||||
import QRCode from 'qrcode';
|
||||
import AccountPanel from './AccountPanel.vue';
|
||||
import SendComposer from './SendComposer.vue';
|
||||
import InstallButton from './InstallButton.vue';
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
device,
|
||||
account,
|
||||
connected,
|
||||
discoveryEnabled,
|
||||
peers,
|
||||
requests,
|
||||
items,
|
||||
@@ -54,6 +56,7 @@ import {
|
||||
markRead,
|
||||
uploadProgress,
|
||||
sendFiles,
|
||||
retryFiles,
|
||||
cancelTransfer,
|
||||
download,
|
||||
} from './client';
|
||||
@@ -358,12 +361,16 @@ onMounted(async () => {
|
||||
}
|
||||
});
|
||||
const onFocus = () => void refresh().catch(showError);
|
||||
const onReceiptSkipped = () =>
|
||||
notify('보관함 용량이 부족해 받은 항목에 저장하지 않았어요. 파일 다운로드는 진행됩니다.');
|
||||
window.addEventListener('hop-receipt-skipped', onReceiptSkipped);
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') onFocus();
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hop-receipt-skipped', onReceiptSkipped);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
clearInterval(timer);
|
||||
@@ -421,7 +428,9 @@ async function submitFiles(files) {
|
||||
if (!selected.value) throw Error('받을 기기를 선택해 주세요.');
|
||||
forwardedItem.value = null;
|
||||
itemToSend.value = null;
|
||||
await sendFiles(selected.value.id, files);
|
||||
if (currentTransfer.value?.kind === 'file' && currentTransfer.value.status === 'accepted')
|
||||
await retryFiles(currentTransfer.value.id, files);
|
||||
else await sendFiles(selected.value.id, files);
|
||||
});
|
||||
}
|
||||
async function createSpace() {
|
||||
@@ -509,6 +518,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<span class="header-tagline">기기 사이를 가볍게.</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<InstallButton />
|
||||
<button
|
||||
class="icon-button theme-toggle"
|
||||
:aria-label="theme === 'light' ? '다크 테마로 전환' : '라이트 테마로 전환'"
|
||||
@@ -628,7 +638,27 @@ async function spaceAction(space, action, memberId) {
|
||||
</Teleport>
|
||||
<template v-if="tab === 'devices'">
|
||||
<section class="devices-stage">
|
||||
<div class="stage-heading"><h1>어디로 보낼까요?</h1></div>
|
||||
<div class="stage-heading">
|
||||
<h1>어디로 보낼까요?</h1>
|
||||
<p class="discovery-status" role="status">
|
||||
<LoaderCircle
|
||||
v-if="connected && device?.discoverable && (spaces.length || discoveryEnabled)"
|
||||
class="spin"
|
||||
:size="13"
|
||||
/>
|
||||
{{
|
||||
!connected
|
||||
? '연결을 확인하고 있어요.'
|
||||
: spaces.length
|
||||
? '방 안의 기기를 확인하고 있어요.'
|
||||
: !device?.discoverable
|
||||
? '발견 허용이 꺼져 있어요.'
|
||||
: discoveryEnabled
|
||||
? '주변 기기를 계속 찾고 있어요.'
|
||||
: '주변 검색 설정이 필요해요. QR로 연결할 수 있어요.'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<section class="nearby-section">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
@@ -878,6 +908,9 @@ async function spaceAction(space, action, memberId) {
|
||||
네트워크가 달라도 연결할 수 있어요.<br />연결한 뒤에도 전송마다 수락이 필요합니다.
|
||||
</p>
|
||||
<div v-if="invite && invite.space.expiresAt > now" class="invite-display">
|
||||
<p class="discovery-status">
|
||||
<LoaderCircle class="spin" :size="14" />기기 참여를 기다리고 있어요.
|
||||
</p>
|
||||
<img :src="qr" alt="임시 연결 공간 초대 QR 코드" width="208" height="208" />
|
||||
<p v-if="localOnlyQr" class="local-qr-note">
|
||||
현재 QR은 이 PC의 개발 주소입니다. 휴대폰 접속은 아직 설정하지 않았어요. 같은 PC의
|
||||
|
||||
94
apps/web/src/InstallButton.vue
Normal file
94
apps/web/src/InstallButton.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { ref, shallowRef, computed, onMounted, onUnmounted, nextTick } from 'vue';
|
||||
import { Download, X } from '@lucide/vue';
|
||||
const standalone = window.matchMedia('(display-mode: standalone)');
|
||||
const installed = ref(standalone.matches || navigator.standalone === true);
|
||||
const prompt = shallowRef(null),
|
||||
help = ref(false),
|
||||
button = ref(null),
|
||||
closeButton = ref(null);
|
||||
const ios =
|
||||
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
|
||||
(/Macintosh/.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
|
||||
const mobile = ios || /Android/.test(navigator.userAgent);
|
||||
const visible = computed(() => !installed.value && (mobile || prompt.value));
|
||||
function available(event) {
|
||||
event.preventDefault();
|
||||
prompt.value = event;
|
||||
}
|
||||
function didInstall() {
|
||||
installed.value = true;
|
||||
prompt.value = null;
|
||||
help.value = false;
|
||||
}
|
||||
function displayChanged() {
|
||||
installed.value = standalone.matches || navigator.standalone === true;
|
||||
}
|
||||
async function install() {
|
||||
if (prompt.value) {
|
||||
const event = prompt.value;
|
||||
prompt.value = null;
|
||||
try {
|
||||
await event.prompt();
|
||||
if ((await event.userChoice).outcome === 'accepted') didInstall();
|
||||
} catch {
|
||||
help.value = true;
|
||||
}
|
||||
} else help.value = true;
|
||||
if (help.value) {
|
||||
await nextTick();
|
||||
closeButton.value?.focus();
|
||||
}
|
||||
}
|
||||
function close() {
|
||||
help.value = false;
|
||||
button.value?.focus();
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('beforeinstallprompt', available);
|
||||
window.addEventListener('appinstalled', didInstall);
|
||||
standalone.addEventListener('change', displayChanged);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('beforeinstallprompt', available);
|
||||
window.removeEventListener('appinstalled', didInstall);
|
||||
standalone.removeEventListener('change', displayChanged);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<button
|
||||
v-if="visible"
|
||||
ref="button"
|
||||
class="pwa-install-button"
|
||||
aria-label="hop. 앱 설치"
|
||||
@click="install"
|
||||
>
|
||||
<Download :size="16" /><span>설치</span>
|
||||
</button>
|
||||
<Teleport to="body">
|
||||
<div v-if="help" class="modal-backdrop" @click.self="close" @keydown.esc.stop="close">
|
||||
<section class="modal install-help" role="dialog" aria-modal="true" aria-label="앱 설치 안내">
|
||||
<header>
|
||||
<h2>홈 화면에 hop.</h2>
|
||||
<button
|
||||
ref="closeButton"
|
||||
class="icon-button"
|
||||
aria-label="설치 안내 닫기"
|
||||
@click="close"
|
||||
@keydown.tab.prevent
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</header>
|
||||
<p v-if="ios">
|
||||
Safari에서 공유 버튼을 누른 뒤<br /><strong>홈 화면에 추가</strong>를 선택하세요.
|
||||
</p>
|
||||
<p v-else>
|
||||
브라우저 메뉴에서<br /><strong>앱 설치</strong> 또는 <strong>홈 화면에 추가</strong>를
|
||||
선택하세요.
|
||||
</p>
|
||||
<small>설치 후 홈 화면에서 바로 열 수 있어요.</small>
|
||||
</section>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -45,7 +45,16 @@ 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 retryable = computed(
|
||||
() =>
|
||||
props.transfer?.kind === 'file' &&
|
||||
props.transfer.status === 'accepted' &&
|
||||
!props.transferSource &&
|
||||
(!props.progress?.length || props.progress.some((item) => item.status === 'failed')),
|
||||
);
|
||||
const mutable = computed(
|
||||
() => !props.busy && !props.disabled && (!props.transfer || retryable.value),
|
||||
);
|
||||
const ready = computed(() =>
|
||||
source.value === 'url'
|
||||
? validUrl.value && !reading.value
|
||||
@@ -56,6 +65,7 @@ const ready = computed(() =>
|
||||
async function loadClipboard(force = false) {
|
||||
if (
|
||||
!props.targetKey ||
|
||||
props.transfer ||
|
||||
!props.readClipboardOnSelection ||
|
||||
props.contentMode === 'file' ||
|
||||
!mutable.value ||
|
||||
@@ -129,6 +139,9 @@ watch(
|
||||
if (transfer && props.transferSource) {
|
||||
source.value = 'vault';
|
||||
selectedVault.value = props.transferSource;
|
||||
} else if (transfer?.kind === 'file') {
|
||||
source.value = 'file';
|
||||
urlInput.value = '';
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -233,7 +246,7 @@ function pasted(event) {
|
||||
choose(event.clipboardData.files);
|
||||
return;
|
||||
}
|
||||
if (props.contentMode === 'file') return;
|
||||
if (props.contentMode === 'file' || props.transfer) return;
|
||||
const result = parseUrls(event.clipboardData?.getData('text/plain') ?? '');
|
||||
if (result.urls.length && !result.invalid.length) {
|
||||
event.preventDefault();
|
||||
@@ -245,7 +258,7 @@ function pasted(event) {
|
||||
}
|
||||
}
|
||||
function submit() {
|
||||
if (props.transfer) {
|
||||
if (props.transfer && !retryable.value) {
|
||||
emit('cancel');
|
||||
return;
|
||||
}
|
||||
@@ -387,7 +400,13 @@ function size(value) {
|
||||
>
|
||||
</template>
|
||||
<p v-if="transfer" class="composer-transfer-state" role="status">
|
||||
{{ transfer.status === 'pending' ? '상대 기기의 수락을 기다립니다.' : '전송 중입니다.' }}
|
||||
{{
|
||||
retryable
|
||||
? '같은 파일로 이어 보낼 수 있어요.'
|
||||
: transfer.status === 'pending'
|
||||
? '상대 기기의 수락을 기다립니다.'
|
||||
: '전송 중입니다.'
|
||||
}}
|
||||
</p>
|
||||
<div v-for="(upload, index) in progress" :key="index" class="composer-progress">
|
||||
<span
|
||||
@@ -399,13 +418,16 @@ function size(value) {
|
||||
<button
|
||||
class="button primary content-submit"
|
||||
:class="{ 'link-send': source === 'url', 'send-button': source === 'file' }"
|
||||
:disabled="busy || disabled || (!transfer && !ready)"
|
||||
:disabled="busy || disabled || ((!transfer || retryable) && !ready)"
|
||||
@click="submit"
|
||||
>
|
||||
{{ transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
|
||||
}}<X v-if="transfer" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
{{ retryable ? '다시 전송' : transfer ? '전송 취소' : saveMode ? '저장하기' : '전송하기'
|
||||
}}<X v-if="transfer && !retryable" :size="16" /><ArrowUpRight v-else :size="16" />
|
||||
</button>
|
||||
<div v-if="!contentMode" class="content-switches">
|
||||
<div v-if="retryable" class="content-switches">
|
||||
<button class="text-button room-leave" @click="emit('cancel')">전송 취소</button>
|
||||
</div>
|
||||
<div v-else-if="!contentMode && !transfer" 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">
|
||||
|
||||
@@ -28,9 +28,10 @@ let socket;
|
||||
let reauthorizing;
|
||||
const drafts = new Map();
|
||||
const completing = new Set();
|
||||
export async function api(path, method = 'GET', body, key) {
|
||||
export async function api(path, method = 'GET', body, key, signal) {
|
||||
const response = await fetch(`/api${path}`, {
|
||||
method,
|
||||
signal,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
@@ -40,7 +41,11 @@ export async function api(path, method = 'GET', body, key) {
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message ?? '요청을 처리하지 못했습니다.');
|
||||
if (!response.ok)
|
||||
throw Object.assign(new Error(payload.message ?? '요청을 처리하지 못했습니다.'), {
|
||||
status: response.status,
|
||||
code: payload.code,
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
export async function bootstrap() {
|
||||
@@ -68,6 +73,10 @@ function connect() {
|
||||
connected.value = false;
|
||||
});
|
||||
socket.on('inbox:updated', () => window.dispatchEvent(new Event('hop-account-updated')));
|
||||
socket.on('receipt:skipped', (event) => {
|
||||
if (event.accountId === account.value?.id)
|
||||
window.dispatchEvent(new Event('hop-receipt-skipped'));
|
||||
});
|
||||
socket.on('account:revoked', () => {
|
||||
clearAccountUploads(account.value?.id);
|
||||
account.value = null;
|
||||
@@ -232,73 +241,156 @@ export async function sendFiles(targetDeviceId, files) {
|
||||
await refresh();
|
||||
return requestId;
|
||||
}
|
||||
export function uploadFile(id, index, file, accountId) {
|
||||
const progressKey = `${id}:${index}`;
|
||||
const chunkBytes = 8 * 1024 * 1024;
|
||||
async function fileFingerprint(file, signal) {
|
||||
const digests = [];
|
||||
for (let offset = 0; offset < file.size; offset += chunkBytes) {
|
||||
signal.throwIfAborted();
|
||||
const bytes = await file.slice(offset, offset + chunkBytes).arrayBuffer();
|
||||
digests.push(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)));
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
const combined = new Uint8Array(digests.length * 32);
|
||||
digests.forEach((digest, index) => combined.set(digest, index * 32));
|
||||
return [...new Uint8Array(await crypto.subtle.digest('SHA-256', combined))]
|
||||
.map((value) => value.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
export async function uploadFile(id, index, file, accountId) {
|
||||
const progressKey = `${id}:${index}`,
|
||||
controller = new AbortController();
|
||||
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 = '업로드하지 못했습니다.';
|
||||
uploadRequests.set(progressKey, controller);
|
||||
const check = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (cancelledTransfers.has(id)) throw Error('전송이 취소되었습니다.');
|
||||
if (accountId && account.value?.id !== accountId) throw Error('계정 세션이 종료되었습니다.');
|
||||
};
|
||||
const sendChunk = (uploadId, offset) =>
|
||||
new Promise((resolve, reject) => {
|
||||
check();
|
||||
const xhr = new XMLHttpRequest();
|
||||
const abort = () => xhr.abort();
|
||||
const finish = () => controller.signal.removeEventListener('abort', abort);
|
||||
controller.signal.addEventListener('abort', abort, { once: true });
|
||||
xhr.open('PUT', `/api/uploads/resumable/${uploadId}`);
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrfToken.value);
|
||||
xhr.setRequestHeader('Upload-Offset', String(offset));
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (uploadProgress.value[progressKey])
|
||||
uploadProgress.value[progressKey].progress = Math.min(
|
||||
99,
|
||||
Math.round(((offset + event.loaded) / Math.max(1, file.size)) * 100),
|
||||
);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
finish();
|
||||
let payload;
|
||||
try {
|
||||
message = JSON.parse(xhr.responseText).message;
|
||||
} catch {}
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message,
|
||||
};
|
||||
reject(Error(message));
|
||||
payload = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(payload);
|
||||
else
|
||||
reject(
|
||||
Object.assign(Error(payload.message || '파일 조각을 보내지 못했습니다.'), {
|
||||
status: xhr.status,
|
||||
code: payload.code,
|
||||
}),
|
||||
);
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
reject(Object.assign(Error('업로드 연결이 중단되었습니다.'), { status: 0 }));
|
||||
};
|
||||
xhr.send(file.slice(offset, offset + chunkBytes));
|
||||
});
|
||||
const retryable = (error) =>
|
||||
error.status === 0 ||
|
||||
error.status >= 500 ||
|
||||
[408, 429].includes(error.status) ||
|
||||
['UPLOAD_OFFSET', 'UPLOAD_ACTIVE'].includes(error.code);
|
||||
try {
|
||||
const fingerprint = await fileFingerprint(file, controller.signal);
|
||||
check();
|
||||
let state = await api(
|
||||
'/uploads/resumable',
|
||||
'POST',
|
||||
{ requestId: id, fileIndex: index, fingerprint },
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
let failures = 0;
|
||||
while (state.offset < file.size) {
|
||||
check();
|
||||
try {
|
||||
state = await sendChunk(state.uploadId, state.offset);
|
||||
failures = 0;
|
||||
} catch (error) {
|
||||
check();
|
||||
if (!retryable(error) || ++failures > 3) throw error;
|
||||
await new Promise((resolve) => setTimeout(resolve, failures * 400));
|
||||
check();
|
||||
state = await api(
|
||||
`/uploads/resumable/${state.uploadId}`,
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
}
|
||||
}
|
||||
check();
|
||||
if (state.status !== 'stored') {
|
||||
try {
|
||||
await api(
|
||||
`/uploads/resumable/${state.uploadId}/complete`,
|
||||
'POST',
|
||||
{},
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
} catch (error) {
|
||||
check();
|
||||
const current = await api(
|
||||
`/uploads/resumable/${state.uploadId}`,
|
||||
'GET',
|
||||
undefined,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
if (current.status !== 'stored') throw error;
|
||||
}
|
||||
}
|
||||
check();
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 100,
|
||||
status: 'stored',
|
||||
accountId,
|
||||
};
|
||||
xhr.onerror = xhr.onabort = () => {
|
||||
finish();
|
||||
if (accountId && account.value?.id !== accountId) {
|
||||
delete uploadProgress.value[progressKey];
|
||||
reject(Error('계정 세션이 종료되었습니다.'));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (cancelledTransfers.has(id) || (accountId && account.value?.id !== accountId))
|
||||
delete uploadProgress.value[progressKey];
|
||||
else
|
||||
uploadProgress.value[progressKey] = {
|
||||
name: file.name,
|
||||
progress: 0,
|
||||
status: 'failed',
|
||||
accountId,
|
||||
message: '업로드가 중단되었습니다.',
|
||||
message: error.message || '업로드가 중단되었습니다.',
|
||||
};
|
||||
reject(Error('업로드가 중단되었습니다.'));
|
||||
};
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
xhr.send(form);
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
uploadRequests.delete(progressKey);
|
||||
}
|
||||
}
|
||||
async function deliverFiles(id) {
|
||||
if (fileSending.has(id)) return;
|
||||
@@ -317,6 +409,12 @@ async function deliverFiles(id) {
|
||||
}
|
||||
if (requests.value.find((r) => r.id === id)?.status !== 'accepted') break;
|
||||
}
|
||||
if (
|
||||
Object.entries(uploadProgress.value).some(
|
||||
([key, value]) => key.startsWith(`${id}:`) && value.status === 'failed',
|
||||
)
|
||||
)
|
||||
return;
|
||||
await refresh();
|
||||
if (requests.value.find((r) => r.id === id)?.transferStatus === 'stored') fileDrafts.delete(id);
|
||||
} finally {
|
||||
|
||||
@@ -4,3 +4,8 @@ import App from './App.vue';
|
||||
import './style.css';
|
||||
applyTheme(initialTheme());
|
||||
createApp(App).mount('#app');
|
||||
if (import.meta.env.PROD && window.isSecureContext && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
void navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2932,6 +2932,46 @@ a.button {
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
}
|
||||
.pwa-install-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
padding: 8px 0;
|
||||
min-height: 36px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.install-help p {
|
||||
margin: 28px 0 18px;
|
||||
line-height: 1.9;
|
||||
font-size: 14px;
|
||||
}
|
||||
.install-help small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.discovery-status {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
@media (max-width: 360px) {
|
||||
.header-tagline {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.discovery-status .spin {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.header-tagline {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
|
||||
Reference in New Issue
Block a user