92bed208e0
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
1457 lines
54 KiB
TypeScript
1457 lines
54 KiB
TypeScript
"use client"
|
|
|
|
import { useEffect, useState, useRef } from "react"
|
|
import { useRouter } from "next/navigation"
|
|
import { Header } from "@/components/Header"
|
|
import { DirectoryTree } from "@/components/navigator/DirectoryTree"
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
import { Button } from "@/components/ui/button"
|
|
import { Dialog } from "@/components/ui/dialog"
|
|
import { formatBytes } from "@/lib/utils"
|
|
import {
|
|
AlertCircle, Download, Trash2, Edit2, ChevronUp, RefreshCw, Upload,
|
|
FolderPlus, Home, ChevronRight, Copy, Scissors, Clipboard, Grid2x2, List, Search as SearchIcon
|
|
} from "lucide-react"
|
|
|
|
interface FileEntry {
|
|
name: string
|
|
path: string
|
|
is_dir: boolean
|
|
is_link: boolean
|
|
link_target?: string
|
|
size: number
|
|
modified: number
|
|
modified_iso: string
|
|
permissions: string
|
|
uid: number
|
|
gid: number
|
|
error?: string
|
|
}
|
|
|
|
interface SpaceInfo {
|
|
used: number
|
|
available: number
|
|
total: number
|
|
}
|
|
|
|
export default function NavigatorPage() {
|
|
const router = useRouter()
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
// State
|
|
const [currentPath, setCurrentPath] = useState("")
|
|
const [files, setFiles] = useState<FileEntry[]>([])
|
|
const [spaceInfo, setSpaceInfo] = useState<SpaceInfo | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
|
|
// Admin mode state
|
|
const [adminMode, setAdminMode] = useState(false)
|
|
const [showUnlockDialog, setShowUnlockDialog] = useState(false)
|
|
const [unlockPassword, setUnlockPassword] = useState("")
|
|
const [unlockError, setUnlockError] = useState<string | null>(null)
|
|
|
|
// Selected file state
|
|
const [selectedFile, setSelectedFile] = useState<FileEntry | null>(null)
|
|
|
|
// View mode and multi-select
|
|
const [viewMode, setViewMode] = useState<"list" | "grid">("list")
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [lastSelected, setLastSelected] = useState<string | null>(null)
|
|
const [clipboard, setClipboard] = useState<{ paths: string[]; op: "copy" | "cut" } | null>(null)
|
|
const [isDragOver, setIsDragOver] = useState(false)
|
|
|
|
// Search
|
|
const [searchQuery, setSearchQuery] = useState("")
|
|
const [searchResults, setSearchResults] = useState<FileEntry[] | null>(null)
|
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
|
|
|
// Rename dialog
|
|
const [renameDialog, setRenameDialog] = useState(false)
|
|
const [renameTarget, setRenameTarget] = useState<{ path: string; name: string } | null>(null)
|
|
const [renameName, setRenameName] = useState("")
|
|
|
|
// Preview
|
|
const [previewContent, setPreviewContent] = useState<string | null>(null)
|
|
|
|
// Permission edit state
|
|
const [editMode, setEditMode] = useState(false)
|
|
const [editOwner, setEditOwner] = useState("")
|
|
const [editGroup, setEditGroup] = useState("")
|
|
const [usernames, setUsernames] = useState<string[]>([])
|
|
const [groupnames, setGroupnames] = useState<string[]>([])
|
|
const [editPerms, setEditPerms] = useState({
|
|
ownerRead: false,
|
|
ownerWrite: false,
|
|
ownerExec: false,
|
|
groupRead: false,
|
|
groupWrite: false,
|
|
groupExec: false,
|
|
otherRead: false,
|
|
otherWrite: false,
|
|
otherExec: false,
|
|
})
|
|
|
|
// Dialog states
|
|
const [newFolderDialog, setNewFolderDialog] = useState(false)
|
|
const [newFolderName, setNewFolderName] = useState("")
|
|
const [deleteDialog, setDeleteDialog] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<string | null>(null)
|
|
|
|
// Auth check and initial load
|
|
useEffect(() => {
|
|
const token = localStorage.getItem("access_token")
|
|
if (!token) {
|
|
router.push("/login")
|
|
return
|
|
}
|
|
// Load admin mode state from sessionStorage
|
|
const savedAdminMode = sessionStorage.getItem("files_admin_mode") === "true"
|
|
setAdminMode(savedAdminMode)
|
|
loadDirectory("")
|
|
loadUsersAndGroups()
|
|
}, [router])
|
|
|
|
const loadUsersAndGroups = async () => {
|
|
try {
|
|
const headers = getAuthHeader()
|
|
// Load users
|
|
const usersRes = await fetch(getApiUrl("/api/identities/users"), { headers })
|
|
if (usersRes.ok) {
|
|
const usersData = await usersRes.json()
|
|
const names = (usersData.users || []).map((u: any) => u.username || String(u.uid))
|
|
setUsernames(names)
|
|
}
|
|
// Load groups
|
|
const groupsRes = await fetch(getApiUrl("/api/identities/groups"), { headers })
|
|
if (groupsRes.ok) {
|
|
const groupsData = await groupsRes.json()
|
|
const names = (groupsData.groups || []).map((g: any) => g.groupname)
|
|
setGroupnames(names)
|
|
}
|
|
} catch (err) {
|
|
// Silently fail - autocomplete is optional
|
|
}
|
|
}
|
|
|
|
const getAuthHeader = () => {
|
|
const token = localStorage.getItem("access_token")
|
|
return {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
}
|
|
}
|
|
|
|
const getApiUrl = (path: string) => {
|
|
const baseUrl = process.env.NEXT_PUBLIC_API_URL || ""
|
|
return baseUrl + path
|
|
}
|
|
|
|
const loadDirectory = async (path: string) => {
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
setEditMode(false)
|
|
|
|
const headers = getAuthHeader()
|
|
|
|
// Load directory listing
|
|
const listRes = await fetch(
|
|
getApiUrl(`/api/navigator/browse?path=${encodeURIComponent(path)}&admin=true`),
|
|
{ headers }
|
|
)
|
|
if (!listRes.ok) {
|
|
const errorData = await listRes.json().catch(() => ({}))
|
|
throw new Error(errorData.detail || "Failed to load directory")
|
|
}
|
|
const listData = await listRes.json()
|
|
|
|
setCurrentPath(path || "/")
|
|
setFiles(listData.entries || [])
|
|
|
|
// Load space info
|
|
try {
|
|
const spaceRes = await fetch(getApiUrl("/api/navigator/space"), { headers })
|
|
if (spaceRes.ok) {
|
|
const spaceData = await spaceRes.json()
|
|
setSpaceInfo(spaceData)
|
|
}
|
|
} catch {
|
|
// Space info optional
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load directory")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleNavigate = (path: string) => {
|
|
loadDirectory(path)
|
|
}
|
|
|
|
const handleUnlock = async () => {
|
|
setUnlockError(null)
|
|
try {
|
|
const headers = getAuthHeader()
|
|
const res = await fetch(getApiUrl("/api/navigator/unlock"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ password: unlockPassword }),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json()
|
|
throw new Error(data.detail || "Invalid password")
|
|
}
|
|
|
|
// Success - enable admin mode
|
|
setAdminMode(true)
|
|
sessionStorage.setItem("files_admin_mode", "true")
|
|
setShowUnlockDialog(false)
|
|
setUnlockPassword("")
|
|
setError(null)
|
|
// Reload to show admin interface
|
|
loadDirectory("")
|
|
} catch (err) {
|
|
setUnlockError(err instanceof Error ? err.message : "Failed to unlock")
|
|
}
|
|
}
|
|
|
|
const handleLockDown = () => {
|
|
setAdminMode(false)
|
|
sessionStorage.removeItem("files_admin_mode")
|
|
setCurrentPath("")
|
|
loadDirectory("")
|
|
}
|
|
|
|
const handleUpClick = () => {
|
|
const parts = currentPath.split("/").filter(Boolean)
|
|
if (parts.length > 0) {
|
|
parts.pop()
|
|
handleNavigate("/" + parts.join("/"))
|
|
} else {
|
|
handleNavigate("")
|
|
}
|
|
}
|
|
|
|
const handleCreateFolder = async () => {
|
|
if (!newFolderName.trim()) return
|
|
try {
|
|
const folderPath = currentPath === "/" ? "/" + newFolderName : currentPath + "/" + newFolderName
|
|
const headers = getAuthHeader()
|
|
const res = await fetch(getApiUrl("/api/navigator/mkdir"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ path: folderPath }),
|
|
})
|
|
if (!res.ok) throw new Error("Failed to create folder")
|
|
setNewFolderName("")
|
|
setNewFolderDialog(false)
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to create folder")
|
|
}
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
if (!deleteTarget) return
|
|
try {
|
|
const headers = getAuthHeader()
|
|
const res = await fetch(
|
|
getApiUrl(`/api/navigator/delete?path=${encodeURIComponent(deleteTarget)}&recursive=true`),
|
|
{ method: "DELETE", headers }
|
|
)
|
|
if (!res.ok) throw new Error("Failed to delete")
|
|
setDeleteDialog(false)
|
|
setDeleteTarget(null)
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to delete")
|
|
}
|
|
}
|
|
|
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = e.currentTarget.files
|
|
if (!files) return
|
|
|
|
setUploading(true)
|
|
try {
|
|
const headers = { Authorization: `Bearer ${localStorage.getItem("access_token")}` }
|
|
const uploadPath = currentPath === "/" ? "" : currentPath
|
|
|
|
for (const file of files) {
|
|
const formData = new FormData()
|
|
formData.append("file", file)
|
|
|
|
const res = await fetch(
|
|
getApiUrl(`/api/navigator/upload?path=${encodeURIComponent(uploadPath)}`),
|
|
{ method: "POST", headers, body: formData }
|
|
)
|
|
if (!res.ok) throw new Error(`Failed to upload ${file.name}`)
|
|
}
|
|
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Upload failed")
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
const handleDownload = async () => {
|
|
if (!selectedFile || selectedFile.is_dir) return
|
|
try {
|
|
const token = localStorage.getItem("access_token")
|
|
window.location.href = getApiUrl(`/api/navigator/download?path=${encodeURIComponent(selectedFile.path)}&access_token=${token}`)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Download failed")
|
|
}
|
|
}
|
|
|
|
const handleSelectFile = (file: FileEntry) => {
|
|
setSelectedFile(file)
|
|
setEditMode(false)
|
|
if (!file.is_dir) {
|
|
setEditOwner(`${file.uid}`)
|
|
setEditGroup(`${file.gid}`)
|
|
parsePermissions(file.permissions)
|
|
}
|
|
}
|
|
|
|
const parsePermissions = (mode: string) => {
|
|
// Parse mode string like "-rw-r--r--"
|
|
if (mode.length < 10) return
|
|
|
|
const bits = mode.substring(1)
|
|
setEditPerms({
|
|
ownerRead: bits[0] === "r",
|
|
ownerWrite: bits[1] === "w",
|
|
ownerExec: bits[2] === "x" || bits[2] === "s",
|
|
groupRead: bits[3] === "r",
|
|
groupWrite: bits[4] === "w",
|
|
groupExec: bits[5] === "x" || bits[5] === "s",
|
|
otherRead: bits[6] === "r",
|
|
otherWrite: bits[7] === "w",
|
|
otherExec: bits[8] === "x" || bits[8] === "t",
|
|
})
|
|
}
|
|
|
|
const calculateMode = () => {
|
|
let mode = 0
|
|
if (editPerms.ownerRead) mode |= 0o400
|
|
if (editPerms.ownerWrite) mode |= 0o200
|
|
if (editPerms.ownerExec) mode |= 0o100
|
|
if (editPerms.groupRead) mode |= 0o40
|
|
if (editPerms.groupWrite) mode |= 0o20
|
|
if (editPerms.groupExec) mode |= 0o10
|
|
if (editPerms.otherRead) mode |= 0o4
|
|
if (editPerms.otherWrite) mode |= 0o2
|
|
if (editPerms.otherExec) mode |= 0o1
|
|
return mode.toString(8).padStart(3, "0")
|
|
}
|
|
|
|
const handleSavePermissions = async () => {
|
|
if (!selectedFile) return
|
|
try {
|
|
const headers = getAuthHeader()
|
|
const mode = calculateMode()
|
|
|
|
const res = await fetch(getApiUrl("/api/navigator/permissions"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
path: selectedFile.path,
|
|
mode,
|
|
recursive: false,
|
|
}),
|
|
})
|
|
if (!res.ok) throw new Error("Failed to save permissions")
|
|
|
|
// Also save owner/group if changed
|
|
if (editOwner || editGroup) {
|
|
await fetch(getApiUrl("/api/navigator/owner"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({
|
|
path: selectedFile.path,
|
|
owner: editOwner,
|
|
group: editGroup || undefined,
|
|
}),
|
|
})
|
|
}
|
|
|
|
setEditMode(false)
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to save permissions")
|
|
}
|
|
}
|
|
|
|
// Breadcrumb navigation
|
|
const breadcrumbs = (currentPath === "/" ? [] : currentPath.split("/").filter(Boolean)).map(
|
|
(part, idx, arr) => ({
|
|
name: part,
|
|
path: "/" + arr.slice(0, idx + 1).join("/"),
|
|
})
|
|
)
|
|
|
|
const capacityPercent = spaceInfo
|
|
? Math.round(((spaceInfo.total - spaceInfo.available) / spaceInfo.total) * 100)
|
|
: 0
|
|
|
|
// Helper: Get icon for file type
|
|
const getFileIcon = (file: FileEntry): string => {
|
|
if (file.is_dir) return "📁"
|
|
if (file.is_link) return "🔗"
|
|
const ext = file.name.split(".").pop()?.toLowerCase()
|
|
if (["jpg", "jpeg", "png", "gif", "svg", "webp"].includes(ext || "")) return "🖼️"
|
|
if (["txt", "log", "md", "json", "yaml", "yml", "conf"].includes(ext || "")) return "📝"
|
|
if (["tar", "gz", "zip", "rar", "7z"].includes(ext || "")) return "📦"
|
|
if (["sh", "py", "js", "ts", "jsx", "tsx", "go", "rs"].includes(ext || "")) return "📄"
|
|
return "📄"
|
|
}
|
|
|
|
// Helper: Handle search with debounce
|
|
const handleSearch = (query: string) => {
|
|
setSearchQuery(query)
|
|
|
|
if (searchTimeoutRef.current) {
|
|
clearTimeout(searchTimeoutRef.current)
|
|
}
|
|
|
|
if (!query.trim()) {
|
|
setSearchResults(null)
|
|
return
|
|
}
|
|
|
|
searchTimeoutRef.current = setTimeout(async () => {
|
|
try {
|
|
const headers = getAuthHeader()
|
|
const results = await fetch(
|
|
getApiUrl(`/api/navigator/search?q=${encodeURIComponent(query)}&path=${encodeURIComponent(currentPath || "/")}`),
|
|
{ headers }
|
|
)
|
|
if (results.ok) {
|
|
const data = await results.json()
|
|
setSearchResults(data.results || [])
|
|
}
|
|
} catch (err) {
|
|
console.error("Search failed:", err)
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
// Helper: Handle file selection with shift-click and ctrl-click
|
|
const handleFileClick = (file: FileEntry, e: React.MouseEvent) => {
|
|
const isCtrlClick = e.ctrlKey || e.metaKey
|
|
const isShiftClick = e.shiftKey
|
|
|
|
if (isCtrlClick || isShiftClick) {
|
|
e.preventDefault()
|
|
const newSelected = new Set(selected)
|
|
|
|
if (isShiftClick && lastSelected) {
|
|
const filesList = displayFiles
|
|
const lastIndex = filesList.findIndex(f => f.path === lastSelected)
|
|
const currentIndex = filesList.findIndex(f => f.path === file.path)
|
|
const start = Math.min(lastIndex, currentIndex)
|
|
const end = Math.max(lastIndex, currentIndex)
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
if (i >= 0 && i < filesList.length) {
|
|
newSelected.add(filesList[i].path)
|
|
}
|
|
}
|
|
} else {
|
|
if (newSelected.has(file.path)) {
|
|
newSelected.delete(file.path)
|
|
} else {
|
|
newSelected.add(file.path)
|
|
}
|
|
}
|
|
|
|
setSelected(newSelected)
|
|
setLastSelected(file.path)
|
|
} else {
|
|
// Single click: select only this file
|
|
setSelected(new Set())
|
|
setLastSelected(file.path)
|
|
}
|
|
}
|
|
|
|
// Helper: Copy selected files
|
|
const handleCopy = () => {
|
|
if (selected.size === 0) return
|
|
setClipboard({ paths: Array.from(selected), op: "copy" })
|
|
}
|
|
|
|
// Helper: Cut selected files
|
|
const handleCut = () => {
|
|
if (selected.size === 0) return
|
|
setClipboard({ paths: Array.from(selected), op: "cut" })
|
|
}
|
|
|
|
// Helper: Paste files
|
|
const handlePaste = async () => {
|
|
if (!clipboard) return
|
|
|
|
try {
|
|
const headers = getAuthHeader()
|
|
|
|
for (const srcPath of clipboard.paths) {
|
|
const srcName = srcPath.split("/").pop() || "unknown"
|
|
const dstPath = currentPath === "/" ? "/" + srcName : currentPath + "/" + srcName
|
|
|
|
if (clipboard.op === "copy") {
|
|
const res = await fetch(getApiUrl("/api/navigator/copy"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ src: srcPath, dst: dstPath, overwrite: false }),
|
|
})
|
|
if (!res.ok) throw new Error("Copy failed")
|
|
} else {
|
|
// cut
|
|
const res = await fetch(getApiUrl("/api/navigator/move"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ src: srcPath, dst: dstPath, overwrite: false }),
|
|
})
|
|
if (!res.ok) throw new Error("Move failed")
|
|
}
|
|
}
|
|
|
|
setClipboard(null)
|
|
setSelected(new Set())
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Paste failed")
|
|
}
|
|
}
|
|
|
|
// Helper: Handle rename
|
|
const handleRename = async () => {
|
|
if (!renameTarget || !renameName.trim()) return
|
|
|
|
try {
|
|
const headers = getAuthHeader()
|
|
const res = await fetch(getApiUrl("/api/navigator/rename"), {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ old_path: renameTarget.path, new_name: renameName }),
|
|
})
|
|
if (!res.ok) throw new Error("Failed to rename")
|
|
|
|
setRenameDialog(false)
|
|
setRenameTarget(null)
|
|
setRenameName("")
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to rename")
|
|
}
|
|
}
|
|
|
|
// Helper: Handle drag over
|
|
const handleDragOver = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsDragOver(true)
|
|
}
|
|
|
|
// Helper: Handle drag leave
|
|
const handleDragLeave = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsDragOver(false)
|
|
}
|
|
|
|
// Helper: Handle drop
|
|
const handleDrop = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
setIsDragOver(false)
|
|
|
|
const droppedFiles = e.dataTransfer.files
|
|
if (!droppedFiles) return
|
|
|
|
setUploading(true)
|
|
const uploadAsync = async () => {
|
|
try {
|
|
const headers = { Authorization: `Bearer ${localStorage.getItem("access_token")}` }
|
|
const uploadPath = currentPath === "/" ? "" : currentPath
|
|
|
|
for (const file of droppedFiles) {
|
|
const formData = new FormData()
|
|
formData.append("file", file)
|
|
|
|
const res = await fetch(
|
|
getApiUrl(`/api/navigator/upload?path=${encodeURIComponent(uploadPath)}`),
|
|
{ method: "POST", headers, body: formData }
|
|
)
|
|
if (!res.ok) throw new Error(`Failed to upload ${file.name}`)
|
|
}
|
|
|
|
loadDirectory(currentPath)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Upload failed")
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}
|
|
|
|
uploadAsync()
|
|
}
|
|
|
|
// Display files (either from search or directory)
|
|
const displayFiles = searchResults !== null ? searchResults : files
|
|
|
|
return (
|
|
<div className="min-h-screen bg-background">
|
|
<Header />
|
|
|
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* Page Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">File Manager</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<p className="text-muted-foreground">
|
|
Browse and manage files in {currentPath || "/"}
|
|
</p>
|
|
{adminMode && (
|
|
<span className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded">
|
|
Admin Mode
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{!adminMode ? (
|
|
<Button
|
|
onClick={() => setShowUnlockDialog(true)}
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-amber-600"
|
|
>
|
|
🔓 Unlock Admin
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={handleLockDown}
|
|
variant="destructive"
|
|
size="sm"
|
|
>
|
|
🔒 Lock Down
|
|
</Button>
|
|
)}
|
|
<Button onClick={() => loadDirectory(currentPath)} variant="outline" size="sm">
|
|
<RefreshCw className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Space Info */}
|
|
{spaceInfo && (
|
|
<Card className="mb-6">
|
|
<CardContent className="pt-6">
|
|
<div className="flex justify-between text-sm mb-2">
|
|
<span className="text-muted-foreground">Capacity</span>
|
|
<span className="font-medium">{capacityPercent}%</span>
|
|
</div>
|
|
<div className="w-full bg-muted rounded-full h-2 mb-4 overflow-hidden">
|
|
<div
|
|
className="bg-primary h-full transition-all"
|
|
style={{ width: `${Math.min(capacityPercent, 100)}%` }}
|
|
/>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Total</div>
|
|
<div className="font-medium">{formatBytes(spaceInfo.total)}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Used</div>
|
|
<div className="font-medium">{formatBytes(spaceInfo.total - spaceInfo.available)}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-muted-foreground text-xs">Available</div>
|
|
<div className="font-medium">{formatBytes(spaceInfo.available)}</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<Card className="mb-6 border-red-200 bg-red-50">
|
|
<CardContent className="flex items-center gap-3 pt-6">
|
|
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
|
<p className="text-sm text-red-800">{error}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Left Sidebar - Directory Tree */}
|
|
<div className="hidden lg:block lg:col-span-1">
|
|
<Card className="sticky top-20">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm">Verzeichnisse</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-2 max-h-[calc(100vh-12rem)] overflow-y-auto">
|
|
<DirectoryTree
|
|
currentPath={currentPath}
|
|
onNavigate={handleNavigate}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="lg:col-span-2">
|
|
{/* Navigation Bar */}
|
|
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleNavigate("")}
|
|
className="px-2"
|
|
>
|
|
<Home className="w-4 h-4" />
|
|
</Button>
|
|
{breadcrumbs.map((crumb) => (
|
|
<div key={crumb.path} className="flex items-center gap-2">
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleNavigate(crumb.path)}
|
|
className="px-2"
|
|
>
|
|
{crumb.name}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
{currentPath !== "/" && currentPath !== "" && (
|
|
<>
|
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
|
<span className="text-sm text-muted-foreground">(current)</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Toolbar */}
|
|
<div className="flex gap-2 mb-4 flex-wrap items-center">
|
|
<Button onClick={handleUpClick} variant="outline" size="sm">
|
|
<ChevronUp className="w-4 h-4 mr-1" />
|
|
Up
|
|
</Button>
|
|
<Button onClick={() => setNewFolderDialog(true)} size="sm">
|
|
<FolderPlus className="w-4 h-4 mr-1" />
|
|
New Folder
|
|
</Button>
|
|
<Button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
size="sm"
|
|
disabled={uploading}
|
|
>
|
|
<Upload className="w-4 h-4 mr-1" />
|
|
{uploading ? "Uploading..." : "Upload"}
|
|
</Button>
|
|
|
|
{/* Selection toolbar */}
|
|
{selected.size > 0 && (
|
|
<>
|
|
<div className="h-6 border-l border-border mx-1" />
|
|
<span className="text-sm text-muted-foreground">{selected.size} selected</span>
|
|
<Button onClick={handleCopy} variant="outline" size="sm">
|
|
<Copy className="w-4 h-4" />
|
|
</Button>
|
|
<Button onClick={handleCut} variant="outline" size="sm">
|
|
<Scissors className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
const firstSelected = Array.from(selected)[0]
|
|
setDeleteTarget(firstSelected)
|
|
setDeleteDialog(true)
|
|
}}
|
|
variant="destructive"
|
|
size="sm"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{/* Paste button */}
|
|
{clipboard && (
|
|
<>
|
|
<div className="h-6 border-l border-border mx-1" />
|
|
<Button onClick={handlePaste} variant="outline" size="sm" className="text-green-600">
|
|
<Clipboard className="w-4 h-4 mr-1" />
|
|
Paste ({clipboard.op})
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
<div className="flex-1" />
|
|
|
|
{/* View toggle */}
|
|
<div className="flex gap-1 border border-border rounded p-1">
|
|
<Button
|
|
onClick={() => setViewMode("list")}
|
|
variant={viewMode === "list" ? "default" : "ghost"}
|
|
size="sm"
|
|
>
|
|
<List className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
onClick={() => setViewMode("grid")}
|
|
variant={viewMode === "grid" ? "default" : "ghost"}
|
|
size="sm"
|
|
>
|
|
<Grid2x2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
onChange={handleUpload}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
|
|
{/* Path Input */}
|
|
<div className="mb-4">
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
placeholder={adminMode ? "Enter path (e.g., /home, /root)" : "Enter path (e.g., media, backup)"}
|
|
value={currentPath}
|
|
onChange={(e) => setCurrentPath(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
handleNavigate(e.currentTarget.value)
|
|
}
|
|
}}
|
|
className="flex-1 px-3 py-2 border border-border rounded bg-background text-foreground text-sm font-mono"
|
|
/>
|
|
<Button
|
|
onClick={() => handleNavigate(currentPath)}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
Go
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search Input */}
|
|
<div className="mb-4">
|
|
<div className="flex gap-2">
|
|
<SearchIcon className="w-5 h-5 text-muted-foreground flex-shrink-0 mt-0.5" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search files..."
|
|
value={searchQuery}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="flex-1 px-3 py-2 border border-border rounded bg-background text-foreground text-sm"
|
|
/>
|
|
{searchQuery && (
|
|
<Button
|
|
onClick={() => {
|
|
setSearchQuery("")
|
|
setSearchResults(null)
|
|
}}
|
|
variant="outline"
|
|
size="sm"
|
|
>
|
|
Clear
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* File List */}
|
|
<Card
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
className={`relative transition-colors ${isDragOver ? "border-2 border-blue-500 bg-blue-50/50" : ""}`}
|
|
>
|
|
{isDragOver && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-blue-500/10 rounded-lg pointer-events-none">
|
|
<div className="text-center">
|
|
<p className="text-lg font-medium text-blue-600">Drop files to upload</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<CardHeader>
|
|
<CardTitle className="text-base">
|
|
{searchResults ? `Search Results (${displayFiles.length})` : "Files & Folders"}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{loading ? (
|
|
<div className="p-8 text-center text-muted-foreground">Loading...</div>
|
|
) : displayFiles.length === 0 ? (
|
|
<div className="p-8 text-center text-muted-foreground">
|
|
{searchResults ? "No results found" : "No files"}
|
|
</div>
|
|
) : viewMode === "list" ? (
|
|
// List View
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b border-border bg-muted/30">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium w-8"></th>
|
|
<th className="px-4 py-3 text-left font-medium">Name</th>
|
|
<th className="px-4 py-3 text-left font-medium">Size</th>
|
|
<th className="px-4 py-3 text-left font-medium">Modified</th>
|
|
<th className="px-4 py-3 text-left font-medium">Permissions</th>
|
|
<th className="px-4 py-3 text-left font-medium">Owner</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayFiles.map((file) => (
|
|
<tr
|
|
key={file.path}
|
|
onClick={(e) => {
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
handleFileClick(file, e)
|
|
} else {
|
|
handleSelectFile(file)
|
|
}
|
|
}}
|
|
className={`border-b border-border/50 cursor-pointer hover:bg-muted/50 ${
|
|
selectedFile?.path === file.path ? "bg-accent/20" : ""
|
|
} ${selected.has(file.path) ? "bg-blue-100" : ""}`}
|
|
>
|
|
<td className="px-4 py-3 text-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(file.path)}
|
|
onChange={() => {
|
|
const newSelected = new Set(selected)
|
|
if (newSelected.has(file.path)) {
|
|
newSelected.delete(file.path)
|
|
} else {
|
|
newSelected.add(file.path)
|
|
}
|
|
setSelected(newSelected)
|
|
setLastSelected(file.path)
|
|
}}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs flex items-center gap-2">
|
|
<span className="text-lg">
|
|
{file.is_link ? "🔗" : file.is_dir ? "📁" : getFileIcon(file)}
|
|
</span>
|
|
<span
|
|
className={`cursor-pointer hover:underline ${
|
|
file.is_dir || file.is_link ? "text-primary" : ""
|
|
}`}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (file.is_dir || file.is_link) {
|
|
handleNavigate(file.path)
|
|
}
|
|
}}
|
|
>
|
|
{file.name}
|
|
</span>
|
|
{file.is_link && (
|
|
<span
|
|
className="text-xs text-amber-600"
|
|
title={`Link to: ${file.link_target}`}
|
|
>
|
|
→ {file.link_target}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{file.is_dir ? "—" : formatBytes(file.size)}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-muted-foreground">
|
|
{new Date(file.modified * 1000).toLocaleString()}
|
|
</td>
|
|
<td className="px-4 py-3 font-mono text-xs">{file.permissions}</td>
|
|
<td className="px-4 py-3 text-xs">
|
|
{file.uid}/{file.gid}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
) : (
|
|
// Grid View
|
|
<div className="p-4 grid grid-cols-4 sm:grid-cols-6 lg:grid-cols-8 gap-4">
|
|
{displayFiles.map((file) => (
|
|
<div
|
|
key={file.path}
|
|
onClick={(e) => {
|
|
if (e.ctrlKey || e.metaKey || e.shiftKey) {
|
|
handleFileClick(file, e)
|
|
} else if (e.detail === 2) {
|
|
// Double click: navigate
|
|
if (file.is_dir || file.is_link) {
|
|
handleNavigate(file.path)
|
|
}
|
|
} else {
|
|
// Single click: select
|
|
handleSelectFile(file)
|
|
}
|
|
}}
|
|
className={`p-3 rounded-lg border text-center cursor-pointer transition-colors ${
|
|
selectedFile?.path === file.path ? "bg-accent/20 border-accent" : "hover:bg-muted"
|
|
} ${selected.has(file.path) ? "bg-blue-100 border-blue-400" : "border-border"}`}
|
|
>
|
|
<div className="text-3xl mb-2">{getFileIcon(file)}</div>
|
|
<div className="text-xs font-mono break-words line-clamp-2">{file.name}</div>
|
|
{!file.is_dir && (
|
|
<div className="text-xs text-muted-foreground mt-1">{formatBytes(file.size)}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Right Sidebar - File Details */}
|
|
<div>
|
|
{selectedFile ? (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">
|
|
{selectedFile.is_dir ? "Folder" : selectedFile.is_link ? "Link" : "File"} Properties
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 max-h-[70vh] overflow-y-auto">
|
|
{editMode ? (
|
|
<>
|
|
{/* Edit Mode */}
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-sm font-medium">Owner</label>
|
|
<input
|
|
type="text"
|
|
value={editOwner}
|
|
onChange={(e) => setEditOwner(e.target.value)}
|
|
list="owners-list"
|
|
className="w-full mt-1 px-2 py-1 text-sm border border-border rounded bg-background"
|
|
placeholder="Username or UID"
|
|
/>
|
|
<datalist id="owners-list">
|
|
{usernames.map((name) => (
|
|
<option key={name} value={name} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
<div>
|
|
<label className="text-sm font-medium">Group</label>
|
|
<input
|
|
type="text"
|
|
value={editGroup}
|
|
onChange={(e) => setEditGroup(e.target.value)}
|
|
list="groups-list"
|
|
className="w-full mt-1 px-2 py-1 text-sm border border-border rounded bg-background"
|
|
placeholder="Group name or GID"
|
|
/>
|
|
<datalist id="groups-list">
|
|
{groupnames.map((name) => (
|
|
<option key={name} value={name} />
|
|
))}
|
|
</datalist>
|
|
</div>
|
|
|
|
{/* Permission Checkboxes */}
|
|
<div className="border-t pt-3 mt-3">
|
|
<label className="text-sm font-medium block mb-3">Permissions</label>
|
|
<div className="grid grid-cols-4 gap-2 text-xs">
|
|
<div></div>
|
|
<div className="text-center font-medium">Read</div>
|
|
<div className="text-center font-medium">Write</div>
|
|
<div className="text-center font-medium">Exec</div>
|
|
|
|
<div className="font-medium">Owner</div>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.ownerRead}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, ownerRead: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.ownerWrite}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, ownerWrite: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.ownerExec}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, ownerExec: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
<div className="font-medium">Group</div>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.groupRead}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, groupRead: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.groupWrite}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, groupWrite: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.groupExec}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, groupExec: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
|
|
<div className="font-medium">Other</div>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.otherRead}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, otherRead: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.otherWrite}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, otherWrite: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="flex items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={editPerms.otherExec}
|
|
onChange={(e) =>
|
|
setEditPerms({ ...editPerms, otherExec: e.target.checked })
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-3 border-t">
|
|
<Button size="sm" onClick={handleSavePermissions} className="flex-1">
|
|
Save
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => setEditMode(false)}
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* View Mode - Preview */}
|
|
|
|
{/* Image Preview */}
|
|
{["jpg", "jpeg", "png", "gif", "svg", "webp"].includes(
|
|
selectedFile.name.split(".").pop()?.toLowerCase() || ""
|
|
) && (
|
|
<div className="mb-4">
|
|
<img
|
|
src={getApiUrl(`/api/navigator/download?path=${encodeURIComponent(
|
|
selectedFile.path
|
|
)}&access_token=${localStorage.getItem("access_token")}`)}
|
|
alt={selectedFile.name}
|
|
className="w-full rounded border border-border max-h-64 object-contain"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Text Preview */}
|
|
{["txt", "log", "md", "json", "yaml", "yml", "conf", "sh", "py", "js", "ts", "jsx", "tsx"].includes(
|
|
selectedFile.name.split(".").pop()?.toLowerCase() || ""
|
|
) && (
|
|
<div className="mb-4">
|
|
{previewContent !== null ? (
|
|
<div className="bg-muted p-3 rounded border border-border max-h-64 overflow-y-auto">
|
|
<pre className="text-xs whitespace-pre-wrap break-words font-mono">
|
|
{previewContent.length > 5000
|
|
? previewContent.substring(0, 5000) + "\n... (truncated)"
|
|
: previewContent}
|
|
</pre>
|
|
</div>
|
|
) : (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={async () => {
|
|
try {
|
|
const headers = getAuthHeader()
|
|
const res = await fetch(
|
|
getApiUrl(`/api/navigator/read?path=${encodeURIComponent(selectedFile.path)}`),
|
|
{ headers }
|
|
)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setPreviewContent(data.content || "")
|
|
}
|
|
} catch (err) {
|
|
setError("Failed to load preview")
|
|
}
|
|
}}
|
|
className="w-full"
|
|
>
|
|
Load Preview
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* File Info */}
|
|
<div className="space-y-2 text-sm border-t pt-3">
|
|
<div>
|
|
<span className="text-muted-foreground">Name:</span>
|
|
<p className="font-mono break-all">{selectedFile.name}</p>
|
|
</div>
|
|
{selectedFile.is_link && (
|
|
<div>
|
|
<span className="text-muted-foreground">Link Target:</span>
|
|
<p className="font-mono text-xs text-amber-600">{selectedFile.link_target}</p>
|
|
</div>
|
|
)}
|
|
{!selectedFile.is_dir && !selectedFile.is_link && (
|
|
<div>
|
|
<span className="text-muted-foreground">Size:</span>
|
|
<p className="font-medium">{formatBytes(selectedFile.size)}</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-muted-foreground">Modified:</span>
|
|
<p className="text-xs">
|
|
{new Date(selectedFile.modified * 1000).toLocaleString()}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Permissions:</span>
|
|
<p className="font-mono text-xs">{selectedFile.permissions}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-muted-foreground">Owner/Group:</span>
|
|
<p className="font-mono text-xs">
|
|
{selectedFile.uid}/{selectedFile.gid}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-3 border-t flex-col">
|
|
{!selectedFile.is_dir && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleDownload}
|
|
className="w-full"
|
|
>
|
|
<Download className="w-4 h-4 mr-1" />
|
|
Download
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setRenameTarget({
|
|
path: selectedFile.path,
|
|
name: selectedFile.name,
|
|
})
|
|
setRenameName(selectedFile.name)
|
|
setRenameDialog(true)
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<Edit2 className="w-4 h-4 mr-1" />
|
|
Rename
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setEditMode(true)}
|
|
className="w-full"
|
|
>
|
|
<Edit2 className="w-4 h-4 mr-1" />
|
|
Permissions
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="destructive"
|
|
onClick={() => {
|
|
setDeleteTarget(selectedFile.path)
|
|
setDeleteDialog(true)
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6 text-center text-muted-foreground">
|
|
Select a file to view details
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* New Folder Dialog */}
|
|
<Dialog open={newFolderDialog} onClose={() => setNewFolderDialog(false)} title="Create Folder">
|
|
<input
|
|
type="text"
|
|
placeholder="Folder name"
|
|
value={newFolderName}
|
|
onChange={(e) => setNewFolderName(e.target.value)}
|
|
className="w-full px-3 py-2 border border-border rounded bg-background text-foreground mb-4"
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleCreateFolder} className="flex-1">
|
|
Create
|
|
</Button>
|
|
<Button onClick={() => setNewFolderDialog(false)} variant="outline" className="flex-1">
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<Dialog open={deleteDialog} onClose={() => setDeleteDialog(false)} title="Delete File">
|
|
<div className="space-y-4">
|
|
<p className="text-sm">
|
|
Are you sure you want to delete <span className="font-mono">{deleteTarget}</span>?
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleDelete} variant="destructive" className="flex-1">
|
|
Delete
|
|
</Button>
|
|
<Button onClick={() => setDeleteDialog(false)} variant="outline" className="flex-1">
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Admin Unlock Dialog */}
|
|
<Dialog open={showUnlockDialog} onClose={() => setShowUnlockDialog(false)} title="Unlock Admin Mode">
|
|
<div className="space-y-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Enter the admin password to unlock full filesystem access.
|
|
</p>
|
|
{unlockError && (
|
|
<div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
|
|
{unlockError}
|
|
</div>
|
|
)}
|
|
<input
|
|
type="password"
|
|
placeholder="Admin password"
|
|
value={unlockPassword}
|
|
onChange={(e) => setUnlockPassword(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleUnlock()}
|
|
className="w-full px-3 py-2 border border-border rounded bg-background text-foreground"
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleUnlock} className="flex-1">
|
|
Unlock
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setShowUnlockDialog(false)
|
|
setUnlockPassword("")
|
|
setUnlockError(null)
|
|
}}
|
|
variant="outline"
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
|
|
{/* Rename Dialog */}
|
|
<Dialog open={renameDialog} onClose={() => setRenameDialog(false)} title="Rename File">
|
|
<div className="space-y-4">
|
|
<input
|
|
type="text"
|
|
placeholder="New name"
|
|
value={renameName}
|
|
onChange={(e) => setRenameName(e.target.value)}
|
|
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
|
className="w-full px-3 py-2 border border-border rounded bg-background text-foreground"
|
|
autoFocus
|
|
/>
|
|
<div className="flex gap-2">
|
|
<Button onClick={handleRename} className="flex-1">
|
|
Rename
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
setRenameDialog(false)
|
|
setRenameTarget(null)
|
|
setRenameName("")
|
|
}}
|
|
variant="outline"
|
|
className="flex-1"
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|