"use client"; import { useEffect, useState, useCallback, useRef } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { FolderOpen, File, FileText, ChevronRight, Upload, Download, Trash2, RefreshCw, ArrowLeft, Home, } from "lucide-react"; import { toast } from "sonner"; import { useDropzone } from "react-dropzone"; import { formatDistanceToNow } from "date-fns"; import dynamic from "next/dynamic"; const MonacoEditor = dynamic(() => import("@monaco-editor/react"), { ssr: false }); interface FileEntry { name: string; path: string; isDirectory: boolean; size: number; modifiedAt: number; } function formatBytes(bytes: number): string { if (bytes === 0) return "—"; const k = 1024; const sizes = ["B", "KB", "MB", "GB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; } const TEXT_EXTENSIONS = new Set([".txt", ".yml", ".yaml", ".json", ".properties", ".toml", ".cfg", ".conf", ".log", ".md", ".sh", ".ini"]); function getEditorLanguage(name: string): string { const ext = name.split(".").pop() ?? ""; const map: Record = { json: "json", yml: "yaml", yaml: "yaml", properties: "ini", toml: "ini", cfg: "ini", sh: "shell", md: "markdown", log: "plaintext", }; return map[ext] ?? "plaintext"; } function isEditable(name: string): boolean { const ext = "." + name.split(".").pop()?.toLowerCase(); return TEXT_EXTENSIONS.has(ext); } export default function FilesPage() { const [currentPath, setCurrentPath] = useState("/"); const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(true); const [deleteTarget, setDeleteTarget] = useState(null); const [editFile, setEditFile] = useState<{ path: string; content: string } | null>(null); const [saving, setSaving] = useState(false); const fetchEntries = useCallback(async (path: string) => { setLoading(true); try { const res = await fetch(`/api/files/list?path=${encodeURIComponent(path)}`); if (!res.ok) throw new Error((await res.json()).error); const data = await res.json(); setEntries(data.entries); setCurrentPath(data.path); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to load directory"); } finally { setLoading(false); } }, []); useEffect(() => { fetchEntries("/"); }, [fetchEntries]); const handleOpen = (entry: FileEntry) => { if (entry.isDirectory) { fetchEntries(entry.path); } else if (isEditable(entry.name)) { openEditor(entry); } else { window.open(`/api/files/download?path=${encodeURIComponent(entry.path)}`, "_blank"); } }; const openEditor = async (entry: FileEntry) => { try { const res = await fetch(`/api/files/download?path=${encodeURIComponent(entry.path)}`); const text = await res.text(); setEditFile({ path: entry.path, content: text }); } catch { toast.error("Failed to open file"); } }; const saveFile = async () => { if (!editFile) return; setSaving(true); try { const blob = new Blob([editFile.content], { type: "text/plain" }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const FileClass = (globalThis as any).File ?? Blob; const file = new FileClass([blob], editFile.path.split("/").pop() ?? "file") as File; const dir = editFile.path.split("/").slice(0, -1).join("/") || "/"; const form = new FormData(); form.append("file", file); const res = await fetch(`/api/files/upload?path=${encodeURIComponent(dir)}`, { method: "POST", body: form, }); if (!res.ok) throw new Error((await res.json()).error); toast.success("File saved"); setEditFile(null); } catch (err) { toast.error(err instanceof Error ? err.message : "Save failed"); } finally { setSaving(false); } }; const handleDelete = async (entry: FileEntry) => { try { const res = await fetch("/api/files/delete", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ filePath: entry.path }), }); if (!res.ok) throw new Error((await res.json()).error); toast.success(`${entry.name} deleted`); fetchEntries(currentPath); } catch (err) { toast.error(err instanceof Error ? err.message : "Delete failed"); } setDeleteTarget(null); }; const { getRootProps, getInputProps, isDragActive, open: openUpload } = useDropzone({ noClick: true, noKeyboard: true, onDrop: async (acceptedFiles) => { for (const file of acceptedFiles) { const form = new FormData(); form.append("file", file); try { const res = await fetch(`/api/files/upload?path=${encodeURIComponent(currentPath)}`, { method: "POST", body: form, }); if (!res.ok) throw new Error((await res.json()).error); toast.success(`${file.name} uploaded`); } catch (err) { toast.error(`Failed to upload ${file.name}`); } } fetchEntries(currentPath); }, }); // Breadcrumbs const parts = currentPath === "/" ? [] : currentPath.split("/").filter(Boolean); const navigateUp = () => { if (parts.length === 0) return; const parent = parts.length === 1 ? "/" : "/" + parts.slice(0, -1).join("/"); fetchEntries(parent); }; if (editFile) { const fileName = editFile.path.split("/").pop() ?? "file"; return (

{fileName}

{editFile.path}

setEditFile((prev) => prev ? { ...prev, content: v ?? "" } : null)} options={{ fontSize: 13, minimap: { enabled: false }, scrollBeyondLastLine: false, wordWrap: "on", padding: { top: 16, bottom: 16 }, }} />
); } return (
{isDragActive && (

Drop files to upload

)}

File Explorer

Browse and manage Minecraft server files

{/* Breadcrumbs */}
{parts.map((part, i) => ( ))}
{loading ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : entries.length === 0 ? (

Empty directory

) : (
{parts.length > 0 && ( )} {entries.map((entry) => ( {entry.isDirectory ? ( fetchEntries(entry.path)} className="text-zinc-300 focus:text-white focus:bg-zinc-800" > Open ) : ( <> {isEditable(entry.name) && ( openEditor(entry)} className="text-zinc-300 focus:text-white focus:bg-zinc-800" > Edit )} window.open( `/api/files/download?path=${encodeURIComponent(entry.path)}`, "_blank", ) } className="text-zinc-300 focus:text-white focus:bg-zinc-800" > Download )} setDeleteTarget(entry)} className="text-red-400 focus:text-red-300 focus:bg-zinc-800" > Delete ))}
)}

Double-click to open files/folders • Right-click for options • Drag and drop to upload

setDeleteTarget(null)}> Delete {deleteTarget?.isDirectory ? "folder" : "file"}? {deleteTarget?.name}{" "} will be permanently deleted. {deleteTarget?.isDirectory && " All contents will be removed."} Cancel deleteTarget && handleDelete(deleteTarget)} className="bg-red-600 hover:bg-red-500 text-white" > Delete
); }