Clarify storage limits and unify file metadata displays

This commit is contained in:
2026-10-15 11:23:33 +09:00
parent 3786baf60c
commit 471e2653d8
22 changed files with 401 additions and 84 deletions

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatSize as size, formatAdminDate as expiryDate } from './displayFormat';
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
@@ -133,15 +134,7 @@ async function act(fn) {
busy.value = false;
}
}
function size(value) {
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', {
month: 'short',
@@ -667,7 +660,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>[보관 만료] {{ expiryDate(item.expiresAt) }} 까지</span>
<div>
<button class="button small" @click="emit('send-item', item)">
<Send :size="14" />기기로 보내기</button

View File

@@ -13,6 +13,7 @@ import {
X,
} from '@lucide/vue';
import { api, showError } from './client';
import { formatSize as size, formatAdminDate as date } from './displayFormat';
const emit = defineEmits(['devices']);
const overview = ref(null),
files = ref([]),
@@ -29,22 +30,6 @@ const maxConcurrent = ref(4),
countries = ref(''),
ips = ref('');
const countryNames = new Intl.DisplayNames(['ko'], { type: 'region' });
const size = (bytes) =>
bytes >= 1073741824
? `${(bytes / 1073741824).toFixed(1)} GiB`
: bytes >= 1048576
? `${(bytes / 1048576).toFixed(1)} MiB`
: `${Math.max(0, Math.round(bytes / 1024))} KiB`;
const date = (value) =>
value
? new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(new Date(value))
: '확인 불가';
function region(location) {
if (!location) return '확인 불가';
return (
@@ -58,7 +43,7 @@ function region(location) {
}
function form(settings) {
maxConcurrent.value = settings.maxConcurrentUploads;
capacity.value = settings.totalStorageLimitMb / 1024;
capacity.value = Number(((settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1));
countries.value = settings.blockedCountries.join(', ');
ips.value = settings.blockedIps.join('\n');
}
@@ -120,7 +105,11 @@ async function save() {
const settings = await api('/admin/settings', 'PATCH', {
revision: overview.value.settings.revision,
maxConcurrentUploads: Number(maxConcurrent.value),
totalStorageLimitMb: Math.round(Number(capacity.value) * 1024),
totalStorageLimitMb:
Number(capacity.value) ===
Number(((overview.value.settings.totalStorageLimitMb * 1048576) / 1e9).toFixed(1))
? overview.value.settings.totalStorageLimitMb
: Math.round((Number(capacity.value) * 1e9) / 1048576),
blockedCountries: split(countries.value),
blockedIps: split(ips.value),
});
@@ -277,18 +266,19 @@ onMounted(async () => {
><span>{{ size(file.size) }}</span>
</div>
<div class="admin-file-info">
<span>{{
file.uploader?.name ||
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
}}</span
<span
>[{{
file.uploader?.name ||
(file.uploader?.kind === 'user' ? '로그인 계정' : '비로그인 기기')
}}]</span
><code>{{ file.path.split('/')[0] }}</code>
</div>
<div class="admin-file-info">
<Globe :size="15" /><span>최근 접속 지역 · {{ region(file.lastLocation) }}</span
<Globe :size="15" /><span>접속 지역 · {{ region(file.lastLocation) }}</span
><code>{{ file.lastLocation?.ip || 'IP 확인 불가' }}</code>
</div>
<footer>
<small>보관 만료 · {{ date(file.expiresAt) }}까지</small
<small>[보관 만료] {{ date(file.expiresAt) }}{{ file.expiresAt ? ' 까지' : '' }}</small
><button class="text-button admin-delete" :disabled="busy" @click="confirm(file)">
<Trash2 :size="16" />원본 삭제
</button>
@@ -307,19 +297,48 @@ onMounted(async () => {
required
:disabled="busy" /></label
><label
>전체 파일 저장 한도 (GiB)<input
>전체 파일 저장 한도 (GB)<input
v-model="capacity"
type="number"
min="1"
max="1024"
min="1.1"
max="1099.5"
step="any"
required
:disabled="busy"
/></label>
</div>
<p class="admin-muted">
기기·개인별 한도와 디스크 최소 여유 공간도 함께 적용합니다. 한도를 줄여도 기존 파일은
삭제하지 않습니다.
전체 한도는 모든 사용자가 올린 원본 파일과 업로드 예약을 합친 용량입니다. 개인마다 이
용량을 배정하는 것은 아닙니다.
</p>
<dl class="admin-limit-details">
<div>
<dt>로그인 보관함</dt>
<dd>계정당 {{ size(overview.limits.accountBytes) }} · 임시 저장과 받은 파일 합산</dd>
</div>
<div>
<dt>기기 전송 업로드</dt>
<dd>
기기 세션당 {{ size(overview.limits.deviceBytes) }} · 로그인 여부와 관계없이 적용
</dd>
</div>
<div>
<dt>기기 전송 업로드 전체</dt>
<dd>모든 기기 세션 합계 {{ size(overview.limits.devicesTotalBytes) }}</dd>
</div>
<div>
<dt>파일 한 개</dt>
<dd>최대 {{ size(overview.limits.perFileBytes) }}</dd>
</div>
<div>
<dt>NAS 최소 여유 공간</dt>
<dd>업로드 예약을 반영해 {{ size(overview.limits.minDiskFreeBytes) }} 이상 확보</dd>
</div>
</dl>
<p class="admin-muted">
전체·기기 업로드 한도나 NAS 여유 공간이 부족하면 새 업로드를 거절합니다. 로그인 보관함이
가득 차면 수신·다운로드는 진행하고 받은 항목에 저장하지 않습니다. 한도를 줄여도 기존
파일은 삭제하지 않습니다.
</p>
<label
>차단할 국가 코드<input

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatSize as size } from './displayFormat';
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import {
ArrowUpRight,
@@ -122,12 +123,7 @@ const modalTitle = computed(() =>
function contentLabel(kind) {
return kind === 'file' ? '파일' : kind === 'text' ? '텍스트' : '링크';
}
function transferSize(bytes) {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
if (bytes >= 1e5) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1)} KB`;
return '1 KB 미만';
}
const normalizedJoinCode = computed(() => joinCode.value.replace(/[\s-]/g, '').toUpperCase());
const validJoinCode = computed(() =>
/^(?:[A-Z2-9]{4}|[A-Z2-9]{6}|[A-Z2-9]{8})$/.test(normalizedJoinCode.value),
@@ -743,15 +739,7 @@ function time(value) {
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(
@@ -983,9 +971,7 @@ async function spaceAction(space, action, memberId) {
<p>
<template v-if="r.kind === 'file'"
>{{ r.count }}개 ·
<template v-if="r.count > 1"
>총 {{ transferSize(r.byteSize) }} ·
</template></template
<template v-if="r.count > 1">총 {{ size(r.byteSize) }} · </template></template
>
<span
>{{
@@ -998,7 +984,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">{{ transferSize(file.size) }}</span>
><span class="request-file-size">{{ size(file.size) }}</span>
</li>
</ul>
<div class="incoming-actions">
@@ -1201,7 +1187,6 @@ async function spaceAction(space, action, memberId) {
>
<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>

View File

@@ -1,4 +1,5 @@
<script setup>
import { formatSize as size } from './displayFormat';
import { ref, computed, watch, nextTick } from 'vue';
import {
FileUp,
@@ -306,13 +307,6 @@ function submit() {
function fileProgress(index) {
return props.progress?.find((upload) => upload.index === index) ?? props.progress?.[index];
}
function size(value) {
return value >= 1048576
? (value / 1048576).toFixed(1) + ' MiB'
: value >= 1024
? (value / 1024).toFixed(1) + ' KiB'
: value + ' B';
}
</script>
<template>
<section
@@ -389,7 +383,7 @@ function size(value) {
class="content-feedback"
role="status"
>
텍스트는 최대 {{ Math.floor(textLimitBytes / 1024) }}KiB까지 보낼 수 있어요.
텍스트는 최대 {{ size(textLimitBytes) }}까지 보낼 수 있어요.
</p>
</template>
<template v-else-if="source === 'file'">

View File

@@ -0,0 +1,24 @@
export function formatSize(bytes) {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)}GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)}MB`;
if (bytes >= 1000) return `${(bytes / 1000).toFixed(1).replace(/\.0$/, '')}KB`;
return bytes === 0 ? '0KB' : '1KB 미만';
}
export function formatAdminDate(value) {
if (!value) return '확인 불가';
const parts = Object.fromEntries(
new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
})
.formatToParts(new Date(value))
.map(({ type, value }) => [type, value]),
);
return `${parts.year}년 ${parts.month}월 ${parts.day}일 ${parts.hour}:${parts.minute}`;
}

View File

@@ -4090,6 +4090,24 @@ a.button {
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.admin-limit-details {
display: grid;
gap: 12px;
margin: 0;
font-size: 14px;
line-height: 1.6;
}
.admin-limit-details > div {
display: grid;
gap: 3px;
}
.admin-limit-details dt {
font-weight: 550;
}
.admin-limit-details dd {
margin: 0;
color: var(--muted);
}
.admin-muted {
font-size: 14px;
line-height: 1.6;