Files
zmb-webui/frontend/app/navigator/page.tsx
Claude Code 6d74d874b6 ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
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>
2026-04-22 00:43:05 +02:00

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>
)
}