442 lines
16 KiB
TypeScript
442 lines
16 KiB
TypeScript
"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<string, string> = {
|
|
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<FileEntry[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [deleteTarget, setDeleteTarget] = useState<FileEntry | null>(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 (
|
|
<div className="p-6 h-full flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setEditFile(null)}
|
|
className="border-zinc-700 text-zinc-400 hover:text-white"
|
|
>
|
|
<ArrowLeft className="w-4 h-4 mr-1.5" />
|
|
Back
|
|
</Button>
|
|
<div>
|
|
<h1 className="text-lg font-bold text-white">{fileName}</h1>
|
|
<p className="text-xs text-zinc-500 font-mono">{editFile.path}</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={saveFile}
|
|
disabled={saving}
|
|
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
|
size="sm"
|
|
>
|
|
{saving ? "Saving..." : "Save File"}
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 rounded-xl overflow-hidden border border-zinc-800" style={{ minHeight: "500px" }}>
|
|
<MonacoEditor
|
|
height="100%"
|
|
language={getEditorLanguage(fileName)}
|
|
theme="vs-dark"
|
|
value={editFile.content}
|
|
onChange={(v) => setEditFile((prev) => prev ? { ...prev, content: v ?? "" } : null)}
|
|
options={{
|
|
fontSize: 13,
|
|
minimap: { enabled: false },
|
|
scrollBeyondLastLine: false,
|
|
wordWrap: "on",
|
|
padding: { top: 16, bottom: 16 },
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-6 space-y-4" {...getRootProps()}>
|
|
<input {...getInputProps()} />
|
|
|
|
{isDragActive && (
|
|
<div className="fixed inset-0 z-50 bg-emerald-500/10 border-2 border-dashed border-emerald-500/50 flex items-center justify-center pointer-events-none">
|
|
<div className="text-center text-emerald-400">
|
|
<Upload className="w-12 h-12 mx-auto mb-2" />
|
|
<p className="text-lg font-semibold">Drop files to upload</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">File Explorer</h1>
|
|
<p className="text-zinc-400 text-sm mt-1">
|
|
Browse and manage Minecraft server files
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => fetchEntries(currentPath)}
|
|
className="border-zinc-700 text-zinc-400 hover:text-white"
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={openUpload}
|
|
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
|
>
|
|
<Upload className="w-4 h-4 mr-1.5" />
|
|
Upload
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breadcrumbs */}
|
|
<div className="flex items-center gap-1 text-sm text-zinc-500">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => fetchEntries("/")}
|
|
className="h-7 px-2 text-zinc-400 hover:text-white"
|
|
>
|
|
<Home className="w-3.5 h-3.5" />
|
|
</Button>
|
|
{parts.map((part, i) => (
|
|
<span key={i} className="flex items-center gap-1">
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => fetchEntries("/" + parts.slice(0, i + 1).join("/"))}
|
|
className="h-7 px-2 text-zinc-400 hover:text-white"
|
|
>
|
|
{part}
|
|
</Button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
<Card className="bg-zinc-900 border-zinc-800">
|
|
<CardContent className="p-0">
|
|
{loading ? (
|
|
<div className="p-4 space-y-2">
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<Skeleton key={i} className="h-10 w-full bg-zinc-800" />
|
|
))}
|
|
</div>
|
|
) : entries.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
|
<FolderOpen className="w-10 h-10 mb-3 opacity-50" />
|
|
<p>Empty directory</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-zinc-800">
|
|
{parts.length > 0 && (
|
|
<button
|
|
onClick={navigateUp}
|
|
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
|
|
>
|
|
<ArrowLeft className="w-4 h-4 text-zinc-600" />
|
|
<span className="text-sm text-zinc-500">..</span>
|
|
</button>
|
|
)}
|
|
{entries.map((entry) => (
|
|
<ContextMenu key={entry.path}>
|
|
<ContextMenuTrigger>
|
|
<button
|
|
onDoubleClick={() => handleOpen(entry)}
|
|
className="flex items-center gap-3 px-4 py-2.5 w-full hover:bg-zinc-800/50 transition-colors text-left"
|
|
>
|
|
{entry.isDirectory ? (
|
|
<FolderOpen className="w-4 h-4 text-amber-500 shrink-0" />
|
|
) : isEditable(entry.name) ? (
|
|
<FileText className="w-4 h-4 text-blue-400 shrink-0" />
|
|
) : (
|
|
<File className="w-4 h-4 text-zinc-500 shrink-0" />
|
|
)}
|
|
<span className="flex-1 text-sm text-zinc-300 truncate">
|
|
{entry.name}
|
|
</span>
|
|
{!entry.isDirectory && (
|
|
<span className="text-xs text-zinc-600 shrink-0">
|
|
{formatBytes(entry.size)}
|
|
</span>
|
|
)}
|
|
<span className="text-xs text-zinc-600 shrink-0 hidden sm:block">
|
|
{entry.modifiedAt
|
|
? formatDistanceToNow(new Date(entry.modifiedAt), {
|
|
addSuffix: true,
|
|
})
|
|
: ""}
|
|
</span>
|
|
</button>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent className="bg-zinc-900 border-zinc-700">
|
|
{entry.isDirectory ? (
|
|
<ContextMenuItem
|
|
onClick={() => fetchEntries(entry.path)}
|
|
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
|
>
|
|
<FolderOpen className="w-4 h-4 mr-2" />
|
|
Open
|
|
</ContextMenuItem>
|
|
) : (
|
|
<>
|
|
{isEditable(entry.name) && (
|
|
<ContextMenuItem
|
|
onClick={() => openEditor(entry)}
|
|
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
|
>
|
|
<FileText className="w-4 h-4 mr-2" />
|
|
Edit
|
|
</ContextMenuItem>
|
|
)}
|
|
<ContextMenuItem
|
|
onClick={() =>
|
|
window.open(
|
|
`/api/files/download?path=${encodeURIComponent(entry.path)}`,
|
|
"_blank",
|
|
)
|
|
}
|
|
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
|
>
|
|
<Download className="w-4 h-4 mr-2" />
|
|
Download
|
|
</ContextMenuItem>
|
|
</>
|
|
)}
|
|
<ContextMenuSeparator className="bg-zinc-700" />
|
|
<ContextMenuItem
|
|
onClick={() => setDeleteTarget(entry)}
|
|
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
|
|
>
|
|
<Trash2 className="w-4 h-4 mr-2" />
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<p className="text-xs text-zinc-600 text-center">
|
|
Double-click to open files/folders • Right-click for options • Drag and drop to upload
|
|
</p>
|
|
|
|
<AlertDialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
|
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle className="text-white">
|
|
Delete {deleteTarget?.isDirectory ? "folder" : "file"}?
|
|
</AlertDialogTitle>
|
|
<AlertDialogDescription className="text-zinc-400">
|
|
<span className="font-mono text-white">{deleteTarget?.name}</span>{" "}
|
|
will be permanently deleted.
|
|
{deleteTarget?.isDirectory && " All contents will be removed."}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deleteTarget && handleDelete(deleteTarget)}
|
|
className="bg-red-600 hover:bg-red-500 text-white"
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</div>
|
|
);
|
|
}
|