Clarify transfer rejections and improve storage readability

This commit is contained in:
2026-10-15 17:18:07 +09:00
parent 60a14e9c6c
commit 8fb8c203f0
17 changed files with 300 additions and 55 deletions

View File

@@ -1,5 +1,6 @@
<script setup>
import { formatSize as size, formatAdminDate as expiryDate } from './displayFormat';
import { formatAdminDate as expiryDate } from './displayFormat';
import SizeLabel from './SizeLabel.vue';
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
@@ -653,7 +654,7 @@ async function resendVerification() {
<FileIcon :size="24" />
<div>
<strong>{{ item.file.name }}</strong
><span>{{ size(item.file.size) }}</span>
><span><SizeLabel :bytes="item.file.size" /></span>
</div>
</div>
<pre v-else-if="item.kind === 'text'" class="item-body account-text-preview">{{
@@ -724,8 +725,8 @@ 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) }} · 항목별 최대 7일
보관
내 보관함 <SizeLabel :bytes="storage.usedBytes" /> /
<SizeLabel :bytes="storage.limitBytes" /> · 항목별 최대 7일 보관
</p>
</section>
<div v-if="compose" class="modal-backdrop" @click.self="!saveTransfer && (compose = false)">

View File

@@ -11,7 +11,8 @@ import {
X,
} from '@lucide/vue';
import { api, showError } from './client';
import { formatSize as size, formatAdminDate as date, formatCountry } from './displayFormat';
import { formatAdminDate as date, formatCountry } from './displayFormat';
import SizeLabel from './SizeLabel.vue';
import NetworkPanel from './NetworkPanel.vue';
import CapacityPanel from './CapacityPanel.vue';
import AdminAudit from './AdminAudit.vue';
@@ -147,14 +148,14 @@ onMounted(async () => {
<article>
<Folder :size="21" /><span>저장 파일</span><strong>{{ overview.storedFiles }}개</strong
><small
>{{ size(overview.storedBytes) }} /
{{ size(overview.settings.totalStorageLimitMb * 1048576) }}</small
>
><SizeLabel :bytes="overview.storedBytes" /> /
<SizeLabel :bytes="overview.settings.totalStorageLimitMb * 1048576"
/></small>
</article>
<article>
<HardDrive :size="21" /><span>디스크 여유</span
><strong>{{ size(overview.diskFreeBytes) }}</strong
><small>업로드 예약 {{ size(overview.reservedBytes) }}</small>
><strong><SizeLabel :bytes="overview.diskFreeBytes" /></strong
><small>업로드 예약 <SizeLabel :bytes="overview.reservedBytes" /></small>
</article>
<article>
<RefreshCw :size="21" /><span>업로드 처리</span
@@ -201,18 +202,22 @@ onMounted(async () => {
<article v-for="file in files" :key="file.id" class="admin-file">
<div class="admin-file-heading">
<strong>{{ file.name }}</strong
><span>{{ size(file.size) }}</span>
><span><SizeLabel :bytes="file.size" /></span>
</div>
<div class="admin-file-info admin-file-owner">
<code>{{
file.uploader?.id || file.path.split('/')[0].replace(/^(user|guest)-/, '')
}}</code>
<span>{{
file.uploader?.kind === 'user' ? file.uploader.email || '삭제된 계정' : '비로그인'
<span aria-hidden="true">·</span
><span>{{
file.uploader?.kind === 'user'
? file.uploader.email || '삭제된 계정'
: '게스트(Guest)'
}}</span>
</div>
<div class="admin-file-info">
<Globe :size="15" /><span>접속 지역 · {{ region(file.lastLocation) }}</span
><span aria-hidden="true">·</span
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
</div>
<footer>

View File

@@ -1,5 +1,5 @@
<script setup>
import { formatSize as size } from './displayFormat';
import SizeLabel from './SizeLabel.vue';
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
@@ -70,6 +70,7 @@ import {
error,
transferCooldown,
transferConnecting,
latestRequestedTransfer,
waitForConnection,
requestTransfer,
bootstrap,
@@ -436,8 +437,25 @@ async function closeSend() {
itemToSend.value = null;
}
}
watch(requests, (current, previous) => {
for (const request of current)
const notifiedRejections = new Set();
watch([requests, latestRequestedTransfer], ([current, requestedId], [previous]) => {
const before = new Map(previous.map((request) => [request.id, request]));
for (const request of current) {
if (
request.direction === 'sent' &&
request.status === 'rejected' &&
!notifiedRejections.has(request.id) &&
(request.id === requestedId || before.get(request.id)?.status === 'pending')
) {
notifiedRejections.add(request.id);
if (request.recipientDevice?.id === selected.value?.id) {
sendOpen.value = false;
selected.value = null;
itemToSend.value = null;
forwardedItem.value = null;
}
notify(`${request.recipientDevice?.name || '상대방'}에서 전송을 거절했어요.`);
}
if (
request.direction === 'sent' &&
request.transferStatus === 'stored' &&
@@ -449,6 +467,9 @@ watch(requests, (current, previous) => {
itemToSend.value = null;
notify('전송했습니다.');
}
}
const visible = new Set(current.map((request) => request.id));
for (const id of notifiedRejections) if (!visible.has(id)) notifiedRejections.delete(id);
});
const selectionEpoch = ref(0);
watch(spaces, (current, previous) => {
@@ -541,12 +562,16 @@ async function forwardVault(item) {
await act(async () => {
const target = selected.value;
await prepareTransfer(target);
await requestTransfer(`/vault/${item.accessId}/send`, { targetDeviceId: target.id });
const { requestId } = await requestTransfer(`/vault/${item.accessId}/send`, {
targetDeviceId: target.id,
});
await refresh();
if (account.value?.id !== owner) return;
const request = requests.value.find((request) => request.id === requestId);
if (request?.status === 'rejected' || request?.transferStatus === 'stored') return;
forwardedItem.value = item;
sendOpen.value = true;
itemToSend.value = null;
await refresh();
});
}
watch(
@@ -985,7 +1010,9 @@ async function spaceAction(space, action, memberId) {
<p>
<template v-if="r.kind === 'file'"
>{{ r.count }}개 ·
<template v-if="r.count > 1">총 {{ size(r.byteSize) }} · </template></template
<template v-if="r.count > 1"
>총 <SizeLabel :bytes="r.byteSize" /> ·
</template></template
>
<span
>{{
@@ -998,7 +1025,7 @@ async function spaceAction(space, action, memberId) {
<ul v-if="r.files" class="request-files">
<li v-for="file in r.files" :key="file.id">
<span class="request-file-name">{{ file.name }}</span
><span class="request-file-size">{{ size(file.size) }}</span>
><span class="request-file-size"><SizeLabel :bytes="file.size" /></span>
</li>
</ul>
<p

View File

@@ -2,7 +2,8 @@
import { ref, reactive } from 'vue';
import { Pencil, RotateCcw } from '@lucide/vue';
import { api, showError } from './client';
import { formatSize as size, SIZE_GB } from './displayFormat';
import { SIZE_GB } from './displayFormat';
import SizeLabel from './SizeLabel.vue';
const props = defineProps({
settings: { type: Object, required: true },
defaults: { type: Object, required: true },
@@ -158,7 +159,7 @@ async function save() {
<div v-for="limit in limits" :key="limit.key">
<dt>{{ limit.label }}</dt>
<dd>
<strong>{{ size(bytes(settings, limit.key)) }}</strong
<strong><SizeLabel :bytes="bytes(settings, limit.key)" /></strong
><small>{{ limit.hint }}</small>
</dd>
</div>

View File

@@ -2,12 +2,8 @@
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { Plus, Trash2, X, RefreshCw, Pencil } from '@lucide/vue';
import { api, showError } from './client';
import {
formatAdminDate as date,
formatCountry as country,
formatSize as size,
SIZE_GB,
} from './displayFormat';
import { formatAdminDate as date, formatCountry as country, SIZE_GB } from './displayFormat';
import SizeLabel from './SizeLabel.vue';
const props = defineProps({ settings: { type: Object, required: true }, suggestedIp: String });
const emit = defineEmits(['saved', 'activity', 'consumed']);
const state = ref(props.settings),
@@ -391,7 +387,7 @@ function modalKey(event) {
</div>
<div>
<dt>요청한 파일 용량</dt>
<dd>{{ size(state.autoUploadBytes) }}</dd>
<dd><SizeLabel :bytes="state.autoUploadBytes" /></dd>
</div>
<div>
<dt>자동 차단 시간</dt>

View File

@@ -1,5 +1,5 @@
<script setup>
import { formatSize as size } from './displayFormat';
import SizeLabel from './SizeLabel.vue';
import { ref, computed, watch, nextTick } from 'vue';
import {
FileUp,
@@ -409,7 +409,7 @@ function fileProgress(index) {
class="content-feedback"
role="status"
>
텍스트는 최대 {{ size(textLimitBytes) }}까지 보낼 수 있어요.
텍스트는 최대 <SizeLabel :bytes="textLimitBytes" />까지 보낼 수 있어요.
</p>
</template>
<template v-else-if="source === 'file'">
@@ -427,7 +427,7 @@ function fileProgress(index) {
<li v-for="(file, index) in files" :key="index">
<div class="selected-file-row">
<FileIcon :size="15" /><span>{{ file.name }}</span
><small>{{ size(file.size) }}</small
><small><SizeLabel :bytes="file.size" /></small
><button
class="icon-button"
:disabled="!mutable"

View File

@@ -0,0 +1,38 @@
<script setup>
import { computed } from 'vue';
import { formatSize } from './displayFormat';
const props = defineProps({ bytes: { type: Number, required: true } });
const formatted = computed(() => formatSize(props.bytes));
const parts = computed(() => formatted.value.match(/^(.*?)(\.\d+)?(KB|MB|GB)$/));
</script>
<template>
<span class="file-size"
><template v-if="parts"
><span class="file-size-whole">{{ parts[1] }}</span
><span v-if="parts[2]" class="file-size-fraction">{{ parts[2] }}</span
><span class="file-size-unit">{{ parts[3] }}</span></template
><template v-else>{{ formatted }}</template></span
>
</template>
<style scoped>
.file-size,
.file-size > span {
display: inline;
margin: 0;
padding: 0;
font: inherit;
color: inherit;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.file-size > .file-size-fraction {
font-size: 0.75em;
opacity: 0.6;
}
.file-size > .file-size-unit {
margin-inline-start: 0.15em;
font-size: 0.85em;
}
</style>

View File

@@ -28,6 +28,8 @@ export const device = ref(null),
export const pendingPeers = ref([]);
export const transferCooldown = ref({ until: 0, targetDeviceId: null });
export const transferConnecting = ref(false);
// Also observe a decision that arrives before the first pending snapshot/HTTP response.
export const latestRequestedTransfer = ref(null);
let pendingTimer;
let deviceInfo = {};
export const nextCursor = ref(null);
@@ -88,7 +90,9 @@ export async function requestTransfer(path, input) {
if (device.value?.id !== owner)
throw Error('기기 세션이 변경되었습니다. 다시 선택해 주세요.');
try {
return await api(path, 'POST', input, key);
const result = await api(path, 'POST', input, key);
latestRequestedTransfer.value = result.requestId;
return result;
} catch (error) {
// Only a known, still-authorized peer in its reconnect grace period is retried.
if (error.code !== 'TARGET_RECONNECTING' || Date.now() >= deadline) throw error;
@@ -279,8 +283,10 @@ export const refresh = createRefreshQueue(async () => {
if (
['rejected', 'expired', 'cancelled'].includes(req.status) ||
req.transferStatus === 'stored'
)
) {
drafts.delete(req.id);
fileDrafts.delete(req.id);
}
}
});
export async function send(targetDeviceId, kind, values) {