pegasus/frontend/src/Uploader.tsx

759 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend/src/Uploader.tsx
import React from 'react'
import { api, WhoAmI } from './api'
import * as tus from 'tus-js-client'
type FileRow = { name: string; path: string; is_dir: boolean; size: number; mtime: number }
type Sel = { file: File; desc: string; date: string; finalName: string; progress?: number; err?: string }
console.log('[Pegasus] FE bundle activated at', new Date().toISOString())
// ---------- helpers ----------
function sanitizeDesc(s: string) {
s = s.trim().replace(/\s+/g, '_').replace(/[^A-Za-z0-9._-]+/g, '_')
if (!s) s = 'upload'
return s.slice(0, 64)
}
function sanitizeFolderName(s: string) {
// 1 level only; convert whitespace -> underscores; allow [A-Za-z0-9_.-]
s = s.trim().replace(/[\/]+/g, '/').replace(/^\//, '').replace(/\/.*$/, '')
s = s.replace(/\s+/g, '_').replace(/[^\w.\-]/g, '_').replace(/_+/g, '_')
return s.slice(0, 64)
}
function extOf(n: string) {
const i = n.lastIndexOf('.')
return i > -1 ? n.slice(i + 1).toLowerCase() : ''
}
function stemOf(n: string) {
const i = n.lastIndexOf('.')
const stem = i > -1 ? n.slice(0, i) : n
// keep safe charset and avoid extra dots within segments
return sanitizeDesc(stem.replace(/\./g, '_')) || 'file'
}
function composeName(date: string, desc: string, orig: string) {
const d = date || new Date().toISOString().slice(0, 10)
const [Y, M, D] = d.split('-')
const sDesc = sanitizeDesc(desc)
const sStem = stemOf(orig)
const ext = extOf(orig) || 'bin'
// New pattern: YYYY.MM.DD.Description.OriginalStem.ext
return `${Y}.${M}.${D}.${sDesc}.${sStem}.${ext}`
}
function clampOneLevel(p: string) {
if (!p) return ''
return p.replace(/^\/+|\/+$/g, '').split('/')[0] || ''
}
function normalizeRows(listRaw: any[]): FileRow[] {
return (Array.isArray(listRaw) ? listRaw : []).map((r: any) => ({
name: r?.name ?? r?.Name ?? '',
path: r?.path ?? r?.Path ?? '',
is_dir: Boolean(r?.is_dir ?? r?.IsDir ?? r?.isDir ?? false),
size: Number(r?.size ?? r?.Size ?? 0),
mtime: Number(r?.mtime ?? r?.Mtime ?? 0),
}))
}
const videoExt = new Set(['mp4', 'mkv', 'mov', 'avi', 'm4v', 'webm', 'mpg', 'mpeg', 'ts', 'm2ts'])
const imageExt = new Set(['jpg', 'jpeg', 'png', 'gif', 'heic', 'heif', 'webp', 'bmp', 'tif', 'tiff'])
const extLower = (n: string) => (n.includes('.') ? n.split('.').pop()!.toLowerCase() : '')
const isVideoFile = (f: File) => f.type.startsWith('video/') || videoExt.has(extLower(f.name))
const isImageFile = (f: File) => f.type.startsWith('image/') || imageExt.has(extLower(f.name))
function isDetailedError(e: unknown): e is tus.DetailedError {
return typeof e === 'object' && e !== null && ('originalRequest' in (e as any) || 'originalResponse' in (e as any))
}
function isLikelyMobileUA(): boolean {
if (typeof window === 'undefined') return false
const ua = navigator.userAgent || ''
const coarse = window.matchMedia && window.matchMedia('(pointer: coarse)').matches
return coarse || /Mobi|Android|iPhone|iPad|iPod/i.test(ua)
}
function useIsMobile(): boolean {
const [mobile, setMobile] = React.useState(false)
React.useEffect(() => {
setMobile(isLikelyMobileUA())
}, [])
return mobile
}
// Disable tus resume completely (v2 API: addUpload/removeUpload/listUploads)
const NoResumeUrlStorage: any = {
addUpload: async (_u: any) => {},
removeUpload: async (_u: any) => {},
listUploads: async () => [],
findUploadsByFingerprint: async (_fp: string) => [],
}
const NoResumeFingerprint = async () => `noresume-${Date.now()}-${Math.random().toString(36).slice(2)}`
// ---------- thumbnail ----------
function PreviewThumb({ file, size = 96 }: { file: File; size?: number }) {
const [url, setUrl] = React.useState<string>()
React.useEffect(() => {
const u = URL.createObjectURL(file)
setUrl(u)
return () => {
try {
URL.revokeObjectURL(u)
} catch {}
}
}, [file])
if (!url) return null
const baseStyle: React.CSSProperties = { width: size, height: size, objectFit: 'cover', borderRadius: 12 }
if (isImageFile(file)) return <img src={url} alt="" style={baseStyle} className="preview-thumb" />
if (isVideoFile(file)) return <video src={url} muted preload="metadata" playsInline style={baseStyle} className="preview-thumb" />
return (
<div style={{ ...baseStyle, display: 'grid', placeItems: 'center' }} className="preview-thumb">
📄
</div>
)
}
// ---------- component ----------
export default function Uploader() {
const mobile = useIsMobile()
const [me, setMe] = React.useState<WhoAmI | undefined>()
const [libs, setLibs] = React.useState<string[]>([])
const [lib, setLib] = React.useState<string>('') // required to upload
const [sub, setSub] = React.useState<string>('')
const [rootDirs, setRootDirs] = React.useState<string[]>([])
const [rows, setRows] = React.useState<FileRow[]>([])
const [status, setStatus] = React.useState<string>('')
const [globalDate, setGlobalDate] = React.useState<string>(new Date().toISOString().slice(0, 10))
const [uploading, setUploading] = React.useState<boolean>(false)
const [sel, setSel] = React.useState<Sel[]>([])
const [bulkDesc, setBulkDesc] = React.useState<string>('') // helper: apply to all videos
const folderInputRef = React.useRef<HTMLInputElement>(null)
// keep raw input for folder name (let user type anything; sanitize on create)
const [newFolderRaw, setNewFolderRaw] = React.useState('')
React.useEffect(() => {
const el = folderInputRef.current
if (!el) return
if (mobile) {
el.removeAttribute('webkitdirectory')
el.removeAttribute('directory')
} else {
el.setAttribute('webkitdirectory', '')
el.setAttribute('directory', '')
}
}, [mobile])
// initial load
React.useEffect(() => {
;(async () => {
try {
setStatus('Loading profile…')
const m = await api<WhoAmI>('/api/whoami')
setMe(m as any)
const mm: any = m
const L: string[] =
Array.isArray(mm?.roots) ? mm.roots : Array.isArray(mm?.libs) ? mm.libs : typeof mm?.root === 'string' && mm.root ? [mm.root] : []
setLibs(L)
setLib('') // do NOT auto-pick; user will choose
setSub('')
setStatus('Choose a library to start')
} catch (e: any) {
const msg = String(e?.message || e || '')
setStatus(`Profile error: ${msg}`)
if (msg.toLowerCase().includes('no mapping')) {
alert('Your account is not linked to any upload library yet. Please contact the admin to be granted access.')
try {
await api('/api/logout', { method: 'POST' })
} catch {}
location.replace('/')
}
}
})()
}, [])
React.useEffect(() => {
;(async () => {
if (!lib) {
setRootDirs([])
setRows([])
setSub('')
return
}
try {
setStatus(`Loading library “${lib}”…`)
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] refresh error', e)
setStatus(`List error: ${e?.message || e}`)
}
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lib])
async function refresh(currLib: string, currSub: string) {
if (!currLib) return
const one = clampOneLevel(currSub)
const listRoot = normalizeRows(await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path: '' })}`))
const rootDirNames = listRoot
.filter((e) => e.is_dir)
.map((e) => e.name)
.filter(Boolean)
setRootDirs(rootDirNames.sort((a, b) => a.localeCompare(b)))
const subOk = one && rootDirNames.includes(one) ? one : ''
if (subOk !== currSub) setSub(subOk)
const path = subOk ? subOk : ''
const list = subOk ? await api<any[]>(`/api/list?${new URLSearchParams({ lib: currLib, path })}`) : listRoot
setRows(normalizeRows(list))
const show = `/${[currLib, path].filter(Boolean).join('/')}`
setStatus(`Ready · Destination: ${show}`)
}
function handleChoose(files: FileList) {
const arr = Array.from(files).map((f) => {
const base = (f as any).webkitRelativePath || f.name
const name = base.split('/').pop() || f.name
const desc = '' // start empty; required later for videos only
return { file: f, desc, date: globalDate, finalName: composeName(globalDate, sanitizeDesc(desc), name), progress: 0 }
})
console.log('[Pegasus] selected files', arr.map((a) => a.file.name))
setSel(arr)
}
// recompute finalName when global date changes
React.useEffect(() => {
setSel((old) => old.map((x) => ({ ...x, date: globalDate, finalName: composeName(globalDate, x.desc, x.file.name) })))
}, [globalDate])
// Warn before closing mid-upload
React.useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (uploading) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [uploading])
// Apply description to all videos helper
function applyDescToAllVideos() {
if (!bulkDesc.trim()) return
setSel((old) =>
old.map((x) => (isVideoFile(x.file) ? { ...x, desc: bulkDesc, finalName: composeName(x.date, bulkDesc, x.file.name) } : x))
)
}
async function doUpload() {
if (!me) {
setStatus('Not signed in')
return
}
if (!lib) {
alert('Please select a Library to upload into.')
return
}
// Require description only for videos:
const missingVideos = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
if (missingVideos > 0) {
alert(`Please add a short description for all videos. ${missingVideos} video(s) missing a description.`)
return
}
setStatus('Starting upload…')
setUploading(true)
try {
for (const s of sel) {
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve, reject) => {
const opts: tus.UploadOptions & { withCredentials?: boolean } = {
endpoint: '/tus/',
chunkSize: 5 * 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000, 10000],
metadata: {
filename: s.file.name,
lib: lib,
subdir: sub || '',
date: s.date,
desc: s.desc, // server enforces: required for videos only
},
onError: (err: Error | tus.DetailedError) => {
let msg = String(err)
if (isDetailedError(err)) {
const status = (err.originalRequest as any)?.status
const statusText = (err.originalRequest as any)?.statusText
if (status) msg = `HTTP ${status} ${statusText || ''}`.trim()
}
console.error('[Pegasus] tus error', s.file.name, err)
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, err: msg } : x)))
setStatus(`${s.file.name}: ${msg}`)
alert(`Upload failed: ${s.file.name}\n\n${msg}`)
reject(err as any)
},
onProgress: (sent: number, total: number) => {
const pct = Math.floor((sent / Math.max(total, 1)) * 100)
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: pct } : x)))
setStatus(`${s.finalName}: ${pct}%`)
},
onSuccess: () => {
setSel((old) => old.map((x) => (x.file === s.file ? { ...x, progress: 100, err: undefined } : x)))
setStatus(`${s.finalName} uploaded`)
console.log('[Pegasus] tus success', s.file.name)
resolve()
},
}
opts.withCredentials = true
;(opts as any).urlStorage = NoResumeUrlStorage
;(opts as any).fingerprint = NoResumeFingerprint
const up = new tus.Upload(s.file, opts)
console.log('[Pegasus] tus.start', { name: s.file.name, size: s.file.size, type: s.file.type, lib, sub })
up.start()
})
}
setStatus('All uploads complete')
setSel([])
await refresh(lib, sub)
} finally {
setUploading(false)
}
}
// -------- one-level subfolder ops --------
async function createSubfolder(nameRaw: string) {
const name = sanitizeFolderName(nameRaw)
if (!name) return
try {
await api(`/api/mkdir`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, path: name }),
})
await refresh(lib, name) // jump into new folder
setSub(name)
} catch (e: any) {
console.error('[Pegasus] mkdir error', e)
alert(`Create folder failed:\n${e?.message || e}`)
}
}
async function renameFolder(oldName: string) {
const nn = prompt('New folder name:', oldName)
const newName = sanitizeFolderName(nn || '')
if (!newName || newName === oldName) return
try {
await api('/api/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldName, to: newName }),
})
const newSub = sub === oldName ? newName : sub
setSub(newSub)
await refresh(lib, newSub)
} catch (e: any) {
console.error('[Pegasus] rename folder error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deleteFolder(name: string) {
if (!confirm(`Delete folder “${name}” (and its contents)?`)) return
try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: name, recursive: 'true' })}`, { method: 'DELETE' })
const newSub = sub === name ? '' : sub
setSub(newSub)
await refresh(lib, newSub)
} catch (e: any) {
console.error('[Pegasus] delete folder error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// destination listing (actions: rename files only at library root)
async function renamePath(oldp: string) {
const base = oldp.split('/').pop() || ''
const name = prompt('New name (YYYY.MM.DD.Description.OrigStem.ext):', base)
if (!name) return
try {
await api('/api/rename', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ lib: lib, from: oldp, to: oldp.split('/').slice(0, -1).concat(sanitizeFolderName(name)).join('/') }),
})
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] rename error', e)
alert(`Rename failed:\n${e?.message || e}`)
}
}
async function deletePath(p: string, recursive: boolean) {
if (!confirm(`Delete ${p}${recursive ? ' (recursive)' : ''}?`)) return
try {
await api(`/api/file?${new URLSearchParams({ lib: lib, path: p, recursive: recursive ? 'true' : 'false' })}`, {
method: 'DELETE',
})
await refresh(lib, sub)
} catch (e: any) {
console.error('[Pegasus] delete error', e)
alert(`Delete failed:\n${e?.message || e}`)
}
}
// sort rows
const sortedRows = React.useMemo(() => {
const arr = Array.isArray(rows) ? rows.slice() : []
return arr.sort((a, b) => {
const dirFirst = Number(b?.is_dir ? 1 : 0) - Number(a?.is_dir ? 1 : 0)
if (dirFirst !== 0) return dirFirst
const an = a?.name ?? ''
const bn = b?.name ?? ''
return an.localeCompare(bn)
})
}, [rows])
// Existing filenames in the destination (to block collisions)
const existingNames = React.useMemo(() => new Set(sortedRows.filter((r) => !r.is_dir).map((r) => r.name)), [sortedRows])
// Duplicates inside this batch
const duplicateNamesInSelection = React.useMemo(() => {
const counts = new Map<string, number>()
sel.forEach((s) => counts.set(s.finalName, (counts.get(s.finalName) || 0) + 1))
return new Set(Array.from(counts.entries()).filter(([, c]) => c > 1).map(([n]) => n))
}, [sel])
const hasNameIssues = React.useMemo(
() => sel.some((s) => duplicateNamesInSelection.has(s.finalName) || existingNames.has(s.finalName)),
[sel, duplicateNamesInSelection, existingNames]
)
const destPath = `/${[lib, sub].filter(Boolean).join('/')}`
const videosNeedingDesc = sel.filter((s) => isVideoFile(s.file) && !s.desc.trim()).length
return (
<>
{/* Header context */}
<section>
<hgroup>
<h2>Signed in: {me?.username}</h2>
<p className="meta">
Destination: <strong>{destPath || '/(choose a library)'}</strong>
</p>
</hgroup>
</section>
{/* Choose content */}
<section>
<h3>Choose content</h3>
<div className="grid-3">
<label>
Default date
<input type="date" value={globalDate} onChange={(e) => setGlobalDate(e.target.value)} />
</label>
</div>
<div className="file-picker">
{mobile ? (
<>
<label>
Gallery/Photos
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
<label>
Camera (optional)
<input type="file" accept="image/*,video/*" capture="environment" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
</>
) : (
<>
<label>
Select file(s)
<input type="file" multiple accept="image/*,video/*" onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
<label>
Select folder(s)
<input type="file" multiple ref={folderInputRef} onChange={(e) => e.target.files && handleChoose(e.target.files)} />
</label>
</>
)}
</div>
</section>
{/* Review */}
<section>
<h3>Review Files</h3>
{sel.length === 0 ? (
<p className="meta">Select at least one file.</p>
) : (
<>
<article>
<div style={{ display: 'grid', gap: 12 }}>
<label>
Description for all videos (optional)
<input placeholder="Short video description" value={bulkDesc} onChange={(e) => setBulkDesc(e.target.value)} />
</label>
<button type="button" className="stretch" onClick={applyDescToAllVideos} disabled={!bulkDesc.trim()}>
Apply to all videos
</button>
{videosNeedingDesc > 0 ? (
<small className="meta bad">{videosNeedingDesc} video(s) need a description</small>
) : (
<small className="meta mono-wrap">All videos have descriptions</small>
)}
</div>
</article>
{sel.map((s, i) => (
<article key={i} className="video-card">
<div className="thumb-row">
<PreviewThumb file={s.file} size={isVideoFile(s.file) ? 176 : 128} />
</div>
<h4 className="filename wrap-anywhere" title={s.file.name}>
{s.file.name}
</h4>
<label>
{isVideoFile(s.file) ? 'Short description (required for video)' : 'Description (optional for image)'}
<input
required={isVideoFile(s.file)}
aria-invalid={isVideoFile(s.file) && !s.desc.trim()}
placeholder={isVideoFile(s.file) ? 'Required for video' : 'Optional for image'}
value={s.desc}
onChange={(e) => {
const v = e.target.value
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, desc: v, finalName: composeName(x.date, v, x.file.name) } : x)))
}}
/>
</label>
<label>
Date
<input
type="date"
value={s.date}
onChange={(e) => {
const v = e.target.value
setSel((old) => old.map((x, idx) => (idx === i ? { ...x, date: v, finalName: composeName(v, x.desc, x.file.name) } : x)))
}}
/>
</label>
<small className="meta" title={s.finalName}>
{s.finalName}
</small>
{typeof s.progress === 'number' && <progress max={100} value={s.progress}></progress>}
{s.err && <small className="meta bad">Error: {s.err}</small>}
{duplicateNamesInSelection.has(s.finalName) && (
<small className="meta bad">Duplicate name in this batch. Adjust description or date.</small>
)}
{existingNames.has(s.finalName) && <small className="meta bad">A file with this name already exists here.</small>}
</article>
))}
</>
)}
</section>
{/* Destination */}
<section>
<h3>Select Destination</h3>
<p className="meta">Manage a library & choose a destination.</p>
{/* Library only (required). No subfolder dropdown. */}
<div className="grid-3">
<label>
Library (required)
<select
value={lib}
onChange={(e) => {
const v = e.target.value
setLib(v)
setSub('')
if (v) void refresh(v, '')
}}
>
<option value=""> Select a library </option>
{libs.map((L) => (
<option key={L} value={L}>
{L}
</option>
))}
</select>
</label>
</div>
{!lib ? (
<p className="meta">Select a library to create subfolders and view contents.</p>
) : (
<>
{/* New subfolder: free typing; sanitize on create */}
<div className="grid-3">
<label style={{ gridColumn: '1 / -1' }}>
Add a new subfolder (optional):
<input
placeholder="letters, numbers, underscores, dashes"
value={newFolderRaw}
onChange={(e) => setNewFolderRaw(e.target.value)}
inputMode="text"
/>
</label>
{newFolderRaw.trim() && (
<div className="row-center" style={{ gridColumn: '1 / -1', marginTop: 8 }}>
<button
type="button"
onClick={() => {
void createSubfolder(newFolderRaw)
setNewFolderRaw('')
}}
>
Create
</button>
</div>
)}
</div>
{/* Current selection with actions; includes Home button to go to root */}
<article className="card">
<div className="row-between" style={{ marginBottom: 6 }}>
<div>
<span className="meta">Current Sub-Folder:</span> <strong className="wrap-anywhere">{sub || '(library root)'}</strong>
</div>
</div>
{sub && (
<div className="sub-actions">
<button
type="button"
className="icon-btn"
title="Go to library root"
aria-label="Go to library root"
onClick={() => { setSub(''); void refresh(lib, '') }}
>
🏠
</button>
<button
type="button"
onClick={() => void renameFolder(sub)}
>
Rename
</button>
<button
type="button"
className="icon-btn danger"
title="Delete subfolder"
aria-label="Delete subfolder"
onClick={() => void deleteFolder(sub)}
>
</button>
</div>
)}
</article>
{/* Subfolders: only Select (right), name + icon (left); clamp width to avoid horizontal overflow */}
<details open style={{ marginTop: 8 }}>
<summary>Subfolders</summary>
{rootDirs.length === 0 ? (
<p className="meta">
No subfolders yet. Youll upload into <b>/{lib}</b>.
</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{rootDirs.map((d) => (
<article key={d} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={d}>📁 {d}</div>
</div>
<div className="row-right">
<button
onClick={() => {
setSub(d)
void refresh(lib, d)
}}
>
Select
</button>
</div>
</article>
))}
</div>
)}
</details>
{/* Contents: info left (clamped), actions right */}
<details open style={{ marginTop: 8 }}>
<summary>Contents of {destPath || '/(choose)'}</summary>
{sortedRows.length === 0 ? (
<p className="meta">Empty.</p>
) : (
<div style={{ display: 'grid', gap: 8 }}>
{sortedRows.map((f) => (
<article key={f.path} className="list-row">
<div className="name" style={{ flex: '1 1 auto', minWidth: 0 }}>
<div className="wrap-anywhere" title={f.name || '(unnamed)'}>{f.is_dir ? '📁' : '🎞️'} {f.name || '(unnamed)'}</div>
<small className="meta meta-wrap">{f.is_dir ? 'folder' : fmt(f.size)} · {f.mtime ? new Date(f.mtime * 1000).toLocaleString() : ''}</small>
</div>
<div className="row-right">
{f.is_dir ? (
<button
onClick={() => {
setSub(f.name)
void refresh(lib, f.name)
}}
>
Open
</button>
) : (
<button
className="icon-btn bad"
title="Delete"
aria-label={`Delete ${f.name}`}
onClick={() => void deletePath(f.path, false)}
>
</button>
)}
</div>
</article>
))}
</div>
)}
</details>
</>
)}
</section>
<footer style={{ display: 'flex', gap: 12, alignItems: 'center', justifyContent: 'space-between', marginTop: 8 }}>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<small className="meta" aria-live="polite">
{status}
</small>
{hasNameIssues && <small className="meta bad">Resolve duplicate/existing filename conflicts to enable Upload.</small>}
</div>
<button
type="button"
onClick={() => {
const disabled = !lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues
if (!disabled) void doUpload()
}}
disabled={!lib || !sel.length || uploading || videosNeedingDesc > 0 || hasNameIssues}
>
Upload {sel.length ? `(${sel.length})` : ''}
</button>
</footer>
</>
)
}
function fmt(n: number) {
if (n < 1024) return n + ' B'
const u = ['KB', 'MB', 'GB', 'TB']
let i = -1
do {
n /= 1024
i++
} while (n >= 1024 && i < u.length - 1)
return n.toFixed(1) + ' ' + u[i]
}