new changes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user