Files
CubeAdmin/app/(dashboard)/files/page.tsx
2026-03-08 15:49:34 +01:00

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