Use visible NAS upload storage and reconcile missing files
This commit is contained in:
@@ -65,8 +65,7 @@ const storage = ref({ usedBytes: 0, limitBytes: 0 });
|
||||
const securityAction = ref('none'),
|
||||
currentPassword = ref(''),
|
||||
newPassword = ref(''),
|
||||
newConfirmation = ref(''),
|
||||
newEmail = ref('');
|
||||
newConfirmation = ref('');
|
||||
const preferencesDialog = ref(null);
|
||||
let preferencesTrigger;
|
||||
watch(preferencesOpen, async (open) => {
|
||||
@@ -106,7 +105,6 @@ function selectSecurityAction(action = 'none') {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = action;
|
||||
}
|
||||
function openPreferences() {
|
||||
@@ -181,7 +179,6 @@ function clearPrivate() {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
newConfirmation.value = '';
|
||||
newEmail.value = '';
|
||||
securityAction.value = 'none';
|
||||
}
|
||||
const refreshLists = createRefreshQueue(async () => {
|
||||
@@ -377,6 +374,12 @@ async function copy(item) {
|
||||
});
|
||||
}
|
||||
async function download(item) {
|
||||
try {
|
||||
await api(`/access/${item.accessId}`);
|
||||
} catch (error) {
|
||||
await refreshLists().catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file.name;
|
||||
@@ -429,13 +432,6 @@ async function securitySubmit() {
|
||||
await refreshAccount();
|
||||
reconnect();
|
||||
message('비밀번호를 변경했습니다. 다시 로그인해 주세요.');
|
||||
} else {
|
||||
await api('/auth/change-email', 'POST', {
|
||||
email: newEmail.value,
|
||||
password: currentPassword.value,
|
||||
});
|
||||
selectSecurityAction();
|
||||
message('새 주소의 인증 메일을 확인해 주세요. 인증 후 변경됩니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -602,19 +598,19 @@ async function resendVerification() {
|
||||
</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
|
||||
><button
|
||||
role="tab"
|
||||
:aria-selected="view === 'vault'"
|
||||
:class="{ active: view === 'vault' }"
|
||||
@click="view = 'vault'"
|
||||
>
|
||||
임시 저장
|
||||
</button>
|
||||
</div>
|
||||
<form class="compact-search" @submit.prevent="act(refreshLists)">
|
||||
@@ -662,7 +658,7 @@ async function resendVerification() {
|
||||
><button type="button" class="text-button" @click="editing = null">취소</button>
|
||||
</form>
|
||||
<div class="item-footer">
|
||||
<span>{{ date(item.expiresAt) }} 삭제</span>
|
||||
<span>보관 만료 · {{ date(item.expiresAt) }}까지</span>
|
||||
<div>
|
||||
<button class="button small" @click="emit('send-item', item)">
|
||||
<Send :size="14" />기기로 보내기</button
|
||||
@@ -801,13 +797,7 @@ async function resendVerification() {
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<h2>
|
||||
{{
|
||||
securityAction === 'password'
|
||||
? '비밀번호 변경'
|
||||
: securityAction === 'email'
|
||||
? '이메일 변경'
|
||||
: '계정 설정'
|
||||
}}
|
||||
{{ securityAction === 'password' ? '비밀번호 변경' : '계정 설정' }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="icon-button" @click="closePreferences" aria-label="계정 설정 닫기">
|
||||
@@ -820,13 +810,6 @@ async function resendVerification() {
|
||||
<strong>이메일</strong>
|
||||
<p>{{ account.email }}</p>
|
||||
</div>
|
||||
<button
|
||||
class="button small"
|
||||
@click="selectSecurityAction('email')"
|
||||
aria-label="이메일 변경"
|
||||
>
|
||||
변경
|
||||
</button>
|
||||
</div>
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
@@ -843,32 +826,26 @@ async function resendVerification() {
|
||||
</div>
|
||||
</div>
|
||||
<form v-else class="preferences-form" @submit.prevent="securitySubmit">
|
||||
<p v-if="securityAction === 'email'" class="preferences-hint">
|
||||
새 이메일로 받은 인증을 완료하면 변경됩니다.
|
||||
</p>
|
||||
<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 autocomplete="email" /></label
|
||||
><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
|
||||
><button class="button primary" :disabled="busy">{{ busy ? '변경 중…' : '변경' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -47,6 +47,7 @@ import { createNearbySearch } from './nearbySearch';
|
||||
import { useVisibleViewport } from './viewport';
|
||||
useVisibleViewport();
|
||||
import { createDeliveryReceiver } from './deliveryReceiver';
|
||||
import { createRefreshQueue } from './refreshQueue';
|
||||
import { initialTheme, applyTheme as setTheme } from './theme';
|
||||
import {
|
||||
device,
|
||||
@@ -423,16 +424,31 @@ const vaultItems = ref([]),
|
||||
vaultCursor = ref(null),
|
||||
forwardedItem = ref(null),
|
||||
itemToSend = ref(null);
|
||||
async function loadVaultChoices() {
|
||||
const refreshVaultChoices = createRefreshQueue(async () => {
|
||||
if (!account.value) return;
|
||||
const owner = account.value.id;
|
||||
await act(async () => {
|
||||
const result = await api('/vault/contents');
|
||||
const wanted = Math.max(30, vaultItems.value.length);
|
||||
const refreshed = [];
|
||||
let cursor = null;
|
||||
do {
|
||||
const query = new URLSearchParams({ limit: String(Math.min(100, wanted - refreshed.length)) });
|
||||
if (cursor) query.set('cursor', cursor);
|
||||
const result = await api(`/vault/contents?${query}`);
|
||||
if (account.value?.id !== owner) return;
|
||||
vaultItems.value = result.items;
|
||||
vaultCursor.value = result.nextCursor;
|
||||
});
|
||||
refreshed.push(...result.items);
|
||||
cursor = result.nextCursor;
|
||||
} while (cursor && refreshed.length < wanted);
|
||||
vaultItems.value = refreshed;
|
||||
vaultCursor.value = cursor;
|
||||
});
|
||||
async function loadVaultChoices() {
|
||||
await act(refreshVaultChoices);
|
||||
}
|
||||
const onVaultUpdated = () => {
|
||||
if (sendOpen.value && account.value && vaultItems.value.length)
|
||||
void refreshVaultChoices().catch(showError);
|
||||
};
|
||||
window.addEventListener('hop-account-updated', onVaultUpdated);
|
||||
async function moreVault() {
|
||||
const owner = account.value?.id;
|
||||
await act(async () => {
|
||||
@@ -515,6 +531,7 @@ const onVisibility = () => {
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('hop-account-updated', onVaultUpdated);
|
||||
window.removeEventListener('hop-receipt-skipped', onReceiptSkipped);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
@@ -831,7 +848,17 @@ async function spaceAction(space, action, memberId) {
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div v-if="receivedLinks.length" class="received-link-notice" role="status">
|
||||
<div
|
||||
v-if="
|
||||
receivedLinks.length &&
|
||||
!sendOpen &&
|
||||
!modal &&
|
||||
!incoming.length &&
|
||||
!receivedTexts.length
|
||||
"
|
||||
class="received-link-notice"
|
||||
role="status"
|
||||
>
|
||||
<Link :size="18" /><span>링크를 열었어요.</span
|
||||
><button class="text-button" @click="copyReceived(receivedLinks[0])">주소 복사</button
|
||||
><button
|
||||
@@ -1116,7 +1143,7 @@ async function spaceAction(space, action, memberId) {
|
||||
<div v-if="error" class="error-toast" role="alert">
|
||||
<span>{{ error }}</span
|
||||
><button class="icon-button" @click="error = ''" aria-label="오류 닫기">
|
||||
<X :size="17" />
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="modal" class="modal-backdrop" @click.self="modal = null" @keydown.esc="modal = null">
|
||||
|
||||
@@ -146,8 +146,17 @@ function connect() {
|
||||
])
|
||||
socket.on(event, () => void refresh().catch(showError));
|
||||
}
|
||||
let errorTimer;
|
||||
export function showError(err) {
|
||||
error.value = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
clearTimeout(errorTimer);
|
||||
const raw = err instanceof Error ? err.message : '처리 중 오류가 발생했습니다.';
|
||||
const message = /^(Failed to fetch|NetworkError|Load failed)$/i.test(raw)
|
||||
? '서버에 연결하지 못했어요. 잠시 후 다시 시도해 주세요.'
|
||||
: raw;
|
||||
error.value = message;
|
||||
errorTimer = setTimeout(() => {
|
||||
if (error.value === message) error.value = '';
|
||||
}, 5000);
|
||||
}
|
||||
export const refresh = createRefreshQueue(async () => {
|
||||
const snapshot = await api('/device/snapshot');
|
||||
@@ -523,6 +532,12 @@ export async function undoDelete(id) {
|
||||
await refresh();
|
||||
}
|
||||
export async function download(item) {
|
||||
try {
|
||||
await api(`/access/${item.accessId}`);
|
||||
} catch (err) {
|
||||
await refresh().catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
const a = document.createElement('a');
|
||||
a.href = `/api/access/${item.accessId}/download`;
|
||||
a.download = item.file?.name ?? '';
|
||||
|
||||
@@ -7,6 +7,7 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
const blockedLinks = ref([]);
|
||||
const receivedTexts = ref([]);
|
||||
const receivedLinks = ref([]);
|
||||
let linkNoticeTimer;
|
||||
function reserve(request) {
|
||||
if (pending.has(request.id)) return;
|
||||
const tabs = [];
|
||||
@@ -57,6 +58,10 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
try {
|
||||
tab.location.replace(item.url);
|
||||
receivedLinks.value = [item];
|
||||
clearTimeout(linkNoticeTimer);
|
||||
linkNoticeTimer = setTimeout(() => {
|
||||
receivedLinks.value = [];
|
||||
}, 5000);
|
||||
void markRead(item).catch(onError);
|
||||
} catch {
|
||||
tab.close();
|
||||
@@ -73,6 +78,7 @@ export function createDeliveryReceiver({ download, markRead, onError }) {
|
||||
void markRead(item).catch(onError);
|
||||
}
|
||||
function dispose() {
|
||||
clearTimeout(linkNoticeTimer);
|
||||
for (const id of pending.keys()) abort(id);
|
||||
blockedLinks.value = [];
|
||||
receivedTexts.value = [];
|
||||
|
||||
@@ -1186,10 +1186,21 @@ h1 span {
|
||||
bottom: 90px;
|
||||
background: #8b4036;
|
||||
color: #fff;
|
||||
padding: 7px 12px;
|
||||
padding: 4px 16px;
|
||||
gap: 0;
|
||||
}
|
||||
.error-toast button {
|
||||
.error-toast > span {
|
||||
min-width: 0;
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.error-toast .icon-button {
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
margin-right: -13px;
|
||||
}
|
||||
.inline-info {
|
||||
font-size: 11px;
|
||||
|
||||
Reference in New Issue
Block a user