"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(null) // State const [currentPath, setCurrentPath] = useState("") const [files, setFiles] = useState([]) const [spaceInfo, setSpaceInfo] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(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(null) // Selected file state const [selectedFile, setSelectedFile] = useState(null) // View mode and multi-select const [viewMode, setViewMode] = useState<"list" | "grid">("list") const [selected, setSelected] = useState>(new Set()) const [lastSelected, setLastSelected] = useState(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(null) const searchTimeoutRef = useRef(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(null) // Permission edit state const [editMode, setEditMode] = useState(false) const [editOwner, setEditOwner] = useState("") const [editGroup, setEditGroup] = useState("") const [usernames, setUsernames] = useState([]) const [groupnames, setGroupnames] = useState([]) 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(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) => { 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 (
{/* Page Header */}

File Manager

Browse and manage files in {currentPath || "/"}

{adminMode && ( Admin Mode )}
{!adminMode ? ( ) : ( )}
{/* Space Info */} {spaceInfo && (
Capacity {capacityPercent}%
Total
{formatBytes(spaceInfo.total)}
Used
{formatBytes(spaceInfo.total - spaceInfo.available)}
Available
{formatBytes(spaceInfo.available)}
)} {/* Error Alert */} {error && (

{error}

)}
{/* Left Sidebar - Directory Tree */}
Verzeichnisse
{/* Main Content */}
{/* Navigation Bar */}
{breadcrumbs.map((crumb) => (
))} {currentPath !== "/" && currentPath !== "" && ( <> (current) )}
{/* Toolbar */}
{/* Selection toolbar */} {selected.size > 0 && ( <>
{selected.size} selected )} {/* Paste button */} {clipboard && ( <>
)}
{/* View toggle */}
{/* Path Input */}
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" />
{/* Search Input */}
handleSearch(e.target.value)} className="flex-1 px-3 py-2 border border-border rounded bg-background text-foreground text-sm" /> {searchQuery && ( )}
{/* File List */} {isDragOver && (

Drop files to upload

)} {searchResults ? `Search Results (${displayFiles.length})` : "Files & Folders"} {loading ? (
Loading...
) : displayFiles.length === 0 ? (
{searchResults ? "No results found" : "No files"}
) : viewMode === "list" ? ( // List View
{displayFiles.map((file) => ( { 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" : ""}`} > ))}
Name Size Modified Permissions Owner
{ 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()} /> {file.is_link ? "🔗" : file.is_dir ? "📁" : getFileIcon(file)} { e.stopPropagation() if (file.is_dir || file.is_link) { handleNavigate(file.path) } }} > {file.name} {file.is_link && ( → {file.link_target} )} {file.is_dir ? "—" : formatBytes(file.size)} {new Date(file.modified * 1000).toLocaleString()} {file.permissions} {file.uid}/{file.gid}
) : ( // Grid View
{displayFiles.map((file) => (
{ 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"}`} >
{getFileIcon(file)}
{file.name}
{!file.is_dir && (
{formatBytes(file.size)}
)}
))}
)}
{/* Right Sidebar - File Details */}
{selectedFile ? ( {selectedFile.is_dir ? "Folder" : selectedFile.is_link ? "Link" : "File"} Properties {editMode ? ( <> {/* Edit Mode */}
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" /> {usernames.map((name) => (
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" /> {groupnames.map((name) => (
{/* Permission Checkboxes */}
Read
Write
Exec
Owner
Group
Other
) : ( <> {/* View Mode - Preview */} {/* Image Preview */} {["jpg", "jpeg", "png", "gif", "svg", "webp"].includes( selectedFile.name.split(".").pop()?.toLowerCase() || "" ) && (
{selectedFile.name}
)} {/* Text Preview */} {["txt", "log", "md", "json", "yaml", "yml", "conf", "sh", "py", "js", "ts", "jsx", "tsx"].includes( selectedFile.name.split(".").pop()?.toLowerCase() || "" ) && (
{previewContent !== null ? (
                                {previewContent.length > 5000
                                  ? previewContent.substring(0, 5000) + "\n... (truncated)"
                                  : previewContent}
                              
) : ( )}
)} {/* File Info */}
Name:

{selectedFile.name}

{selectedFile.is_link && (
Link Target:

{selectedFile.link_target}

)} {!selectedFile.is_dir && !selectedFile.is_link && (
Size:

{formatBytes(selectedFile.size)}

)}
Modified:

{new Date(selectedFile.modified * 1000).toLocaleString()}

Permissions:

{selectedFile.permissions}

Owner/Group:

{selectedFile.uid}/{selectedFile.gid}

{!selectedFile.is_dir && ( )}
)}
) : ( Select a file to view details )}
{/* New Folder Dialog */} setNewFolderDialog(false)} title="Create Folder"> setNewFolderName(e.target.value)} className="w-full px-3 py-2 border border-border rounded bg-background text-foreground mb-4" autoFocus />
{/* Delete Confirmation Dialog */} setDeleteDialog(false)} title="Delete File">

Are you sure you want to delete {deleteTarget}?

{/* Admin Unlock Dialog */} setShowUnlockDialog(false)} title="Unlock Admin Mode">

Enter the admin password to unlock full filesystem access.

{unlockError && (
{unlockError}
)} 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 />
{/* Rename Dialog */} setRenameDialog(false)} title="Rename File">
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 />
) }