Initial push
This commit is contained in:
166
app/(auth)/accept-invite/page.tsx
Normal file
166
app/(auth)/accept-invite/page.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert } from "@/components/ui/alert";
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";
|
||||
|
||||
function AcceptInvitePageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token");
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setError("Invalid or missing invitation token.");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError("Password must be at least 8 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/accept-invite", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ token, name, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/login"), 2000);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center gap-2 text-2xl font-bold text-emerald-500">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="2" y="2" width="11" height="11" fill="#10b981" rx="2" />
|
||||
<rect x="15" y="2" width="11" height="11" fill="#10b981" rx="2" opacity="0.6" />
|
||||
<rect x="2" y="15" width="11" height="11" fill="#10b981" rx="2" opacity="0.6" />
|
||||
<rect x="15" y="15" width="11" height="11" fill="#10b981" rx="2" />
|
||||
</svg>
|
||||
CubeAdmin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Accept Invitation</CardTitle>
|
||||
<CardDescription className="text-zinc-400">
|
||||
Create your account to access the Minecraft server panel.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{success ? (
|
||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-500" />
|
||||
<p className="text-white font-medium">Account created!</p>
|
||||
<p className="text-zinc-400 text-sm">
|
||||
Redirecting you to the login page...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<Alert className="bg-red-500/10 border-red-500/30 text-red-400">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span className="ml-2 text-sm">{error}</span>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Your Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
required
|
||||
disabled={!token || loading}
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
required
|
||||
disabled={!token || loading}
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Confirm Password</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
required
|
||||
disabled={!token || loading}
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!token || loading || !name || !password || !confirmPassword}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating account...
|
||||
</>
|
||||
) : (
|
||||
"Create Account"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AcceptInvitePage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<AcceptInvitePageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
377
app/(auth)/login/page.tsx
Normal file
377
app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useState, useTransition } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { z } from "zod";
|
||||
import { toast } from "sonner";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Eye, EyeOff, Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation schema
|
||||
// ---------------------------------------------------------------------------
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required"),
|
||||
});
|
||||
|
||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||
type FieldErrors = Partial<Record<keyof LoginFormValues, string>>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CubeAdmin logo (duplicated from sidebar so this page is self-contained)
|
||||
// ---------------------------------------------------------------------------
|
||||
function CubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polygon points="16,4 28,10 16,16 4,10" fill="#059669" opacity="0.9" />
|
||||
<polygon points="4,10 16,16 16,28 4,22" fill="#047857" opacity="0.95" />
|
||||
<polygon points="28,10 16,16 16,28 28,22" fill="#10b981" opacity="0.85" />
|
||||
<polyline
|
||||
points="4,10 16,4 28,10 16,16 4,10"
|
||||
stroke="#34d399"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<line
|
||||
x1="16" y1="16" x2="16" y2="28"
|
||||
stroke="#34d399"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Form field component
|
||||
// ---------------------------------------------------------------------------
|
||||
interface FormFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
error?: string;
|
||||
placeholder?: string;
|
||||
autoComplete?: string;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode; // for additional elements (e.g., show/hide button)
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FormField({
|
||||
id,
|
||||
label,
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
placeholder,
|
||||
autoComplete,
|
||||
disabled,
|
||||
children,
|
||||
className,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor={id} className="text-xs font-medium text-zinc-300">
|
||||
{label}
|
||||
</Label>
|
||||
<div className={cn("relative", className)}>
|
||||
<Input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange((e.target as HTMLInputElement).value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
disabled={disabled}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? `${id}-error` : undefined}
|
||||
className={cn(
|
||||
"h-9 bg-zinc-900/60 border-zinc-800 text-zinc-100 placeholder:text-zinc-600 focus-visible:border-emerald-500/50 focus-visible:ring-emerald-500/20",
|
||||
children && "pr-10",
|
||||
error &&
|
||||
"border-red-500/50 focus-visible:border-red-500/50 focus-visible:ring-red-500/20"
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
{error && (
|
||||
<p
|
||||
id={`${id}-error`}
|
||||
className="flex items-center gap-1 text-xs text-red-400"
|
||||
role="alert"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 flex-shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login page
|
||||
// ---------------------------------------------------------------------------
|
||||
function LoginPageInner() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
function validate(): LoginFormValues | null {
|
||||
const result = loginSchema.safeParse({ email, password });
|
||||
if (!result.success) {
|
||||
const errors: FieldErrors = {};
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as keyof LoginFormValues;
|
||||
if (!errors[field]) errors[field] = issue.message;
|
||||
}
|
||||
setFieldErrors(errors);
|
||||
return null;
|
||||
}
|
||||
setFieldErrors({});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setGlobalError(null);
|
||||
|
||||
const values = validate();
|
||||
if (!values) return;
|
||||
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const { error } = await authClient.signIn.email({
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
// Map common Better Auth error codes to friendly messages
|
||||
const message = mapAuthError(error.code ?? error.message ?? "");
|
||||
setGlobalError(message);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success("Signed in successfully");
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setGlobalError("An unexpected error occurred. Please try again.");
|
||||
console.error("[login] Unexpected error:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-950 px-4">
|
||||
{/* Background grid pattern */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 bg-[size:32px_32px] opacity-[0.02]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, #ffffff 1px, transparent 1px), linear-gradient(to bottom, #ffffff 1px, transparent 1px)",
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Subtle radial glow */}
|
||||
<div
|
||||
className="pointer-events-none fixed inset-0 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="h-[500px] w-[500px] rounded-full bg-emerald-500/5 blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="relative z-10 w-full max-w-sm">
|
||||
{/* Logo + branding */}
|
||||
<div className="mb-8 flex flex-col items-center gap-3">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-zinc-900 ring-1 ring-white/[0.08]">
|
||||
<CubeIcon className="h-9 w-9" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-100">
|
||||
CubeAdmin
|
||||
</h1>
|
||||
<p className="mt-0.5 text-xs text-zinc-500">
|
||||
Minecraft Server Management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="rounded-xl bg-zinc-900/50 p-6 ring-1 ring-white/[0.08] backdrop-blur-sm">
|
||||
<div className="mb-5">
|
||||
<h2 className="text-base font-semibold text-zinc-100">
|
||||
Sign in to your account
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Enter your credentials to access the admin panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Global error banner */}
|
||||
{globalError && (
|
||||
<div
|
||||
className="mb-4 flex items-start gap-2.5 rounded-lg bg-red-500/10 px-3 py-2.5 ring-1 ring-red-500/20"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-red-400" />
|
||||
<p className="text-xs text-red-300">{globalError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-4">
|
||||
{/* Email */}
|
||||
<FormField
|
||||
id="email"
|
||||
label="Email address"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={setEmail}
|
||||
error={fieldErrors.email}
|
||||
placeholder="admin@example.com"
|
||||
autoComplete="email"
|
||||
disabled={isPending}
|
||||
/>
|
||||
|
||||
{/* Password */}
|
||||
<FormField
|
||||
id="password"
|
||||
label="Password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
error={fieldErrors.password}
|
||||
placeholder="••••••••"
|
||||
autoComplete="current-password"
|
||||
disabled={isPending}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 rounded text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50"
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</FormField>
|
||||
|
||||
{/* Forgot password */}
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs text-zinc-500 transition-colors hover:text-zinc-300 focus-visible:outline-none focus-visible:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="mt-1 h-9 w-full bg-emerald-600 text-white hover:bg-emerald-500 focus-visible:ring-emerald-500/50 disabled:opacity-60 border-0 font-medium"
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Signing in…
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-center text-[11px] text-zinc-600">
|
||||
CubeAdmin — Secure server management
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<LoginPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function mapAuthError(code: string): string {
|
||||
const normalised = code.toLowerCase();
|
||||
|
||||
if (
|
||||
normalised.includes("invalid_credentials") ||
|
||||
normalised.includes("invalid credentials") ||
|
||||
normalised.includes("user_not_found") ||
|
||||
normalised.includes("incorrect password") ||
|
||||
normalised.includes("wrong password")
|
||||
) {
|
||||
return "Invalid email or password. Please check your credentials and try again.";
|
||||
}
|
||||
|
||||
if (normalised.includes("account_not_found")) {
|
||||
return "No account found with this email address.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalised.includes("email_not_verified") ||
|
||||
normalised.includes("email not verified")
|
||||
) {
|
||||
return "Please verify your email address before signing in.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalised.includes("too_many_requests") ||
|
||||
normalised.includes("rate_limit")
|
||||
) {
|
||||
return "Too many sign-in attempts. Please wait a few minutes before trying again.";
|
||||
}
|
||||
|
||||
if (
|
||||
normalised.includes("account_disabled") ||
|
||||
normalised.includes("user_banned")
|
||||
) {
|
||||
return "Your account has been disabled. Please contact your administrator.";
|
||||
}
|
||||
|
||||
return "Sign in failed. Please try again or contact your administrator.";
|
||||
}
|
||||
198
app/(dashboard)/audit/page.tsx
Normal file
198
app/(dashboard)/audit/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ScrollText, RefreshCw, Search, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface AuditEntry {
|
||||
log: {
|
||||
id: string;
|
||||
userId: string;
|
||||
action: string;
|
||||
target: string;
|
||||
targetId: string | null;
|
||||
details: string | null;
|
||||
ipAddress: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
}
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
"server.start": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
"server.stop": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
|
||||
"server.restart": "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
"player.ban": "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
"player.unban": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
"player.kick": "bg-amber-500/20 text-amber-400 border-amber-500/30",
|
||||
"file.upload": "bg-blue-500/20 text-blue-400 border-blue-500/30",
|
||||
"file.delete": "bg-red-500/20 text-red-400 border-red-500/30",
|
||||
"plugin.enable": "bg-emerald-500/20 text-emerald-400 border-emerald-500/30",
|
||||
"plugin.disable": "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
|
||||
};
|
||||
|
||||
function getActionColor(action: string): string {
|
||||
return ACTION_COLORS[action] ?? "bg-zinc-500/20 text-zinc-400 border-zinc-500/30";
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState("");
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const LIMIT = 50;
|
||||
|
||||
const fetchLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
limit: String(LIMIT),
|
||||
});
|
||||
if (search) params.set("action", search);
|
||||
const res = await fetch(`/api/audit?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEntries(data.logs);
|
||||
setHasMore(data.logs.length === LIMIT);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Audit Log</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Complete history of admin actions
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Filter by action (e.g. player.ban)"
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
|
||||
))}
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<ScrollText className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No audit log entries</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
<span>Action</span>
|
||||
<span>Details</span>
|
||||
<span>User</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
{entries.map(({ log, userName, userEmail }) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="grid grid-cols-[auto_1fr_auto_auto] gap-4 px-4 py-3 items-start hover:bg-zinc-800/50 transition-colors"
|
||||
>
|
||||
<Badge className={`text-xs shrink-0 mt-0.5 ${getActionColor(log.action)}`}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
<div>
|
||||
{log.targetId && (
|
||||
<p className="text-sm text-zinc-300">
|
||||
Target:{" "}
|
||||
<span className="font-mono text-xs text-zinc-400">
|
||||
{log.targetId}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
{log.details && (
|
||||
<p className="text-xs text-zinc-500 font-mono mt-0.5 truncate max-w-xs">
|
||||
{log.details}
|
||||
</p>
|
||||
)}
|
||||
{log.ipAddress && (
|
||||
<p className="text-xs text-zinc-600 mt-0.5">
|
||||
IP: {log.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-zinc-300">
|
||||
{userName ?? "Unknown"}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-600">{userEmail ?? ""}</p>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 whitespace-nowrap">
|
||||
{formatDistanceToNow(new Date(log.createdAt), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-zinc-500">Page {page}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page === 1 || loading}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || loading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
324
app/(dashboard)/backups/page.tsx
Normal file
324
app/(dashboard)/backups/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
254
app/(dashboard)/console/page.tsx
Normal file
254
app/(dashboard)/console/page.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Terminal, Send, Trash2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LogLine {
|
||||
text: string;
|
||||
timestamp: number;
|
||||
type: "info" | "warn" | "error" | "raw";
|
||||
}
|
||||
|
||||
function classifyLine(line: string): LogLine["type"] {
|
||||
if (/\[WARN\]|WARNING/i.test(line)) return "warn";
|
||||
if (/\[ERROR\]|SEVERE|Exception|Error/i.test(line)) return "error";
|
||||
return "info";
|
||||
}
|
||||
|
||||
function LineColor({ type }: { type: LogLine["type"] }) {
|
||||
const colors = {
|
||||
info: "text-zinc-300",
|
||||
warn: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
raw: "text-zinc-500",
|
||||
};
|
||||
return colors[type];
|
||||
}
|
||||
|
||||
const MAX_LINES = 1000;
|
||||
|
||||
export default function ConsolePage() {
|
||||
const [lines, setLines] = useState<LogLine[]>([]);
|
||||
const [command, setCommand] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState(-1);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = io("/console", {
|
||||
transports: ["websocket"],
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.on("connect", () => setConnected(true));
|
||||
socket.on("disconnect", () => setConnected(false));
|
||||
socket.on("connect_error", () => {
|
||||
toast.error("Failed to connect to server console");
|
||||
});
|
||||
|
||||
socket.on("output", (data: { line: string; timestamp: number }) => {
|
||||
setLines((prev) => {
|
||||
const newLine: LogLine = {
|
||||
text: data.line,
|
||||
timestamp: data.timestamp,
|
||||
type: classifyLine(data.line),
|
||||
};
|
||||
const updated = [...prev, newLine];
|
||||
return updated.length > MAX_LINES ? updated.slice(-MAX_LINES) : updated;
|
||||
});
|
||||
});
|
||||
|
||||
// Receive buffered history on connect
|
||||
socket.on("history", (data: { lines: string[] }) => {
|
||||
const historicalLines = data.lines.map((line) => ({
|
||||
text: line,
|
||||
timestamp: Date.now(),
|
||||
type: classifyLine(line) as LogLine["type"],
|
||||
}));
|
||||
setLines(historicalLines);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (autoScrollRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [lines]);
|
||||
|
||||
const sendCommand = useCallback(() => {
|
||||
const cmd = command.trim();
|
||||
if (!cmd || !socketRef.current) return;
|
||||
|
||||
// Add to local history
|
||||
setHistory((prev) => {
|
||||
const updated = [cmd, ...prev.filter((h) => h !== cmd)].slice(0, 50);
|
||||
return updated;
|
||||
});
|
||||
setHistoryIndex(-1);
|
||||
|
||||
// Echo to console
|
||||
setLines((prev) => [
|
||||
...prev,
|
||||
{ text: `> ${cmd}`, timestamp: Date.now(), type: "raw" },
|
||||
]);
|
||||
|
||||
socketRef.current.emit("command", { command: cmd });
|
||||
setCommand("");
|
||||
}, [command]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
sendCommand();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.min(historyIndex + 1, history.length - 1);
|
||||
setHistoryIndex(newIndex);
|
||||
if (history[newIndex]) setCommand(history[newIndex]);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const newIndex = Math.max(historyIndex - 1, -1);
|
||||
setHistoryIndex(newIndex);
|
||||
setCommand(newIndex === -1 ? "" : history[newIndex] ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (ts: number) =>
|
||||
new Date(ts).toLocaleTimeString("en", { hour12: false });
|
||||
|
||||
return (
|
||||
<div className="p-6 h-full flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Server Console</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time server output and command input
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
className={
|
||||
connected
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30"
|
||||
: "bg-red-500/20 text-red-400 border-red-500/30"
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
|
||||
connected ? "bg-emerald-500" : "bg-red-500"
|
||||
} animate-pulse`}
|
||||
/>
|
||||
{connected ? "Connected" : "Disconnected"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
onClick={() => setLines([])}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1.5" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800 flex-1 min-h-0 flex flex-col">
|
||||
<CardHeader className="py-3 px-4 border-b border-zinc-800 flex-row items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-emerald-500" />
|
||||
<CardTitle className="text-sm font-medium text-zinc-400">
|
||||
Console Output
|
||||
</CardTitle>
|
||||
<span className="ml-auto text-xs text-zinc-600">
|
||||
{lines.length} lines
|
||||
</span>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-0 flex-1 min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={(e) => {
|
||||
const el = e.currentTarget;
|
||||
autoScrollRef.current =
|
||||
el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
|
||||
}}
|
||||
className="h-full overflow-y-auto font-mono text-xs p-4 space-y-0.5"
|
||||
style={{ maxHeight: "calc(100vh - 280px)" }}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-40 text-zinc-600">
|
||||
<Terminal className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p>Waiting for server output...</p>
|
||||
</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} className="flex gap-3 leading-5">
|
||||
<span className="text-zinc-700 shrink-0 select-none">
|
||||
{formatTime(line.timestamp)}
|
||||
</span>
|
||||
<span
|
||||
className={`break-all ${
|
||||
{
|
||||
info: "text-zinc-300",
|
||||
warn: "text-amber-400",
|
||||
error: "text-red-400",
|
||||
raw: "text-emerald-400",
|
||||
}[line.type]
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Command input */}
|
||||
<div className="p-3 border-t border-zinc-800 flex gap-2">
|
||||
<div className="flex-1 flex items-center gap-2 bg-zinc-950 border border-zinc-700 rounded-md px-3 focus-within:border-emerald-500/50 transition-colors">
|
||||
<span className="text-emerald-500 font-mono text-sm select-none">
|
||||
/
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter server command... (↑↓ for history)"
|
||||
disabled={!connected}
|
||||
className="flex-1 bg-transparent py-2 text-sm text-white placeholder:text-zinc-600 outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={sendCommand}
|
||||
disabled={!connected || !command.trim()}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white shrink-0"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{!connected && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
|
||||
<AlertCircle className="w-4 h-4 shrink-0" />
|
||||
Console disconnected. The server may be offline or restarting.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
441
app/(dashboard)/files/page.tsx
Normal file
441
app/(dashboard)/files/page.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
31
app/(dashboard)/layout.tsx
Normal file
31
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Topbar } from "@/components/layout/topbar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dashboard Layout
|
||||
// Renders the persistent sidebar + topbar shell around all dashboard pages.
|
||||
// Auth protection is handled in middleware.ts.
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-screen w-full overflow-hidden bg-zinc-950">
|
||||
{/* Fixed sidebar */}
|
||||
<Sidebar />
|
||||
|
||||
{/* Main content column */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Sticky topbar */}
|
||||
<Topbar />
|
||||
|
||||
{/* Scrollable page content */}
|
||||
<main className="flex-1 overflow-y-auto bg-zinc-950">
|
||||
<div className="min-h-full p-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
app/(dashboard)/map/page.tsx
Normal file
127
app/(dashboard)/map/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Map, ExternalLink, AlertCircle } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
|
||||
export default function MapPage() {
|
||||
const [bluemapUrl, setBluemapUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [iframeLoaded, setIframeLoaded] = useState(false);
|
||||
const [iframeError, setIframeError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch BlueMap URL from server settings
|
||||
fetch("/api/server/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
setBluemapUrl(data?.settings?.bluemapUrl ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-48 bg-zinc-800" />
|
||||
<Skeleton className="h-[70vh] w-full bg-zinc-800 rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bluemapUrl) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Interactive BlueMap integration
|
||||
</p>
|
||||
</div>
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="flex flex-col items-center justify-center py-20">
|
||||
<Map className="w-12 h-12 text-zinc-600 mb-4" />
|
||||
<h3 className="text-white font-semibold mb-2">
|
||||
BlueMap not configured
|
||||
</h3>
|
||||
<p className="text-zinc-500 text-sm text-center max-w-md mb-6">
|
||||
BlueMap is a 3D world map plugin for Minecraft servers. Configure
|
||||
its URL in <strong>Server Settings</strong> to embed it here.
|
||||
</p>
|
||||
<a href="/server" className={buttonVariants({ className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>Go to Server Settings</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4 h-full flex flex-col">
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">3D World Map</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Powered by BlueMap — live world view with player positions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs">
|
||||
BlueMap
|
||||
</Badge>
|
||||
<a href={bluemapUrl} target="_blank" rel="noopener noreferrer" className={buttonVariants({ variant: "outline", size: "sm", className: "border-zinc-700 text-zinc-400 hover:text-white" })}>
|
||||
<ExternalLink className="w-4 h-4 mr-1.5" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 rounded-xl border border-zinc-800 overflow-hidden bg-zinc-950 relative">
|
||||
{!iframeLoaded && !iframeError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
|
||||
<div className="flex flex-col items-center gap-3 text-zinc-500">
|
||||
<div className="w-8 h-8 border-2 border-emerald-500/30 border-t-emerald-500 rounded-full animate-spin" />
|
||||
<p className="text-sm">Loading BlueMap...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{iframeError && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-zinc-950 z-10">
|
||||
<div className="flex flex-col items-center gap-3 text-zinc-500 max-w-sm text-center">
|
||||
<AlertCircle className="w-10 h-10 text-amber-500" />
|
||||
<p className="text-white font-medium">Could not load BlueMap</p>
|
||||
<p className="text-sm">
|
||||
Make sure BlueMap is running at{" "}
|
||||
<code className="text-emerald-400 text-xs">{bluemapUrl}</code>{" "}
|
||||
and that the URL is accessible from your browser.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400"
|
||||
onClick={() => {
|
||||
setIframeError(false);
|
||||
setIframeLoaded(false);
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
src={bluemapUrl}
|
||||
className="w-full h-full border-0"
|
||||
style={{ minHeight: "600px" }}
|
||||
onLoad={() => setIframeLoaded(true)}
|
||||
onError={() => setIframeError(true)}
|
||||
title="BlueMap 3D World Map"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
app/(dashboard)/monitoring/page.tsx
Normal file
269
app/(dashboard)/monitoring/page.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from "recharts";
|
||||
import { Activity, HardDrive, Clock, Cpu, Server } from "lucide-react";
|
||||
|
||||
interface DataPoint {
|
||||
time: string;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
players: number;
|
||||
}
|
||||
|
||||
interface MonitoringData {
|
||||
system: {
|
||||
cpuPercent: number;
|
||||
totalMemMb: number;
|
||||
usedMemMb: number;
|
||||
loadAvg: number[];
|
||||
uptime: number;
|
||||
};
|
||||
server: { running: boolean; uptime?: number };
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const MAX_HISTORY = 60; // 60 data points (e.g. 2 minutes at 2s intervals)
|
||||
|
||||
function formatUptime(s: number) {
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m ${s % 60}s`;
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [data, setData] = useState<MonitoringData | null>(null);
|
||||
const [history, setHistory] = useState<DataPoint[]>([]);
|
||||
const socketRef = useRef<ReturnType<typeof io> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Use REST polling (Socket.io monitoring namespace is optional here)
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring");
|
||||
if (!res.ok) return;
|
||||
const json: MonitoringData = await res.json();
|
||||
setData(json);
|
||||
const time = new Date(json.timestamp).toLocaleTimeString("en", {
|
||||
hour12: false,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
setHistory((prev) => {
|
||||
const updated = [
|
||||
...prev,
|
||||
{
|
||||
time,
|
||||
cpu: json.system.cpuPercent,
|
||||
memory: Math.round(
|
||||
(json.system.usedMemMb / json.system.totalMemMb) * 100,
|
||||
),
|
||||
players: 0,
|
||||
},
|
||||
];
|
||||
return updated.length > MAX_HISTORY
|
||||
? updated.slice(-MAX_HISTORY)
|
||||
: updated;
|
||||
});
|
||||
} catch {}
|
||||
};
|
||||
|
||||
poll();
|
||||
const interval = setInterval(poll, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const memPercent = data
|
||||
? Math.round((data.system.usedMemMb / data.system.totalMemMb) * 100)
|
||||
: 0;
|
||||
|
||||
const chartTheme = {
|
||||
grid: "#27272a",
|
||||
axis: "#52525b",
|
||||
tooltip: { bg: "#18181b", border: "#3f3f46", text: "#fff" },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Monitoring</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time system and server performance metrics
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{[
|
||||
{
|
||||
title: "CPU",
|
||||
value: `${data?.system.cpuPercent ?? 0}%`,
|
||||
icon: Cpu,
|
||||
color: "text-blue-500",
|
||||
bg: "bg-blue-500/10",
|
||||
},
|
||||
{
|
||||
title: "Memory",
|
||||
value: `${memPercent}%`,
|
||||
icon: HardDrive,
|
||||
color: "text-emerald-500",
|
||||
bg: "bg-emerald-500/10",
|
||||
sub: `${data?.system.usedMemMb ?? 0} / ${data?.system.totalMemMb ?? 0} MB`,
|
||||
},
|
||||
{
|
||||
title: "Load Avg",
|
||||
value: data?.system.loadAvg[0].toFixed(2) ?? "—",
|
||||
icon: Activity,
|
||||
color: "text-amber-500",
|
||||
bg: "bg-amber-500/10",
|
||||
},
|
||||
{
|
||||
title: "Uptime",
|
||||
value: data ? formatUptime(data.system.uptime) : "—",
|
||||
icon: Clock,
|
||||
color: "text-violet-500",
|
||||
bg: "bg-violet-500/10",
|
||||
},
|
||||
].map(({ title, value, icon: Icon, color, bg, sub }) => (
|
||||
<Card key={title} className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-5">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<p className="text-sm text-zinc-400">{title}</p>
|
||||
<div className={`p-2 rounded-lg ${bg}`}>
|
||||
<Icon className={`w-4 h-4 ${color}`} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
{sub && <p className="text-xs text-zinc-500 mt-1">{sub}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CPU chart */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4 text-blue-500" />
|
||||
CPU Usage Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="cpuGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: chartTheme.tooltip.bg,
|
||||
border: `1px solid ${chartTheme.tooltip.border}`,
|
||||
borderRadius: 8,
|
||||
color: chartTheme.tooltip.text,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(v) => [`${v}%`, "CPU"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cpu"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
fill="url(#cpuGrad)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "#3b82f6" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory chart */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-emerald-500" />
|
||||
Memory Usage Over Time
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<AreaChart data={history}>
|
||||
<defs>
|
||||
<linearGradient id="memGrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid stroke={chartTheme.grid} strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
stroke={chartTheme.axis}
|
||||
tick={{ fontSize: 11, fill: "#71717a" }}
|
||||
tickFormatter={(v) => `${v}%`}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: chartTheme.tooltip.bg,
|
||||
border: `1px solid ${chartTheme.tooltip.border}`,
|
||||
borderRadius: 8,
|
||||
color: chartTheme.tooltip.text,
|
||||
fontSize: 12,
|
||||
}}
|
||||
formatter={(v) => [`${v}%`, "Memory"]}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="memory"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
fill="url(#memGrad)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: "#10b981" }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
app/(dashboard)/page.tsx
Normal file
277
app/(dashboard)/page.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Users,
|
||||
Puzzle,
|
||||
HardDrive,
|
||||
Activity,
|
||||
Clock,
|
||||
Zap,
|
||||
TrendingUp,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MonitoringData {
|
||||
system: {
|
||||
cpuPercent: number;
|
||||
totalMemMb: number;
|
||||
usedMemMb: number;
|
||||
loadAvg: number[];
|
||||
uptime: number;
|
||||
};
|
||||
server: {
|
||||
running: boolean;
|
||||
uptime?: number;
|
||||
startedAt?: string;
|
||||
};
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface StatsData {
|
||||
totalPlayers: number;
|
||||
onlinePlayers: number;
|
||||
enabledPlugins: number;
|
||||
totalPlugins: number;
|
||||
pendingBackups: number;
|
||||
recentAlerts: string[];
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon: Icon,
|
||||
accent = "emerald",
|
||||
loading = false,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
icon: React.ElementType;
|
||||
accent?: "emerald" | "blue" | "amber" | "red";
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const colors = {
|
||||
emerald: "text-emerald-500 bg-emerald-500/10",
|
||||
blue: "text-blue-500 bg-blue-500/10",
|
||||
amber: "text-amber-500 bg-amber-500/10",
|
||||
red: "text-red-500 bg-red-500/10",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-zinc-400 mb-1">{title}</p>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-20 bg-zinc-800" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold text-white">{value}</p>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-xs text-zinc-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-2.5 rounded-lg ${colors[accent]}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h ${m}m`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [monitoring, setMonitoring] = useState<MonitoringData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMonitoring = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/monitoring");
|
||||
if (res.ok) setMonitoring(await res.json());
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMonitoring();
|
||||
const interval = setInterval(fetchMonitoring, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const memPercent = monitoring
|
||||
? Math.round((monitoring.system.usedMemMb / monitoring.system.totalMemMb) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Real-time overview of your Minecraft server
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status banner */}
|
||||
<div
|
||||
className={`flex items-center gap-3 p-4 rounded-xl border ${
|
||||
monitoring?.server.running
|
||||
? "bg-emerald-500/10 border-emerald-500/30 text-emerald-400"
|
||||
: "bg-red-500/10 border-red-500/30 text-red-400"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full animate-pulse ${
|
||||
monitoring?.server.running ? "bg-emerald-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
Server is {monitoring?.server.running ? "Online" : "Offline"}
|
||||
</span>
|
||||
{monitoring?.server.running && monitoring.server.uptime && (
|
||||
<span className="text-sm opacity-70 ml-auto">
|
||||
Up for {formatUptime(monitoring.server.uptime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
title="CPU Usage"
|
||||
value={loading ? "—" : `${monitoring?.system.cpuPercent ?? 0}%`}
|
||||
subtitle={`Load avg: ${monitoring?.system.loadAvg[0].toFixed(2) ?? "—"}`}
|
||||
icon={Activity}
|
||||
accent="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Memory"
|
||||
value={loading ? "—" : `${monitoring?.system.usedMemMb ?? 0} MB`}
|
||||
subtitle={`of ${monitoring?.system.totalMemMb ?? 0} MB total`}
|
||||
icon={HardDrive}
|
||||
accent={memPercent > 85 ? "red" : memPercent > 60 ? "amber" : "emerald"}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="System Uptime"
|
||||
value={loading ? "—" : formatUptime(monitoring?.system.uptime ?? 0)}
|
||||
icon={Clock}
|
||||
accent="emerald"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Server Status"
|
||||
value={monitoring?.server.running ? "Online" : "Offline"}
|
||||
icon={Zap}
|
||||
accent={monitoring?.server.running ? "emerald" : "red"}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Resource gauges */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
CPU Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-4 w-full bg-zinc-800" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-white font-medium">
|
||||
{monitoring?.system.cpuPercent ?? 0}%
|
||||
</span>
|
||||
<span className="text-zinc-500">100%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={monitoring?.system.cpuPercent ?? 0}
|
||||
className="h-2 bg-zinc-800"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<HardDrive className="w-4 h-4 text-emerald-500" />
|
||||
Memory Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-4 w-full bg-zinc-800" />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-white font-medium">
|
||||
{monitoring?.system.usedMemMb ?? 0} MB
|
||||
</span>
|
||||
<span className="text-zinc-500">
|
||||
{monitoring?.system.totalMemMb ?? 0} MB
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={memPercent}
|
||||
className="h-2 bg-zinc-800"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick info */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-zinc-400 flex items-center gap-2">
|
||||
<TrendingUp className="w-4 h-4 text-emerald-500" />
|
||||
Quick Actions
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{[
|
||||
{ label: "View Console", href: "/console", icon: "⌨️" },
|
||||
{ label: "Manage Players", href: "/players", icon: "👥" },
|
||||
{ label: "Plugins", href: "/plugins", icon: "🔌" },
|
||||
{ label: "Create Backup", href: "/backups", icon: "💾" },
|
||||
].map(({ label, href, icon }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-sm text-zinc-300 hover:text-white"
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
386
app/(dashboard)/players/page.tsx
Normal file
386
app/(dashboard)/players/page.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Ban,
|
||||
UserX,
|
||||
Shield,
|
||||
Clock,
|
||||
Users,
|
||||
WifiOff,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface Player {
|
||||
id: string;
|
||||
uuid: string | null;
|
||||
username: string;
|
||||
firstSeen: number;
|
||||
lastSeen: number;
|
||||
isOnline: boolean;
|
||||
playTime: number;
|
||||
role: string | null;
|
||||
isBanned: boolean;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
function PlayerAvatar({ username }: { username: string }) {
|
||||
return (
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage
|
||||
src={`https://crafatar.com/avatars/${username}?size=32&overlay`}
|
||||
alt={username}
|
||||
/>
|
||||
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-xs">
|
||||
{username.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
function formatPlayTime(minutes: number): string {
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h === 0) return `${m}m`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export default function PlayersPage() {
|
||||
const [players, setPlayers] = useState<Player[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "online" | "banned">("all");
|
||||
const [selectedPlayer, setSelectedPlayer] = useState<Player | null>(null);
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false);
|
||||
const [banReason, setBanReason] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
const fetchPlayers = useCallback(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ q: search, limit: "100" });
|
||||
if (filter === "online") params.set("online", "true");
|
||||
if (filter === "banned") params.set("banned", "true");
|
||||
const res = await fetch(`/api/players?${params}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlayers(data.players);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [search, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchPlayers();
|
||||
}, [fetchPlayers]);
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!selectedPlayer || !banReason.trim()) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/players/${selectedPlayer.id}?action=ban`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: banReason }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${selectedPlayer.username} has been banned`);
|
||||
setBanDialogOpen(false);
|
||||
setBanReason("");
|
||||
fetchPlayers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to ban player");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async (player: Player) => {
|
||||
try {
|
||||
const res = await fetch(`/api/players/${player.id}?action=unban`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${player.username} has been unbanned`);
|
||||
fetchPlayers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to unban player");
|
||||
}
|
||||
};
|
||||
|
||||
const handleKick = async (player: Player) => {
|
||||
try {
|
||||
const res = await fetch(`/api/players/${player.id}?action=kick`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason: "Kicked by admin" }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success(`${player.username} has been kicked`);
|
||||
fetchPlayers();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to kick player");
|
||||
}
|
||||
};
|
||||
|
||||
const onlinePlayers = players.filter((p) => p.isOnline).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Players</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage players, bans, and permissions
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse" />
|
||||
{onlinePlayers} online
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
onClick={fetchPlayers}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search players..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(["all", "online", "banned"] as const).map((f) => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={filter === f ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilter(f)}
|
||||
className={
|
||||
filter === f
|
||||
? "bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
: "border-zinc-700 text-zinc-400 hover:text-white"
|
||||
}
|
||||
>
|
||||
{f.charAt(0).toUpperCase() + f.slice(1)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Players table */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-12 w-full bg-zinc-800" />
|
||||
))}
|
||||
</div>
|
||||
) : players.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<Users className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No players found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{/* Header */}
|
||||
<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 className="w-8" />
|
||||
<span>Player</span>
|
||||
<span>Status</span>
|
||||
<span>Play Time</span>
|
||||
<span>Last Seen</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{players.map((player) => (
|
||||
<div
|
||||
key={player.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"
|
||||
>
|
||||
<PlayerAvatar username={player.username} />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{player.username}
|
||||
</span>
|
||||
{player.role && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-zinc-700 text-zinc-400"
|
||||
>
|
||||
{player.role}
|
||||
</Badge>
|
||||
)}
|
||||
{player.isBanned && (
|
||||
<Badge className="text-xs bg-red-500/20 text-red-400 border-red-500/30">
|
||||
Banned
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{player.uuid ?? "UUID unknown"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
player.isOnline ? "bg-emerald-500" : "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
player.isOnline ? "text-emerald-400" : "text-zinc-500"
|
||||
}`}
|
||||
>
|
||||
{player.isOnline ? "Online" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm text-zinc-400">
|
||||
{formatPlayTime(player.playTime)}
|
||||
</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
{formatDistanceToNow(new Date(player.lastSeen), {
|
||||
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"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
onClick={() =>
|
||||
(window.location.href = `/players/${player.id}`)
|
||||
}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
View Profile
|
||||
</DropdownMenuItem>
|
||||
{player.isOnline && (
|
||||
<DropdownMenuItem
|
||||
className="text-amber-400 focus:text-amber-300 focus:bg-zinc-800"
|
||||
onClick={() => handleKick(player)}
|
||||
>
|
||||
<WifiOff className="w-4 h-4 mr-2" />
|
||||
Kick
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator className="bg-zinc-700" />
|
||||
{player.isBanned ? (
|
||||
<DropdownMenuItem
|
||||
className="text-emerald-400 focus:text-emerald-300 focus:bg-zinc-800"
|
||||
onClick={() => handleUnban(player)}
|
||||
>
|
||||
<UserX className="w-4 h-4 mr-2" />
|
||||
Unban
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
className="text-red-400 focus:text-red-300 focus:bg-zinc-800"
|
||||
onClick={() => {
|
||||
setSelectedPlayer(player);
|
||||
setBanDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Ban className="w-4 h-4 mr-2" />
|
||||
Ban
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ban Dialog */}
|
||||
<Dialog open={banDialogOpen} onOpenChange={setBanDialogOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
Ban {selectedPlayer?.username}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">
|
||||
This will ban the player from the server and record the ban in the
|
||||
history.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Label className="text-zinc-300">Reason</Label>
|
||||
<Textarea
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
placeholder="Enter ban reason..."
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBanDialogOpen(false)}
|
||||
className="border-zinc-700 text-zinc-400"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBan}
|
||||
disabled={!banReason.trim() || actionLoading}
|
||||
className="bg-red-600 hover:bg-red-500 text-white"
|
||||
>
|
||||
{actionLoading ? "Banning..." : "Ban Player"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
app/(dashboard)/plugins/page.tsx
Normal file
275
app/(dashboard)/plugins/page.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Puzzle,
|
||||
Search,
|
||||
RefreshCw,
|
||||
MoreHorizontal,
|
||||
Power,
|
||||
RotateCcw,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface Plugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string | null;
|
||||
description: string | null;
|
||||
isEnabled: boolean;
|
||||
jarFile: string | null;
|
||||
installedAt: number;
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [plugins, setPlugins] = useState<Plugin[]>([]);
|
||||
const [jarFiles, setJarFiles] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
const fetchPlugins = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/plugins");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setPlugins(data.plugins);
|
||||
setJarFiles(data.jarFiles);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlugins();
|
||||
}, [fetchPlugins]);
|
||||
|
||||
const handleAction = async (
|
||||
name: string,
|
||||
action: "enable" | "disable" | "reload",
|
||||
) => {
|
||||
setActionLoading(`${name}-${action}`);
|
||||
try {
|
||||
const res = await fetch(`/api/plugins?action=${action}&name=${encodeURIComponent(name)}`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
const labels = { enable: "enabled", disable: "disabled", reload: "reloaded" };
|
||||
toast.success(`${name} ${labels[action]}`);
|
||||
fetchPlugins();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Action failed");
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = plugins.filter((p) =>
|
||||
p.name.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
const enabledCount = plugins.filter((p) => p.isEnabled).length;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Plugins</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage your server plugins
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30">
|
||||
{enabledCount} / {plugins.length} active
|
||||
</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchPlugins}
|
||||
className="border-zinc-700 text-zinc-400 hover:text-white"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<a href="/files?path=plugins" className={buttonVariants({ size: "sm", className: "bg-emerald-600 hover:bg-emerald-500 text-white" })}>
|
||||
<Upload className="w-4 h-4 mr-1.5" />
|
||||
Upload Plugin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||
<Input
|
||||
placeholder="Search plugins..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9 bg-zinc-900 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Plugins grid */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-32 bg-zinc-800 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-zinc-600">
|
||||
<Puzzle className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>
|
||||
{plugins.length === 0
|
||||
? "No plugins installed"
|
||||
: "No plugins match your search"}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{filtered.map((plugin) => (
|
||||
<Card
|
||||
key={plugin.id}
|
||||
className={`bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors ${
|
||||
!plugin.isEnabled ? "opacity-60" : ""
|
||||
}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`p-1.5 rounded-md ${
|
||||
plugin.isEnabled
|
||||
? "bg-emerald-500/10"
|
||||
: "bg-zinc-800"
|
||||
}`}
|
||||
>
|
||||
<Puzzle
|
||||
className={`w-4 h-4 ${
|
||||
plugin.isEnabled
|
||||
? "text-emerald-500"
|
||||
: "text-zinc-500"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{plugin.name}
|
||||
</p>
|
||||
{plugin.version && (
|
||||
<p className="text-xs text-zinc-500">
|
||||
v{plugin.version}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex shrink-0 items-center justify-center rounded-lg text-sm font-medium w-7 h-7 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"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
handleAction(
|
||||
plugin.name,
|
||||
plugin.isEnabled ? "disable" : "enable",
|
||||
)
|
||||
}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
disabled={!!actionLoading}
|
||||
>
|
||||
<Power className="w-4 h-4 mr-2" />
|
||||
{plugin.isEnabled ? "Disable" : "Enable"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAction(plugin.name, "reload")}
|
||||
className="text-zinc-300 focus:text-white focus:bg-zinc-800"
|
||||
disabled={!plugin.isEnabled || !!actionLoading}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reload
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{plugin.description && (
|
||||
<p className="text-xs text-zinc-500 line-clamp-2 mb-3">
|
||||
{plugin.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-3 pt-3 border-t border-zinc-800">
|
||||
<Badge
|
||||
className={
|
||||
plugin.isEnabled
|
||||
? "bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-xs"
|
||||
: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30 text-xs"
|
||||
}
|
||||
>
|
||||
{plugin.isEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={plugin.isEnabled}
|
||||
disabled={
|
||||
actionLoading === `${plugin.name}-enable` ||
|
||||
actionLoading === `${plugin.name}-disable`
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
handleAction(plugin.name, checked ? "enable" : "disable")
|
||||
}
|
||||
className="scale-75"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jar files not in DB */}
|
||||
{jarFiles.filter(
|
||||
(jar) => !plugins.find((p) => p.jarFile === jar || p.name + ".jar" === jar),
|
||||
).length > 0 && (
|
||||
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium text-zinc-500">
|
||||
Jar files detected (not yet in database)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{jarFiles.map((jar) => (
|
||||
<Badge
|
||||
key={jar}
|
||||
variant="outline"
|
||||
className="border-zinc-700 text-zinc-400 text-xs"
|
||||
>
|
||||
{jar}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
346
app/(dashboard)/scheduler/page.tsx
Normal file
346
app/(dashboard)/scheduler/page.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
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 { Clock, Plus, MoreHorizontal, Trash2, Edit, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface Task {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
cronExpression: string;
|
||||
command: string;
|
||||
isEnabled: boolean;
|
||||
lastRun: number | null;
|
||||
nextRun: number | null;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const CRON_PRESETS = [
|
||||
{ label: "Every minute", value: "* * * * *" },
|
||||
{ label: "Every 5 minutes", value: "*/5 * * * *" },
|
||||
{ label: "Every hour", value: "0 * * * *" },
|
||||
{ label: "Every day at midnight", value: "0 0 * * *" },
|
||||
{ label: "Every Sunday at 3am", value: "0 3 * * 0" },
|
||||
];
|
||||
|
||||
function TaskForm({
|
||||
initial,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
initial?: Partial<Task>;
|
||||
onSubmit: (data: Omit<Task, "id" | "lastRun" | "nextRun" | "createdAt">) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState(initial?.name ?? "");
|
||||
const [description, setDescription] = useState(initial?.description ?? "");
|
||||
const [cronExpression, setCronExpression] = useState(initial?.cronExpression ?? "0 0 * * *");
|
||||
const [command, setCommand] = useState(initial?.command ?? "");
|
||||
const [isEnabled, setIsEnabled] = useState(initial?.isEnabled ?? true);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-zinc-300">Task Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. Daily restart"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-zinc-300">Description (optional)</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-zinc-300">Cron Expression</Label>
|
||||
<Input
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="* * * * *"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{CRON_PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.value}
|
||||
type="button"
|
||||
onClick={() => setCronExpression(p.value)}
|
||||
className="text-xs px-2 py-0.5 rounded bg-zinc-700 text-zinc-400 hover:bg-zinc-600 hover:text-white transition-colors"
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-zinc-300">Minecraft Command</Label>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="e.g. say Server restart in 5 minutes"
|
||||
className="mt-1 bg-zinc-800 border-zinc-700 text-white font-mono"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
Enter a Minecraft command (without leading /) to execute via RCON.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={isEnabled}
|
||||
onCheckedChange={setIsEnabled}
|
||||
/>
|
||||
<Label className="text-zinc-300">Enable immediately</Label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} className="border-zinc-700 text-zinc-400">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onSubmit({ name, description: description || null, cronExpression, command, isEnabled })}
|
||||
disabled={!name || !cronExpression || !command || loading}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
{loading ? "Saving..." : "Save Task"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SchedulerPage() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editTask, setEditTask] = useState<Task | null>(null);
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/scheduler");
|
||||
if (res.ok) setTasks((await res.json()).tasks);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTasks(); }, [fetchTasks]);
|
||||
|
||||
const handleCreate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/scheduler", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Task created");
|
||||
setDialogOpen(false);
|
||||
fetchTasks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to create task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (data: Parameters<typeof TaskForm>[0]["onSubmit"] extends (d: infer D) => void ? D : never) => {
|
||||
if (!editTask) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/scheduler/${editTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Task updated");
|
||||
setEditTask(null);
|
||||
fetchTasks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update task");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/scheduler/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
toast.success("Task deleted");
|
||||
setTasks((p) => p.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to delete");
|
||||
}
|
||||
setDeleteId(null);
|
||||
};
|
||||
|
||||
const toggleTask = async (task: Task) => {
|
||||
try {
|
||||
const res = await fetch(`/api/scheduler/${task.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isEnabled: !task.isEnabled }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
fetchTasks();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to toggle");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Scheduler</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">Automated recurring tasks</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchTasks} className="border-zinc-700 text-zinc-400 hover:text-white">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setDialogOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> New Task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{[1,2,3].map(i => <Skeleton key={i} className="h-16 w-full bg-zinc-800" />)}
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-600">
|
||||
<Clock className="w-10 h-10 mb-3 opacity-50" />
|
||||
<p>No scheduled tasks</p>
|
||||
<p className="text-sm mt-1">Create a task to automate server commands</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{tasks.map(task => (
|
||||
<div key={task.id} className="flex items-center gap-4 px-4 py-4 hover:bg-zinc-800/50 transition-colors">
|
||||
<Switch checked={task.isEnabled} onCheckedChange={() => toggleTask(task)} className="shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<p className="text-sm font-medium text-white">{task.name}</p>
|
||||
{task.description && (
|
||||
<span className="text-xs text-zinc-500">— {task.description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<code className="text-xs text-emerald-400 bg-emerald-500/10 px-1.5 py-0.5 rounded">{task.cronExpression}</code>
|
||||
<code className="text-xs text-zinc-400 font-mono">{task.command}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
{task.lastRun ? (
|
||||
<p className="text-xs text-zinc-500">Last: {formatDistanceToNow(new Date(task.lastRun), { addSuffix: true })}</p>
|
||||
) : (
|
||||
<p className="text-xs text-zinc-600">Never run</p>
|
||||
)}
|
||||
</div>
|
||||
<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">
|
||||
<DropdownMenuItem onClick={() => setEditTask(task)} className="text-zinc-300 focus:text-white focus:bg-zinc-800">
|
||||
<Edit className="w-4 h-4 mr-2" /> Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setDeleteId(task.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>
|
||||
|
||||
{/* Create dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Scheduled Task</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">Schedule a Minecraft command to run automatically.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TaskForm onSubmit={handleCreate} onCancel={() => setDialogOpen(false)} loading={saving} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={!!editTask} onOpenChange={(o) => !o && setEditTask(null)}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Edit Task</DialogTitle>
|
||||
</DialogHeader>
|
||||
{editTask && <TaskForm initial={editTask} onSubmit={handleUpdate} onCancel={() => setEditTask(null)} loading={saving} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirm */}
|
||||
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<AlertDialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">Delete task?</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-zinc-400">This will stop and remove the scheduled task permanently.</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-zinc-700 text-zinc-400">Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => deleteId && handleDelete(deleteId)} className="bg-red-600 hover:bg-red-500 text-white">Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
app/(dashboard)/server/page.tsx
Normal file
362
app/(dashboard)/server/page.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Settings, RefreshCw, Download, Server, Map } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ServerSettings {
|
||||
minecraftPath?: string;
|
||||
serverJar?: string;
|
||||
serverVersion?: string;
|
||||
serverType?: string;
|
||||
maxRam?: number;
|
||||
minRam?: number;
|
||||
rconEnabled?: boolean;
|
||||
rconPort?: number;
|
||||
javaArgs?: string;
|
||||
autoStart?: boolean;
|
||||
restartOnCrash?: boolean;
|
||||
backupEnabled?: boolean;
|
||||
backupSchedule?: string;
|
||||
bluemapEnabled?: boolean;
|
||||
bluemapUrl?: string;
|
||||
}
|
||||
|
||||
const SERVER_TYPES = ["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"];
|
||||
|
||||
export default function ServerPage() {
|
||||
const [settings, setSettings] = useState<ServerSettings>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [versions, setVersions] = useState<string[]>([]);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState("paper");
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/server/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.settings) {
|
||||
setSettings(data.settings);
|
||||
setSelectedType(data.settings.serverType ?? "paper");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchVersions = useCallback(async (type: string) => {
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const res = await fetch(`/api/server/versions?type=${type}`);
|
||||
if (res.ok) setVersions((await res.json()).versions);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchSettings(); }, [fetchSettings]);
|
||||
useEffect(() => { fetchVersions(selectedType); }, [selectedType, fetchVersions]);
|
||||
|
||||
const save = async (updates: Partial<ServerSettings>) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/server/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error);
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
toast.success("Settings saved");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-48 bg-zinc-800" />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full bg-zinc-800 rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Server Settings</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Configure your Minecraft server and CubeAdmin options
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general" className="space-y-4">
|
||||
<TabsList className="bg-zinc-900 border border-zinc-800">
|
||||
<TabsTrigger value="general" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
General
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="performance" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
Performance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="updates" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
Updates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="integrations" className="data-[state=active]:bg-zinc-800 data-[state=active]:text-white text-zinc-400">
|
||||
Integrations
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* General */}
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Server className="w-4 h-4 text-emerald-500" />
|
||||
Server Configuration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Minecraft Server Path</Label>
|
||||
<Input
|
||||
value={settings.minecraftPath ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, minecraftPath: e.target.value }))}
|
||||
placeholder="/opt/minecraft/server"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Server JAR filename</Label>
|
||||
<Input
|
||||
value={settings.serverJar ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, serverJar: e.target.value }))}
|
||||
placeholder="server.jar"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300">Auto-start on boot</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Start server when CubeAdmin starts</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.autoStart ?? false}
|
||||
onCheckedChange={(v) => save({ autoStart: v })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300">Auto-restart on crash</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Automatically restart if server crashes</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.restartOnCrash ?? false}
|
||||
onCheckedChange={(v) => save({ restartOnCrash: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => save({ minecraftPath: settings.minecraftPath, serverJar: settings.serverJar })}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Performance */}
|
||||
<TabsContent value="performance" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300">JVM Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Min RAM (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.minRam ?? 512}
|
||||
onChange={(e) => setSettings(p => ({ ...p, minRam: parseInt(e.target.value) }))}
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
min={256}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Max RAM (MB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.maxRam ?? 2048}
|
||||
onChange={(e) => setSettings(p => ({ ...p, maxRam: parseInt(e.target.value) }))}
|
||||
className="bg-zinc-800 border-zinc-700 text-white"
|
||||
min={512}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Additional Java Arguments</Label>
|
||||
<Input
|
||||
value={settings.javaArgs ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, javaArgs: e.target.value }))}
|
||||
placeholder="-XX:+UseG1GC -XX:MaxGCPauseMillis=200"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
Aikar's flags are applied by default with the Docker image.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => save({ minRam: settings.minRam, maxRam: settings.maxRam, javaArgs: settings.javaArgs })}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Updates */}
|
||||
<TabsContent value="updates" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-emerald-500" />
|
||||
Server Version
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Server Type</Label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(v) => { if (v) { setSelectedType(v); fetchVersions(v); } }}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
{SERVER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t} className="text-zinc-300 focus:bg-zinc-700 focus:text-white capitalize">
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Version</Label>
|
||||
<Select
|
||||
value={settings.serverVersion ?? ""}
|
||||
onValueChange={(v) => setSettings(p => ({ ...p, serverVersion: v ?? undefined }))}
|
||||
disabled={loadingVersions}
|
||||
>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue placeholder={loadingVersions ? "Loading..." : "Select version"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700 max-h-60">
|
||||
{versions.map((v) => (
|
||||
<SelectItem key={v} value={v} className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
|
||||
{v}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-400 text-sm">
|
||||
<span>⚠️</span>
|
||||
<p>Changing server version requires a server restart. Always backup first!</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => save({ serverType: selectedType, serverVersion: settings.serverVersion })}
|
||||
disabled={saving || !settings.serverVersion}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Apply Version"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Integrations */}
|
||||
<TabsContent value="integrations" className="space-y-4">
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base font-medium text-zinc-300 flex items-center gap-2">
|
||||
<Map className="w-4 h-4 text-emerald-500" />
|
||||
BlueMap Integration
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800 border border-zinc-700">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-300">Enable BlueMap</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Show the 3D map in the Map section</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.bluemapEnabled ?? false}
|
||||
onCheckedChange={(v) => save({ bluemapEnabled: v })}
|
||||
/>
|
||||
</div>
|
||||
{settings.bluemapEnabled && (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">BlueMap URL</Label>
|
||||
<Input
|
||||
value={settings.bluemapUrl ?? ""}
|
||||
onChange={(e) => setSettings(p => ({ ...p, bluemapUrl: e.target.value }))}
|
||||
placeholder="http://localhost:8100"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">
|
||||
The URL where BlueMap is accessible from your browser.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => save({ bluemapEnabled: settings.bluemapEnabled, bluemapUrl: settings.bluemapUrl })}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
app/(dashboard)/team/page.tsx
Normal file
239
app/(dashboard)/team/page.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { UserPlus, Mail, Clock, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
interface TeamUser {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
role: string | null;
|
||||
createdAt: number;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
interface PendingInvite {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
expiresAt: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const ROLE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
superadmin: { label: "Super Admin", color: "bg-amber-500/20 text-amber-400 border-amber-500/30" },
|
||||
admin: { label: "Admin", color: "bg-blue-500/20 text-blue-400 border-blue-500/30" },
|
||||
moderator: { label: "Moderator", color: "bg-violet-500/20 text-violet-400 border-violet-500/30" },
|
||||
};
|
||||
|
||||
export default function TeamPage() {
|
||||
const [users, setUsers] = useState<TeamUser[]>([]);
|
||||
const [invites, setInvites] = useState<PendingInvite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [inviteOpen, setInviteOpen] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
const [inviteRole, setInviteRole] = useState<"admin" | "moderator">("moderator");
|
||||
const [inviting, setInviting] = useState(false);
|
||||
|
||||
const fetchTeam = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/team");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUsers(data.users);
|
||||
setInvites(data.pendingInvites ?? []);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTeam(); }, [fetchTeam]);
|
||||
|
||||
const handleInvite = async () => {
|
||||
if (!inviteEmail) return;
|
||||
setInviting(true);
|
||||
try {
|
||||
const res = await fetch("/api/team", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
toast.success(`Invitation sent to ${inviteEmail}`);
|
||||
setInviteOpen(false);
|
||||
setInviteEmail("");
|
||||
fetchTeam();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to send invitation");
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Team</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Manage who can access the CubeAdmin panel
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchTeam} className="border-zinc-700 text-zinc-400 hover:text-white">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button size="sm" className="bg-emerald-600 hover:bg-emerald-500 text-white" onClick={() => setInviteOpen(true)}>
|
||||
<UserPlus className="w-4 h-4 mr-1.5" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active members */}
|
||||
<Card className="bg-zinc-900 border-zinc-800">
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-4 space-y-3">
|
||||
{[1, 2, 3].map(i => <Skeleton key={i} className="h-14 w-full bg-zinc-800" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Active Members ({users.length})
|
||||
</div>
|
||||
{users.map(user => (
|
||||
<div key={user.id} className="flex items-center gap-4 px-4 py-3 hover:bg-zinc-800/50 transition-colors">
|
||||
<Avatar className="w-9 h-9 shrink-0">
|
||||
<AvatarImage src={user.image ?? undefined} />
|
||||
<AvatarFallback className="bg-zinc-800 text-zinc-400 text-sm">
|
||||
{user.name?.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">{user.name}</p>
|
||||
<p className="text-xs text-zinc-500 truncate">{user.email}</p>
|
||||
</div>
|
||||
<Badge className={`text-xs ${ROLE_CONFIG[user.role ?? ""]?.color ?? "bg-zinc-500/20 text-zinc-400"}`}>
|
||||
{ROLE_CONFIG[user.role ?? ""]?.label ?? user.role ?? "No role"}
|
||||
</Badge>
|
||||
<p className="text-xs text-zinc-600 hidden sm:block shrink-0">
|
||||
Joined {formatDistanceToNow(new Date(user.createdAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pending invites */}
|
||||
{invites.length > 0 && (
|
||||
<Card className="bg-zinc-900 border-zinc-800 border-dashed">
|
||||
<CardContent className="p-0">
|
||||
<div className="divide-y divide-zinc-800">
|
||||
<div className="px-4 py-2.5 text-xs font-medium text-zinc-500 uppercase tracking-wide">
|
||||
Pending Invitations ({invites.length})
|
||||
</div>
|
||||
{invites.map(invite => (
|
||||
<div key={invite.id} className="flex items-center gap-4 px-4 py-3">
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-800 flex items-center justify-center shrink-0">
|
||||
<Mail className="w-4 h-4 text-zinc-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-300">{invite.email}</p>
|
||||
<p className="text-xs text-zinc-500 flex items-center gap-1 mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Expires {formatDistanceToNow(new Date(invite.expiresAt), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
<Badge className={`text-xs ${ROLE_CONFIG[invite.role]?.color ?? ""}`}>
|
||||
{ROLE_CONFIG[invite.role]?.label ?? invite.role}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Invite dialog */}
|
||||
<Dialog open={inviteOpen} onOpenChange={setInviteOpen}>
|
||||
<DialogContent className="bg-zinc-900 border-zinc-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Invite Team Member</DialogTitle>
|
||||
<DialogDescription className="text-zinc-400">
|
||||
They'll receive an email with a link to create their account.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Email Address</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="teammate@example.com"
|
||||
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 focus:border-emerald-500/50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-zinc-300">Role</Label>
|
||||
<Select value={inviteRole} onValueChange={(v) => { if (v === "admin" || v === "moderator") setInviteRole(v); }}>
|
||||
<SelectTrigger className="bg-zinc-800 border-zinc-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-zinc-800 border-zinc-700">
|
||||
<SelectItem value="admin" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
|
||||
Admin — Full access except team management
|
||||
</SelectItem>
|
||||
<SelectItem value="moderator" className="text-zinc-300 focus:bg-zinc-700 focus:text-white">
|
||||
Moderator — Player management only
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setInviteOpen(false)} className="border-zinc-700 text-zinc-400">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleInvite}
|
||||
disabled={!inviteEmail || inviting}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white"
|
||||
>
|
||||
{inviting ? "Sending..." : "Send Invitation"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
app/api/accept-invite/route.ts
Normal file
71
app/api/accept-invite/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { invitations, users } from "@/lib/db/schema";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const AcceptSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
name: z.string().min(2).max(100),
|
||||
password: z.string().min(8).max(128),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
let body: z.infer<typeof AcceptSchema>;
|
||||
try {
|
||||
body = AcceptSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const invitation = await db
|
||||
.select()
|
||||
.from(invitations)
|
||||
.where(eq(invitations.token, body.token))
|
||||
.get();
|
||||
|
||||
if (!invitation) {
|
||||
return NextResponse.json({ error: "Invalid invitation token" }, { status: 404 });
|
||||
}
|
||||
if (invitation.acceptedAt) {
|
||||
return NextResponse.json({ error: "Invitation already used" }, { status: 409 });
|
||||
}
|
||||
if (Number(invitation.expiresAt) < Date.now()) {
|
||||
return NextResponse.json({ error: "Invitation has expired" }, { status: 410 });
|
||||
}
|
||||
|
||||
// Check if email already registered
|
||||
const existing = await db.select().from(users).where(eq(users.email, invitation.email)).get();
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "Email already registered" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Create user via Better Auth
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: invitation.email,
|
||||
password: body.password,
|
||||
name: body.name,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to create account";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Mark invitation as accepted and set role
|
||||
await db
|
||||
.update(invitations)
|
||||
.set({ acceptedAt: Date.now() })
|
||||
.where(eq(invitations.token, body.token));
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ role: invitation.role })
|
||||
.where(eq(users.email, invitation.email));
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
48
app/api/audit/route.ts
Normal file
48
app/api/audit/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs, users } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { desc, eq, like, and, gte, lte } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
|
||||
const limit = Math.min(100, parseInt(searchParams.get("limit") ?? "50"));
|
||||
const offset = (page - 1) * limit;
|
||||
const userId = searchParams.get("userId");
|
||||
const action = searchParams.get("action");
|
||||
const from = searchParams.get("from");
|
||||
const to = searchParams.get("to");
|
||||
|
||||
const conditions = [];
|
||||
if (userId) conditions.push(eq(auditLogs.userId, userId));
|
||||
if (action) conditions.push(like(auditLogs.action, `${action}%`));
|
||||
if (from) conditions.push(gte(auditLogs.createdAt, parseInt(from)));
|
||||
if (to) conditions.push(lte(auditLogs.createdAt, parseInt(to)));
|
||||
|
||||
const logs = await db
|
||||
.select({
|
||||
log: auditLogs,
|
||||
userName: users.name,
|
||||
userEmail: users.email,
|
||||
})
|
||||
.from(auditLogs)
|
||||
.leftJoin(users, eq(auditLogs.userId, users.id))
|
||||
.where(conditions.length ? and(...conditions) : undefined)
|
||||
.orderBy(desc(auditLogs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return NextResponse.json({ logs, page, limit });
|
||||
}
|
||||
56
app/api/backups/[id]/route.ts
Normal file
56
app/api/backups/[id]/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { deleteBackup } from "@/lib/backup/manager";
|
||||
import { db } from "@/lib/db";
|
||||
import { backups } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
try {
|
||||
await deleteBackup(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const backup = await db.select().from(backups).where(eq(backups.id, id)).get();
|
||||
if (!backup) return NextResponse.json({ error: "Backup not found" }, { status: 404 });
|
||||
if (backup.status !== "completed") {
|
||||
return NextResponse.json({ error: "Backup not ready" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!backup.path || !fs.existsSync(backup.path)) {
|
||||
return NextResponse.json({ error: "Backup file not found on disk" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileBuffer = fs.readFileSync(backup.path);
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(backup.name)}"`,
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Length": String(fileBuffer.length),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
48
app/api/backups/route.ts
Normal file
48
app/api/backups/route.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { createBackup, listBackups, BackupType } from "@/lib/backup/manager";
|
||||
import { z } from "zod";
|
||||
|
||||
const CreateBackupSchema = z.object({
|
||||
type: z.enum(["worlds", "plugins", "config", "full"]),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const backups = await listBackups();
|
||||
return NextResponse.json({ backups });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 5); // Strict limit for backup creation
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof CreateBackupSchema>;
|
||||
try {
|
||||
body = CreateBackupSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = await createBackup(body.type as BackupType, session.user.id);
|
||||
return NextResponse.json({ success: true, id }, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Backup failed";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
47
app/api/files/delete/route.ts
Normal file
47
app/api/files/delete/route.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import { getClientIp } from "@/lib/security/rateLimit";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const { filePath } = await req.json();
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = sanitizeFilePath(filePath, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(resolvedPath, { recursive: true });
|
||||
} else {
|
||||
fs.unlinkSync(resolvedPath);
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id,
|
||||
action: "file.delete", target: "file", targetId: path.relative(mcBase, resolvedPath),
|
||||
details: null, ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
37
app/api/files/download/route.ts
Normal file
37
app/api/files/download/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const filePath = req.nextUrl.searchParams.get("path") ?? "";
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = sanitizeFilePath(filePath, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(resolvedPath) || fs.statSync(resolvedPath).isDirectory()) {
|
||||
return NextResponse.json({ error: "File not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const fileName = path.basename(resolvedPath);
|
||||
const fileBuffer = fs.readFileSync(resolvedPath);
|
||||
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(fileBuffer.length),
|
||||
// Prevent XSS via content sniffing
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
60
app/api/files/list/route.ts
Normal file
60
app/api/files/list/route.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const relativePath = new URL(req.url).searchParams.get("path") ?? "/";
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = sanitizeFilePath(relativePath, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(resolvedPath, { withFileTypes: true });
|
||||
const files = entries.map((entry) => {
|
||||
const fullPath = path.join(resolvedPath, entry.name);
|
||||
let size = 0;
|
||||
let modifiedAt = 0;
|
||||
try {
|
||||
const stat = fs.statSync(fullPath);
|
||||
size = stat.size;
|
||||
modifiedAt = stat.mtimeMs;
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
name: entry.name,
|
||||
path: path.relative(mcBase, fullPath),
|
||||
isDirectory: entry.isDirectory(),
|
||||
size,
|
||||
modifiedAt,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: directories first, then files, alphabetically
|
||||
files.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
path: path.relative(mcBase, resolvedPath) || "/",
|
||||
entries: files,
|
||||
});
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Cannot read directory" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
71
app/api/files/upload/route.ts
Normal file
71
app/api/files/upload/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sanitizeFilePath } from "@/lib/security/sanitize";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const MAX_FILE_SIZE = 500 * 1024 * 1024; // 500 MB
|
||||
const BLOCKED_EXTENSIONS = new Set([".exe", ".bat", ".cmd", ".sh", ".ps1"]);
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 20);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const mcBase = path.resolve(process.env.MC_SERVER_PATH ?? "/opt/minecraft/server");
|
||||
const targetDir = req.nextUrl.searchParams.get("path") ?? "/";
|
||||
|
||||
let resolvedDir: string;
|
||||
try {
|
||||
resolvedDir = sanitizeFilePath(targetDir, mcBase);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
return NextResponse.json({ error: "File too large (max 500 MB)" }, { status: 413 });
|
||||
}
|
||||
|
||||
// Check extension
|
||||
const ext = path.extname(file.name).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||
return NextResponse.json({ error: "File type not allowed" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Sanitize filename: allow alphanumeric, dots, dashes, underscores, spaces
|
||||
const safeName = file.name.replace(/[^a-zA-Z0-9._\- ]/g, "_");
|
||||
const destPath = path.join(resolvedDir, safeName);
|
||||
|
||||
// Ensure destination is still within base
|
||||
if (!destPath.startsWith(mcBase)) {
|
||||
return NextResponse.json({ error: "Invalid destination" }, { status: 400 });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
fs.mkdirSync(resolvedDir, { recursive: true });
|
||||
fs.writeFileSync(destPath, buffer);
|
||||
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id,
|
||||
action: "file.upload", target: "file", targetId: path.relative(mcBase, destPath),
|
||||
details: JSON.stringify({ size: file.size, name: safeName }), ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, path: path.relative(mcBase, destPath) });
|
||||
}
|
||||
9
app/api/health/route.ts
Normal file
9
app/api/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/** Health check endpoint used by Docker and monitoring. */
|
||||
export async function GET() {
|
||||
return NextResponse.json(
|
||||
{ status: "ok", timestamp: Date.now() },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
66
app/api/monitoring/route.ts
Normal file
66
app/api/monitoring/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import * as os from "node:os";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const totalMemMb = Math.round(os.totalmem() / 1024 / 1024);
|
||||
const freeMemMb = Math.round(os.freemem() / 1024 / 1024);
|
||||
const usedMemMb = totalMemMb - freeMemMb;
|
||||
|
||||
// CPU usage (average across all cores, sampled over 100ms)
|
||||
const cpuPercent = await getCpuPercent();
|
||||
|
||||
const serverStatus = mcProcessManager.getStatus();
|
||||
|
||||
return NextResponse.json({
|
||||
system: {
|
||||
cpuPercent,
|
||||
totalMemMb,
|
||||
usedMemMb,
|
||||
freeMemMb,
|
||||
loadAvg: os.loadavg(),
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
arch: os.arch(),
|
||||
},
|
||||
server: serverStatus,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
function getCpuPercent(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const cpus1 = os.cpus();
|
||||
setTimeout(() => {
|
||||
const cpus2 = os.cpus();
|
||||
let totalIdle = 0;
|
||||
let totalTick = 0;
|
||||
|
||||
for (let i = 0; i < cpus1.length; i++) {
|
||||
const cpu1 = cpus1[i].times;
|
||||
const cpu2 = cpus2[i].times;
|
||||
const idle = cpu2.idle - cpu1.idle;
|
||||
const total =
|
||||
(cpu2.user - cpu1.user) +
|
||||
(cpu2.nice - cpu1.nice) +
|
||||
(cpu2.sys - cpu1.sys) +
|
||||
(cpu2.idle - cpu1.idle) +
|
||||
((cpu2.irq ?? 0) - (cpu1.irq ?? 0));
|
||||
totalIdle += idle;
|
||||
totalTick += total;
|
||||
}
|
||||
|
||||
const percent = totalTick === 0 ? 0 : Math.round(((totalTick - totalIdle) / totalTick) * 100);
|
||||
resolve(percent);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
133
app/api/players/[id]/route.ts
Normal file
133
app/api/players/[id]/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers, playerBans, playerChatHistory, playerSpawnPoints, auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { eq, desc } from "drizzle-orm";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { sanitizeRconCommand } from "@/lib/security/sanitize";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
|
||||
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
|
||||
|
||||
const [bans, chatHistory, spawnPoints] = await Promise.all([
|
||||
db.select().from(playerBans).where(eq(playerBans.playerId, id)).orderBy(desc(playerBans.bannedAt)),
|
||||
db.select().from(playerChatHistory).where(eq(playerChatHistory.playerId, id)).orderBy(desc(playerChatHistory.timestamp)).limit(200),
|
||||
db.select().from(playerSpawnPoints).where(eq(playerSpawnPoints.playerId, id)),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ player, bans, chatHistory, spawnPoints });
|
||||
}
|
||||
|
||||
const BanSchema = z.object({
|
||||
reason: z.string().min(1).max(500),
|
||||
expiresAt: z.number().optional(),
|
||||
});
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin", "moderator"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(req.url);
|
||||
const action = searchParams.get("action");
|
||||
|
||||
const player = await db.select().from(mcPlayers).where(eq(mcPlayers.id, id)).get();
|
||||
if (!player) return NextResponse.json({ error: "Player not found" }, { status: 404 });
|
||||
|
||||
if (action === "ban") {
|
||||
let body: z.infer<typeof BanSchema>;
|
||||
try {
|
||||
body = BanSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert ban record
|
||||
await db.insert(playerBans).values({
|
||||
id: nanoid(),
|
||||
playerId: id,
|
||||
reason: body.reason,
|
||||
bannedBy: session.user.id,
|
||||
bannedAt: Date.now(),
|
||||
expiresAt: body.expiresAt ?? null,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
await db.update(mcPlayers).set({ isBanned: true }).where(eq(mcPlayers.id, id));
|
||||
|
||||
// Execute ban via RCON
|
||||
try {
|
||||
const cmd = sanitizeRconCommand(`ban ${player.username} ${body.reason}`);
|
||||
await rconClient.sendCommand(cmd);
|
||||
} catch { /* RCON might be unavailable */ }
|
||||
|
||||
// Audit
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
action: "player.ban",
|
||||
target: "player",
|
||||
targetId: id,
|
||||
details: JSON.stringify({ reason: body.reason }),
|
||||
ipAddress: ip,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === "unban") {
|
||||
await db.update(playerBans).set({ isActive: false, unbannedBy: session.user.id, unbannedAt: Date.now() }).where(eq(playerBans.playerId, id));
|
||||
await db.update(mcPlayers).set({ isBanned: false }).where(eq(mcPlayers.id, id));
|
||||
|
||||
try {
|
||||
const cmd = sanitizeRconCommand(`pardon ${player.username}`);
|
||||
await rconClient.sendCommand(cmd);
|
||||
} catch { /* RCON might be unavailable */ }
|
||||
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id, action: "player.unban",
|
||||
target: "player", targetId: id, details: null, ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
if (action === "kick") {
|
||||
const { reason } = await req.json();
|
||||
try {
|
||||
const cmd = sanitizeRconCommand(`kick ${player.username} ${reason ?? "Kicked by admin"}`);
|
||||
await rconClient.sendCommand(cmd);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "RCON unavailable" }, { status: 503 });
|
||||
}
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id, action: "player.kick",
|
||||
target: "player", targetId: id, details: JSON.stringify({ reason }), ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
}
|
||||
42
app/api/players/route.ts
Normal file
42
app/api/players/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { desc, like, or, eq } from "drizzle-orm";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const search = searchParams.get("q")?.trim() ?? "";
|
||||
const onlineOnly = searchParams.get("online") === "true";
|
||||
const bannedOnly = searchParams.get("banned") === "true";
|
||||
const page = Math.max(1, parseInt(searchParams.get("page") ?? "1"));
|
||||
const limit = Math.min(100, Math.max(1, parseInt(searchParams.get("limit") ?? "50")));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let query = db.select().from(mcPlayers);
|
||||
|
||||
const conditions = [];
|
||||
if (search) {
|
||||
conditions.push(like(mcPlayers.username, `%${search}%`));
|
||||
}
|
||||
if (onlineOnly) conditions.push(eq(mcPlayers.isOnline, true));
|
||||
if (bannedOnly) conditions.push(eq(mcPlayers.isBanned, true));
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(mcPlayers)
|
||||
.where(conditions.length ? (conditions.length === 1 ? conditions[0] : or(...conditions)) : undefined)
|
||||
.orderBy(desc(mcPlayers.lastSeen))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return NextResponse.json({ players: rows, page, limit });
|
||||
}
|
||||
88
app/api/plugins/route.ts
Normal file
88
app/api/plugins/route.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { plugins, auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { sanitizeRconCommand } from "@/lib/security/sanitize";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
// Read plugins from DB + sync with filesystem
|
||||
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
|
||||
const pluginsDir = path.join(mcPath, "plugins");
|
||||
|
||||
const dbPlugins = await db.select().from(plugins);
|
||||
|
||||
// Try to read actual plugin jars from filesystem
|
||||
let jarFiles: string[] = [];
|
||||
try {
|
||||
jarFiles = fs
|
||||
.readdirSync(pluginsDir)
|
||||
.filter((f) => f.endsWith(".jar"));
|
||||
} catch { /* plugins dir might not exist */ }
|
||||
|
||||
return NextResponse.json({ plugins: dbPlugins, jarFiles });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const action = searchParams.get("action");
|
||||
const pluginName = searchParams.get("name");
|
||||
|
||||
if (!pluginName || !/^[a-zA-Z0-9_\-]+$/.test(pluginName)) {
|
||||
return NextResponse.json({ error: "Invalid plugin name" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (action === "enable" || action === "disable") {
|
||||
const cmd = sanitizeRconCommand(
|
||||
`plugman ${action} ${pluginName}`,
|
||||
);
|
||||
const result = await rconClient.sendCommand(cmd);
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({ isEnabled: action === "enable" })
|
||||
.where(eq(plugins.name, pluginName));
|
||||
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(), userId: session.user.id,
|
||||
action: `plugin.${action}`, target: "plugin", targetId: pluginName,
|
||||
details: null, ipAddress: ip, createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, result });
|
||||
}
|
||||
|
||||
if (action === "reload") {
|
||||
const cmd = sanitizeRconCommand(`plugman reload ${pluginName}`);
|
||||
const result = await rconClient.sendCommand(cmd);
|
||||
return NextResponse.json({ success: true, result });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
}
|
||||
69
app/api/scheduler/[id]/route.ts
Normal file
69
app/api/scheduler/[id]/route.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import cron from "node-cron";
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
description: z.string().max(500).optional(),
|
||||
cronExpression: z.string().max(100).optional(),
|
||||
command: z.string().min(1).max(500).optional(),
|
||||
isEnabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const task = await db.select().from(scheduledTasks).where(eq(scheduledTasks.id, id)).get();
|
||||
if (!task) return NextResponse.json({ error: "Task not found" }, { status: 404 });
|
||||
|
||||
let body: z.infer<typeof UpdateSchema>;
|
||||
try {
|
||||
body = UpdateSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.cronExpression && !cron.validate(body.cronExpression)) {
|
||||
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = { ...task, ...body, updatedAt: Date.now() };
|
||||
await db.update(scheduledTasks).set(updated).where(eq(scheduledTasks.id, id));
|
||||
|
||||
// Reschedule
|
||||
stopTask(id);
|
||||
if (updated.isEnabled) {
|
||||
scheduleTask(id, updated.cronExpression, updated.command);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
stopTask(id);
|
||||
await db.delete(scheduledTasks).where(eq(scheduledTasks.id, id));
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
67
app/api/scheduler/route.ts
Normal file
67
app/api/scheduler/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { scheduleTask, stopTask } from "@/lib/scheduler";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import cron from "node-cron";
|
||||
|
||||
const TaskSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
description: z.string().max(500).optional(),
|
||||
cronExpression: z.string().max(100),
|
||||
command: z.string().min(1).max(500),
|
||||
isEnabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const tasks = await db.select().from(scheduledTasks).orderBy(scheduledTasks.createdAt);
|
||||
return NextResponse.json({ tasks });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof TaskSchema>;
|
||||
try {
|
||||
body = TaskSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!cron.validate(body.cronExpression)) {
|
||||
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
|
||||
}
|
||||
|
||||
const id = nanoid();
|
||||
await db.insert(scheduledTasks).values({
|
||||
id,
|
||||
name: body.name,
|
||||
description: body.description ?? null,
|
||||
cronExpression: body.cronExpression,
|
||||
command: body.command,
|
||||
isEnabled: body.isEnabled,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
if (body.isEnabled) {
|
||||
scheduleTask(id, body.cronExpression, body.command);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, id }, { status: 201 });
|
||||
}
|
||||
74
app/api/server/control/route.ts
Normal file
74
app/api/server/control/route.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { db } from "@/lib/db";
|
||||
import { auditLogs } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const ActionSchema = z.object({
|
||||
action: z.enum(["start", "stop", "restart"]),
|
||||
force: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// Auth
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Role check — only admin+
|
||||
if (!["superadmin", "admin"].includes(session.user.role ?? "")) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 20); // stricter limit for control actions
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
// Validate body
|
||||
let body: z.infer<typeof ActionSchema>;
|
||||
try {
|
||||
body = ActionSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { action, force } = body;
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case "start":
|
||||
await mcProcessManager.start();
|
||||
break;
|
||||
case "stop":
|
||||
await mcProcessManager.stop(force);
|
||||
break;
|
||||
case "restart":
|
||||
await mcProcessManager.restart(force);
|
||||
break;
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await db.insert(auditLogs).values({
|
||||
id: nanoid(),
|
||||
userId: session.user.id,
|
||||
action: `server.${action}${force ? ".force" : ""}`,
|
||||
target: "server",
|
||||
targetId: null,
|
||||
details: JSON.stringify({ action, force }),
|
||||
ipAddress: ip,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true, action });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
66
app/api/server/settings/route.ts
Normal file
66
app/api/server/settings/route.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { serverSettings } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { z } from "zod";
|
||||
|
||||
const UpdateSettingsSchema = z.object({
|
||||
minecraftPath: z.string().optional(),
|
||||
serverJar: z.string().optional(),
|
||||
serverVersion: z.string().optional(),
|
||||
serverType: z.enum(["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"]).optional(),
|
||||
maxRam: z.number().min(512).max(32768).optional(),
|
||||
minRam: z.number().min(256).max(32768).optional(),
|
||||
rconEnabled: z.boolean().optional(),
|
||||
rconPort: z.number().min(1).max(65535).optional(),
|
||||
rconPassword: z.string().min(8).optional(),
|
||||
javaArgs: z.string().max(1000).optional(),
|
||||
autoStart: z.boolean().optional(),
|
||||
restartOnCrash: z.boolean().optional(),
|
||||
backupEnabled: z.boolean().optional(),
|
||||
backupSchedule: z.string().optional(),
|
||||
bluemapEnabled: z.boolean().optional(),
|
||||
bluemapUrl: z.string().url().optional().or(z.literal("")),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const settings = await db.select().from(serverSettings).get();
|
||||
// Never return RCON password
|
||||
if (settings) {
|
||||
const { rconPassword: _, ...safe } = settings;
|
||||
return NextResponse.json({ settings: safe });
|
||||
}
|
||||
return NextResponse.json({ settings: null });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof UpdateSettingsSchema>;
|
||||
try {
|
||||
body = UpdateSettingsSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await db.select().from(serverSettings).get();
|
||||
if (existing) {
|
||||
await db.update(serverSettings).set({ ...body, updatedAt: Date.now() });
|
||||
} else {
|
||||
await db.insert(serverSettings).values({ id: 1, ...body, updatedAt: Date.now() });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
20
app/api/server/status/route.ts
Normal file
20
app/api/server/status/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { mcProcessManager } from "@/lib/minecraft/process";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip);
|
||||
if (!allowed) {
|
||||
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
}
|
||||
|
||||
const status = mcProcessManager.getStatus();
|
||||
return NextResponse.json(status);
|
||||
}
|
||||
35
app/api/server/versions/route.ts
Normal file
35
app/api/server/versions/route.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { fetchVanillaVersions, fetchPaperVersions, fetchFabricVersions, type VersionInfo } from "@/lib/minecraft/versions";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 20);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
const type = req.nextUrl.searchParams.get("type") ?? "vanilla";
|
||||
|
||||
try {
|
||||
let versionInfos: VersionInfo[];
|
||||
switch (type) {
|
||||
case "paper":
|
||||
versionInfos = await fetchPaperVersions();
|
||||
break;
|
||||
case "fabric":
|
||||
versionInfos = await fetchFabricVersions();
|
||||
break;
|
||||
case "vanilla":
|
||||
default:
|
||||
versionInfos = await fetchVanillaVersions();
|
||||
}
|
||||
const versions = versionInfos.map((v) => v.id);
|
||||
return NextResponse.json({ versions, type });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch versions";
|
||||
return NextResponse.json({ error: message }, { status: 503 });
|
||||
}
|
||||
}
|
||||
91
app/api/team/route.ts
Normal file
91
app/api/team/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { users, invitations } from "@/lib/db/schema";
|
||||
import { checkRateLimit, getClientIp } from "@/lib/security/rateLimit";
|
||||
import { sendInvitationEmail } from "@/lib/email";
|
||||
import { z } from "zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { eq, ne } from "drizzle-orm";
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().email().max(254),
|
||||
role: z.enum(["admin", "moderator"]),
|
||||
playerUuid: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const teamUsers = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(ne(users.id, session.user.id));
|
||||
|
||||
const pendingInvites = await db
|
||||
.select()
|
||||
.from(invitations)
|
||||
.where(eq(invitations.acceptedAt, null as unknown as number));
|
||||
|
||||
return NextResponse.json({ users: teamUsers, pendingInvites });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: req.headers });
|
||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
if (session.user.role !== "superadmin") {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const ip = getClientIp(req);
|
||||
const { allowed } = checkRateLimit(ip, 10);
|
||||
if (!allowed) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
|
||||
|
||||
let body: z.infer<typeof InviteSchema>;
|
||||
try {
|
||||
body = InviteSchema.parse(await req.json());
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
const existing = await db.select().from(users).where(eq(users.email, body.email)).get();
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: "User already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
// Create invitation
|
||||
const token = nanoid(48);
|
||||
const expiresAt = Date.now() + 48 * 60 * 60 * 1000; // 48 hours
|
||||
|
||||
await db.insert(invitations).values({
|
||||
id: nanoid(),
|
||||
email: body.email,
|
||||
role: body.role,
|
||||
invitedBy: session.user.id,
|
||||
token,
|
||||
expiresAt,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
const baseUrl = process.env.BETTER_AUTH_URL ?? "http://localhost:3000";
|
||||
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`;
|
||||
|
||||
try {
|
||||
await sendInvitationEmail({
|
||||
to: body.email,
|
||||
invitedByName: session.user.name ?? "An admin",
|
||||
inviteUrl,
|
||||
role: body.role,
|
||||
});
|
||||
} catch (err) {
|
||||
// Log but don't fail — admin can resend
|
||||
console.error("[Team] Failed to send invitation email:", err);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, inviteUrl }, { status: 201 });
|
||||
}
|
||||
@@ -1,33 +1,80 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "@/components/layout/providers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fonts
|
||||
// ---------------------------------------------------------------------------
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "CubeAdmin | Minecraft Server Manager",
|
||||
template: "%s | CubeAdmin",
|
||||
},
|
||||
description:
|
||||
"A professional, self-hosted admin panel for managing your Minecraft server. Monitor performance, manage players, control files, and more.",
|
||||
keywords: [
|
||||
"Minecraft",
|
||||
"server admin",
|
||||
"panel",
|
||||
"management",
|
||||
"console",
|
||||
"monitoring",
|
||||
],
|
||||
authors: [{ name: "CubeAdmin" }],
|
||||
creator: "CubeAdmin",
|
||||
robots: {
|
||||
index: false, // Admin panel should not be indexed
|
||||
follow: false,
|
||||
},
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#0a0a0a" },
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Root Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html
|
||||
lang="en"
|
||||
// Apply dark class by default; next-themes will manage it from here
|
||||
className="dark"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
69
bun.lock
69
bun.lock
@@ -17,8 +17,10 @@
|
||||
"better-auth": "^1.5.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.1.6",
|
||||
@@ -50,6 +52,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/semver": "^7.7.1",
|
||||
"bun-types": "^1.3.10",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
@@ -415,6 +418,40 @@
|
||||
|
||||
"@prisma/studio-core": ["@prisma/studio-core@0.13.1", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@react-email/body": ["@react-email/body@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ=="],
|
||||
|
||||
"@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="],
|
||||
@@ -665,6 +702,8 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
|
||||
|
||||
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
|
||||
@@ -749,6 +788,8 @@
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
@@ -789,6 +830,8 @@
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="],
|
||||
|
||||
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
@@ -901,6 +944,8 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
|
||||
|
||||
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
|
||||
@@ -921,6 +966,8 @@
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
|
||||
|
||||
"drizzle-zod": ["drizzle-zod@0.8.3", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-66yVOuvGhKJnTdiqj1/Xaaz9/qzOdRJADpDa68enqS6g3t0kpNkwNYjUuaeXgZfO/UWuIM9HIhSlJ6C5ZraMww=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
@@ -1117,6 +1164,8 @@
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="],
|
||||
|
||||
"get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="],
|
||||
@@ -1623,6 +1672,12 @@
|
||||
|
||||
"react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
|
||||
@@ -1893,6 +1948,10 @@
|
||||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -2001,6 +2060,16 @@
|
||||
|
||||
"@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
72
components/layout/providers.tsx
Normal file
72
components/layout/providers.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Toaster } from "sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// QueryClient – created once per client session
|
||||
// ---------------------------------------------------------------------------
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't refetch on window focus in development
|
||||
refetchOnWindowFocus: process.env.NODE_ENV === "production",
|
||||
// 30 second stale time by default
|
||||
staleTime: 30_000,
|
||||
// Retry failed queries up to 2 times
|
||||
retry: 2,
|
||||
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
|
||||
},
|
||||
mutations: {
|
||||
// Don't retry mutations by default
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Providers
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
// useState ensures the QueryClient is only created once per component mount
|
||||
// and is not shared between different users on SSR.
|
||||
const [queryClient] = useState(() => makeQueryClient());
|
||||
|
||||
return (
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider delay={400}>
|
||||
{children}
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
richColors
|
||||
closeButton
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "oklch(0.205 0 0)",
|
||||
border: "1px solid oklch(1 0 0 / 10%)",
|
||||
color: "oklch(0.985 0 0)",
|
||||
},
|
||||
className: "cubeadmin-toast",
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
481
components/layout/sidebar.tsx
Normal file
481
components/layout/sidebar.tsx
Normal file
@@ -0,0 +1,481 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Terminal,
|
||||
Activity,
|
||||
Clock,
|
||||
Users,
|
||||
Map,
|
||||
Puzzle,
|
||||
FolderOpen,
|
||||
Archive,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
UserPlus,
|
||||
ScrollText,
|
||||
LogOut,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface ServerStatus {
|
||||
online: boolean;
|
||||
status: "online" | "offline" | "starting" | "stopping";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigation structure
|
||||
// ---------------------------------------------------------------------------
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
title: "Overview",
|
||||
items: [
|
||||
{ label: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Server",
|
||||
items: [
|
||||
{ label: "Console", href: "/console", icon: Terminal },
|
||||
{ label: "Monitoring", href: "/monitoring", icon: Activity },
|
||||
{ label: "Scheduler", href: "/scheduler", icon: Clock },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "World",
|
||||
items: [
|
||||
{ label: "Players", href: "/players", icon: Users },
|
||||
{ label: "Map", href: "/map", icon: Map },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Manage",
|
||||
items: [
|
||||
{ label: "Plugins", href: "/plugins", icon: Puzzle },
|
||||
{ label: "Files", href: "/files", icon: FolderOpen },
|
||||
{ label: "Backups", href: "/backups", icon: Archive },
|
||||
{ label: "Settings", href: "/settings", icon: Settings },
|
||||
{ label: "Updates", href: "/updates", icon: RefreshCw },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Admin",
|
||||
items: [
|
||||
{ label: "Team", href: "/team", icon: UserPlus },
|
||||
{ label: "Audit Log", href: "/audit", icon: ScrollText },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CubeAdmin Logo SVG
|
||||
// ---------------------------------------------------------------------------
|
||||
function CubeIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{/* Top face */}
|
||||
<polygon
|
||||
points="16,4 28,10 16,16 4,10"
|
||||
fill="#059669"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Left face */}
|
||||
<polygon
|
||||
points="4,10 16,16 16,28 4,22"
|
||||
fill="#047857"
|
||||
opacity="0.95"
|
||||
/>
|
||||
{/* Right face */}
|
||||
<polygon
|
||||
points="28,10 16,16 16,28 28,22"
|
||||
fill="#10b981"
|
||||
opacity="0.85"
|
||||
/>
|
||||
{/* Edge highlights */}
|
||||
<polyline
|
||||
points="4,10 16,4 28,10 16,16 4,10"
|
||||
stroke="#34d399"
|
||||
strokeWidth="0.75"
|
||||
strokeLinejoin="round"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
/>
|
||||
<line
|
||||
x1="16" y1="16" x2="16" y2="28"
|
||||
stroke="#34d399"
|
||||
strokeWidth="0.75"
|
||||
opacity="0.4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server status indicator
|
||||
// ---------------------------------------------------------------------------
|
||||
function ServerStatusBadge({ collapsed }: { collapsed: boolean }) {
|
||||
const { data: status } = useQuery<ServerStatus>({
|
||||
queryKey: ["server-status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/server/status");
|
||||
if (!res.ok) return { online: false, status: "offline" as const };
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 8_000,
|
||||
});
|
||||
|
||||
const isOnline = status?.online ?? false;
|
||||
const label = status?.status
|
||||
? status.status.charAt(0).toUpperCase() + status.status.slice(1)
|
||||
: "Unknown";
|
||||
|
||||
const dotColor = {
|
||||
online: "bg-emerald-500",
|
||||
offline: "bg-red-500",
|
||||
starting: "bg-yellow-500 animate-pulse",
|
||||
stopping: "bg-orange-500 animate-pulse",
|
||||
}[status?.status ?? "offline"] ?? "bg-zinc-500";
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex items-center justify-center p-1">
|
||||
<span
|
||||
className={cn("block h-2.5 w-2.5 rounded-full", dotColor)}
|
||||
aria-label={`Server ${label}`}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span>Server: {label}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md bg-white/[0.04] px-2.5 py-1.5 ring-1 ring-white/[0.06]">
|
||||
<span
|
||||
className={cn("block h-2 w-2 flex-shrink-0 rounded-full", dotColor)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isOnline ? "text-emerald-400" : "text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Individual nav item
|
||||
// ---------------------------------------------------------------------------
|
||||
function NavLink({
|
||||
item,
|
||||
isActive,
|
||||
collapsed,
|
||||
}: {
|
||||
item: NavItem;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
}) {
|
||||
const Icon = item.icon;
|
||||
|
||||
const linkContent = (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"group flex items-center gap-3 rounded-md px-2.5 py-2 text-sm font-medium transition-all duration-150 outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50",
|
||||
collapsed && "justify-center px-2",
|
||||
isActive
|
||||
? "bg-emerald-600/20 text-emerald-400 ring-1 ring-emerald-500/20"
|
||||
: "text-zinc-400 hover:bg-white/[0.05] hover:text-zinc-100"
|
||||
)}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
"h-4 w-4 flex-shrink-0 transition-colors duration-150",
|
||||
isActive
|
||||
? "text-emerald-400"
|
||||
: "text-zinc-500 group-hover:text-zinc-300"
|
||||
)}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span className="truncate leading-none">{item.label}</span>
|
||||
)}
|
||||
{!collapsed && isActive && (
|
||||
<ChevronRight className="ml-auto h-3 w-3 text-emerald-500/60" />
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{linkContent}</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<span>{item.label}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return linkContent;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Responsive: auto-collapse on small screens
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
const mq = window.matchMedia("(max-width: 1023px)");
|
||||
const handler = (e: MediaQueryListEvent) => setCollapsed(e.matches);
|
||||
setCollapsed(mq.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const { data: session } = useQuery({
|
||||
queryKey: ["session"],
|
||||
queryFn: async () => {
|
||||
const { data } = await authClient.getSession();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const sidebarWidth = collapsed ? "w-[60px]" : "w-[240px]";
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"relative flex h-screen flex-shrink-0 flex-col border-r border-white/[0.06] bg-[#0a0a0a] transition-[width] duration-200 ease-in-out",
|
||||
sidebarWidth
|
||||
)}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 flex-shrink-0 items-center border-b border-white/[0.06]",
|
||||
collapsed ? "justify-center px-0" : "gap-2.5 px-4"
|
||||
)}
|
||||
>
|
||||
<CubeIcon className="h-7 w-7 flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold tracking-tight text-emerald-400">
|
||||
CubeAdmin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server status */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 border-b border-white/[0.06]",
|
||||
collapsed ? "px-2 py-2" : "px-3 py-2.5"
|
||||
)}
|
||||
>
|
||||
<ServerStatusBadge collapsed={collapsed} />
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto overflow-x-hidden py-3 scrollbar-thin">
|
||||
<div className={cn("space-y-4", collapsed ? "px-1.5" : "px-2")}>
|
||||
{NAV_SECTIONS.map((section) => {
|
||||
const hasActiveItem = section.items.some(
|
||||
(item) =>
|
||||
pathname === item.href || pathname.startsWith(item.href + "/")
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={section.title}>
|
||||
{!collapsed && (
|
||||
<p className="mb-1 px-2.5 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">
|
||||
{section.title}
|
||||
</p>
|
||||
)}
|
||||
{collapsed && hasActiveItem && (
|
||||
<div className="mb-1 h-px w-full bg-emerald-500/20 rounded-full" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
{section.items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center gap-2 border-t border-white/[0.06] px-3 py-2 text-xs text-zinc-600 transition-colors hover:bg-white/[0.04] hover:text-zinc-400",
|
||||
collapsed && "justify-center"
|
||||
)}
|
||||
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 transition-transform duration-200",
|
||||
!collapsed && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
{!collapsed && <span>Collapse</span>}
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-shrink-0 border-t border-white/[0.06]",
|
||||
collapsed ? "p-2" : "p-3"
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2.5 rounded-md p-1.5 text-left transition-colors hover:bg-white/[0.05] outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50",
|
||||
collapsed && "justify-center"
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{session?.user?.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt={session.user.name ?? "User avatar"}
|
||||
className="h-7 w-7 rounded-full object-cover ring-1 ring-white/10"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-emerald-600/30 ring-1 ring-emerald-500/20">
|
||||
<span className="text-xs font-semibold text-emerald-400">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "?")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Online indicator */}
|
||||
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-emerald-500 ring-1 ring-[#0a0a0a]" />
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-xs font-medium text-zinc-200">
|
||||
{session?.user?.name ?? "Loading…"}
|
||||
</p>
|
||||
<p className="truncate text-[10px] text-zinc-500">
|
||||
{(session?.user as { role?: string } | undefined)?.role ?? "Admin"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
side={collapsed ? "right" : "top"}
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-52"
|
||||
>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{session?.user?.name ?? "—"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Account Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
458
components/layout/topbar.tsx
Normal file
458
components/layout/topbar.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Bell,
|
||||
Play,
|
||||
Square,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page title map
|
||||
// ---------------------------------------------------------------------------
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
"/dashboard": "Dashboard",
|
||||
"/console": "Console",
|
||||
"/monitoring": "Monitoring",
|
||||
"/scheduler": "Scheduler",
|
||||
"/players": "Players",
|
||||
"/map": "World Map",
|
||||
"/plugins": "Plugins",
|
||||
"/files": "File Manager",
|
||||
"/backups": "Backups",
|
||||
"/settings": "Server Settings",
|
||||
"/updates": "Updates",
|
||||
"/team": "Team",
|
||||
"/audit": "Audit Log",
|
||||
};
|
||||
|
||||
function usePageTitle(): string {
|
||||
const pathname = usePathname();
|
||||
// Exact match first
|
||||
if (PAGE_TITLES[pathname]) return PAGE_TITLES[pathname];
|
||||
// Find the longest matching prefix
|
||||
const match = Object.keys(PAGE_TITLES)
|
||||
.filter((key) => pathname.startsWith(key + "/"))
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
return match ? PAGE_TITLES[match] : "CubeAdmin";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ServerStatus {
|
||||
online: boolean;
|
||||
status: "online" | "offline" | "starting" | "stopping";
|
||||
playerCount?: number;
|
||||
maxPlayers?: number;
|
||||
}
|
||||
|
||||
type ServerAction = "start" | "stop" | "restart";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Status badge
|
||||
// ---------------------------------------------------------------------------
|
||||
function ServerStatusBadge({ status }: { status: ServerStatus | undefined }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-zinc-700/50 bg-zinc-800/50 px-2 py-0.5 text-xs font-medium text-zinc-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-zinc-500" />
|
||||
Unknown
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const config = {
|
||||
online: {
|
||||
dot: "bg-emerald-500",
|
||||
text: "Online",
|
||||
className: "border-emerald-500/20 bg-emerald-500/10 text-emerald-400",
|
||||
},
|
||||
offline: {
|
||||
dot: "bg-red-500",
|
||||
text: "Offline",
|
||||
className: "border-red-500/20 bg-red-500/10 text-red-400",
|
||||
},
|
||||
starting: {
|
||||
dot: "bg-yellow-500 animate-pulse",
|
||||
text: "Starting…",
|
||||
className: "border-yellow-500/20 bg-yellow-500/10 text-yellow-400",
|
||||
},
|
||||
stopping: {
|
||||
dot: "bg-orange-500 animate-pulse",
|
||||
text: "Stopping…",
|
||||
className: "border-orange-500/20 bg-orange-500/10 text-orange-400",
|
||||
},
|
||||
}[status.status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
config.className
|
||||
)}
|
||||
>
|
||||
<span className={cn("h-1.5 w-1.5 rounded-full", config.dot)} />
|
||||
{config.text}
|
||||
{status.online && status.playerCount !== undefined && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
{status.playerCount}/{status.maxPlayers}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server action button with confirmation dialog
|
||||
// ---------------------------------------------------------------------------
|
||||
interface ActionButtonProps {
|
||||
action: ServerAction;
|
||||
disabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
onConfirm: () => void;
|
||||
serverStatus: ServerStatus | undefined;
|
||||
}
|
||||
|
||||
const ACTION_CONFIG: Record<
|
||||
ServerAction,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
variant: "default" | "outline" | "destructive" | "ghost" | "secondary" | "link";
|
||||
confirmTitle: string;
|
||||
confirmDescription: string;
|
||||
confirmLabel: string;
|
||||
showWhen: (status: ServerStatus | undefined) => boolean;
|
||||
}
|
||||
> = {
|
||||
start: {
|
||||
label: "Start",
|
||||
icon: Play,
|
||||
variant: "outline",
|
||||
confirmTitle: "Start the server?",
|
||||
confirmDescription:
|
||||
"This will start the Minecraft server. Players will be able to connect once it finishes booting.",
|
||||
confirmLabel: "Start Server",
|
||||
showWhen: (s) => !s || s.status === "offline",
|
||||
},
|
||||
stop: {
|
||||
label: "Stop",
|
||||
icon: Square,
|
||||
variant: "outline",
|
||||
confirmTitle: "Stop the server?",
|
||||
confirmDescription:
|
||||
"This will gracefully stop the server. All online players will be disconnected. Unsaved data will be saved first.",
|
||||
confirmLabel: "Stop Server",
|
||||
showWhen: (s) => s?.status === "online",
|
||||
},
|
||||
restart: {
|
||||
label: "Restart",
|
||||
icon: RotateCcw,
|
||||
variant: "outline",
|
||||
confirmTitle: "Restart the server?",
|
||||
confirmDescription:
|
||||
"This will gracefully restart the server. All online players will be temporarily disconnected.",
|
||||
confirmLabel: "Restart Server",
|
||||
showWhen: (s) => s?.status === "online",
|
||||
},
|
||||
};
|
||||
|
||||
function ServerActionButton({
|
||||
action,
|
||||
disabled,
|
||||
isLoading,
|
||||
onConfirm,
|
||||
serverStatus,
|
||||
}: ActionButtonProps) {
|
||||
const config = ACTION_CONFIG[action];
|
||||
const Icon = config.icon;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!config.showWhen(serverStatus)) return null;
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center justify-center rounded-lg border text-xs font-medium h-7 gap-1.5 px-2.5 transition-all",
|
||||
action === "stop" &&
|
||||
"border-red-500/20 text-red-400 hover:bg-red-500/10 hover:border-red-500/30",
|
||||
action === "restart" &&
|
||||
"border-yellow-500/20 text-yellow-400 hover:bg-yellow-500/10 hover:border-yellow-500/30",
|
||||
action === "start" &&
|
||||
"border-emerald-500/20 text-emerald-400 hover:bg-emerald-500/10 hover:border-emerald-500/30"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Icon className="h-3 w-3" />
|
||||
)}
|
||||
{config.label}
|
||||
</AlertDialogTrigger>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{config.confirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{config.confirmDescription}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onConfirm();
|
||||
}}
|
||||
className={cn(
|
||||
action === "stop" &&
|
||||
"bg-red-600 hover:bg-red-700 text-white border-0",
|
||||
action === "restart" &&
|
||||
"bg-yellow-600 hover:bg-yellow-700 text-white border-0",
|
||||
action === "start" &&
|
||||
"bg-emerald-600 hover:bg-emerald-700 text-white border-0"
|
||||
)}
|
||||
>
|
||||
{config.confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notifications bell
|
||||
// ---------------------------------------------------------------------------
|
||||
function NotificationBell() {
|
||||
// TODO: fetch real notification count from /api/notifications
|
||||
const count = 0;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-8 w-8 text-zinc-400 hover:text-zinc-100"
|
||||
aria-label={`Notifications${count > 0 ? ` (${count} unread)` : ""}`}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{count > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-emerald-500 text-[9px] font-bold text-white ring-2 ring-[#0a0a0a]">
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme toggle
|
||||
// ---------------------------------------------------------------------------
|
||||
function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-zinc-400 hover:text-zinc-100"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
aria-label={`Switch to ${isDark ? "light" : "dark"} mode`}
|
||||
>
|
||||
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User avatar dropdown (topbar version)
|
||||
// ---------------------------------------------------------------------------
|
||||
function UserMenu() {
|
||||
const router = useRouter();
|
||||
const { data: session } = useQuery({
|
||||
queryKey: ["session"],
|
||||
queryFn: async () => {
|
||||
const { data } = await authClient.getSession();
|
||||
return data;
|
||||
},
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut();
|
||||
router.push("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition-colors hover:bg-white/[0.05] outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/50">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-600/30 ring-1 ring-emerald-500/20 flex-shrink-0">
|
||||
{session?.user?.image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={session.user.image}
|
||||
alt=""
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold text-emerald-400">
|
||||
{(session?.user?.name ?? session?.user?.email ?? "?")
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown className="h-3 w-3 text-zinc-500" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" sideOffset={8} className="w-48">
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">{session?.user?.name ?? "—"}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{session?.user?.email ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => router.push("/settings")}>
|
||||
<Settings className="h-4 w-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topbar
|
||||
// ---------------------------------------------------------------------------
|
||||
export function Topbar() {
|
||||
const title = usePageTitle();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: serverStatus } = useQuery<ServerStatus>({
|
||||
queryKey: ["server-status"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/server/status");
|
||||
if (!res.ok) return { online: false, status: "offline" as const };
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval: 10_000,
|
||||
staleTime: 8_000,
|
||||
});
|
||||
|
||||
const controlMutation = useMutation({
|
||||
mutationFn: async (action: ServerAction) => {
|
||||
const res = await fetch("/api/server/control", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.message ?? `Failed to ${action} server`);
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onMutate: (action) => {
|
||||
toast.loading(`${capitalize(action)}ing server…`, {
|
||||
id: "server-control",
|
||||
});
|
||||
},
|
||||
onSuccess: (_data, action) => {
|
||||
toast.success(`Server ${action} command sent`, { id: "server-control" });
|
||||
// Refetch status after a short delay
|
||||
setTimeout(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ["server-status"] });
|
||||
}, 2000);
|
||||
},
|
||||
onError: (err: Error, action) => {
|
||||
toast.error(`Failed to ${action} server: ${err.message}`, {
|
||||
id: "server-control",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isOperating = controlMutation.isPending;
|
||||
|
||||
return (
|
||||
<header className="flex h-14 flex-shrink-0 items-center justify-between border-b border-white/[0.06] bg-[#0a0a0a]/80 px-6 backdrop-blur-sm">
|
||||
{/* Left: page title */}
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-sm font-semibold text-zinc-100">{title}</h1>
|
||||
<ServerStatusBadge status={serverStatus} />
|
||||
</div>
|
||||
|
||||
{/* Right: controls */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Server quick-actions */}
|
||||
<div className="mr-2 flex items-center gap-1.5">
|
||||
{(["start", "stop", "restart"] as ServerAction[]).map((action) => (
|
||||
<ServerActionButton
|
||||
key={action}
|
||||
action={action}
|
||||
serverStatus={serverStatus}
|
||||
disabled={isOperating}
|
||||
isLoading={isOperating && controlMutation.variables === action}
|
||||
onConfirm={() => controlMutation.mutate(action)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h-5 w-px bg-white/[0.08]" />
|
||||
|
||||
<NotificationBell />
|
||||
<ThemeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string) {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
74
components/ui/accordion.tsx
Normal file
74
components/ui/accordion.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
187
components/ui/alert-dialog.tsx
Normal file
187
components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
76
components/ui/alert.tsx
Normal file
76
components/ui/alert.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
109
components/ui/avatar.tsx
Normal file
109
components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
components/ui/badge.tsx
Normal file
52
components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
103
components/ui/card.tsx
Normal file
103
components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
29
components/ui/checkbox.tsx
Normal file
29
components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
196
components/ui/command.tsx
Normal file
196
components/ui/command.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
} from "@/components/ui/input-group"
|
||||
import { SearchIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string
|
||||
description?: string
|
||||
className?: string
|
||||
showCloseButton?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||
className
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
271
components/ui/context-menu.tsx
Normal file
271
components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
||||
}
|
||||
|
||||
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger
|
||||
data-slot="context-menu-trigger"
|
||||
className={cn("select-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = 4,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ContextMenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<ContextMenuPrimitive.Popup
|
||||
data-slot="context-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Positioner>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.GroupLabel
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: ContextMenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubmenuTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuContent>) {
|
||||
return (
|
||||
<ContextMenuContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className="shadow-lg"
|
||||
side="right"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: ContextMenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute right-2">
|
||||
<ContextMenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</ContextMenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ContextMenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
157
components/ui/dialog.tsx
Normal file
157
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-background p-4 text-sm ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-base leading-none font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
271
components/ui/dropdown-menu.tsx
Normal file
271
components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
51
components/ui/hover-card.tsx
Normal file
51
components/ui/hover-card.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
|
||||
return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
|
||||
}
|
||||
|
||||
function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 4,
|
||||
...props
|
||||
}: PreviewCardPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PreviewCardPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PreviewCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<PreviewCardPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PreviewCardPrimitive.Popup
|
||||
data-slot="hover-card-content"
|
||||
className={cn(
|
||||
"z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PreviewCardPrimitive.Positioner>
|
||||
</PreviewCardPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
158
components/ui/input-group.tsx
Normal file
158
components/ui/input-group.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
|
||||
"inline-end":
|
||||
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
|
||||
"block-start":
|
||||
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
|
||||
"block-end":
|
||||
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
|
||||
VariantProps<typeof inputGroupButtonVariants> & {
|
||||
type?: "button" | "submit" | "reset"
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
20
components/ui/input.tsx
Normal file
20
components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
components/ui/label.tsx
Normal file
20
components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
283
components/ui/menubar.tsx
Normal file
283
components/ui/menubar.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
import { Menubar as MenubarPrimitive } from "@base-ui/react/menubar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Menubar({ className, ...props }: MenubarPrimitive.Props) {
|
||||
return (
|
||||
<MenubarPrimitive
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"flex h-8 items-center gap-0.5 rounded-lg border bg-background p-[3px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({ ...props }: React.ComponentProps<typeof DropdownMenu>) {
|
||||
return <DropdownMenu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuGroup>) {
|
||||
return <DropdownMenuGroup data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPortal>) {
|
||||
return <DropdownMenuPortal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuTrigger>) {
|
||||
return (
|
||||
<DropdownMenuTrigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn("min-w-36 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuItem>) {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/menubar-item gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuRadioGroup>) {
|
||||
return <DropdownMenuRadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuLabel> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuLabel
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSeparator>) {
|
||||
return (
|
||||
<DropdownMenuSeparator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuShortcut>) {
|
||||
return (
|
||||
<DropdownMenuShortcut
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSub>) {
|
||||
return <DropdownMenuSub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuSubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"gap-1.5 rounded-md px-1.5 py-1 text-sm focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuSubContent>) {
|
||||
return (
|
||||
<DropdownMenuSubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"min-w-32 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
168
components/ui/navigation-menu.tsx
Normal file
168
components/ui/navigation-menu.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { NavigationMenu as NavigationMenuPrimitive } from "@base-ui/react/navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function NavigationMenu({
|
||||
align = "start",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: NavigationMenuPrimitive.Root.Props &
|
||||
Pick<NavigationMenuPrimitive.Positioner.Props, "align">) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<NavigationMenuPositioner align={align} />
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group/navigation-menu-trigger inline-flex h-9 w-max items-center justify-center rounded-lg bg-background px-2.5 py-1.5 text-sm font-medium transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-popup-open:bg-muted/50 data-popup-open:hover:bg-muted data-open:bg-muted/50 data-open:hover:bg-muted data-open:focus:bg-muted"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: NavigationMenuPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon className="relative top-px ml-1 size-3 transition duration-300 group-data-popup-open/navigation-menu-trigger:rotate-180 group-data-open/navigation-menu-trigger:rotate-180" aria-hidden="true" />
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: NavigationMenuPrimitive.Content.Props) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-ending-style:data-activation-direction=left:translate-x-[50%] data-ending-style:data-activation-direction=right:translate-x-[-50%] data-starting-style:data-activation-direction=left:translate-x-[-50%] data-starting-style:data-activation-direction=right:translate-x-[50%] h-full w-auto p-1 transition-[opacity,transform,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[viewport=false]/navigation-menu:rounded-lg group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:ring-1 group-data-[viewport=false]/navigation-menu:ring-foreground/10 group-data-[viewport=false]/navigation-menu:duration-300 data-ending-style:opacity-0 data-starting-style:opacity-0 data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 data-[motion^=from-]:animate-in data-[motion^=from-]:fade-in data-[motion^=to-]:animate-out data-[motion^=to-]:fade-out **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none group-data-[viewport=false]/navigation-menu:data-open:animate-in group-data-[viewport=false]/navigation-menu:data-open:fade-in-0 group-data-[viewport=false]/navigation-menu:data-open:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-closed:animate-out group-data-[viewport=false]/navigation-menu:data-closed:fade-out-0 group-data-[viewport=false]/navigation-menu:data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuPositioner({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 8,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
...props
|
||||
}: NavigationMenuPrimitive.Positioner.Props) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Portal>
|
||||
<NavigationMenuPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
"isolate z-50 h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] data-instant:transition-none data-[side=bottom]:before:top-[-10px] data-[side=bottom]:before:right-0 data-[side=bottom]:before:left-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<NavigationMenuPrimitive.Popup className="data-[ending-style]:easing-[ease] xs:w-(--popup-width) relative h-(--popup-height) w-(--popup-width) origin-(--transform-origin) rounded-lg bg-popover text-popover-foreground shadow ring-1 ring-foreground/10 transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] outline-none data-ending-style:scale-90 data-ending-style:opacity-0 data-ending-style:duration-150 data-starting-style:scale-90 data-starting-style:opacity-0">
|
||||
<NavigationMenuPrimitive.Viewport className="relative size-full overflow-hidden" />
|
||||
</NavigationMenuPrimitive.Popup>
|
||||
</NavigationMenuPrimitive.Positioner>
|
||||
</NavigationMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: NavigationMenuPrimitive.Link.Props) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg p-2 text-sm transition-all outline-none hover:bg-muted focus:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-1 in-data-[slot=navigation-menu-content]:rounded-md data-active:bg-muted/50 data-active:hover:bg-muted data-active:focus:bg-muted [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof NavigationMenuPrimitive.Icon>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Icon
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"top-full z-1 flex h-1.5 items-end justify-center overflow-hidden data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:animate-in data-[state=visible]:fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
|
||||
</NavigationMenuPrimitive.Icon>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuList,
|
||||
NavigationMenuTrigger,
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenuPositioner,
|
||||
}
|
||||
90
components/ui/popover.tsx
Normal file
90
components/ui/popover.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
83
components/ui/progress.tsx
Normal file
83
components/ui/progress.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client"
|
||||
|
||||
import { Progress as ProgressPrimitive } from "@base-ui/react/progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
children,
|
||||
value,
|
||||
...props
|
||||
}: ProgressPrimitive.Root.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
value={value}
|
||||
data-slot="progress"
|
||||
className={cn("flex flex-wrap gap-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ProgressTrack>
|
||||
<ProgressIndicator />
|
||||
</ProgressTrack>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressTrack({ className, ...props }: ProgressPrimitive.Track.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Track
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-track"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressIndicator({
|
||||
className,
|
||||
...props
|
||||
}: ProgressPrimitive.Indicator.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className={cn("h-full bg-primary transition-all", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressLabel({ className, ...props }: ProgressPrimitive.Label.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Label
|
||||
className={cn("text-sm font-medium", className)}
|
||||
data-slot="progress-label"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ProgressValue({ className, ...props }: ProgressPrimitive.Value.Props) {
|
||||
return (
|
||||
<ProgressPrimitive.Value
|
||||
className={cn(
|
||||
"ml-auto text-sm text-muted-foreground tabular-nums",
|
||||
className
|
||||
)}
|
||||
data-slot="progress-value"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Progress,
|
||||
ProgressTrack,
|
||||
ProgressIndicator,
|
||||
ProgressLabel,
|
||||
ProgressValue,
|
||||
}
|
||||
38
components/ui/radio-group.tsx
Normal file
38
components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||
return (
|
||||
<RadioGroupPrimitive
|
||||
data-slot="radio-group"
|
||||
className={cn("grid w-full gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
|
||||
return (
|
||||
<RadioPrimitive.Root
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex size-4 items-center justify-center"
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
55
components/ui/scroll-area.tsx
Normal file
55
components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
201
components/ui/select.tsx
Normal file
201
components/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
25
components/ui/separator.tsx
Normal file
25
components/ui/separator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
135
components/ui/sheet.tsx
Normal file
135
components/ui/sheet.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 duration-100 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background bg-clip-padding text-sm shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-base font-medium text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
13
components/ui/skeleton.tsx
Normal file
13
components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
32
components/ui/switch.tsx
Normal file
32
components/ui/switch.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
116
components/ui/table.tsx
Normal file
116
components/ui/table.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
82
components/ui/tabs.tsx
Normal file
82
components/ui/tabs.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
66
components/ui/tooltip.tsx
Normal file
66
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
BIN
data/cubeadmin.db
Normal file
BIN
data/cubeadmin.db
Normal file
Binary file not shown.
BIN
data/cubeadmin.db-shm
Normal file
BIN
data/cubeadmin.db-shm
Normal file
Binary file not shown.
0
data/cubeadmin.db-wal
Normal file
0
data/cubeadmin.db-wal
Normal file
45
docker/Dockerfile
Normal file
45
docker/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
|
||||
FROM oven/bun:1.3-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (cache layer)
|
||||
COPY package.json bun.lock* ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build Next.js
|
||||
RUN bun run build
|
||||
|
||||
# ─── Stage 2: Production image ────────────────────────────────────────────────
|
||||
FROM oven/bun:1.3-alpine AS runner
|
||||
|
||||
# Security: run as non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# Copy built output
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Copy server entrypoint
|
||||
COPY --from=builder /app/server.ts ./server.ts
|
||||
|
||||
# Create data directory with correct permissions
|
||||
RUN mkdir -p /app/data /app/backups && chown -R nextjs:nodejs /app/data /app/backups
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||
|
||||
CMD ["bun", "--bun", "run", "server.ts"]
|
||||
36
docker/docker-compose.dev.yml
Normal file
36
docker/docker-compose.dev.yml
Normal file
@@ -0,0 +1,36 @@
|
||||
################################################################################
|
||||
# CubeAdmin — Development Docker Compose
|
||||
#
|
||||
# Usage: docker compose -f docker/docker-compose.dev.yml up
|
||||
#
|
||||
# This starts only the Minecraft server + optional BlueMap for development.
|
||||
# The CubeAdmin panel runs on your host machine with `bun run dev`.
|
||||
################################################################################
|
||||
|
||||
services:
|
||||
minecraft:
|
||||
image: itzg/minecraft-server:latest
|
||||
container_name: minecraft_dev
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
stdin_open: true
|
||||
ports:
|
||||
- "25565:25565"
|
||||
- "25575:25575" # Expose RCON for local development
|
||||
environment:
|
||||
- TYPE=${MC_TYPE:-PAPER}
|
||||
- VERSION=${MC_VERSION:-LATEST}
|
||||
- EULA=TRUE
|
||||
- MEMORY=2G
|
||||
- ENABLE_RCON=true
|
||||
- RCON_PORT=25575
|
||||
- RCON_PASSWORD=${MC_RCON_PASSWORD:-devrcon123}
|
||||
- USE_AIKAR_FLAGS=true
|
||||
volumes:
|
||||
- ./mc-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "mc-health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
129
docker/docker-compose.yml
Normal file
129
docker/docker-compose.yml
Normal file
@@ -0,0 +1,129 @@
|
||||
################################################################################
|
||||
# CubeAdmin — Docker Compose
|
||||
#
|
||||
# Usage:
|
||||
# 1. Copy .env.example to .env and fill in your values
|
||||
# 2. docker compose up -d
|
||||
#
|
||||
# Services:
|
||||
# - cubeadmin : The admin panel (Next.js + Bun)
|
||||
# - minecraft : Minecraft server (optional — disable if you manage your own)
|
||||
# - bluemap : BlueMap 3D map (optional)
|
||||
#
|
||||
################################################################################
|
||||
|
||||
services:
|
||||
# ─── CubeAdmin panel ─────────────────────────────────────────────────────
|
||||
cubeadmin:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: cubeadmin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required}
|
||||
- BETTER_AUTH_URL=${BETTER_AUTH_URL:-http://localhost:3000}
|
||||
- RESEND_API_KEY=${RESEND_API_KEY:-}
|
||||
- EMAIL_FROM=${EMAIL_FROM:-CubeAdmin <noreply@example.com>}
|
||||
- MC_SERVER_PATH=/mc-server
|
||||
- MC_RCON_HOST=minecraft
|
||||
- MC_RCON_PORT=${MC_RCON_PORT:-25575}
|
||||
- MC_RCON_PASSWORD=${MC_RCON_PASSWORD:?MC_RCON_PASSWORD is required}
|
||||
- BLUEMAP_URL=${BLUEMAP_URL:-}
|
||||
- DATABASE_PATH=/app/data/cubeadmin.db
|
||||
- BACKUPS_PATH=/app/backups
|
||||
- TRUSTED_ORIGINS=${TRUSTED_ORIGINS:-http://localhost:3000}
|
||||
- INITIAL_ADMIN_EMAIL=${INITIAL_ADMIN_EMAIL:-admin@example.com}
|
||||
- INITIAL_ADMIN_NAME=${INITIAL_ADMIN_NAME:-Administrator}
|
||||
- INITIAL_ADMIN_PASSWORD=${INITIAL_ADMIN_PASSWORD:-ChangeMe123!}
|
||||
volumes:
|
||||
# Persistent database
|
||||
- cubeadmin_data:/app/data
|
||||
# Persistent backups
|
||||
- ${BACKUPS_PATH:-./backups}:/app/backups
|
||||
# Mount Minecraft server directory (read-write for file management)
|
||||
- ${MC_DATA_PATH:-./mc-data}:/mc-server
|
||||
networks:
|
||||
- cubeadmin_net
|
||||
depends_on:
|
||||
minecraft:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# ─── Minecraft Server (Paper MC) ─────────────────────────────────────────
|
||||
# Remove or comment out this service if you manage your Minecraft server separately
|
||||
minecraft:
|
||||
image: itzg/minecraft-server:latest
|
||||
container_name: minecraft
|
||||
restart: unless-stopped
|
||||
tty: true
|
||||
stdin_open: true
|
||||
ports:
|
||||
- "${MC_PORT:-25565}:25565"
|
||||
# Uncomment to expose RCON port externally (not recommended for security)
|
||||
# - "25575:25575"
|
||||
environment:
|
||||
# Server type: VANILLA, PAPER, SPIGOT, FORGE, FABRIC, BUKKIT, BEDROCK
|
||||
- TYPE=${MC_TYPE:-PAPER}
|
||||
- VERSION=${MC_VERSION:-LATEST}
|
||||
- EULA=TRUE
|
||||
- MEMORY=${MC_MEMORY:-2G}
|
||||
- MAX_PLAYERS=${MC_MAX_PLAYERS:-20}
|
||||
- MOTD=${MC_MOTD:-Powered by CubeAdmin}
|
||||
# RCON (required for CubeAdmin command execution)
|
||||
- ENABLE_RCON=true
|
||||
- RCON_PORT=25575
|
||||
- RCON_PASSWORD=${MC_RCON_PASSWORD:?MC_RCON_PASSWORD is required}
|
||||
# Performance
|
||||
- USE_AIKAR_FLAGS=true
|
||||
- VIEW_DISTANCE=10
|
||||
- SIMULATION_DISTANCE=8
|
||||
# Ops
|
||||
- OPS=${MC_OPS:-}
|
||||
- WHITELIST=${MC_WHITELIST:-}
|
||||
- ENABLE_WHITELIST=false
|
||||
volumes:
|
||||
- ${MC_DATA_PATH:-./mc-data}:/data
|
||||
networks:
|
||||
- cubeadmin_net
|
||||
healthcheck:
|
||||
test: ["CMD", "mc-health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
|
||||
# ─── BlueMap (optional 3D map) ───────────────────────────────────────────
|
||||
# BlueMap is usually run as a plugin inside the MC server.
|
||||
# This service provides a standalone BlueMap web viewer.
|
||||
# Configure BLUEMAP_URL=http://localhost:8100 in your .env
|
||||
#
|
||||
# bluemap:
|
||||
# image: nginx:alpine
|
||||
# container_name: bluemap
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - "8100:80"
|
||||
# volumes:
|
||||
# - ${MC_DATA_PATH:-./mc-data}/plugins/BlueMap/web:/usr/share/nginx/html:ro
|
||||
# networks:
|
||||
# - cubeadmin_net
|
||||
|
||||
volumes:
|
||||
cubeadmin_data:
|
||||
name: cubeadmin_data
|
||||
|
||||
networks:
|
||||
cubeadmin_net:
|
||||
name: cubeadmin_net
|
||||
driver: bridge
|
||||
12
drizzle.config.ts
Normal file
12
drizzle.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./lib/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_PATH ?? "./data/cubeadmin.db",
|
||||
},
|
||||
verbose: true,
|
||||
strict: true,
|
||||
});
|
||||
180
drizzle/0000_overjoyed_thundra.sql
Normal file
180
drizzle/0000_overjoyed_thundra.sql
Normal file
@@ -0,0 +1,180 @@
|
||||
CREATE TABLE `accounts` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`account_id` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`access_token` text,
|
||||
`refresh_token` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `audit_logs` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text,
|
||||
`action` text NOT NULL,
|
||||
`target` text,
|
||||
`target_id` text,
|
||||
`details` text,
|
||||
`ip_address` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `backups` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`size` integer,
|
||||
`path` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`status` text DEFAULT 'pending' NOT NULL,
|
||||
`triggered_by` text,
|
||||
FOREIGN KEY (`triggered_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `invitations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`role` text DEFAULT 'moderator' NOT NULL,
|
||||
`invited_by` text NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`accepted_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`invited_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `invitations_token_unique` ON `invitations` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `mc_players` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`uuid` text NOT NULL,
|
||||
`username` text NOT NULL,
|
||||
`first_seen` integer,
|
||||
`last_seen` integer,
|
||||
`is_online` integer DEFAULT false NOT NULL,
|
||||
`play_time` integer DEFAULT 0 NOT NULL,
|
||||
`role` text,
|
||||
`is_banned` integer DEFAULT false NOT NULL,
|
||||
`notes` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `mc_players_uuid_idx` ON `mc_players` (`uuid`);--> statement-breakpoint
|
||||
CREATE TABLE `player_bans` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`reason` text,
|
||||
`banned_by` text,
|
||||
`banned_at` integer NOT NULL,
|
||||
`expires_at` integer,
|
||||
`is_active` integer DEFAULT true NOT NULL,
|
||||
`unbanned_by` text,
|
||||
`unbanned_at` integer,
|
||||
FOREIGN KEY (`player_id`) REFERENCES `mc_players`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`banned_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null,
|
||||
FOREIGN KEY (`unbanned_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `player_chat_history` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`channel` text,
|
||||
`timestamp` integer NOT NULL,
|
||||
`server_id` text,
|
||||
FOREIGN KEY (`player_id`) REFERENCES `mc_players`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `player_spawn_points` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`player_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`world` text NOT NULL,
|
||||
`x` real NOT NULL,
|
||||
`y` real NOT NULL,
|
||||
`z` real NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`player_id`) REFERENCES `mc_players`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `plugins` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`version` text,
|
||||
`description` text,
|
||||
`is_enabled` integer DEFAULT true NOT NULL,
|
||||
`jar_file` text,
|
||||
`config` text,
|
||||
`installed_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `scheduled_tasks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`description` text,
|
||||
`cron_expression` text NOT NULL,
|
||||
`command` text NOT NULL,
|
||||
`is_enabled` integer DEFAULT true NOT NULL,
|
||||
`last_run` integer,
|
||||
`next_run` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `server_settings` (
|
||||
`id` integer PRIMARY KEY DEFAULT 1 NOT NULL,
|
||||
`minecraft_path` text,
|
||||
`server_jar` text,
|
||||
`server_version` text,
|
||||
`server_type` text,
|
||||
`max_ram` integer DEFAULT 4096,
|
||||
`min_ram` integer DEFAULT 1024,
|
||||
`rcon_enabled` integer DEFAULT false NOT NULL,
|
||||
`rcon_port` integer DEFAULT 25575,
|
||||
`rcon_password` text,
|
||||
`java_args` text,
|
||||
`auto_start` integer DEFAULT false NOT NULL,
|
||||
`restart_on_crash` integer DEFAULT false NOT NULL,
|
||||
`backup_enabled` integer DEFAULT false NOT NULL,
|
||||
`backup_schedule` text,
|
||||
`bluemap_enabled` integer DEFAULT false NOT NULL,
|
||||
`bluemap_url` text,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`ip_address` text,
|
||||
`user_agent` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `sessions_token_unique` ON `sessions` (`token`);--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`email_verified` integer DEFAULT false NOT NULL,
|
||||
`image` text,
|
||||
`role` text DEFAULT 'moderator' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
|
||||
CREATE TABLE `verifications` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
1222
drizzle/meta/0000_snapshot.json
Normal file
1222
drizzle/meta/0000_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1772980984285,
|
||||
"tag": "0000_overjoyed_thundra",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
48
lib/auth/client.ts
Normal file
48
lib/auth/client.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { organizationClient } from "better-auth/client/plugins";
|
||||
import { magicLinkClient } from "better-auth/client/plugins";
|
||||
import type { Auth } from "./index";
|
||||
|
||||
/**
|
||||
* Better Auth client instance for use in React components and client-side
|
||||
* code. Mirrors the plugins registered on the server-side `auth` instance.
|
||||
*/
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||
|
||||
plugins: [
|
||||
// Enables organization.* methods (createOrganization, getActiveMember, etc.)
|
||||
organizationClient(),
|
||||
|
||||
// Enables signIn.magicLink and magicLink.verify
|
||||
magicLinkClient(),
|
||||
],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience re-exports so consumers only need to import from this module
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const {
|
||||
signIn,
|
||||
signOut,
|
||||
signUp,
|
||||
useSession,
|
||||
getSession,
|
||||
} = authClient;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inferred client-side types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ClientSession = typeof authClient.$Infer.Session.session;
|
||||
export type ClientUser = typeof authClient.$Infer.Session.user;
|
||||
|
||||
/**
|
||||
* Infer server plugin types on the client side.
|
||||
* Provides full type safety for plugin-specific methods without importing
|
||||
* the server-only `auth` instance into a client bundle.
|
||||
*/
|
||||
export type { Auth };
|
||||
108
lib/auth/index.ts
Normal file
108
lib/auth/index.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { organization } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { db } from "@/lib/db";
|
||||
import * as schema from "@/lib/db/schema";
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
|
||||
export const auth = betterAuth({
|
||||
// -------------------------------------------------------------------------
|
||||
// Core
|
||||
// -------------------------------------------------------------------------
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Database adapter (Drizzle + bun:sqlite)
|
||||
// -------------------------------------------------------------------------
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
schema: {
|
||||
users: schema.users,
|
||||
sessions: schema.sessions,
|
||||
accounts: schema.accounts,
|
||||
verifications: schema.verifications,
|
||||
},
|
||||
usePlural: false,
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Custom user fields
|
||||
// -------------------------------------------------------------------------
|
||||
user: {
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: "string",
|
||||
required: false,
|
||||
defaultValue: "moderator",
|
||||
input: false, // Not settable by the user directly
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Email + password authentication
|
||||
// -------------------------------------------------------------------------
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
requireEmailVerification: false,
|
||||
minPasswordLength: 8,
|
||||
maxPasswordLength: 128,
|
||||
},
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Plugins
|
||||
// -------------------------------------------------------------------------
|
||||
plugins: [
|
||||
// Organization / role support
|
||||
organization(),
|
||||
|
||||
// Magic link — used for invitation acceptance flows
|
||||
magicLink({
|
||||
expiresIn: 60 * 60, // 1 hour
|
||||
disableSignUp: true, // magic links are only for invited users
|
||||
sendMagicLink: async ({ email, url, token }) => {
|
||||
// Delegate to the application's email module. The email module is
|
||||
// responsible for importing and calling Resend (or whichever mailer
|
||||
// is configured). We do a dynamic import so that this file does not
|
||||
// pull in email dependencies at auth-initialisation time on the edge.
|
||||
const { sendMagicLinkEmail } = await import("@/lib/email/index");
|
||||
await sendMagicLinkEmail({ email, url, token });
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Trusted origins — allow env-configured list plus localhost in dev
|
||||
// -------------------------------------------------------------------------
|
||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(",").map((o) => o.trim())
|
||||
: ["http://localhost:3000"],
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Cookie / session security
|
||||
// -------------------------------------------------------------------------
|
||||
advanced: {
|
||||
useSecureCookies: isProduction,
|
||||
defaultCookieAttributes: {
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type helpers for use across the application
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Auth = typeof auth;
|
||||
|
||||
/** The server-side session type returned by auth.api.getSession */
|
||||
export type Session = typeof auth.$Infer.Session.session;
|
||||
|
||||
/** The user type embedded in every session */
|
||||
export type User = typeof auth.$Infer.Session.user;
|
||||
141
lib/backup/manager.ts
Normal file
141
lib/backup/manager.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Backup manager: creates zip archives of worlds, plugins, or config.
|
||||
* Uses the `archiver` package.
|
||||
*/
|
||||
|
||||
import archiver from "archiver";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { db } from "@/lib/db";
|
||||
import { backups } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export type BackupType = "worlds" | "plugins" | "config" | "full";
|
||||
|
||||
const BACKUPS_DIR =
|
||||
process.env.BACKUPS_PATH ?? path.join(process.cwd(), "backups");
|
||||
|
||||
/** Ensure the backups directory exists. */
|
||||
function ensureBackupsDir(): void {
|
||||
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup archive of the specified type.
|
||||
* Returns the backup record ID.
|
||||
*/
|
||||
export async function createBackup(
|
||||
type: BackupType,
|
||||
triggeredBy: string,
|
||||
): Promise<string> {
|
||||
ensureBackupsDir();
|
||||
|
||||
const mcPath = process.env.MC_SERVER_PATH ?? "/opt/minecraft/server";
|
||||
const id = nanoid();
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const fileName = `backup-${type}-${timestamp}.zip`;
|
||||
const filePath = path.join(BACKUPS_DIR, fileName);
|
||||
|
||||
// Insert pending record
|
||||
await db.insert(backups).values({
|
||||
id,
|
||||
name: fileName,
|
||||
type,
|
||||
size: 0,
|
||||
path: filePath,
|
||||
createdAt: Date.now(),
|
||||
status: "running",
|
||||
triggeredBy,
|
||||
});
|
||||
|
||||
try {
|
||||
await archiveBackup(type, mcPath, filePath);
|
||||
|
||||
const stat = fs.statSync(filePath);
|
||||
await db
|
||||
.update(backups)
|
||||
.set({ status: "completed", size: stat.size })
|
||||
.where(eq(backups.id, id));
|
||||
} catch (err) {
|
||||
await db
|
||||
.update(backups)
|
||||
.set({ status: "failed" })
|
||||
.where(eq(backups.id, id));
|
||||
throw err;
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function archiveBackup(
|
||||
type: BackupType,
|
||||
mcPath: string,
|
||||
outPath: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const output = fs.createWriteStream(outPath);
|
||||
const archive = archiver("zip", { zlib: { level: 6 } });
|
||||
|
||||
output.on("close", resolve);
|
||||
archive.on("error", reject);
|
||||
archive.pipe(output);
|
||||
|
||||
const dirsToArchive: { src: string; name: string }[] = [];
|
||||
|
||||
if (type === "worlds" || type === "full") {
|
||||
// Common world directory names
|
||||
for (const dir of ["world", "world_nether", "world_the_end"]) {
|
||||
const p = path.join(mcPath, dir);
|
||||
if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: dir });
|
||||
}
|
||||
}
|
||||
if (type === "plugins" || type === "full") {
|
||||
const p = path.join(mcPath, "plugins");
|
||||
if (fs.existsSync(p)) dirsToArchive.push({ src: p, name: "plugins" });
|
||||
}
|
||||
if (type === "config" || type === "full") {
|
||||
// Config files at server root
|
||||
for (const file of [
|
||||
"server.properties",
|
||||
"ops.json",
|
||||
"whitelist.json",
|
||||
"banned-players.json",
|
||||
"banned-ips.json",
|
||||
"eula.txt",
|
||||
"spigot.yml",
|
||||
"paper.yml",
|
||||
"bukkit.yml",
|
||||
]) {
|
||||
const p = path.join(mcPath, file);
|
||||
if (fs.existsSync(p)) archive.file(p, { name: `config/${file}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { src, name } of dirsToArchive) {
|
||||
archive.directory(src, name);
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
});
|
||||
}
|
||||
|
||||
/** Delete a backup file and its DB record. */
|
||||
export async function deleteBackup(id: string): Promise<void> {
|
||||
const record = await db
|
||||
.select()
|
||||
.from(backups)
|
||||
.where(eq(backups.id, id))
|
||||
.get();
|
||||
if (!record) throw new Error("Backup not found");
|
||||
|
||||
if (record.path && fs.existsSync(record.path)) {
|
||||
fs.unlinkSync(record.path);
|
||||
}
|
||||
await db.delete(backups).where(eq(backups.id, id));
|
||||
}
|
||||
|
||||
/** List all backups from the DB. */
|
||||
export async function listBackups() {
|
||||
return db.select().from(backups).orderBy(backups.createdAt);
|
||||
}
|
||||
102
lib/db/index.ts
Normal file
102
lib/db/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const DB_PATH = "./data/cubeadmin.db";
|
||||
|
||||
// Ensure the data directory exists before opening the database
|
||||
mkdirSync(dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const sqlite = new Database(DB_PATH, { create: true });
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.exec("PRAGMA journal_mode = WAL;");
|
||||
sqlite.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
|
||||
export type DB = typeof db;
|
||||
|
||||
// Re-export all schema tables for convenient imports from a single location
|
||||
export {
|
||||
users,
|
||||
sessions,
|
||||
accounts,
|
||||
verifications,
|
||||
invitations,
|
||||
mcPlayers,
|
||||
playerBans,
|
||||
playerChatHistory,
|
||||
playerSpawnPoints,
|
||||
plugins,
|
||||
backups,
|
||||
scheduledTasks,
|
||||
auditLogs,
|
||||
serverSettings,
|
||||
} from "./schema";
|
||||
|
||||
// Re-export Zod schemas
|
||||
export {
|
||||
insertUserSchema,
|
||||
selectUserSchema,
|
||||
insertSessionSchema,
|
||||
selectSessionSchema,
|
||||
insertAccountSchema,
|
||||
selectAccountSchema,
|
||||
insertVerificationSchema,
|
||||
selectVerificationSchema,
|
||||
insertInvitationSchema,
|
||||
selectInvitationSchema,
|
||||
insertMcPlayerSchema,
|
||||
selectMcPlayerSchema,
|
||||
insertPlayerBanSchema,
|
||||
selectPlayerBanSchema,
|
||||
insertPlayerChatHistorySchema,
|
||||
selectPlayerChatHistorySchema,
|
||||
insertPlayerSpawnPointSchema,
|
||||
selectPlayerSpawnPointSchema,
|
||||
insertPluginSchema,
|
||||
selectPluginSchema,
|
||||
insertBackupSchema,
|
||||
selectBackupSchema,
|
||||
insertScheduledTaskSchema,
|
||||
selectScheduledTaskSchema,
|
||||
insertAuditLogSchema,
|
||||
selectAuditLogSchema,
|
||||
insertServerSettingsSchema,
|
||||
selectServerSettingsSchema,
|
||||
} from "./schema";
|
||||
|
||||
// Re-export inferred types
|
||||
export type {
|
||||
User,
|
||||
NewUser,
|
||||
Session,
|
||||
NewSession,
|
||||
Account,
|
||||
NewAccount,
|
||||
Verification,
|
||||
NewVerification,
|
||||
Invitation,
|
||||
NewInvitation,
|
||||
McPlayer,
|
||||
NewMcPlayer,
|
||||
PlayerBan,
|
||||
NewPlayerBan,
|
||||
PlayerChatHistory,
|
||||
NewPlayerChatHistory,
|
||||
PlayerSpawnPoint,
|
||||
NewPlayerSpawnPoint,
|
||||
Plugin,
|
||||
NewPlugin,
|
||||
Backup,
|
||||
NewBackup,
|
||||
ScheduledTask,
|
||||
NewScheduledTask,
|
||||
AuditLog,
|
||||
NewAuditLog,
|
||||
ServerSettings,
|
||||
NewServerSettings,
|
||||
} from "./schema";
|
||||
31
lib/db/migrate.ts
Normal file
31
lib/db/migrate.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { db } from "./index";
|
||||
|
||||
const MIGRATIONS_FOLDER = resolve(process.cwd(), "drizzle");
|
||||
|
||||
/**
|
||||
* Run all pending Drizzle migrations at startup.
|
||||
*
|
||||
* If the migrations folder does not exist yet (e.g. fresh clone before the
|
||||
* first `bun run db:generate` has been executed) the function exits silently
|
||||
* so that the application can still start in development without crashing.
|
||||
*/
|
||||
export function runMigrations(): void {
|
||||
if (!existsSync(MIGRATIONS_FOLDER)) {
|
||||
console.warn(
|
||||
`[migrate] Migrations folder not found at "${MIGRATIONS_FOLDER}". ` +
|
||||
"Skipping migrations — run `bun run db:generate` to create migration files.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
|
||||
console.log("[migrate] Database migrations applied successfully.");
|
||||
} catch (err) {
|
||||
console.error("[migrate] Failed to apply database migrations:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
359
lib/db/schema.ts
Normal file
359
lib/db/schema.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import {
|
||||
sqliteTable,
|
||||
text,
|
||||
integer,
|
||||
real,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/sqlite-core";
|
||||
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Better Auth core tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: integer("email_verified", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
image: text("image"),
|
||||
role: text("role", {
|
||||
enum: ["superadmin", "admin", "moderator"],
|
||||
})
|
||||
.notNull()
|
||||
.default("moderator"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable("sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable("accounts", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
expiresAt: integer("expires_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const verifications = sqliteTable("verifications", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Invitation system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const invitations = sqliteTable("invitations", {
|
||||
id: text("id").primaryKey(),
|
||||
email: text("email").notNull(),
|
||||
role: text("role", {
|
||||
enum: ["superadmin", "admin", "moderator"],
|
||||
})
|
||||
.notNull()
|
||||
.default("moderator"),
|
||||
invitedBy: text("invited_by")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token").notNull().unique(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
acceptedAt: integer("accepted_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minecraft player management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const mcPlayers = sqliteTable(
|
||||
"mc_players",
|
||||
{
|
||||
id: text("id").primaryKey(), // uuid
|
||||
uuid: text("uuid").notNull(), // Minecraft UUID
|
||||
username: text("username").notNull(),
|
||||
firstSeen: integer("first_seen"),
|
||||
lastSeen: integer("last_seen"),
|
||||
isOnline: integer("is_online", { mode: "boolean" }).notNull().default(false),
|
||||
playTime: integer("play_time").notNull().default(0), // minutes
|
||||
role: text("role"),
|
||||
isBanned: integer("is_banned", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
notes: text("notes"),
|
||||
},
|
||||
(table) => [uniqueIndex("mc_players_uuid_idx").on(table.uuid)],
|
||||
);
|
||||
|
||||
export const playerBans = sqliteTable("player_bans", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => mcPlayers.id, { onDelete: "cascade" }),
|
||||
reason: text("reason"),
|
||||
bannedBy: text("banned_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
bannedAt: integer("banned_at").notNull(),
|
||||
expiresAt: integer("expires_at"),
|
||||
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
|
||||
unbannedBy: text("unbanned_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
unbannedAt: integer("unbanned_at"),
|
||||
});
|
||||
|
||||
export const playerChatHistory = sqliteTable("player_chat_history", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => mcPlayers.id, { onDelete: "cascade" }),
|
||||
message: text("message").notNull(),
|
||||
channel: text("channel"),
|
||||
timestamp: integer("timestamp").notNull(),
|
||||
serverId: text("server_id"),
|
||||
});
|
||||
|
||||
export const playerSpawnPoints = sqliteTable("player_spawn_points", {
|
||||
id: text("id").primaryKey(),
|
||||
playerId: text("player_id")
|
||||
.notNull()
|
||||
.references(() => mcPlayers.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
world: text("world").notNull(),
|
||||
x: real("x").notNull(),
|
||||
y: real("y").notNull(),
|
||||
z: real("z").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const plugins = sqliteTable("plugins", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
version: text("version"),
|
||||
description: text("description"),
|
||||
isEnabled: integer("is_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
jarFile: text("jar_file"),
|
||||
config: text("config"), // JSON blob
|
||||
installedAt: integer("installed_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup management
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const backups = sqliteTable("backups", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
type: text("type", {
|
||||
enum: ["worlds", "plugins", "config", "full"],
|
||||
}).notNull(),
|
||||
size: integer("size"), // bytes
|
||||
path: text("path"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
status: text("status", {
|
||||
enum: ["pending", "running", "completed", "failed"],
|
||||
})
|
||||
.notNull()
|
||||
.default("pending"),
|
||||
triggeredBy: text("triggered_by").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduled tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
cronExpression: text("cron_expression").notNull(),
|
||||
command: text("command").notNull(), // MC command to run
|
||||
isEnabled: integer("is_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
lastRun: integer("last_run"),
|
||||
nextRun: integer("next_run"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audit logs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const auditLogs = sqliteTable("audit_logs", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
target: text("target"),
|
||||
targetId: text("target_id"),
|
||||
details: text("details"), // JSON blob
|
||||
ipAddress: text("ip_address"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server settings (singleton row, id = 1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const serverSettings = sqliteTable("server_settings", {
|
||||
id: integer("id").primaryKey().default(1),
|
||||
minecraftPath: text("minecraft_path"),
|
||||
serverJar: text("server_jar"),
|
||||
serverVersion: text("server_version"),
|
||||
serverType: text("server_type", {
|
||||
enum: ["vanilla", "paper", "spigot", "bukkit", "fabric", "forge", "bedrock"],
|
||||
}),
|
||||
maxRam: integer("max_ram").default(4096),
|
||||
minRam: integer("min_ram").default(1024),
|
||||
rconEnabled: integer("rcon_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
rconPort: integer("rcon_port").default(25575),
|
||||
rconPassword: text("rcon_password"), // stored encrypted
|
||||
javaArgs: text("java_args"),
|
||||
autoStart: integer("auto_start", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
restartOnCrash: integer("restart_on_crash", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
backupEnabled: integer("backup_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
backupSchedule: text("backup_schedule"),
|
||||
bluemapEnabled: integer("bluemap_enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
bluemapUrl: text("bluemap_url"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod schemas (insert + select) for each table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const insertUserSchema = createInsertSchema(users);
|
||||
export const selectUserSchema = createSelectSchema(users);
|
||||
|
||||
export const insertSessionSchema = createInsertSchema(sessions);
|
||||
export const selectSessionSchema = createSelectSchema(sessions);
|
||||
|
||||
export const insertAccountSchema = createInsertSchema(accounts);
|
||||
export const selectAccountSchema = createSelectSchema(accounts);
|
||||
|
||||
export const insertVerificationSchema = createInsertSchema(verifications);
|
||||
export const selectVerificationSchema = createSelectSchema(verifications);
|
||||
|
||||
export const insertInvitationSchema = createInsertSchema(invitations);
|
||||
export const selectInvitationSchema = createSelectSchema(invitations);
|
||||
|
||||
export const insertMcPlayerSchema = createInsertSchema(mcPlayers);
|
||||
export const selectMcPlayerSchema = createSelectSchema(mcPlayers);
|
||||
|
||||
export const insertPlayerBanSchema = createInsertSchema(playerBans);
|
||||
export const selectPlayerBanSchema = createSelectSchema(playerBans);
|
||||
|
||||
export const insertPlayerChatHistorySchema =
|
||||
createInsertSchema(playerChatHistory);
|
||||
export const selectPlayerChatHistorySchema =
|
||||
createSelectSchema(playerChatHistory);
|
||||
|
||||
export const insertPlayerSpawnPointSchema =
|
||||
createInsertSchema(playerSpawnPoints);
|
||||
export const selectPlayerSpawnPointSchema =
|
||||
createSelectSchema(playerSpawnPoints);
|
||||
|
||||
export const insertPluginSchema = createInsertSchema(plugins);
|
||||
export const selectPluginSchema = createSelectSchema(plugins);
|
||||
|
||||
export const insertBackupSchema = createInsertSchema(backups);
|
||||
export const selectBackupSchema = createSelectSchema(backups);
|
||||
|
||||
export const insertScheduledTaskSchema = createInsertSchema(scheduledTasks);
|
||||
export const selectScheduledTaskSchema = createSelectSchema(scheduledTasks);
|
||||
|
||||
export const insertAuditLogSchema = createInsertSchema(auditLogs);
|
||||
export const selectAuditLogSchema = createSelectSchema(auditLogs);
|
||||
|
||||
export const insertServerSettingsSchema = createInsertSchema(serverSettings);
|
||||
export const selectServerSettingsSchema = createSelectSchema(serverSettings);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inferred TypeScript types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
|
||||
export type Session = typeof sessions.$inferSelect;
|
||||
export type NewSession = typeof sessions.$inferInsert;
|
||||
|
||||
export type Account = typeof accounts.$inferSelect;
|
||||
export type NewAccount = typeof accounts.$inferInsert;
|
||||
|
||||
export type Verification = typeof verifications.$inferSelect;
|
||||
export type NewVerification = typeof verifications.$inferInsert;
|
||||
|
||||
export type Invitation = typeof invitations.$inferSelect;
|
||||
export type NewInvitation = typeof invitations.$inferInsert;
|
||||
|
||||
export type McPlayer = typeof mcPlayers.$inferSelect;
|
||||
export type NewMcPlayer = typeof mcPlayers.$inferInsert;
|
||||
|
||||
export type PlayerBan = typeof playerBans.$inferSelect;
|
||||
export type NewPlayerBan = typeof playerBans.$inferInsert;
|
||||
|
||||
export type PlayerChatHistory = typeof playerChatHistory.$inferSelect;
|
||||
export type NewPlayerChatHistory = typeof playerChatHistory.$inferInsert;
|
||||
|
||||
export type PlayerSpawnPoint = typeof playerSpawnPoints.$inferSelect;
|
||||
export type NewPlayerSpawnPoint = typeof playerSpawnPoints.$inferInsert;
|
||||
|
||||
export type Plugin = typeof plugins.$inferSelect;
|
||||
export type NewPlugin = typeof plugins.$inferInsert;
|
||||
|
||||
export type Backup = typeof backups.$inferSelect;
|
||||
export type NewBackup = typeof backups.$inferInsert;
|
||||
|
||||
export type ScheduledTask = typeof scheduledTasks.$inferSelect;
|
||||
export type NewScheduledTask = typeof scheduledTasks.$inferInsert;
|
||||
|
||||
export type AuditLog = typeof auditLogs.$inferSelect;
|
||||
export type NewAuditLog = typeof auditLogs.$inferInsert;
|
||||
|
||||
export type ServerSettings = typeof serverSettings.$inferSelect;
|
||||
export type NewServerSettings = typeof serverSettings.$inferInsert;
|
||||
53
lib/email/index.ts
Normal file
53
lib/email/index.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Resend } from "resend";
|
||||
import { render } from "@react-email/render";
|
||||
import { InvitationEmail } from "./templates/invitation";
|
||||
|
||||
function getResend(): Resend {
|
||||
const key = process.env.RESEND_API_KEY;
|
||||
if (!key) throw new Error("RESEND_API_KEY is not configured");
|
||||
return new Resend(key);
|
||||
}
|
||||
|
||||
export async function sendMagicLinkEmail({
|
||||
email,
|
||||
url,
|
||||
token: _token,
|
||||
}: {
|
||||
email: string;
|
||||
url: string;
|
||||
token: string;
|
||||
}): Promise<void> {
|
||||
const { error } = await getResend().emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>",
|
||||
to: email,
|
||||
subject: "Your CubeAdmin sign-in link",
|
||||
html: `<p>Click the link below to sign in to CubeAdmin. This link expires in 1 hour.</p><p><a href="${url}">${url}</a></p>`,
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Failed to send magic link email: ${error.message}`);
|
||||
}
|
||||
|
||||
export async function sendInvitationEmail({
|
||||
to,
|
||||
invitedByName,
|
||||
inviteUrl,
|
||||
role,
|
||||
}: {
|
||||
to: string;
|
||||
invitedByName: string;
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}): Promise<void> {
|
||||
const html = await render(
|
||||
InvitationEmail({ invitedByName, inviteUrl, role }),
|
||||
);
|
||||
|
||||
const { error } = await getResend().emails.send({
|
||||
from: process.env.EMAIL_FROM ?? "CubeAdmin <noreply@example.com>",
|
||||
to,
|
||||
subject: `You've been invited to CubeAdmin`,
|
||||
html,
|
||||
});
|
||||
|
||||
if (error) throw new Error(`Failed to send email: ${error.message}`);
|
||||
}
|
||||
78
lib/email/templates/invitation.tsx
Normal file
78
lib/email/templates/invitation.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Tailwind,
|
||||
} from "@react-email/components";
|
||||
import * as React from "react";
|
||||
|
||||
interface InvitationEmailProps {
|
||||
invitedByName: string;
|
||||
inviteUrl: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function InvitationEmail({
|
||||
invitedByName,
|
||||
inviteUrl,
|
||||
role,
|
||||
}: InvitationEmailProps) {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>
|
||||
{invitedByName} invited you to manage a Minecraft server on CubeAdmin
|
||||
</Preview>
|
||||
<Tailwind>
|
||||
<Body className="bg-zinc-950 font-sans">
|
||||
<Container className="mx-auto max-w-lg py-12 px-6">
|
||||
{/* Logo */}
|
||||
<Section className="mb-8 text-center">
|
||||
<Heading className="text-2xl font-bold text-emerald-500 m-0">
|
||||
⬛ CubeAdmin
|
||||
</Heading>
|
||||
</Section>
|
||||
|
||||
{/* Card */}
|
||||
<Section className="bg-zinc-900 rounded-xl border border-zinc-800 p-8">
|
||||
<Heading className="text-xl font-semibold text-white mt-0 mb-2">
|
||||
You've been invited
|
||||
</Heading>
|
||||
<Text className="text-zinc-400 mt-0 mb-6">
|
||||
<strong className="text-white">{invitedByName}</strong> has
|
||||
invited you to join CubeAdmin as a{" "}
|
||||
<strong className="text-emerald-400">{role}</strong>. Click the
|
||||
button below to create your account and start managing the
|
||||
Minecraft server.
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
href={inviteUrl}
|
||||
className="bg-emerald-600 hover:bg-emerald-500 text-white font-semibold rounded-lg px-6 py-3 text-sm no-underline inline-block"
|
||||
>
|
||||
Accept Invitation
|
||||
</Button>
|
||||
|
||||
<Text className="text-zinc-500 text-xs mt-6 mb-0">
|
||||
This invitation link expires in 48 hours. If you didn't
|
||||
expect this email, you can safely ignore it.
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-zinc-600 text-xs text-center mt-6">
|
||||
CubeAdmin — Minecraft Server Management
|
||||
</Text>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
}
|
||||
|
||||
export default InvitationEmail;
|
||||
269
lib/minecraft/process.ts
Normal file
269
lib/minecraft/process.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import { EventEmitter } from "node:events"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { serverSettings } from "@/lib/db/schema"
|
||||
import { rconClient } from "@/lib/minecraft/rcon"
|
||||
|
||||
// Maximum number of output lines kept in the ring buffer
|
||||
const RING_BUFFER_SIZE = 500
|
||||
|
||||
export interface ServerStatus {
|
||||
running: boolean
|
||||
pid?: number
|
||||
uptime?: number
|
||||
startedAt?: Date
|
||||
}
|
||||
|
||||
type OutputCallback = (line: string) => void
|
||||
|
||||
export class McProcessManager extends EventEmitter {
|
||||
private process: ReturnType<typeof Bun.spawn> | null = null
|
||||
private startedAt: Date | null = null
|
||||
private outputBuffer: string[] = []
|
||||
private outputCallbacks: Set<OutputCallback> = new Set()
|
||||
private restartOnCrash = false
|
||||
private isIntentionalStop = false
|
||||
private stdoutReader: Promise<void> | null = null
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Start the Minecraft server process.
|
||||
* Reads java command configuration from the `server_settings` table.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.process !== null) {
|
||||
throw new Error("Server is already running")
|
||||
}
|
||||
|
||||
const settings = await this.loadSettings()
|
||||
const cmd = this.buildCommand(settings)
|
||||
|
||||
console.log(`[MC] Starting server: ${cmd.join(" ")}`)
|
||||
|
||||
this.isIntentionalStop = false
|
||||
this.restartOnCrash = settings.restartOnCrash ?? false
|
||||
|
||||
this.process = Bun.spawn(cmd, {
|
||||
cwd: settings.minecraftPath ?? process.env.MC_SERVER_PATH ?? process.cwd(),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
stdin: "pipe",
|
||||
})
|
||||
|
||||
this.startedAt = new Date()
|
||||
|
||||
// Pipe stdout
|
||||
this.stdoutReader = this.readStream(
|
||||
this.process.stdout as ReadableStream<Uint8Array> | null ?? null,
|
||||
"stdout",
|
||||
)
|
||||
// Pipe stderr into the same output stream
|
||||
void this.readStream(
|
||||
this.process.stderr as ReadableStream<Uint8Array> | null ?? null,
|
||||
"stderr",
|
||||
)
|
||||
|
||||
this.emit("started", { pid: this.process.pid })
|
||||
console.log(`[MC] Server started with PID ${this.process.pid}`)
|
||||
|
||||
// Watch for exit
|
||||
void this.watchExit()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the Minecraft server.
|
||||
* @param force - if true, kills the process immediately; if false, sends the
|
||||
* RCON `stop` command and waits for graceful shutdown.
|
||||
*/
|
||||
async stop(force = false): Promise<void> {
|
||||
if (this.process === null) {
|
||||
throw new Error("Server is not running")
|
||||
}
|
||||
|
||||
this.isIntentionalStop = true
|
||||
|
||||
if (force) {
|
||||
console.log("[MC] Force-killing server process")
|
||||
this.process.kill()
|
||||
} else {
|
||||
console.log("[MC] Sending RCON stop command")
|
||||
try {
|
||||
await rconClient.sendCommand("stop")
|
||||
} catch (err) {
|
||||
console.warn("[MC] RCON stop failed, killing process:", err)
|
||||
this.process.kill()
|
||||
}
|
||||
}
|
||||
|
||||
// Wait up to 30 s for the process to exit
|
||||
await Promise.race([
|
||||
this.process.exited,
|
||||
new Promise<void>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Server did not stop in 30 s")), 30_000),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the Minecraft server.
|
||||
* @param force - passed through to `stop()`
|
||||
*/
|
||||
async restart(force = false): Promise<void> {
|
||||
if (this.process !== null) {
|
||||
await this.stop(force)
|
||||
}
|
||||
await this.start()
|
||||
}
|
||||
|
||||
/** Returns current process status */
|
||||
getStatus(): ServerStatus {
|
||||
const running = this.process !== null
|
||||
if (!running) return { running: false }
|
||||
|
||||
return {
|
||||
running: true,
|
||||
pid: this.process!.pid,
|
||||
startedAt: this.startedAt ?? undefined,
|
||||
uptime: this.startedAt
|
||||
? Math.floor((Date.now() - this.startedAt.getTime()) / 1000)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the last RING_BUFFER_SIZE lines of console output */
|
||||
getOutput(): string[] {
|
||||
return [...this.outputBuffer]
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback that receives each new output line.
|
||||
* Returns an unsubscribe function.
|
||||
*/
|
||||
onOutput(cb: OutputCallback): () => void {
|
||||
this.outputCallbacks.add(cb)
|
||||
return () => this.outputCallbacks.delete(cb)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a raw string to the server's stdin (for when RCON is unavailable).
|
||||
*/
|
||||
writeStdin(line: string): void {
|
||||
const stdin = this.process?.stdin
|
||||
if (!stdin) throw new Error("Server is not running")
|
||||
// Bun.spawn stdin is a FileSink (not a WritableStream)
|
||||
const fileSink = stdin as import("bun").FileSink
|
||||
fileSink.write(new TextEncoder().encode(line + "\n"))
|
||||
void fileSink.flush()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async loadSettings() {
|
||||
const rows = await db.select().from(serverSettings).limit(1)
|
||||
const s = rows[0]
|
||||
if (!s) throw new Error("No server settings found in database")
|
||||
return s
|
||||
}
|
||||
|
||||
private buildCommand(settings: Awaited<ReturnType<typeof this.loadSettings>>): string[] {
|
||||
const jarPath = settings.serverJar ?? "server.jar"
|
||||
const minRam = settings.minRam ?? 1024
|
||||
const maxRam = settings.maxRam ?? 4096
|
||||
const extraArgs: string[] = settings.javaArgs
|
||||
? settings.javaArgs.split(/\s+/).filter(Boolean)
|
||||
: []
|
||||
|
||||
return [
|
||||
"java",
|
||||
`-Xms${minRam}M`,
|
||||
`-Xmx${maxRam}M`,
|
||||
...extraArgs,
|
||||
"-jar",
|
||||
jarPath,
|
||||
"--nogui",
|
||||
]
|
||||
}
|
||||
|
||||
private async readStream(
|
||||
stream: ReadableStream<Uint8Array> | null,
|
||||
_tag: string,
|
||||
): Promise<void> {
|
||||
if (!stream) return
|
||||
const reader = stream.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let partial = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
partial += chunk
|
||||
|
||||
const lines = partial.split("\n")
|
||||
partial = lines.pop() ?? ""
|
||||
|
||||
for (const line of lines) {
|
||||
this.pushLine(line)
|
||||
}
|
||||
}
|
||||
// Flush remaining partial content
|
||||
if (partial) this.pushLine(partial)
|
||||
} catch {
|
||||
// Stream closed - normal during shutdown
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
private pushLine(line: string): void {
|
||||
// Ring buffer
|
||||
this.outputBuffer.push(line)
|
||||
if (this.outputBuffer.length > RING_BUFFER_SIZE) {
|
||||
this.outputBuffer.shift()
|
||||
}
|
||||
|
||||
this.emit("output", line)
|
||||
for (const cb of this.outputCallbacks) {
|
||||
try {
|
||||
cb(line)
|
||||
} catch {
|
||||
// Ignore callback errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async watchExit(): Promise<void> {
|
||||
if (!this.process) return
|
||||
const exitCode = await this.process.exited
|
||||
|
||||
const wasRunning = this.process !== null
|
||||
this.process = null
|
||||
this.startedAt = null
|
||||
|
||||
await rconClient.disconnect().catch(() => {})
|
||||
|
||||
if (wasRunning) {
|
||||
this.emit("stopped", { exitCode })
|
||||
console.log(`[MC] Server stopped with exit code ${exitCode}`)
|
||||
|
||||
if (!this.isIntentionalStop && this.restartOnCrash) {
|
||||
this.emit("crash", { exitCode })
|
||||
console.warn(`[MC] Server crashed (exit ${exitCode}), restarting in 5 s…`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||
try {
|
||||
await this.start()
|
||||
} catch (err) {
|
||||
console.error("[MC] Auto-restart failed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mcProcessManager = new McProcessManager()
|
||||
129
lib/minecraft/rcon.ts
Normal file
129
lib/minecraft/rcon.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Rcon } from "rcon-client"
|
||||
import { db } from "@/lib/db/index"
|
||||
import { serverSettings } from "@/lib/db/schema"
|
||||
|
||||
// Shell metacharacters that must never appear in RCON commands
|
||||
const SHELL_METACHAR_RE = /[;&|`$<>\\(){}\[\]!#~]/
|
||||
|
||||
export class RconManager {
|
||||
private client: Rcon | null = null
|
||||
private connecting = false
|
||||
private retryCount = 0
|
||||
private readonly maxRetries = 5
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
/** True when the underlying Rcon socket is open */
|
||||
isConnected(): boolean {
|
||||
return this.client !== null && (this.client as unknown as { authenticated: boolean }).authenticated === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the Minecraft RCON server using credentials stored in the DB.
|
||||
* Resolves when the handshake succeeds, rejects after maxRetries failures.
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.isConnected()) return
|
||||
if (this.connecting) return
|
||||
|
||||
this.connecting = true
|
||||
try {
|
||||
const settings = await db.select().from(serverSettings).limit(1)
|
||||
const cfg = settings[0]
|
||||
if (!cfg) throw new Error("No server settings found in database")
|
||||
|
||||
const rconHost = process.env.MC_RCON_HOST ?? "127.0.0.1"
|
||||
const rconPort = cfg.rconPort ?? 25575
|
||||
const rconPassword = cfg.rconPassword ?? process.env.MC_RCON_PASSWORD ?? ""
|
||||
|
||||
this.client = new Rcon({
|
||||
host: rconHost,
|
||||
port: rconPort,
|
||||
password: rconPassword,
|
||||
timeout: 5000,
|
||||
})
|
||||
|
||||
await this.client.connect()
|
||||
this.retryCount = 0
|
||||
console.log(`[RCON] Connected to ${rconHost}:${rconPort}`)
|
||||
} finally {
|
||||
this.connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Cleanly close the RCON socket */
|
||||
async disconnect(): Promise<void> {
|
||||
if (this.retryTimer) {
|
||||
clearTimeout(this.retryTimer)
|
||||
this.retryTimer = null
|
||||
}
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.end()
|
||||
} catch {
|
||||
// ignore errors during disconnect
|
||||
}
|
||||
this.client = null
|
||||
}
|
||||
console.log("[RCON] Disconnected")
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the Minecraft server via RCON.
|
||||
* Rejects if the command contains shell metacharacters to prevent injection.
|
||||
* Auto-reconnects if disconnected (up to maxRetries attempts with backoff).
|
||||
*/
|
||||
async sendCommand(command: string): Promise<string> {
|
||||
this.validateCommand(command)
|
||||
|
||||
if (!this.isConnected()) {
|
||||
await this.connectWithBackoff()
|
||||
}
|
||||
|
||||
if (!this.client) {
|
||||
throw new Error("RCON client is not connected")
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.client.send(command)
|
||||
return response
|
||||
} catch (err) {
|
||||
// Mark as disconnected and surface error
|
||||
this.client = null
|
||||
throw new Error(`RCON command failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private validateCommand(command: string): void {
|
||||
if (!command || typeof command !== "string") {
|
||||
throw new Error("Command must be a non-empty string")
|
||||
}
|
||||
if (command.length > 1024) {
|
||||
throw new Error("Command exceeds maximum length of 1024 characters")
|
||||
}
|
||||
if (SHELL_METACHAR_RE.test(command)) {
|
||||
throw new Error("Command contains disallowed characters")
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithBackoff(): Promise<void> {
|
||||
while (this.retryCount < this.maxRetries) {
|
||||
const delay = Math.min(1000 * 2 ** this.retryCount, 30_000)
|
||||
this.retryCount++
|
||||
console.warn(`[RCON] Reconnecting (attempt ${this.retryCount}/${this.maxRetries}) in ${delay}ms…`)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
try {
|
||||
await this.connect()
|
||||
return
|
||||
} catch (err) {
|
||||
console.error(`[RCON] Reconnect attempt ${this.retryCount} failed:`, err)
|
||||
}
|
||||
}
|
||||
throw new Error(`RCON failed to reconnect after ${this.maxRetries} attempts`)
|
||||
}
|
||||
}
|
||||
|
||||
export const rconClient = new RconManager()
|
||||
104
lib/minecraft/sync.ts
Normal file
104
lib/minecraft/sync.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Syncs Minecraft server data (players, plugins) into the local database
|
||||
* by parsing server logs and using RCON commands.
|
||||
*/
|
||||
|
||||
import { db } from "@/lib/db";
|
||||
import { mcPlayers, plugins } from "@/lib/db/schema";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
/** Parse the player list from the RCON "list" command response. */
|
||||
export async function syncOnlinePlayers(): Promise<void> {
|
||||
try {
|
||||
const response = await rconClient.sendCommand("list");
|
||||
// Response format: "There are X of a max of Y players online: player1, player2"
|
||||
const match = response.match(/players online: (.*)$/);
|
||||
if (!match) return;
|
||||
|
||||
const onlineNames = match[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Mark all players as offline first
|
||||
await db
|
||||
.update(mcPlayers)
|
||||
.set({ isOnline: false })
|
||||
.where(eq(mcPlayers.isOnline, true));
|
||||
|
||||
// Mark online players
|
||||
for (const name of onlineNames) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(mcPlayers)
|
||||
.where(eq(mcPlayers.username, name))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(mcPlayers)
|
||||
.set({ isOnline: true, lastSeen: Date.now() })
|
||||
.where(eq(mcPlayers.username, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// RCON might not be connected — ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse a log line and update player records accordingly. */
|
||||
export function parseLogLine(
|
||||
line: string,
|
||||
onPlayerJoin?: (name: string) => void,
|
||||
onPlayerLeave?: (name: string) => void,
|
||||
): void {
|
||||
// "[HH:MM:SS] [Server thread/INFO]: PlayerName joined the game"
|
||||
const joinMatch = line.match(/\[.*\]: (\w+) joined the game/);
|
||||
if (joinMatch) {
|
||||
const name = joinMatch[1];
|
||||
upsertPlayer(name, { isOnline: true, lastSeen: Date.now() });
|
||||
onPlayerJoin?.(name);
|
||||
return;
|
||||
}
|
||||
|
||||
// "[HH:MM:SS] [Server thread/INFO]: PlayerName left the game"
|
||||
const leaveMatch = line.match(/\[.*\]: (\w+) left the game/);
|
||||
if (leaveMatch) {
|
||||
const name = leaveMatch[1];
|
||||
upsertPlayer(name, { isOnline: false, lastSeen: Date.now() });
|
||||
onPlayerLeave?.(name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertPlayer(
|
||||
username: string,
|
||||
data: Partial<typeof mcPlayers.$inferInsert>,
|
||||
): Promise<void> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(mcPlayers)
|
||||
.where(eq(mcPlayers.username, username))
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(mcPlayers)
|
||||
.set(data as Record<string, unknown>)
|
||||
.where(eq(mcPlayers.username, username));
|
||||
} else {
|
||||
await db.insert(mcPlayers).values({
|
||||
id: nanoid(),
|
||||
uuid: (data as { uuid?: string }).uuid ?? nanoid(), // placeholder until real UUID is known
|
||||
username,
|
||||
firstSeen: Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
isOnline: false,
|
||||
playTime: 0,
|
||||
isBanned: false,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
}
|
||||
324
lib/minecraft/versions.ts
Normal file
324
lib/minecraft/versions.ts
Normal file
@@ -0,0 +1,324 @@
|
||||
// ---- Types ------------------------------------------------------------------
|
||||
|
||||
export type ServerType = "vanilla" | "paper" | "spigot" | "fabric" | "forge" | "bedrock"
|
||||
|
||||
export interface VersionInfo {
|
||||
id: string
|
||||
type?: string
|
||||
releaseTime?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
// ---- In-memory cache --------------------------------------------------------
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data: T
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
|
||||
const cache = new Map<string, CacheEntry<unknown>>()
|
||||
|
||||
function cacheGet<T>(key: string): T | null {
|
||||
const entry = cache.get(key)
|
||||
if (!entry) return null
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
cache.delete(key)
|
||||
return null
|
||||
}
|
||||
return entry.data as T
|
||||
}
|
||||
|
||||
function cacheSet<T>(key: string, data: T): void {
|
||||
cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
|
||||
}
|
||||
|
||||
// ---- Vanilla ----------------------------------------------------------------
|
||||
|
||||
interface MojangManifest {
|
||||
latest: { release: string; snapshot: string }
|
||||
versions: Array<{
|
||||
id: string
|
||||
type: string
|
||||
url: string
|
||||
releaseTime: string
|
||||
sha1: string
|
||||
}>
|
||||
}
|
||||
|
||||
/** Fetch all versions from the official Mojang version manifest. */
|
||||
export async function fetchVanillaVersions(): Promise<VersionInfo[]> {
|
||||
const key = "vanilla:versions"
|
||||
const cached = cacheGet<VersionInfo[]>(key)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch(
|
||||
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json",
|
||||
)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Mojang manifest: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const manifest: MojangManifest = await res.json()
|
||||
const versions: VersionInfo[] = manifest.versions.map((v) => ({
|
||||
id: v.id,
|
||||
type: v.type,
|
||||
releaseTime: v.releaseTime,
|
||||
url: v.url,
|
||||
}))
|
||||
|
||||
cacheSet(key, versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// ---- Paper ------------------------------------------------------------------
|
||||
|
||||
interface PaperBuildsResponse {
|
||||
project_id: string
|
||||
project_name: string
|
||||
version: string
|
||||
builds: number[]
|
||||
}
|
||||
|
||||
/** Fetch all Paper MC versions from the PaperMC API. */
|
||||
export async function fetchPaperVersions(): Promise<VersionInfo[]> {
|
||||
const key = "paper:versions"
|
||||
const cached = cacheGet<VersionInfo[]>(key)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch("https://api.papermc.io/v2/projects/paper")
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Paper versions: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const data: { versions: string[] } = await res.json()
|
||||
const versions: VersionInfo[] = data.versions.map((id) => ({ id }))
|
||||
|
||||
cacheSet(key, versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// ---- Fabric -----------------------------------------------------------------
|
||||
|
||||
interface FabricGameVersion {
|
||||
version: string
|
||||
stable: boolean
|
||||
}
|
||||
|
||||
/** Fetch all Fabric-supported Minecraft versions. */
|
||||
export async function fetchFabricVersions(): Promise<VersionInfo[]> {
|
||||
const key = "fabric:versions"
|
||||
const cached = cacheGet<VersionInfo[]>(key)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch("https://meta.fabricmc.net/v2/versions/game")
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch Fabric versions: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const data: FabricGameVersion[] = await res.json()
|
||||
const versions: VersionInfo[] = data.map((v) => ({
|
||||
id: v.version,
|
||||
type: v.stable ? "release" : "snapshot",
|
||||
}))
|
||||
|
||||
cacheSet(key, versions)
|
||||
return versions
|
||||
}
|
||||
|
||||
// ---- Download URL resolution ------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the direct download URL for a given server type + version.
|
||||
* Throws if the type/version combination cannot be resolved.
|
||||
*/
|
||||
export async function getDownloadUrl(
|
||||
type: ServerType,
|
||||
version: string,
|
||||
): Promise<string> {
|
||||
validateVersion(version)
|
||||
|
||||
switch (type) {
|
||||
case "vanilla":
|
||||
return getVanillaDownloadUrl(version)
|
||||
case "paper":
|
||||
return getPaperDownloadUrl(version)
|
||||
case "fabric":
|
||||
return getFabricDownloadUrl(version)
|
||||
case "spigot":
|
||||
throw new Error(
|
||||
"Spigot cannot be downloaded directly; use BuildTools instead.",
|
||||
)
|
||||
case "forge":
|
||||
throw new Error(
|
||||
"Forge installers must be downloaded from files.minecraftforge.net.",
|
||||
)
|
||||
case "bedrock":
|
||||
throw new Error(
|
||||
"Bedrock server downloads require manual acceptance of Microsoft's EULA.",
|
||||
)
|
||||
default:
|
||||
throw new Error(`Unsupported server type: ${type}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function getVanillaDownloadUrl(version: string): Promise<string> {
|
||||
const versions = await fetchVanillaVersions()
|
||||
const entry = versions.find((v) => v.id === version)
|
||||
if (!entry?.url) throw new Error(`Vanilla version not found: ${version}`)
|
||||
|
||||
// The URL points to a version JSON; fetch it to get the server jar URL
|
||||
const cacheKey = `vanilla:jar-url:${version}`
|
||||
const cached = cacheGet<string>(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const res = await fetch(entry.url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch version manifest for ${version}: ${res.status}`)
|
||||
}
|
||||
|
||||
const versionData: {
|
||||
downloads: { server?: { url: string } }
|
||||
} = await res.json()
|
||||
|
||||
const url = versionData.downloads.server?.url
|
||||
if (!url) throw new Error(`No server download available for vanilla ${version}`)
|
||||
|
||||
cacheSet(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
|
||||
async function getPaperDownloadUrl(version: string): Promise<string> {
|
||||
const cacheKey = `paper:jar-url:${version}`
|
||||
const cached = cacheGet<string>(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// Get the latest build number for this version
|
||||
const buildsRes = await fetch(
|
||||
`https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}`,
|
||||
)
|
||||
if (!buildsRes.ok) {
|
||||
throw new Error(`Paper version ${version} not found: ${buildsRes.status}`)
|
||||
}
|
||||
|
||||
const buildsData: PaperBuildsResponse = await buildsRes.json()
|
||||
const latestBuild = buildsData.builds.at(-1)
|
||||
if (latestBuild === undefined) {
|
||||
throw new Error(`No builds found for Paper ${version}`)
|
||||
}
|
||||
|
||||
const url = `https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}/builds/${latestBuild}/downloads/paper-${version}-${latestBuild}.jar`
|
||||
cacheSet(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
|
||||
async function getFabricDownloadUrl(version: string): Promise<string> {
|
||||
const cacheKey = `fabric:jar-url:${version}`
|
||||
const cached = cacheGet<string>(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
// Get latest loader and installer versions
|
||||
const [loadersRes, installersRes] = await Promise.all([
|
||||
fetch("https://meta.fabricmc.net/v2/versions/loader"),
|
||||
fetch("https://meta.fabricmc.net/v2/versions/installer"),
|
||||
])
|
||||
|
||||
if (!loadersRes.ok || !installersRes.ok) {
|
||||
throw new Error("Failed to fetch Fabric loader/installer versions")
|
||||
}
|
||||
|
||||
const loaders: Array<{ version: string; stable: boolean }> = await loadersRes.json()
|
||||
const installers: Array<{ version: string; stable: boolean }> = await installersRes.json()
|
||||
|
||||
const latestLoader = loaders.find((l) => l.stable)
|
||||
const latestInstaller = installers.find((i) => i.stable)
|
||||
|
||||
if (!latestLoader || !latestInstaller) {
|
||||
throw new Error("Could not determine latest stable Fabric loader/installer")
|
||||
}
|
||||
|
||||
const url = `https://meta.fabricmc.net/v2/versions/loader/${encodeURIComponent(version)}/${encodeURIComponent(latestLoader.version)}/${encodeURIComponent(latestInstaller.version)}/server/jar`
|
||||
cacheSet(cacheKey, url)
|
||||
return url
|
||||
}
|
||||
|
||||
// ---- Download ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Download a server jar to `destPath`.
|
||||
* @param onProgress - optional callback receiving bytes downloaded and total bytes
|
||||
*/
|
||||
export async function downloadServer(
|
||||
type: ServerType,
|
||||
version: string,
|
||||
destPath: string,
|
||||
onProgress?: (downloaded: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
validateDestPath(destPath)
|
||||
|
||||
const url = await getDownloadUrl(type, version)
|
||||
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to download server jar: ${res.status} ${res.statusText}`)
|
||||
}
|
||||
|
||||
const contentLength = Number(res.headers.get("content-length") ?? "0")
|
||||
const body = res.body
|
||||
if (!body) throw new Error("Response body is empty")
|
||||
|
||||
const file = Bun.file(destPath)
|
||||
const writer = file.writer()
|
||||
|
||||
let downloaded = 0
|
||||
const reader = body.getReader()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
writer.write(value)
|
||||
downloaded += value.byteLength
|
||||
|
||||
if (onProgress && contentLength > 0) {
|
||||
onProgress(downloaded, contentLength)
|
||||
}
|
||||
}
|
||||
await writer.end()
|
||||
} catch (err) {
|
||||
writer.end()
|
||||
throw err
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Versions] Downloaded ${type} ${version} → ${destPath} (${downloaded} bytes)`,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Input validation -------------------------------------------------------
|
||||
|
||||
/** Minecraft version strings are like "1.21.4", "24w44a" - allow alphanumeric + dots + dashes */
|
||||
function validateVersion(version: string): void {
|
||||
if (!version || typeof version !== "string") {
|
||||
throw new Error("Version must be a non-empty string")
|
||||
}
|
||||
if (!/^[\w.\-+]{1,64}$/.test(version)) {
|
||||
throw new Error(`Invalid version string: ${version}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Prevent path traversal in destination paths */
|
||||
function validateDestPath(destPath: string): void {
|
||||
if (!destPath || typeof destPath !== "string") {
|
||||
throw new Error("Destination path must be a non-empty string")
|
||||
}
|
||||
if (destPath.includes("..")) {
|
||||
throw new Error("Destination path must not contain '..'")
|
||||
}
|
||||
if (!destPath.endsWith(".jar")) {
|
||||
throw new Error("Destination path must end with .jar")
|
||||
}
|
||||
}
|
||||
75
lib/scheduler/index.ts
Normal file
75
lib/scheduler/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Task scheduler using node-cron.
|
||||
* Loads enabled tasks from the DB on startup and registers them.
|
||||
*/
|
||||
|
||||
import cron, { ScheduledTask } from "node-cron";
|
||||
import { db } from "@/lib/db";
|
||||
import { scheduledTasks } from "@/lib/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { rconClient } from "@/lib/minecraft/rcon";
|
||||
import { sanitizeRconCommand } from "@/lib/security/sanitize";
|
||||
|
||||
const activeJobs = new Map<string, ScheduledTask>();
|
||||
|
||||
/** Load all enabled tasks from DB and schedule them. */
|
||||
export async function initScheduler(): Promise<void> {
|
||||
const tasks = await db
|
||||
.select()
|
||||
.from(scheduledTasks)
|
||||
.where(eq(scheduledTasks.isEnabled, true));
|
||||
|
||||
for (const task of tasks) {
|
||||
scheduleTask(task.id, task.cronExpression, task.command);
|
||||
}
|
||||
}
|
||||
|
||||
/** Schedule a single task. Returns false if cron expression is invalid. */
|
||||
export function scheduleTask(
|
||||
id: string,
|
||||
expression: string,
|
||||
command: string,
|
||||
): boolean {
|
||||
if (!cron.validate(expression)) return false;
|
||||
|
||||
// Stop existing job if any
|
||||
stopTask(id);
|
||||
|
||||
const job = cron.schedule(expression, async () => {
|
||||
try {
|
||||
const safeCmd = sanitizeRconCommand(command);
|
||||
await rconClient.sendCommand(safeCmd);
|
||||
await db
|
||||
.update(scheduledTasks)
|
||||
.set({ lastRun: Date.now() })
|
||||
.where(eq(scheduledTasks.id, id));
|
||||
} catch (err) {
|
||||
console.error(`[Scheduler] Task ${id} failed:`, err);
|
||||
}
|
||||
});
|
||||
|
||||
activeJobs.set(id, job);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Stop and remove a scheduled task. */
|
||||
export function stopTask(id: string): void {
|
||||
const existing = activeJobs.get(id);
|
||||
if (existing) {
|
||||
existing.stop();
|
||||
activeJobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop all active jobs. */
|
||||
export function stopAllTasks(): void {
|
||||
for (const [id, job] of activeJobs.entries()) {
|
||||
job.stop();
|
||||
activeJobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** List active job IDs. */
|
||||
export function getActiveTaskIds(): string[] {
|
||||
return Array.from(activeJobs.keys());
|
||||
}
|
||||
59
lib/security/rateLimit.ts
Normal file
59
lib/security/rateLimit.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* In-memory rate limiter (per IP, per minute window).
|
||||
* For production at scale, replace with Redis-backed solution.
|
||||
*/
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
windowStart: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, RateLimitEntry>();
|
||||
const WINDOW_MS = 60_000; // 1 minute
|
||||
|
||||
// Cleanup stale entries every 5 minutes to prevent memory leaks
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store.entries()) {
|
||||
if (now - entry.windowStart > WINDOW_MS * 2) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}, 300_000);
|
||||
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
limit: number = parseInt(process.env.RATE_LIMIT_RPM ?? "100"),
|
||||
): { allowed: boolean; remaining: number; resetAt: number } {
|
||||
const now = Date.now();
|
||||
const entry = store.get(ip);
|
||||
|
||||
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
||||
store.set(ip, { count: 1, windowStart: now });
|
||||
return { allowed: true, remaining: limit - 1, resetAt: now + WINDOW_MS };
|
||||
}
|
||||
|
||||
if (entry.count >= limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: entry.windowStart + WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: limit - entry.count,
|
||||
resetAt: entry.windowStart + WINDOW_MS,
|
||||
};
|
||||
}
|
||||
|
||||
/** Extract real IP from request (handles proxies). */
|
||||
export function getClientIp(request: Request): string {
|
||||
return (
|
||||
request.headers.get("x-real-ip") ??
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
60
lib/security/sanitize.ts
Normal file
60
lib/security/sanitize.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Input sanitization utilities.
|
||||
* Prevents XSS, command injection, and path traversal attacks.
|
||||
*/
|
||||
|
||||
/** Shell metacharacters that must not appear in RCON commands sent to the OS. */
|
||||
const SHELL_UNSAFE = /[;&|`$(){}[\]<>\\'"*?!#~]/;
|
||||
|
||||
/**
|
||||
* Validate a Minecraft RCON command.
|
||||
* Commands go to the MC RCON protocol (not a shell), but we still strip
|
||||
* shell metacharacters as a defense-in-depth measure.
|
||||
*/
|
||||
export function sanitizeRconCommand(cmd: string): string {
|
||||
if (typeof cmd !== "string") throw new Error("Command must be a string");
|
||||
const trimmed = cmd.trim();
|
||||
if (trimmed.length === 0) throw new Error("Command cannot be empty");
|
||||
if (trimmed.length > 32767) throw new Error("Command too long");
|
||||
// RCON commands start with / or a bare word — never allow OS-level metacharacters
|
||||
if (SHELL_UNSAFE.test(trimmed)) {
|
||||
throw new Error("Command contains forbidden characters");
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a file system path relative to a base directory.
|
||||
* Prevents path traversal (e.g. "../../etc/passwd").
|
||||
*/
|
||||
export function sanitizeFilePath(
|
||||
inputPath: string,
|
||||
baseDir: string,
|
||||
): string {
|
||||
const path = require("node:path");
|
||||
const resolved = path.resolve(baseDir, inputPath);
|
||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||
throw new Error("Path traversal detected");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from user-provided strings to prevent stored XSS.
|
||||
* Use this before storing free-text fields in the database.
|
||||
*/
|
||||
export function stripHtml(input: string): string {
|
||||
return input.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
/** Validate a Minecraft UUID (8-4-4-4-12 hex). */
|
||||
export function isValidMcUuid(uuid: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
|
||||
uuid,
|
||||
);
|
||||
}
|
||||
|
||||
/** Validate a Minecraft username (3-16 chars, alphanumeric + underscore). */
|
||||
export function isValidMcUsername(name: string): boolean {
|
||||
return /^[a-zA-Z0-9_]{3,16}$/.test(name);
|
||||
}
|
||||
258
lib/socket/server.ts
Normal file
258
lib/socket/server.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type { Server, Socket } from "socket.io"
|
||||
import os from "node:os"
|
||||
import type { auth as AuthType } from "@/lib/auth/index"
|
||||
import { mcProcessManager } from "@/lib/minecraft/process"
|
||||
import { rconClient } from "@/lib/minecraft/rcon"
|
||||
|
||||
// Shell metacharacters - same set as rcon.ts for defence-in-depth
|
||||
const SHELL_METACHAR_RE = /[;&|`$<>\\(){}\[\]!#~]/
|
||||
|
||||
// ---- Auth middleware ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns a Socket.io middleware that validates the Better Auth session token
|
||||
* from the `better-auth.session_token` cookie (or the `auth` handshake header).
|
||||
*/
|
||||
function makeAuthMiddleware(auth: typeof AuthType) {
|
||||
return async (socket: Socket, next: (err?: Error) => void) => {
|
||||
try {
|
||||
// Prefer the cookie sent during the upgrade handshake
|
||||
const rawCookie: string =
|
||||
(socket.handshake.headers.cookie as string | undefined) ?? ""
|
||||
|
||||
const token = parseCookieToken(rawCookie) ?? socket.handshake.auth?.token
|
||||
|
||||
if (!token || typeof token !== "string") {
|
||||
return next(new Error("Authentication required"))
|
||||
}
|
||||
|
||||
// Use Better Auth's built-in session verification
|
||||
const session = await auth.api.getSession({
|
||||
headers: new Headers({
|
||||
cookie: `better-auth.session_token=${token}`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!session?.user) {
|
||||
return next(new Error("Invalid or expired session"))
|
||||
}
|
||||
|
||||
// Attach user info to the socket for later use
|
||||
;(socket.data as Record<string, unknown>).user = session.user
|
||||
next()
|
||||
} catch (err) {
|
||||
next(
|
||||
new Error(
|
||||
`Auth error: ${err instanceof Error ? err.message : "unknown"}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract the value of `better-auth.session_token` from a Cookie header string */
|
||||
function parseCookieToken(cookieHeader: string): string | null {
|
||||
for (const part of cookieHeader.split(";")) {
|
||||
const [rawKey, ...rest] = part.trim().split("=")
|
||||
if (rawKey?.trim() === "better-auth.session_token") {
|
||||
return decodeURIComponent(rest.join("=").trim())
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- /console namespace -----------------------------------------------------
|
||||
|
||||
function setupConsoleNamespace(io: Server): void {
|
||||
const consoleNsp = io.of("/console")
|
||||
|
||||
consoleNsp.on("connection", (socket: Socket) => {
|
||||
console.log(`[Socket /console] Client connected: ${socket.id}`)
|
||||
|
||||
// Send buffered output so the client gets historical lines immediately
|
||||
const history = mcProcessManager.getOutput()
|
||||
socket.emit("history", history)
|
||||
|
||||
// Stream live output
|
||||
const unsubscribe = mcProcessManager.onOutput((line: string) => {
|
||||
socket.emit("output", line)
|
||||
})
|
||||
|
||||
// Forward process lifecycle events to this client
|
||||
const onStarted = (data: unknown) => socket.emit("server:started", data)
|
||||
const onStopped = (data: unknown) => socket.emit("server:stopped", data)
|
||||
const onCrash = (data: unknown) => socket.emit("server:crash", data)
|
||||
|
||||
mcProcessManager.on("started", onStarted)
|
||||
mcProcessManager.on("stopped", onStopped)
|
||||
mcProcessManager.on("crash", onCrash)
|
||||
|
||||
// Handle commands sent by the client
|
||||
socket.on("command", async (rawCommand: unknown) => {
|
||||
if (typeof rawCommand !== "string" || !rawCommand.trim()) {
|
||||
socket.emit("error", { message: "Command must be a non-empty string" })
|
||||
return
|
||||
}
|
||||
|
||||
const command = rawCommand.trim()
|
||||
|
||||
if (SHELL_METACHAR_RE.test(command)) {
|
||||
socket.emit("error", { message: "Command contains disallowed characters" })
|
||||
return
|
||||
}
|
||||
|
||||
if (command.length > 1024) {
|
||||
socket.emit("error", { message: "Command too long" })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let response: string
|
||||
if (rconClient.isConnected()) {
|
||||
response = await rconClient.sendCommand(command)
|
||||
} else {
|
||||
// Fallback: write directly to stdin
|
||||
mcProcessManager.writeStdin(command)
|
||||
response = "(sent via stdin)"
|
||||
}
|
||||
socket.emit("command:response", { command, response })
|
||||
} catch (err) {
|
||||
socket.emit("error", {
|
||||
message: `Command failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`[Socket /console] Client disconnected: ${socket.id}`)
|
||||
unsubscribe()
|
||||
mcProcessManager.off("started", onStarted)
|
||||
mcProcessManager.off("stopped", onStopped)
|
||||
mcProcessManager.off("crash", onCrash)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- /monitoring namespace --------------------------------------------------
|
||||
|
||||
interface MonitoringStats {
|
||||
cpu: number
|
||||
ram: { usedMB: number; totalMB: number }
|
||||
uptime: number
|
||||
server: {
|
||||
running: boolean
|
||||
pid?: number
|
||||
uptimeSecs?: number
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute CPU usage as a percentage across all cores (averaged over a 100 ms window) */
|
||||
function getCpuPercent(): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
const cpus1 = os.cpus()
|
||||
|
||||
setTimeout(() => {
|
||||
const cpus2 = os.cpus()
|
||||
let idleDiff = 0
|
||||
let totalDiff = 0
|
||||
|
||||
for (let i = 0; i < cpus1.length; i++) {
|
||||
const t1 = cpus1[i]!.times
|
||||
const t2 = cpus2[i]!.times
|
||||
|
||||
const idle = t2.idle - t1.idle
|
||||
const total =
|
||||
t2.user + t2.nice + t2.sys + t2.idle + t2.irq -
|
||||
(t1.user + t1.nice + t1.sys + t1.idle + t1.irq)
|
||||
|
||||
idleDiff += idle
|
||||
totalDiff += total
|
||||
}
|
||||
|
||||
const percent = totalDiff === 0 ? 0 : (1 - idleDiff / totalDiff) * 100
|
||||
resolve(Math.round(percent * 10) / 10)
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
function setupMonitoringNamespace(io: Server): void {
|
||||
const monitoringNsp = io.of("/monitoring")
|
||||
|
||||
// Interval for the polling loop; only active when at least one client is connected
|
||||
let interval: ReturnType<typeof setInterval> | null = null
|
||||
let clientCount = 0
|
||||
|
||||
const startPolling = () => {
|
||||
if (interval) return
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
const [cpuPercent] = await Promise.all([getCpuPercent()])
|
||||
|
||||
const totalMem = os.totalmem()
|
||||
const freeMem = os.freemem()
|
||||
const usedMem = totalMem - freeMem
|
||||
|
||||
const status = mcProcessManager.getStatus()
|
||||
|
||||
const stats: MonitoringStats = {
|
||||
cpu: cpuPercent,
|
||||
ram: {
|
||||
usedMB: Math.round(usedMem / 1024 / 1024),
|
||||
totalMB: Math.round(totalMem / 1024 / 1024),
|
||||
},
|
||||
uptime: os.uptime(),
|
||||
server: {
|
||||
running: status.running,
|
||||
pid: status.pid,
|
||||
uptimeSecs: status.uptime,
|
||||
},
|
||||
}
|
||||
|
||||
monitoringNsp.emit("stats", stats)
|
||||
} catch (err) {
|
||||
console.error("[Socket /monitoring] Stats collection error:", err)
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
const stopPolling = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval)
|
||||
interval = null
|
||||
}
|
||||
}
|
||||
|
||||
monitoringNsp.on("connection", (socket: Socket) => {
|
||||
console.log(`[Socket /monitoring] Client connected: ${socket.id}`)
|
||||
clientCount++
|
||||
startPolling()
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
console.log(`[Socket /monitoring] Client disconnected: ${socket.id}`)
|
||||
clientCount--
|
||||
if (clientCount <= 0) {
|
||||
clientCount = 0
|
||||
stopPolling()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Public setup function --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Attach all Socket.io namespaces to the given `io` server.
|
||||
* Must be called after the HTTP server has been created.
|
||||
*/
|
||||
export function setupSocketServer(io: Server, auth: typeof AuthType): void {
|
||||
const authMiddleware = makeAuthMiddleware(auth)
|
||||
|
||||
// Apply auth middleware to both namespaces
|
||||
io.of("/console").use(authMiddleware)
|
||||
io.of("/monitoring").use(authMiddleware)
|
||||
|
||||
setupConsoleNamespace(io)
|
||||
setupMonitoringNamespace(io)
|
||||
|
||||
console.log("[Socket.io] Namespaces /console and /monitoring ready")
|
||||
}
|
||||
123
next.config.ts
123
next.config.ts
@@ -1,7 +1,128 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "crafatar.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "mc-heads.net",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "visage.surgeplay.com",
|
||||
pathname: "/**",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "minotar.net",
|
||||
pathname: "/**",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
async headers() {
|
||||
const cspDirectives = [
|
||||
"default-src 'self'",
|
||||
// Scripts: self + strict-dynamic (Turbopack compatible)
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
// Styles: self + unsafe-inline (required for Tailwind/CSS-in-JS in Next.js)
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
// Fonts
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
// Images: self + data URIs + MC avatar APIs
|
||||
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
||||
// Connect: self + WebSocket for Socket.io
|
||||
"connect-src 'self' ws: wss:",
|
||||
// Frames: allow same-origin (BlueMap) + configurable origins
|
||||
"frame-src 'self'",
|
||||
// Frame ancestors: only same origin (replaces X-Frame-Options)
|
||||
"frame-ancestors 'self'",
|
||||
// Workers: self + blob (xterm.js, Monaco)
|
||||
"worker-src 'self' blob:",
|
||||
// Media
|
||||
"media-src 'self'",
|
||||
// Manifest
|
||||
"manifest-src 'self'",
|
||||
// Object: none
|
||||
"object-src 'none'",
|
||||
// Base URI
|
||||
"base-uri 'self'",
|
||||
// Form actions
|
||||
"form-action 'self'",
|
||||
// Upgrade insecure requests in production
|
||||
...(process.env.NODE_ENV === "production"
|
||||
? ["upgrade-insecure-requests"]
|
||||
: []),
|
||||
].join("; ");
|
||||
|
||||
const securityHeaders = [
|
||||
{
|
||||
key: "Content-Security-Policy",
|
||||
value: cspDirectives,
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "strict-origin-when-cross-origin",
|
||||
},
|
||||
{
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), browsing-topics=()",
|
||||
},
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
source: "/(.*)",
|
||||
headers: securityHeaders,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// Turbopack config (Next.js 16 default bundler)
|
||||
turbopack: {},
|
||||
|
||||
// Disable powered-by header
|
||||
poweredByHeader: false,
|
||||
|
||||
// Enable strict mode
|
||||
reactStrictMode: true,
|
||||
|
||||
// Compress responses
|
||||
compress: true,
|
||||
|
||||
// Server actions body size limit
|
||||
experimental: {
|
||||
serverActions: {
|
||||
bodySizeLimit: "10mb",
|
||||
},
|
||||
},
|
||||
|
||||
// Remove 'import crypto' at top — not needed in static headers
|
||||
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -24,8 +24,10 @@
|
||||
"better-auth": "^1.5.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"lucide-react": "^0.577.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.1.6",
|
||||
@@ -57,6 +59,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/semver": "^7.7.1",
|
||||
"bun-types": "^1.3.10",
|
||||
"drizzle-kit": "^0.31.9",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
|
||||
201
proxy.ts
Normal file
201
proxy.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import crypto from "crypto";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Simple in-memory rate limiter (resets on cold start / per-instance)
|
||||
// For production use a Redis-backed store via @upstash/ratelimit or similar.
|
||||
// ---------------------------------------------------------------------------
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
}
|
||||
|
||||
const rateLimitMap = new Map<string, RateLimitEntry>();
|
||||
const RATE_LIMIT_MAX = 100; // requests per window
|
||||
const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
|
||||
|
||||
function checkRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitMap.get(ip);
|
||||
|
||||
if (!entry || now > entry.resetAt) {
|
||||
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
|
||||
return true; // allowed
|
||||
}
|
||||
|
||||
if (entry.count >= RATE_LIMIT_MAX) {
|
||||
return false; // blocked
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
return true; // allowed
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths that bypass middleware entirely
|
||||
// ---------------------------------------------------------------------------
|
||||
const PUBLIC_PATH_PREFIXES = [
|
||||
"/api/auth",
|
||||
"/_next",
|
||||
"/favicon.ico",
|
||||
"/public",
|
||||
];
|
||||
|
||||
const STATIC_EXTENSIONS = /\.(png|jpe?g|gif|svg|ico|webp|woff2?|ttf|otf|eot|css|js\.map)$/i;
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
if (STATIC_EXTENSIONS.test(pathname)) return true;
|
||||
return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Security headers applied to every response
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildCSP(nonce: string): string {
|
||||
return [
|
||||
"default-src 'self'",
|
||||
`script-src 'self' 'nonce-${nonce}'`,
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com data:",
|
||||
"img-src 'self' data: blob: https://crafatar.com https://mc-heads.net https://visage.surgeplay.com https://minotar.net",
|
||||
"connect-src 'self' ws: wss:",
|
||||
"frame-src 'self'",
|
||||
"frame-ancestors 'self'",
|
||||
"worker-src 'self' blob:",
|
||||
"media-src 'self'",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
function applySecurityHeaders(response: NextResponse, nonce: string): void {
|
||||
const csp = buildCSP(nonce);
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
response.headers.set("X-Frame-Options", "SAMEORIGIN");
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
"camera=(), microphone=(), geolocation=(), browsing-topics=()"
|
||||
);
|
||||
// Pass the nonce to pages so they can inject it into inline scripts
|
||||
response.headers.set("x-nonce", nonce);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
export async function proxy(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
// Skip middleware for public/static paths
|
||||
if (isPublicPath(pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
// Generate a fresh nonce for this request
|
||||
const nonce = crypto.randomBytes(16).toString("base64");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiting for API routes
|
||||
// ---------------------------------------------------------------------------
|
||||
if (pathname.startsWith("/api/")) {
|
||||
const ip =
|
||||
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
||||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
if (!checkRateLimit(ip)) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: "Too many requests. Please slow down." }),
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Retry-After": "60",
|
||||
"X-RateLimit-Limit": String(RATE_LIMIT_MAX),
|
||||
"X-RateLimit-Window": "60",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth guard for dashboard routes
|
||||
// ---------------------------------------------------------------------------
|
||||
if (pathname.startsWith("/(dashboard)") || isDashboardRoute(pathname)) {
|
||||
try {
|
||||
// Dynamically import auth to avoid circular deps at module init
|
||||
const { auth } = await import("@/lib/auth/index");
|
||||
const session = await auth.api.getSession({
|
||||
headers: request.headers,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
loginUrl.searchParams.set("callbackUrl", pathname);
|
||||
const redirectResponse = NextResponse.redirect(loginUrl);
|
||||
applySecurityHeaders(redirectResponse, nonce);
|
||||
return redirectResponse;
|
||||
}
|
||||
} catch {
|
||||
// If auth module is not yet available (during initial setup), allow through
|
||||
// In production this should not happen
|
||||
console.error("[middleware] Auth check failed – denying access");
|
||||
const loginUrl = new URL("/login", request.url);
|
||||
const redirectResponse = NextResponse.redirect(loginUrl);
|
||||
applySecurityHeaders(redirectResponse, nonce);
|
||||
return redirectResponse;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Allow the request through, attach security headers
|
||||
// ---------------------------------------------------------------------------
|
||||
const response = NextResponse.next();
|
||||
applySecurityHeaders(response, nonce);
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the pathname maps to a dashboard route.
|
||||
* Next.js route groups (dashboard) don't appear in the URL, so we protect
|
||||
* all non-auth, non-api routes that would render inside the dashboard layout.
|
||||
*/
|
||||
function isDashboardRoute(pathname: string): boolean {
|
||||
const AUTH_ROUTES = ["/login", "/register", "/forgot-password", "/reset-password", "/verify"];
|
||||
if (AUTH_ROUTES.some((r) => pathname.startsWith(r))) return false;
|
||||
if (pathname.startsWith("/api/")) return false;
|
||||
if (pathname === "/") return true;
|
||||
|
||||
const DASHBOARD_SEGMENTS = [
|
||||
"/dashboard",
|
||||
"/console",
|
||||
"/monitoring",
|
||||
"/scheduler",
|
||||
"/players",
|
||||
"/map",
|
||||
"/plugins",
|
||||
"/files",
|
||||
"/backups",
|
||||
"/settings",
|
||||
"/updates",
|
||||
"/team",
|
||||
"/audit",
|
||||
];
|
||||
return DASHBOARD_SEGMENTS.some((seg) => pathname.startsWith(seg));
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimisation)
|
||||
* - favicon.ico
|
||||
*/
|
||||
"/((?!_next/static|_next/image|favicon.ico).*)",
|
||||
],
|
||||
};
|
||||
99
server.ts
Normal file
99
server.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import next from "next"
|
||||
import { createServer } from "http"
|
||||
import { Server } from "socket.io"
|
||||
import { runMigrations } from "@/lib/db/migrate"
|
||||
import { mcProcessManager } from "@/lib/minecraft/process"
|
||||
import { setupSocketServer } from "@/lib/socket/server"
|
||||
import { auth } from "@/lib/auth/index"
|
||||
|
||||
const dev = process.env.NODE_ENV !== "production"
|
||||
const port = Number(process.env.PORT ?? 3000)
|
||||
const hostname = process.env.HOSTNAME ?? "0.0.0.0"
|
||||
|
||||
async function main(): Promise<void> {
|
||||
// ---- 1. Run DB migrations -------------------------------------------------
|
||||
console.log("[Server] Running database migrations…")
|
||||
await runMigrations()
|
||||
console.log("[Server] Migrations complete")
|
||||
|
||||
// ---- 2. Prepare Next.js app -----------------------------------------------
|
||||
const app = next({ dev, hostname, port })
|
||||
const handle = app.getRequestHandler()
|
||||
await app.prepare()
|
||||
console.log(`[Server] Next.js ready (${dev ? "development" : "production"})`)
|
||||
|
||||
// ---- 3. Create Node.js HTTP server ----------------------------------------
|
||||
const httpServer = createServer((req, res) => {
|
||||
handle(req, res).catch((err: unknown) => {
|
||||
console.error("[Server] Next.js handler error:", err)
|
||||
res.statusCode = 500
|
||||
res.end("Internal Server Error")
|
||||
})
|
||||
})
|
||||
|
||||
// ---- 4. Attach Socket.io --------------------------------------------------
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
// Restrict to same origin in production; allow all in dev for convenience
|
||||
origin: dev ? "*" : (process.env.NEXTAUTH_URL ?? `http://${hostname}:${port}`),
|
||||
credentials: true,
|
||||
},
|
||||
// Use websocket transport first, fall back to polling
|
||||
transports: ["websocket", "polling"],
|
||||
})
|
||||
|
||||
// Delegate namespace setup (auth middleware + event handlers) to the module
|
||||
setupSocketServer(io, auth)
|
||||
|
||||
// ---- 5. Start HTTP server -------------------------------------------------
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
httpServer.on("error", reject)
|
||||
httpServer.listen(port, hostname, () => {
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
console.log(`[Server] Listening on http://${hostname}:${port}`)
|
||||
|
||||
// ---- 6. Initialize Minecraft process manager ------------------------------
|
||||
// (Does NOT auto-start the MC server; that is controlled through the UI)
|
||||
console.log("[Server] McProcessManager initialized")
|
||||
|
||||
// ---- 7. Graceful shutdown -------------------------------------------------
|
||||
const shutdown = async (signal: string): Promise<never> => {
|
||||
console.log(`\n[Server] Received ${signal}, shutting down…`)
|
||||
|
||||
// Stop accepting new connections
|
||||
httpServer.close()
|
||||
|
||||
// Disconnect all Socket.io clients
|
||||
await io.close()
|
||||
|
||||
// Stop the Minecraft server gracefully if it is running
|
||||
const status = mcProcessManager.getStatus()
|
||||
if (status.running) {
|
||||
console.log("[Server] Stopping Minecraft server…")
|
||||
try {
|
||||
await mcProcessManager.stop(false)
|
||||
} catch {
|
||||
// If graceful stop fails, force kill
|
||||
try {
|
||||
await mcProcessManager.stop(true)
|
||||
} catch (err) {
|
||||
console.error("[Server] Force stop failed:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[Server] Goodbye.")
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"))
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error("[Server] Fatal startup error:", err)
|
||||
process.exit(1)
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user