new changes

This commit is contained in:
Niranjan
2026-04-07 09:46:22 +05:30
parent b679cc3bb5
commit 5e86cc7e40
36 changed files with 1077 additions and 208 deletions

View File

@@ -163,12 +163,67 @@ export async function downloadSiteBackup(siteId: number, filename: string): Prom
URL.revokeObjectURL(url)
}
export interface FileListItem {
name: string
is_dir: boolean
size: number
mtime?: string
mtime_ts?: number
mode?: string
mode_symbolic?: string
owner?: string
group?: string
}
export async function listFiles(path: string) {
return apiRequest<{ path: string; items: { name: string; is_dir: boolean; size: number }[] }>(
`/files/list?path=${encodeURIComponent(path)}`
return apiRequest<{ path: string; items: FileListItem[] }>(`/files/list?path=${encodeURIComponent(path)}`)
}
export async function fileDirSize(path: string) {
return apiRequest<{ size: number }>(`/files/dir-size?path=${encodeURIComponent(path)}`)
}
export async function fileSearch(q: string, path: string, maxResults = 200) {
return apiRequest<{ path: string; query: string; results: { path: string; name: string; is_dir: boolean }[] }>(
`/files/search?q=${encodeURIComponent(q)}&path=${encodeURIComponent(path)}&max_results=${maxResults}`
)
}
export async function fileChmod(filePath: string, mode: string, recursive = false) {
return apiRequest<{ status: boolean; msg: string }>('/files/chmod', {
method: 'POST',
body: JSON.stringify({ file_path: filePath, mode, recursive }),
})
}
export async function fileTouch(parentPath: string, name: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/touch', {
method: 'POST',
body: JSON.stringify({ path: parentPath, name }),
})
}
export async function fileCopy(sourceParent: string, name: string, destParent: string, destName?: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/copy', {
method: 'POST',
body: JSON.stringify({ path: sourceParent, name, dest_path: destParent, dest_name: destName ?? null }),
})
}
export async function fileMove(sourceParent: string, name: string, destParent: string, destName?: string) {
return apiRequest<{ status: boolean; msg: string }>('/files/move', {
method: 'POST',
body: JSON.stringify({ path: sourceParent, name, dest_path: destParent, dest_name: destName ?? null }),
})
}
export async function fileCompress(parentPath: string, names: string[], archiveName: string) {
return apiRequest<{ status: boolean; msg: string; archive?: string }>('/files/compress', {
method: 'POST',
body: JSON.stringify({ path: parentPath, names, archive_name: archiveName }),
})
}
export async function uploadFile(path: string, file: File) {
const form = new FormData()
form.append('path', path)

View File

@@ -1,27 +1,58 @@
import { useEffect, useState, useRef } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Modal from 'react-bootstrap/Modal'
import { listFiles, downloadFile, uploadFile, readFile, writeFile, mkdirFile, renameFile, deleteFile } from '../api/client'
import Dropdown from 'react-bootstrap/Dropdown'
import {
listFiles,
downloadFile,
uploadFile,
readFile,
writeFile,
mkdirFile,
renameFile,
deleteFile,
fileDirSize,
fileSearch,
fileChmod,
fileTouch,
fileCopy,
fileMove,
fileCompress,
type FileListItem,
} from '../api/client'
import { PageHeader, AdminButton, AdminAlert, AdminTable, EmptyState } from '../components/admin'
interface FileItem {
name: string
is_dir: boolean
size: number
function joinPath(dir: string, name: string): string {
if (dir === '/') return `/${name}`
return `${dir.replace(/\/$/, '')}/${name}`
}
function parentPath(p: string): string {
const parts = p.replace(/\/$/, '').split('/').filter(Boolean)
parts.pop()
return parts.length === 0 ? '/' : `/${parts.join('/')}`
}
function formatSize(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'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md', '.py', '.php', '.sh', '.conf', '.env']
const TEXT_EXT = ['.txt', '.html', '.htm', '.css', '.js', '.json', '.xml', '.md', '.py', '.php', '.sh', '.conf', '.env', '.ini', '.log', '.yml', '.yaml']
type Clip = { op: 'copy' | 'cut'; entries: { parent: string; name: string }[] }
export function FilesPage() {
const [path, setPath] = useState('/')
const [items, setItems] = useState<FileItem[]>([])
const [pathInput, setPathInput] = useState('/')
const [items, setItems] = useState<FileListItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [filter, setFilter] = useState('')
const [selected, setSelected] = useState<Set<string>>(() => new Set())
const [clipboard, setClipboard] = useState<Clip | null>(null)
const [dirSizes, setDirSizes] = useState<Record<string, number>>({})
const [downloading, setDownloading] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const [editingFile, setEditingFile] = useState<string | null>(null)
@@ -29,44 +60,86 @@ export function FilesPage() {
const [savingEdit, setSavingEdit] = useState(false)
const [showMkdir, setShowMkdir] = useState(false)
const [mkdirName, setMkdirName] = useState('')
const [renaming, setRenaming] = useState<FileItem | null>(null)
const [showNewFile, setShowNewFile] = useState(false)
const [newFileName, setNewFileName] = useState('')
const [renaming, setRenaming] = useState<FileListItem | null>(null)
const [renameValue, setRenameValue] = useState('')
const [showChmod, setShowChmod] = useState(false)
const [chmodItem, setChmodItem] = useState<FileListItem | null>(null)
const [chmodMode, setChmodMode] = useState('0644')
const [chmodRecursive, setChmodRecursive] = useState(false)
const [showCompress, setShowCompress] = useState(false)
const [compressName, setCompressName] = useState('archive.zip')
const [showSearchModal, setShowSearchModal] = useState(false)
const [searchQ, setSearchQ] = useState('')
const [searchLoading, setSearchLoading] = useState(false)
const [searchResults, setSearchResults] = useState<{ path: string; name: string; is_dir: boolean }[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const loadDir = (p: string) => {
const loadDir = useCallback((p: string) => {
setLoading(true)
setError('')
setSelected(new Set())
listFiles(p)
.then((data) => {
setPath(data.path)
setPathInput(data.path)
setItems(data.items.sort((a, b) => (a.is_dir === b.is_dir ? 0 : a.is_dir ? -1 : 1)))
setDirSizes({})
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}
}, [])
useEffect(() => {
loadDir(path)
}, [])
const handleNavigate = (item: FileItem) => {
if (item.is_dir) {
const newPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
loadDir(newPath)
}
const filteredItems = useMemo(() => {
const q = filter.trim().toLowerCase()
if (!q) return items
return items.filter((i) => i.name.toLowerCase().includes(q))
}, [items, filter])
const pathSegments = path.replace(/\/$/, '').split('/').filter(Boolean)
const canGoBack = pathSegments.length > 0
const toggleSelect = (name: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(name)) next.delete(name)
else next.add(name)
return next
})
}
const selectAll = () => {
if (selected.size === filteredItems.length) setSelected(new Set())
else setSelected(new Set(filteredItems.map((i) => i.name)))
}
const selectedList = useMemo(
() => filteredItems.filter((i) => selected.has(i.name)),
[filteredItems, selected]
)
const handleNavigate = (item: FileListItem) => {
if (item.is_dir) loadDir(joinPath(path, item.name))
}
const handleBack = () => {
const parts = path.replace(/\/$/, '').split('/').filter(Boolean)
if (parts.length === 0) return
parts.pop()
const newPath = parts.length === 0 ? '/' : '/' + parts.join('/')
loadDir(newPath)
if (!canGoBack) return
loadDir(parentPath(path))
}
const handleDownload = (item: FileItem) => {
const goPathInput = () => {
const p = pathInput.trim() || '/'
loadDir(p.startsWith('/') ? p : `/${p}`)
}
const handleDownload = (item: FileListItem) => {
if (item.is_dir) return
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
const fullPath = joinPath(path, item.name)
setDownloading(item.name)
downloadFile(fullPath)
.catch((err) => setError(err.message))
@@ -87,8 +160,8 @@ export function FilesPage() {
})
}
const handleEdit = (item: FileItem) => {
const fullPath = path.endsWith('/') ? path + item.name : path + '/' + item.name
const handleEdit = (item: FileListItem) => {
const fullPath = joinPath(path, item.name)
readFile(fullPath)
.then((data) => {
setEditingFile(fullPath)
@@ -124,6 +197,19 @@ export function FilesPage() {
.catch((err) => setError(err.message))
}
const handleTouch = (e: React.FormEvent) => {
e.preventDefault()
const name = newFileName.trim()
if (!name) return
fileTouch(path, name)
.then(() => {
setShowNewFile(false)
setNewFileName('')
loadDir(path)
})
.catch((err) => setError(err.message))
}
const handleRename = () => {
if (!renaming || !renameValue.trim()) return
const newName = renameValue.trim()
@@ -140,44 +226,253 @@ export function FilesPage() {
.catch((err) => setError(err.message))
}
const handleDelete = (item: FileItem) => {
const handleDelete = (item: FileListItem) => {
if (!confirm(`Delete ${item.is_dir ? 'folder' : 'file'} "${item.name}"?`)) return
deleteFile(path, item.name, item.is_dir)
.then(() => loadDir(path))
.catch((err) => setError(err.message))
}
const pathSegments = path.replace(/\/$/, '').split('/').filter(Boolean)
const canGoBack = pathSegments.length > 0
const batchDelete = () => {
if (selectedList.length === 0) return
if (!confirm(`Delete ${selectedList.length} item(s)?`)) return
Promise.all(
selectedList.map((i) => deleteFile(path, i.name, i.is_dir))
)
.then(() => loadDir(path))
.catch((err) => setError(err.message))
}
const copySelection = () => {
if (selectedList.length === 0) return
setClipboard({
op: 'copy',
entries: selectedList.map((i) => ({ parent: path, name: i.name })),
})
}
const cutSelection = () => {
if (selectedList.length === 0) return
setClipboard({
op: 'cut',
entries: selectedList.map((i) => ({ parent: path, name: i.name })),
})
}
const pasteHere = () => {
if (!clipboard || clipboard.entries.length === 0) return
const tasks = clipboard.entries.map((e) => {
if (clipboard.op === 'copy') return fileCopy(e.parent, e.name, path)
return fileMove(e.parent, e.name, path)
})
Promise.all(tasks)
.then(() => {
if (clipboard?.op === 'cut') setClipboard(null)
loadDir(path)
})
.catch((err) => setError(err.message))
}
const openChmod = (item: FileListItem) => {
setChmodItem(item)
setChmodMode(item.mode ? item.mode.padStart(3, '0') : '0644')
setChmodRecursive(item.is_dir)
setShowChmod(true)
}
const submitChmod = () => {
if (!chmodItem) return
const fp = joinPath(path, chmodItem.name)
fileChmod(fp, chmodMode, chmodRecursive && chmodItem.is_dir)
.then(() => {
setShowChmod(false)
setChmodItem(null)
loadDir(path)
})
.catch((err) => setError(err.message))
}
const openCompress = () => {
const names = selectedList.length > 0 ? selectedList.map((i) => i.name) : []
if (names.length === 0) return
setCompressName(`archive-${Date.now()}.zip`)
setShowCompress(true)
}
const submitCompress = () => {
const names = selectedList.length > 0 ? selectedList.map((i) => i.name) : []
if (!compressName.trim() || names.length === 0) return
fileCompress(path, names, compressName.trim())
.then(() => {
setShowCompress(false)
loadDir(path)
})
.catch((err) => setError(err.message))
}
const calcDirSize = (item: FileListItem) => {
if (!item.is_dir) return
const fp = joinPath(path, item.name)
fileDirSize(fp)
.then((r) => setDirSizes((d) => ({ ...d, [item.name]: r.size })))
.catch((err) => setError(err.message))
}
const runDeepSearch = () => {
const q = searchQ.trim()
if (!q) return
setSearchLoading(true)
setSearchResults([])
fileSearch(q, path, 300)
.then((r) => {
setSearchResults(r.results)
setShowSearchModal(true)
})
.catch((err) => setError(err.message))
.finally(() => setSearchLoading(false))
}
const navigateToSearchHit = (hit: { path: string; is_dir: boolean }) => {
setShowSearchModal(false)
if (hit.is_dir) loadDir(hit.path)
else loadDir(parentPath(hit.path))
}
const breadcrumbNavigate = (idx: number) => {
if (idx < 0) {
loadDir('/')
return
}
const segs = pathSegments.slice(0, idx + 1)
loadDir(`/${segs.join('/')}`)
}
return (
<>
<PageHeader title="Files" />
<div className="d-flex flex-wrap align-items-center gap-2 mb-3">
<AdminButton variant="secondary" size="sm" onClick={handleBack} disabled={!canGoBack}>
<i className="ti ti-arrow-left me-1" aria-hidden />
Back
</AdminButton>
<input ref={fileInputRef} type="file" className="d-none" onChange={handleUpload} />
<AdminButton variant="success" size="sm" onClick={() => setShowMkdir(true)}>
<i className="ti ti-folder-plus me-1" aria-hidden />
New Folder
</AdminButton>
<AdminButton variant="primary" size="sm" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? (
<span className="spinner-border spinner-border-sm me-1" role="status" />
) : (
<i className="ti ti-upload me-1" aria-hidden />
)}
Upload
</AdminButton>
<code className="small bg-body-secondary px-2 py-1 rounded ms-auto text-break">Path: {path || '/'}</code>
<div className="card mb-3">
<div className="card-body py-2">
<div className="d-flex flex-wrap align-items-center gap-2 mb-2">
<AdminButton variant="secondary" size="sm" onClick={handleBack} disabled={!canGoBack}>
<i className="ti ti-arrow-left me-1" aria-hidden />
Back
</AdminButton>
<AdminButton variant="outline-secondary" size="sm" onClick={() => loadDir(path)} disabled={loading}>
<i className="ti ti-refresh me-1" aria-hidden />
Refresh
</AdminButton>
<Dropdown as="span">
<Dropdown.Toggle variant="success" size="sm" id="files-new-dropdown">
<i className="ti ti-plus me-1" aria-hidden />
New
</Dropdown.Toggle>
<Dropdown.Menu>
<Dropdown.Item onClick={() => setShowMkdir(true)}>
<i className="ti ti-folder-plus me-2" />
Folder
</Dropdown.Item>
<Dropdown.Item onClick={() => setShowNewFile(true)}>
<i className="ti ti-file-plus me-2" />
File
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<input ref={fileInputRef} type="file" className="d-none" onChange={handleUpload} />
<AdminButton variant="primary" size="sm" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? <span className="spinner-border spinner-border-sm me-1" role="status" /> : <i className="ti ti-upload me-1" aria-hidden />}
Upload
</AdminButton>
{selectedList.length === 1 && !selectedList[0].is_dir ? (
<AdminButton variant="outline-primary" size="sm" onClick={() => handleDownload(selectedList[0])}>
<i className="ti ti-download me-1" aria-hidden />
Download
</AdminButton>
) : null}
<AdminButton variant="outline-secondary" size="sm" onClick={copySelection} disabled={selectedList.length === 0}>
<i className="ti ti-copy me-1" aria-hidden />
Copy
</AdminButton>
<AdminButton variant="outline-secondary" size="sm" onClick={cutSelection} disabled={selectedList.length === 0}>
<i className="ti ti-cut me-1" aria-hidden />
Cut
</AdminButton>
<AdminButton variant="outline-primary" size="sm" onClick={pasteHere} disabled={!clipboard || clipboard.entries.length === 0}>
<i className="ti ti-clipboard me-1" aria-hidden />
Paste
</AdminButton>
<AdminButton variant="warning" size="sm" onClick={openCompress} disabled={selectedList.length === 0}>
<i className="ti ti-file-zip me-1" aria-hidden />
Compress
</AdminButton>
<AdminButton variant="outline-danger" size="sm" onClick={batchDelete} disabled={selectedList.length === 0}>
<i className="ti ti-trash me-1" aria-hidden />
Delete
</AdminButton>
</div>
<div className="d-flex flex-wrap align-items-center gap-2">
<div className="input-group input-group-sm" style={{ minWidth: 240, maxWidth: 480 }}>
<span className="input-group-text">Path</span>
<input
className="form-control font-monospace small"
value={pathInput}
onChange={(e) => setPathInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && goPathInput()}
/>
<AdminButton variant="primary" size="sm" className="rounded-0 rounded-end" type="button" onClick={goPathInput}>
Go
</AdminButton>
</div>
<div className="input-group input-group-sm flex-grow-1" style={{ minWidth: 200 }}>
<span className="input-group-text">
<i className="ti ti-search" aria-hidden />
</span>
<input
className="form-control"
placeholder="Filter current folder…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
<div className="input-group input-group-sm" style={{ minWidth: 200 }}>
<input
className="form-control"
placeholder="Search subfolders…"
value={searchQ}
onChange={(e) => setSearchQ(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && runDeepSearch()}
/>
<AdminButton variant="outline-secondary" size="sm" type="button" onClick={runDeepSearch} disabled={searchLoading}>
{searchLoading ? <span className="spinner-border spinner-border-sm" role="status" /> : 'Search'}
</AdminButton>
</div>
</div>
<nav aria-label="breadcrumb" className="mt-2 mb-0">
<ol className="breadcrumb mb-0 small py-1">
<li className="breadcrumb-item">
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => loadDir('/')}>
/
</button>
</li>
{pathSegments.map((seg, i) => (
<li key={`${seg}-${i}`} className={`breadcrumb-item${i === pathSegments.length - 1 ? ' active' : ''}`}>
{i === pathSegments.length - 1 ? (
seg
) : (
<button type="button" className="btn btn-link btn-sm p-0 text-decoration-none" onClick={() => breadcrumbNavigate(i)}>
{seg}
</button>
)}
</li>
))}
</ol>
</nav>
</div>
</div>
<Modal show={showMkdir} onHide={() => { setShowMkdir(false); setMkdirName('') }} centered>
<Modal.Header closeButton>
<Modal.Title>New Folder</Modal.Title>
<Modal.Title>New folder</Modal.Title>
</Modal.Header>
<form onSubmit={handleMkdir}>
<Modal.Body>
@@ -200,6 +495,109 @@ export function FilesPage() {
</form>
</Modal>
<Modal show={showNewFile} onHide={() => { setShowNewFile(false); setNewFileName('') }} centered>
<Modal.Header closeButton>
<Modal.Title>New file</Modal.Title>
</Modal.Header>
<form onSubmit={handleTouch}>
<Modal.Body>
<input
value={newFileName}
onChange={(e) => setNewFileName(e.target.value)}
placeholder="filename.txt"
className="form-control"
autoFocus
/>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => { setShowNewFile(false); setNewFileName('') }}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary">
Create
</AdminButton>
</Modal.Footer>
</form>
</Modal>
<Modal show={showChmod} onHide={() => { setShowChmod(false); setChmodItem(null) }} centered>
<Modal.Header closeButton>
<Modal.Title>Permissions</Modal.Title>
</Modal.Header>
<Modal.Body>
{chmodItem ? (
<>
<p className="small font-monospace text-break mb-2">{joinPath(path, chmodItem.name)}</p>
<label className="form-label small">Mode (octal)</label>
<input className="form-control mb-2 font-monospace" value={chmodMode} onChange={(e) => setChmodMode(e.target.value)} placeholder="0644" />
{chmodItem.is_dir ? (
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="chmod-rec"
checked={chmodRecursive}
onChange={(e) => setChmodRecursive(e.target.checked)}
/>
<label className="form-check-label small" htmlFor="chmod-rec">
Recursive (chmod entire tree)
</label>
</div>
) : null}
</>
) : null}
</Modal.Body>
<Modal.Footer>
<AdminButton variant="secondary" onClick={() => { setShowChmod(false); setChmodItem(null) }}>
Cancel
</AdminButton>
<AdminButton variant="primary" onClick={submitChmod}>
Apply
</AdminButton>
</Modal.Footer>
</Modal>
<Modal show={showCompress} onHide={() => setShowCompress(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Compress to ZIP</Modal.Title>
</Modal.Header>
<Modal.Body>
<p className="small text-secondary mb-2">{selectedList.length} item(s) selected</p>
<label className="form-label small">Archive name</label>
<input className="form-control font-monospace" value={compressName} onChange={(e) => setCompressName(e.target.value)} />
</Modal.Body>
<Modal.Footer>
<AdminButton variant="secondary" onClick={() => setShowCompress(false)}>
Cancel
</AdminButton>
<AdminButton variant="primary" onClick={submitCompress}>
Create ZIP
</AdminButton>
</Modal.Footer>
</Modal>
<Modal show={showSearchModal} onHide={() => setShowSearchModal(false)} size="lg" scrollable>
<Modal.Header closeButton>
<Modal.Title>Search results{searchQ ? `: “${searchQ}` : ''}</Modal.Title>
</Modal.Header>
<Modal.Body>
{searchResults.length === 0 ? (
<p className="text-secondary small mb-0">No matches.</p>
) : (
<ul className="list-group list-group-flush">
{searchResults.map((r) => (
<li key={r.path} className="list-group-item d-flex justify-content-between align-items-center">
<span className="small font-monospace text-break me-2">{r.path}</span>
<AdminButton size="sm" variant="outline-primary" onClick={() => navigateToSearchHit(r)}>
Open
</AdminButton>
</li>
))}
</ul>
)}
</Modal.Body>
</Modal>
<Modal show={!!editingFile} onHide={() => setEditingFile(null)} fullscreen="lg-down" size="lg">
<Modal.Header closeButton>
<Modal.Title className="text-break small font-monospace">{editingFile}</Modal.Title>
@@ -231,116 +629,212 @@ export function FilesPage() {
<span className="spinner-border text-secondary" role="status" />
</div>
) : (
<AdminTable>
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{items.length === 0 ? (
<>
<AdminTable>
<thead>
<tr>
<td colSpan={3} className="p-0">
<EmptyState title="Empty directory" description="Upload files or create a folder." />
</td>
<th style={{ width: 40 }}>
<input
type="checkbox"
className="form-check-input"
checked={filteredItems.length > 0 && selected.size === filteredItems.length}
onChange={selectAll}
aria-label="Select all"
/>
</th>
<th>Name</th>
<th>Size</th>
<th className="d-none d-lg-table-cell">Modified</th>
<th className="d-none d-md-table-cell">Permission</th>
<th className="d-none d-xl-table-cell">Owner</th>
<th className="text-end" style={{ minWidth: 120 }}>
Operation
</th>
</tr>
) : (
items.map((item) => (
<tr key={item.name}>
<td>
<button
type="button"
onClick={() => handleNavigate(item)}
className="btn btn-link text-start text-decoration-none p-0 d-inline-flex align-items-center gap-2"
>
<i className={`ti ${item.is_dir ? 'ti-folder text-warning' : 'ti-file text-secondary'}`} aria-hidden />
<span>{item.name}</span>
</button>
</td>
<td className="text-secondary">{item.is_dir ? '—' : formatSize(item.size)}</td>
<td className="text-end">
{renaming?.name === item.name ? (
<span className="d-inline-flex gap-1 align-items-center flex-wrap justify-content-end">
<input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
className="form-control form-control-sm"
style={{ width: '8rem' }}
autoFocus
/>
<button type="button" className="btn btn-link btn-sm text-success p-1" title="Save" onClick={handleRename}>
<i className="ti ti-check" aria-hidden />
</button>
<button
type="button"
className="btn btn-link btn-sm p-1"
onClick={() => {
setRenaming(null)
setRenameValue('')
}}
>
Cancel
</button>
</span>
) : (
<span className="d-inline-flex gap-1 justify-content-end">
<button
type="button"
className="btn btn-link btn-sm text-secondary p-1"
title="Rename"
onClick={() => {
setRenaming(item)
setRenameValue(item.name)
}}
>
<i className="ti ti-pencil" aria-hidden />
</button>
{!item.is_dir && canEdit(item.name) ? (
<button
type="button"
className="btn btn-link btn-sm text-warning p-1"
title="Edit"
onClick={() => handleEdit(item)}
>
<i className="ti ti-edit" aria-hidden />
</button>
) : null}
{!item.is_dir ? (
<button
type="button"
className="btn btn-link btn-sm text-primary p-1"
title="Download"
disabled={downloading === item.name}
onClick={() => handleDownload(item)}
>
{downloading === item.name ? (
<span className="spinner-border spinner-border-sm" role="status" />
) : (
<i className="ti ti-download" aria-hidden />
)}
</button>
) : null}
<button
type="button"
className="btn btn-link btn-sm text-danger p-1"
title="Delete"
onClick={() => handleDelete(item)}
>
<i className="ti ti-trash" aria-hidden />
</button>
</span>
)}
</thead>
<tbody>
{filteredItems.length === 0 ? (
<tr>
<td colSpan={7} className="p-0">
<EmptyState title="Empty directory" description="Upload files, create a folder, or adjust the path filter." />
</td>
</tr>
))
)}
</tbody>
</AdminTable>
) : (
filteredItems.map((item) => (
<tr key={item.name}>
<td onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
className="form-check-input"
checked={selected.has(item.name)}
onChange={() => toggleSelect(item.name)}
aria-label={`Select ${item.name}`}
/>
</td>
<td>
{item.is_dir ? (
<button
type="button"
onClick={() => handleNavigate(item)}
className="btn btn-link text-start text-decoration-none p-0 d-inline-flex align-items-center gap-2"
>
<i className="ti ti-folder text-warning" aria-hidden />
<span>{item.name}</span>
</button>
) : (
<span className="d-inline-flex align-items-center gap-2">
<i className="ti ti-file text-secondary" aria-hidden />
{item.name}
</span>
)}
</td>
<td className="text-secondary small">
{item.is_dir ? (
dirSizes[item.name] !== undefined ? (
formatSize(dirSizes[item.name])
) : (
<button type="button" className="btn btn-link btn-sm p-0" onClick={() => calcDirSize(item)}>
Calculate
</button>
)
) : (
formatSize(item.size)
)}
</td>
<td className="small text-secondary d-none d-lg-table-cell">{item.mtime ?? '—'}</td>
<td className="small font-monospace d-none d-md-table-cell">
{item.mode_symbolic ? (
<>
<span title={`${item.mode_symbolic} (${item.mode})`}>{item.mode_symbolic}</span>
</>
) : (
item.mode ?? '—'
)}
</td>
<td className="small d-none d-xl-table-cell">
{item.owner ? `${item.owner}:${item.group ?? ''}` : '—'}
</td>
<td className="text-end">
{renaming?.name === item.name ? (
<span className="d-inline-flex gap-1 align-items-center flex-wrap justify-content-end">
<input
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
className="form-control form-control-sm"
style={{ width: '7rem' }}
autoFocus
/>
<button type="button" className="btn btn-link btn-sm text-success p-1" title="Save" onClick={handleRename}>
<i className="ti ti-check" aria-hidden />
</button>
<button
type="button"
className="btn btn-link btn-sm p-1"
onClick={() => {
setRenaming(null)
setRenameValue('')
}}
>
Cancel
</button>
</span>
) : (
<Dropdown align="end" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Dropdown.Toggle variant="light" size="sm" className="py-0 border">
More
</Dropdown.Toggle>
<Dropdown.Menu>
{item.is_dir ? (
<Dropdown.Item onClick={() => handleNavigate(item)}>
<i className="ti ti-folder-open me-2" />
Open
</Dropdown.Item>
) : (
<>
<Dropdown.Item onClick={() => handleDownload(item)} disabled={downloading === item.name}>
<i className="ti ti-download me-2" />
Download
</Dropdown.Item>
{canEdit(item.name) ? (
<Dropdown.Item onClick={() => handleEdit(item)}>
<i className="ti ti-edit me-2" />
Edit
</Dropdown.Item>
) : null}
</>
)}
<Dropdown.Divider />
<Dropdown.Item onClick={() => { setClipboard({ op: 'copy', entries: [{ parent: path, name: item.name }] }) }}>
<i className="ti ti-copy me-2" />
Copy
</Dropdown.Item>
<Dropdown.Item onClick={() => { setClipboard({ op: 'cut', entries: [{ parent: path, name: item.name }] }) }}>
<i className="ti ti-cut me-2" />
Cut
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
setRenaming(item)
setRenameValue(item.name)
}}
>
<i className="ti ti-pencil me-2" />
Rename
</Dropdown.Item>
<Dropdown.Item onClick={() => openChmod(item)}>
<i className="ti ti-lock me-2" />
Permission
</Dropdown.Item>
<Dropdown.Item
onClick={() => {
setSelected(new Set([item.name]))
setCompressName(`${item.name.replace(/\.[^/.]+$/, '') || 'archive'}.zip`)
setShowCompress(true)
}}
>
<i className="ti ti-file-zip me-2" />
Compress
</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item className="text-danger" onClick={() => handleDelete(item)}>
<i className="ti ti-trash me-2" />
Delete
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
)}
</td>
</tr>
))
)}
</tbody>
</AdminTable>
{!loading && filteredItems.length > 0 ? (
<div className="card-footer py-2 small text-secondary d-flex flex-wrap gap-3">
<span>
Directories:{' '}
<strong>{filteredItems.filter((i) => i.is_dir).length}</strong>
</span>
<span>
Files:{' '}
<strong>{filteredItems.filter((i) => !i.is_dir).length}</strong>
</span>
<span>
Showing <strong>{filteredItems.length}</strong> of <strong>{items.length}</strong> in folder
</span>
</div>
) : null}
</>
)}
</div>
{clipboard && clipboard.entries.length > 0 ? (
<div className="alert alert-light border mt-3 mb-0 small py-2">
<i className="ti ti-clipboard me-1" aria-hidden />
Clipboard: <strong>{clipboard.op}</strong> ({clipboard.entries.length} item) use <strong>Paste</strong> in the target folder.
</div>
) : null}
</>
)
}