Clarify storage limits and unify file metadata displays
This commit is contained in:
8
apps/server/drizzle/0013_file_upload_contexts.sql
Normal file
8
apps/server/drizzle/0013_file_upload_contexts.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE `file_upload_contexts` (
|
||||
`fileId` text PRIMARY KEY NOT NULL,
|
||||
`context` text NOT NULL,
|
||||
`expiresAt` integer NOT NULL,
|
||||
FOREIGN KEY (`fileId`) REFERENCES `files`(`id`) ON DELETE CASCADE
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `file_upload_context_expiry` ON `file_upload_contexts` (`expiresAt`);
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1791973081784,
|
||||
"tag": "0012_admin_controls",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1792030497381,
|
||||
"tag": "0013_file_upload_contexts",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -240,6 +240,13 @@ export class Administration {
|
||||
const disk = statfsSync(this.service.cfg.uploadDir, { bigint: true });
|
||||
return {
|
||||
settings: { ...this.state },
|
||||
limits: {
|
||||
perFileBytes: this.service.cfg.maxFileBytes,
|
||||
accountBytes: this.service.cfg.userQuota,
|
||||
deviceBytes: this.service.cfg.guestQuota,
|
||||
devicesTotalBytes: this.service.cfg.totalGuestQuota,
|
||||
minDiskFreeBytes: this.service.cfg.minFreeBytes,
|
||||
},
|
||||
storedFiles: Number(stored.count),
|
||||
storedBytes: Number(stored.bytes),
|
||||
reservedBytes: Number(reserved.bytes),
|
||||
@@ -285,8 +292,7 @@ export class Administration {
|
||||
const items = page
|
||||
.filter((file) => this.service.storage.availability.check(file))
|
||||
.map((file) => {
|
||||
const owner = this.service.storage.layout.ownerInfo.details(file.storageKey),
|
||||
events = owner ? this.service.storage.layout.ownerInfo.read(owner).events : [];
|
||||
const owner = this.service.storage.layout.ownerInfo.details(file.storageKey);
|
||||
const expiry = this.service.db
|
||||
.select({ at: sql`max(${s.items.expiresAt})` })
|
||||
.from(s.items)
|
||||
@@ -307,7 +313,7 @@ export class Administration {
|
||||
name: this.service.storage.layout.ownerInfo.name(owner),
|
||||
}
|
||||
: null,
|
||||
lastLocation: events.at(-1) ?? null,
|
||||
lastLocation: this.service.storage.layout.ownerInfo.fileLocation(file),
|
||||
};
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -224,6 +224,17 @@ export const files = sqliteTable(
|
||||
),
|
||||
],
|
||||
);
|
||||
export const fileUploadContexts = sqliteTable(
|
||||
'file_upload_contexts',
|
||||
{
|
||||
fileId: text()
|
||||
.primaryKey()
|
||||
.references(() => files.id, { onDelete: 'cascade' }),
|
||||
context: text().notNull(),
|
||||
expiresAt: integer().notNull(),
|
||||
},
|
||||
(t) => [index('file_upload_context_expiry').on(t.expiresAt)],
|
||||
);
|
||||
export const reservations = sqliteTable(
|
||||
'quota_reservations',
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'node:fs';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { and, eq, inArray, like } from 'drizzle-orm';
|
||||
import { and, eq, gt, inArray, like, lte, or } from 'drizzle-orm';
|
||||
import * as s from './db/schema.js';
|
||||
import { clientIp } from './security.js';
|
||||
|
||||
@@ -135,11 +135,62 @@ export class OwnerInfo {
|
||||
const owner = this.details(file.storageKey);
|
||||
if (!owner) return;
|
||||
const { events } = this.read(owner);
|
||||
if (events.some((event) => event.requestId === requestId)) return;
|
||||
events.push({ requestId, recordedAt: new Date().toISOString(), ...region });
|
||||
const existing = events.find((event) => event.requestId === requestId),
|
||||
context = existing ?? { requestId, recordedAt: new Date().toISOString(), ...region };
|
||||
for (const row of this.service.storage.fileRows(requestId)) this.saveFileContext(row, context);
|
||||
if (existing) return;
|
||||
events.push(context);
|
||||
this.write(owner, events);
|
||||
}
|
||||
saveFileContext(file, context) {
|
||||
const expiresAt =
|
||||
Date.parse(context.recordedAt) + this.service.cfg.fileRetentionDays * 86400000;
|
||||
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return;
|
||||
this.service.db
|
||||
.insert(s.fileUploadContexts)
|
||||
.values({
|
||||
fileId: file.id,
|
||||
context: JSON.stringify(context),
|
||||
expiresAt,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
fileLocation(file) {
|
||||
const stored = this.service.db
|
||||
.select()
|
||||
.from(s.fileUploadContexts)
|
||||
.where(
|
||||
and(
|
||||
eq(s.fileUploadContexts.fileId, file.id),
|
||||
gt(s.fileUploadContexts.expiresAt, Date.now()),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
if (stored) return JSON.parse(stored.context);
|
||||
const owner = this.details(file.storageKey),
|
||||
context = owner
|
||||
? this.read(owner).events.find((event) => event.requestId === file.requestId)
|
||||
: null;
|
||||
if (context) this.saveFileContext(file, context);
|
||||
return context ?? null;
|
||||
}
|
||||
async cleanup() {
|
||||
this.service.db
|
||||
.delete(s.fileUploadContexts)
|
||||
.where(
|
||||
or(
|
||||
lte(s.fileUploadContexts.expiresAt, Date.now()),
|
||||
inArray(
|
||||
s.fileUploadContexts.fileId,
|
||||
this.service.db
|
||||
.select({ id: s.files.id })
|
||||
.from(s.files)
|
||||
.where(eq(s.files.status, 'failed')),
|
||||
),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
for (const entry of await readdir(this.layout.originals, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const owner = this.details(entry.name + '/file');
|
||||
|
||||
@@ -76,6 +76,13 @@ test('only the verified active hello account can view and mutate admin APIs; gue
|
||||
}
|
||||
assert.equal((await f.call(admin, '/auth/me')).json().account.administrator, true);
|
||||
assert.equal((await f.call(other, '/auth/me')).json().account.administrator, false);
|
||||
assert.deepEqual((await f.call(admin, '/admin/summary')).json().limits, {
|
||||
perFileBytes: f.cfg.maxFileBytes,
|
||||
accountBytes: f.cfg.userQuota,
|
||||
deviceBytes: f.cfg.guestQuota,
|
||||
devicesTotalBytes: f.cfg.totalGuestQuota,
|
||||
minDiskFreeBytes: f.cfg.minFreeBytes,
|
||||
});
|
||||
assert.equal((await f.call(caseAlias, '/auth/me')).json().account.administrator, false);
|
||||
assert.equal((await patch(f, other, { uploadsPaused: true })).statusCode, 403);
|
||||
assert.equal(
|
||||
|
||||
24
apps/server/test/display-format.test.js
Normal file
24
apps/server/test/display-format.test.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { formatSize, formatAdminDate } from '../../web/src/displayFormat.js';
|
||||
|
||||
test('shared display sizes use decimal KB below 1MB and switch only at MB/GB boundaries', () => {
|
||||
for (const [bytes, expected] of [
|
||||
[0, '0KB'],
|
||||
[10, '1KB 미만'],
|
||||
[1000, '1KB'],
|
||||
[938000, '938KB'],
|
||||
[999000, '999KB'],
|
||||
[1000000, '1.0MB'],
|
||||
[1100000, '1.1MB'],
|
||||
[1000000000, '1.0GB'],
|
||||
[1700000000, '1.7GB'],
|
||||
])
|
||||
assert.equal(formatSize(bytes), expected);
|
||||
});
|
||||
|
||||
test('admin dates include year and unambiguous Korea 24-hour time independent of host timezone', () => {
|
||||
assert.equal(formatAdminDate(Date.parse('2026-10-22T02:02:00Z')), '2026년 10월 22일 11:02');
|
||||
assert.equal(formatAdminDate(Date.parse('2026-10-21T15:02:00Z')), '2026년 10월 22일 00:02');
|
||||
assert.equal(formatAdminDate(null), '확인 불가');
|
||||
});
|
||||
@@ -85,6 +85,22 @@ test('region records are NAS-only, deduplicated by request, bounded and cleaned
|
||||
source: 'cloudflare',
|
||||
});
|
||||
assert.equal(json().recentLocations.length, 20);
|
||||
const info = f.service.storage.layout.ownerInfo;
|
||||
assert.equal(
|
||||
info.fileLocation(row).city,
|
||||
'Daejeon',
|
||||
'newer uploads cannot replace this file location',
|
||||
);
|
||||
info.record(row, id, { ip: '1.1.1.1', countryCode: 'JP', city: 'Tokyo' });
|
||||
assert.equal(
|
||||
info.fileLocation(row).city,
|
||||
'Daejeon',
|
||||
'idempotent retries cannot overwrite the original snapshot',
|
||||
);
|
||||
assert.equal(info.fileLocation(row).ip, '8.8.8.8');
|
||||
const publicRequest = await f.call(b, '/transfer-requests');
|
||||
assert.equal(publicRequest.body.includes('Daejeon'), false);
|
||||
assert.equal(publicRequest.body.includes('8.8.8.8'), false);
|
||||
const response = await f.app.inject({
|
||||
url: '/uploads/originals/' + row.storageKey.split('/')[0] + '/owner-info.json',
|
||||
});
|
||||
@@ -102,6 +118,93 @@ test('region records are NAS-only, deduplicated by request, bounded and cleaned
|
||||
f.service.db.update(s.files).set({ status: 'failed' }).where(eq(s.files.id, row.id)).run();
|
||||
await f.service.storage.layout.ownerInfo.cleanup();
|
||||
assert.equal(existsSync(owner.path), false);
|
||||
assert.equal(f.service.db.select().from(s.fileUploadContexts).all().length, 0);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('each file keeps its own request location; legacy lookup matches its request and expired snapshots are removed', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const a = await f.create(),
|
||||
b = await f.create();
|
||||
await f.space(a, b);
|
||||
f.cfg.clientIpSource = 'cloudflare';
|
||||
async function upload(country, city, ip) {
|
||||
const request = await f.call(
|
||||
a,
|
||||
'/transfer-requests',
|
||||
'POST',
|
||||
{
|
||||
targetDeviceId: b.deviceId,
|
||||
kind: 'file',
|
||||
count: 1,
|
||||
byteSize: 4,
|
||||
files: [{ name: 'test.txt', size: 4, type: 'text/plain' }],
|
||||
},
|
||||
randomUUID(),
|
||||
{ 'cf-connecting-ip': ip, 'cf-ipcountry': country, 'cf-ipcity': city },
|
||||
);
|
||||
assert.equal(request.statusCode, 200, request.body);
|
||||
const id = request.json().requestId;
|
||||
await f.call(b, `/transfer-requests/${id}/accept`, 'POST', {}, randomUUID());
|
||||
const result = await f.call(
|
||||
a,
|
||||
`/uploads?requestId=${id}&fileIndex=0`,
|
||||
'POST',
|
||||
Buffer.from(
|
||||
'--loc\r\nContent-Disposition: form-data; name="file"; filename="test.txt"\r\nContent-Type: text/plain\r\n\r\ntest\r\n--loc--\r\n',
|
||||
),
|
||||
randomUUID(),
|
||||
{ 'content-type': 'multipart/form-data; boundary=loc' },
|
||||
);
|
||||
assert.equal(result.statusCode, 200, result.body);
|
||||
return f.service.storage.fileRows(id)[0];
|
||||
}
|
||||
const first = await upload('KR', 'Daejeon', '8.8.8.8'),
|
||||
second = await upload('JP', 'Tokyo', '1.1.1.1'),
|
||||
info = f.service.storage.layout.ownerInfo;
|
||||
const locations = new Map(
|
||||
f.service.admin.files({}).items.map((file) => [file.id, file.lastLocation]),
|
||||
);
|
||||
assert.equal(locations.get(first.id).city, 'Daejeon');
|
||||
assert.equal(locations.get(second.id).city, 'Tokyo');
|
||||
f.service.db
|
||||
.delete(s.fileUploadContexts)
|
||||
.where(eq(s.fileUploadContexts.fileId, first.id))
|
||||
.run();
|
||||
assert.equal(
|
||||
info.fileLocation(first).city,
|
||||
'Daejeon',
|
||||
'recover only the matching legacy request',
|
||||
);
|
||||
f.service.db
|
||||
.delete(s.fileUploadContexts)
|
||||
.where(eq(s.fileUploadContexts.fileId, first.id))
|
||||
.run();
|
||||
const owner = info.details(first.storageKey);
|
||||
info.write(
|
||||
owner,
|
||||
info.read(owner).events.filter((event) => event.requestId !== first.requestId),
|
||||
);
|
||||
assert.equal(
|
||||
info.fileLocation(first),
|
||||
null,
|
||||
'a missing old record must not use the newest location',
|
||||
);
|
||||
f.service.db
|
||||
.update(s.fileUploadContexts)
|
||||
.set({ expiresAt: Date.now() - 1 })
|
||||
.run();
|
||||
const old = info.read(owner).events.map((event) => ({
|
||||
...event,
|
||||
recordedAt: new Date(Date.now() - 8 * 86400000).toISOString(),
|
||||
}));
|
||||
info.write(owner, old);
|
||||
await info.cleanup();
|
||||
assert.equal(f.service.db.select().from(s.fileUploadContexts).all().length, 0);
|
||||
assert.equal(info.fileLocation(second), null);
|
||||
} finally {
|
||||
await f.close();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'">
|
||||
|
||||
24
apps/web/src/displayFormat.js
Normal file
24
apps/web/src/displayFormat.js
Normal 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user