mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): enhance directory browser with metadata and sorting (#1055)
* feat(web): enhance directory browser with metadata and sorting * test(web): update ToolCard fixture timestamps
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { DirectoryEntry } from '@/types/api'
|
||||
import { sortDirectoryEntries } from './directory-sort'
|
||||
|
||||
const entries: DirectoryEntry[] = [
|
||||
{ name: 'large.txt', type: 'file', size: 500, modified: 20 },
|
||||
{ name: 'folder-b', type: 'directory', size: 999, modified: 30 },
|
||||
{ name: 'small.txt', type: 'file', size: 10, modified: 10 },
|
||||
{ name: 'unknown.txt', type: 'file' },
|
||||
{ name: 'folder-a', type: 'directory', size: 1, modified: 5 },
|
||||
]
|
||||
|
||||
describe('directory sorting', () => {
|
||||
it('defaults to folders first and name ascending', () => {
|
||||
expect(sortDirectoryEntries(entries, { field: 'name', direction: 'asc' }, 'en').map((entry) => entry.name)).toEqual([
|
||||
'folder-a', 'folder-b', 'large.txt', 'small.txt', 'unknown.txt',
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts files by size while keeping folders alphabetic', () => {
|
||||
expect(sortDirectoryEntries(entries, { field: 'size', direction: 'desc' }, 'en').map((entry) => entry.name)).toEqual([
|
||||
'folder-a', 'folder-b', 'large.txt', 'small.txt', 'unknown.txt',
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts by modified time and leaves missing metadata last', () => {
|
||||
expect(sortDirectoryEntries(entries, { field: 'modified', direction: 'desc' }, 'en').map((entry) => entry.name)).toEqual([
|
||||
'folder-b', 'folder-a', 'large.txt', 'small.txt', 'unknown.txt',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DirectoryEntry } from '@/types/api'
|
||||
import type { Locale } from '@/lib/use-translation'
|
||||
|
||||
export type DirectorySortField = 'name' | 'modified' | 'size'
|
||||
export type DirectorySortDirection = 'asc' | 'desc'
|
||||
export type DirectorySort = { field: DirectorySortField; direction: DirectorySortDirection }
|
||||
|
||||
export const DEFAULT_DIRECTORY_SORT: DirectorySort = { field: 'name', direction: 'asc' }
|
||||
|
||||
function compareOptionalNumbers(left: number | undefined, right: number | undefined, direction: DirectorySortDirection): number {
|
||||
const leftMissing = left === undefined || !Number.isFinite(left)
|
||||
const rightMissing = right === undefined || !Number.isFinite(right)
|
||||
if (leftMissing && rightMissing) return 0
|
||||
if (leftMissing) return 1
|
||||
if (rightMissing) return -1
|
||||
return direction === 'asc' ? left - right : right - left
|
||||
}
|
||||
|
||||
export function sortDirectoryEntries(
|
||||
entries: DirectoryEntry[],
|
||||
sort: DirectorySort,
|
||||
locale: Locale,
|
||||
): DirectoryEntry[] {
|
||||
const collator = new Intl.Collator(locale, { numeric: true, sensitivity: 'base' })
|
||||
const byName = (left: DirectoryEntry, right: DirectoryEntry, direction: DirectorySortDirection) => {
|
||||
const result = collator.compare(left.name, right.name)
|
||||
return direction === 'asc' ? result : -result
|
||||
}
|
||||
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftDirectory = left.type === 'directory'
|
||||
const rightDirectory = right.type === 'directory'
|
||||
if (leftDirectory !== rightDirectory) return leftDirectory ? -1 : 1
|
||||
|
||||
if (sort.field === 'name') return byName(left, right, sort.direction)
|
||||
if (sort.field === 'size' && leftDirectory) return byName(left, right, 'asc')
|
||||
|
||||
const result = compareOptionalNumbers(left[sort.field], right[sort.field], sort.direction)
|
||||
return result || byName(left, right, 'asc')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export function downloadBase64File(fileName: string, base64Content: string, mimeType?: string | null): void {
|
||||
const byteChars = atob(base64Content)
|
||||
const bytes = new Uint8Array(byteChars.length)
|
||||
for (let index = 0; index < byteChars.length; index += 1) {
|
||||
bytes[index] = byteChars.charCodeAt(index)
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(new Blob([bytes], { type: mimeType ?? 'application/octet-stream' }))
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { formatFileMetadata, formatFileSize, formatModifiedTime } from './file-metadata'
|
||||
|
||||
describe('file metadata formatting', () => {
|
||||
it('formats byte sizes using compact binary units', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B')
|
||||
expect(formatFileSize(999)).toBe('999 B')
|
||||
expect(formatFileSize(1536)).toBe('1.5 KB')
|
||||
expect(formatFileSize(12 * 1024)).toBe('12 KB')
|
||||
expect(formatFileSize(2.25 * 1024 * 1024)).toBe('2.3 MB')
|
||||
})
|
||||
|
||||
it('ignores invalid metadata', () => {
|
||||
expect(formatFileSize(undefined)).toBeNull()
|
||||
expect(formatFileSize(-1)).toBeNull()
|
||||
expect(formatModifiedTime(Number.NaN, 'en')).toBeNull()
|
||||
})
|
||||
|
||||
it('combines available size and modified time', () => {
|
||||
vi.stubGlobal('Intl', {
|
||||
...Intl,
|
||||
DateTimeFormat: class {
|
||||
format() { return '2026/07/16 10:31' }
|
||||
},
|
||||
})
|
||||
expect(formatFileMetadata(1024, 1_784_175_060_000, 'en')).toBe('2026/07/16 10:31 · 1 KB')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Locale } from '@/lib/use-translation'
|
||||
|
||||
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const
|
||||
|
||||
export function formatFileSize(bytes: number | undefined): string | null {
|
||||
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0) return null
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
|
||||
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1)
|
||||
const value = bytes / (1024 ** unitIndex)
|
||||
const formatted = value >= 10 ? Math.round(value).toString() : value.toFixed(1).replace(/\.0$/, '')
|
||||
return `${formatted} ${BYTE_UNITS[unitIndex]}`
|
||||
}
|
||||
|
||||
export function formatModifiedTime(modified: number | undefined, locale: Locale): string | null {
|
||||
if (modified === undefined || !Number.isFinite(modified)) return null
|
||||
const date = new Date(modified)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function formatFileMetadata(size: number | undefined, modified: number | undefined, locale: Locale): string | null {
|
||||
return [formatModifiedTime(modified, locale), formatFileSize(size)].filter(Boolean).join(' · ') || null
|
||||
}
|
||||
@@ -341,6 +341,21 @@ export default {
|
||||
'files.directories.empty': 'Empty directory.',
|
||||
'files.directories.error.listFailed': 'Failed to list directory.',
|
||||
'files.directories.error.listFailedWithDetail': 'Failed to list directory: {error}',
|
||||
'files.directories.download': 'Download file',
|
||||
'files.directories.downloadNamed': 'Download {name}',
|
||||
'files.directories.download.error.title': 'Download failed',
|
||||
'files.directories.download.error.default': 'Failed to download file.',
|
||||
'files.sort.title': 'Sort files',
|
||||
'files.sort.by': 'Sort by',
|
||||
'files.sort.name': 'Name',
|
||||
'files.sort.modified': 'Modified time',
|
||||
'files.sort.size': 'File size',
|
||||
'files.sort.nameAsc': 'A to Z',
|
||||
'files.sort.nameDesc': 'Z to A',
|
||||
'files.sort.oldest': 'Oldest first',
|
||||
'files.sort.newest': 'Newest first',
|
||||
'files.sort.smallest': 'Smallest first',
|
||||
'files.sort.largest': 'Largest first',
|
||||
|
||||
// File page
|
||||
'file.page.fallbackName': 'File',
|
||||
|
||||
@@ -345,6 +345,21 @@ export default {
|
||||
'files.directories.empty': '空目录。',
|
||||
'files.directories.error.listFailed': '加载目录失败。',
|
||||
'files.directories.error.listFailedWithDetail': '加载目录失败:{error}',
|
||||
'files.directories.download': '下载文件',
|
||||
'files.directories.downloadNamed': '下载 {name}',
|
||||
'files.directories.download.error.title': '下载失败',
|
||||
'files.directories.download.error.default': '文件下载失败。',
|
||||
'files.sort.title': '文件排序',
|
||||
'files.sort.by': '排序依据',
|
||||
'files.sort.name': '名称',
|
||||
'files.sort.modified': '修改时间',
|
||||
'files.sort.size': '文件大小',
|
||||
'files.sort.nameAsc': 'A 到 Z',
|
||||
'files.sort.nameDesc': 'Z 到 A',
|
||||
'files.sort.oldest': '最早优先',
|
||||
'files.sort.newest': '最新优先',
|
||||
'files.sort.smallest': '最小优先',
|
||||
'files.sort.largest': '最大优先',
|
||||
|
||||
// File page
|
||||
'file.page.fallbackName': '文件',
|
||||
|
||||
Reference in New Issue
Block a user