Files
waste-go/web/src/components/FileBrowser.tsx

193 lines
6.4 KiB
TypeScript
Raw Normal View History

import { useState, useMemo } from 'react'
import { useWaste } from '../store'
import { BrowserAdapter } from '../adapter/browser'
import type { FileEntry } from '../types'
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
type SortKey = 'name' | 'size'
type SortDir = 'asc' | 'desc'
interface DirEntry {
kind: 'dir'
name: string
path: string
count: number
}
interface FileRow {
kind: 'file'
name: string
path: string
size: number
}
type Row = DirEntry | FileRow
function buildRows(files: FileEntry[], cwd: string, search: string, sort: SortKey, sortDir: SortDir): Row[] {
// Filter to entries under cwd
const prefix = cwd ? cwd + '/' : ''
const inDir = files.filter(f => {
const p = f.path ?? f.name
return p.startsWith(prefix)
})
if (search.trim()) {
// Flat search across all files under cwd
const q = search.toLowerCase()
return inDir
.filter(f => f.name.toLowerCase().includes(q))
.map(f => ({ kind: 'file' as const, name: f.name, path: f.path ?? f.name, size: f.size_bytes }))
.sort((a, b) => {
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
return sortDir === 'asc' ? cmp : -cmp
})
}
// Build immediate children: dirs and files at this level
const dirs = new Map<string, number>() // dirName → file count
const fileRows: FileRow[] = []
for (const f of inDir) {
const rel = (f.path ?? f.name).slice(prefix.length)
const slash = rel.indexOf('/')
if (slash === -1) {
fileRows.push({ kind: 'file', name: f.name, path: f.path ?? f.name, size: f.size_bytes })
} else {
const dirName = rel.slice(0, slash)
dirs.set(dirName, (dirs.get(dirName) ?? 0) + 1)
}
}
const dirRows: DirEntry[] = Array.from(dirs.entries()).map(([name, count]) => ({
kind: 'dir', name, path: prefix + name, count,
}))
// Sort each group separately, then dirs first
const cmpFiles = (a: FileRow, b: FileRow) => {
const cmp = sort === 'name' ? a.name.localeCompare(b.name) : a.size - b.size
return sortDir === 'asc' ? cmp : -cmp
}
dirRows.sort((a, b) => a.name.localeCompare(b.name))
fileRows.sort(cmpFiles)
return [...dirRows, ...fileRows]
}
export function FileBrowser() {
const { activeFilePeer, fileLists, setActiveFilePeer, adapter, connectedPeers } = useWaste()
const [cwd, setCwd] = useState('')
const [search, setSearch] = useState('')
const [sort, setSort] = useState<SortKey>('name')
const [sortDir, setSortDir] = useState<SortDir>('asc')
if (!activeFilePeer) return null
const peer = connectedPeers.find(p => p.id === activeFilePeer)
const files = fileLists[activeFilePeer] ?? null
function download(path: string) {
if (adapter instanceof BrowserAdapter) {
adapter.requestGet(activeFilePeer!, path)
}
}
function toggleSort(key: SortKey) {
if (sort === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSort(key); setSortDir('asc') }
}
function navigateUp() {
const parts = cwd.split('/')
parts.pop()
setCwd(parts.join('/'))
}
const rows = useMemo(() => {
if (!files) return []
return buildRows(files, cwd, search, sort, sortDir)
}, [files, cwd, search, sort, sortDir])
const cwdParts = cwd ? cwd.split('/') : []
return (
<div className="file-browser">
<div className="file-browser-header">
<span className="file-browser-title">Files · {peer?.alias ?? activeFilePeer.slice(0, 8)}</span>
<button className="file-browser-close" onClick={() => setActiveFilePeer(null)}></button>
</div>
{files === null ? (
<div className="file-browser-empty">Loading</div>
) : files.length === 0 ? (
<div className="file-browser-empty">No files shared</div>
) : (
<>
<div className="file-browser-toolbar">
<input
className="file-browser-search"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="search…"
/>
</div>
{/* Breadcrumb */}
<div className="file-browser-breadcrumb">
<button onClick={() => setCwd('')} className="breadcrumb-seg">~</button>
{cwdParts.map((seg, i) => (
<span key={i}>
<span className="breadcrumb-sep">/</span>
<button
className="breadcrumb-seg"
onClick={() => setCwd(cwdParts.slice(0, i + 1).join('/'))}
>{seg}</button>
</span>
))}
</div>
{/* Sort bar */}
<div className="file-browser-sortbar">
<button className={`sort-btn ${sort === 'name' ? 'active' : ''}`} onClick={() => toggleSort('name')}>
name {sort === 'name' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</button>
<button className={`sort-btn ${sort === 'size' ? 'active' : ''}`} onClick={() => toggleSort('size')}>
size {sort === 'size' ? (sortDir === 'asc' ? '↑' : '↓') : ''}
</button>
</div>
<ul className="file-list">
{cwd && !search && (
<li className="file-entry file-entry-dir" onClick={navigateUp}>
<span className="file-entry-icon">📁</span>
<span className="file-entry-name">..</span>
</li>
)}
{rows.length === 0 && (
<li className="file-browser-empty">no results</li>
)}
{rows.map(row => row.kind === 'dir' ? (
<li key={row.path} className="file-entry file-entry-dir" onClick={() => { setCwd(row.path); setSearch('') }}>
<span className="file-entry-icon">📁</span>
<span className="file-entry-name">{row.name}</span>
<span className="file-entry-size">{row.count} file{row.count !== 1 ? 's' : ''}</span>
</li>
) : (
<li key={row.path} className="file-entry">
<span className="file-entry-icon">📄</span>
<span className="file-entry-name">{row.name}</span>
<span className="file-entry-size">{fmt(row.size)}</span>
<button className="file-entry-dl" onClick={() => download(row.path)}></button>
</li>
))}
</ul>
</>
)}
</div>
)
}