Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

View File

@@ -0,0 +1,324 @@
"use client";
import { useEffect, useState, useCallback } 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Archive,
Download,
Trash2,
RefreshCw,
Plus,
MoreHorizontal,
Globe,
Puzzle,
Settings,
Package,
} from "lucide-react";
import { toast } from "sonner";
import { formatDistanceToNow, format } from "date-fns";
interface Backup {
id: string;
name: string;
type: "worlds" | "plugins" | "config" | "full";
size: number;
path: string;
createdAt: number;
status: "pending" | "running" | "completed" | "failed";
triggeredBy: string;
}
const TYPE_CONFIG = {
worlds: { label: "Worlds", icon: Globe, color: "text-blue-400", bg: "bg-blue-500/10" },
plugins: { label: "Plugins", icon: Puzzle, color: "text-amber-400", bg: "bg-amber-500/10" },
config: { label: "Config", icon: Settings, color: "text-violet-400", bg: "bg-violet-500/10" },
full: { label: "Full", icon: Package, color: "text-emerald-400", bg: "bg-emerald-500/10" },
};
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
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]}`;
}
export default function BackupsPage() {
const [backups, setBackups] = useState<Backup[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(null);
const fetchBackups = useCallback(async () => {
try {
const res = await fetch("/api/backups");
if (res.ok) setBackups((await res.json()).backups);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchBackups();
// Poll every 5s to catch running->completed transitions
const interval = setInterval(fetchBackups, 5000);
return () => clearInterval(interval);
}, [fetchBackups]);
const createBackup = async (type: Backup["type"]) => {
setCreating(true);
try {
const res = await fetch("/api/backups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type }),
});
if (!res.ok) throw new Error((await res.json()).error);
toast.success(`${TYPE_CONFIG[type].label} backup started`);
fetchBackups();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Backup failed");
} finally {
setCreating(false);
}
};
const deleteBackup = async (id: string) => {
try {
const res = await fetch(`/api/backups/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error((await res.json()).error);
toast.success("Backup deleted");
setBackups((prev) => prev.filter((b) => b.id !== id));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
}
setDeleteId(null);
};
const downloadBackup = (id: string) => {
window.open(`/api/backups/${id}`, "_blank");
};
const statusBadge = (status: Backup["status"]) => {
const config = {
pending: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
running: "bg-blue-500/20 text-blue-400 border-blue-500/30",
completed: "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
failed: "bg-red-500/20 text-red-400 border-red-500/30",
};
return (
<Badge className={`text-xs ${config[status]}`}>
{status === "running" && (
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5 animate-pulse" />
)}
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
);
};
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Backups</h1>
<p className="text-zinc-400 text-sm mt-1">
Create and manage server backups
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchBackups}
className="border-zinc-700 text-zinc-400 hover:text-white"
>
<RefreshCw className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger disabled={creating} className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium h-7 gap-1 px-2.5 bg-emerald-600 hover:bg-emerald-500 text-white transition-all">
<Plus className="w-4 h-4 mr-1.5" />
New Backup
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
([type, config]) => {
const Icon = config.icon;
return (
<DropdownMenuItem
key={type}
onClick={() => createBackup(type)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Icon className={`w-4 h-4 mr-2 ${config.color}`} />
{config.label} backup
</DropdownMenuItem>
);
},
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Type summary cards */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{(Object.entries(TYPE_CONFIG) as [Backup["type"], typeof TYPE_CONFIG.full][]).map(
([type, config]) => {
const Icon = config.icon;
const count = backups.filter(
(b) => b.type === type && b.status === "completed",
).length;
return (
<Card
key={type}
className="bg-zinc-900 border-zinc-800 cursor-pointer hover:border-zinc-700 transition-colors"
onClick={() => createBackup(type)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`p-2 rounded-lg ${config.bg}`}>
<Icon className={`w-5 h-5 ${config.color}`} />
</div>
<div>
<p className="text-sm font-medium text-white">
{config.label}
</p>
<p className="text-xs text-zinc-500">{count} backup(s)</p>
</div>
</CardContent>
</Card>
);
},
)}
</div>
{/* Backups list */}
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full bg-zinc-800" />
))}
</div>
) : backups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
<Archive className="w-10 h-10 mb-3 opacity-50" />
<p>No backups yet</p>
<p className="text-sm mt-1">Create your first backup above</p>
</div>
) : (
<div className="divide-y divide-zinc-800">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
<span />
<span>Name</span>
<span>Size</span>
<span>Status</span>
<span>Created</span>
<span />
</div>
{backups.map((backup) => {
const typeConfig = TYPE_CONFIG[backup.type];
const Icon = typeConfig.icon;
return (
<div
key={backup.id}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto] gap-4 px-4 py-3 items-center hover:bg-zinc-800/50 transition-colors"
>
<div className={`p-1.5 rounded-md ${typeConfig.bg}`}>
<Icon className={`w-4 h-4 ${typeConfig.color}`} />
</div>
<div>
<p className="text-sm font-medium text-white truncate max-w-xs">
{backup.name}
</p>
<p className="text-xs text-zinc-500 capitalize">
{backup.type}
</p>
</div>
<span className="text-sm text-zinc-400">
{backup.size > 0 ? formatBytes(backup.size) : "—"}
</span>
{statusBadge(backup.status)}
<span className="text-sm text-zinc-500">
{formatDistanceToNow(new Date(backup.createdAt), {
addSuffix: true,
})}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-8 h-8 text-zinc-500 hover:text-white transition-all">
<MoreHorizontal className="w-4 h-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="bg-zinc-900 border-zinc-700">
{backup.status === "completed" && (
<DropdownMenuItem
onClick={() => downloadBackup(backup.id)}
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
>
<Download className="w-4 h-4 mr-2" />
Download
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => setDeleteId(backup.id)}
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Delete confirmation */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Delete backup?
</AlertDialogTitle>
<AlertDialogDescription className="text-zinc-400">
This will permanently delete the backup file from disk. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-zinc-700 text-zinc-400">
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteId && deleteBackup(deleteId)}
className="bg-red-600 hover:bg-red-500 text-white"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}