Initial commit: Minecraft dashboard
Next.js 16 + Tailwind v4 + shadcn v4 dashboard for managing a modded Forge 1.20.1 server. Includes server controls, player management, mod manager with Modrinth search and dependency resolution, world backups, snapshots, analytics, logs, and chat bridge. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
dd69c17c3b
77 changed files with 7007 additions and 0 deletions
177
components/Analytics.tsx
Normal file
177
components/Analytics.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type MetricEntry = {
|
||||
ts: string;
|
||||
tps: number;
|
||||
ramUsedMB: number;
|
||||
ramTotalMB: number;
|
||||
cpuPercent: number;
|
||||
playersOnline: number;
|
||||
};
|
||||
|
||||
function Sparkline({
|
||||
data,
|
||||
color,
|
||||
max,
|
||||
height = 80,
|
||||
label,
|
||||
unit,
|
||||
currentValue,
|
||||
}: {
|
||||
data: number[];
|
||||
color: string;
|
||||
max?: number;
|
||||
height?: number;
|
||||
label: string;
|
||||
unit: string;
|
||||
currentValue: string;
|
||||
}) {
|
||||
if (data.length < 2) {
|
||||
return (
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{currentValue}
|
||||
<span className="text-xs text-muted-foreground ml-1">{unit}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-full" style={{ height }} />
|
||||
<p className="text-xs text-muted-foreground mt-2">Collecting data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const dataMax = max || Math.max(...data, 1);
|
||||
const w = 300;
|
||||
const points = data.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = height - (v / dataMax) * (height - 10) - 5;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const pathD = `M${points.join(" L")}`;
|
||||
const areaD = `${pathD} L${w},${height} L0,${height} Z`;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg bg-muted p-4">
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-lg font-bold">
|
||||
{currentValue}
|
||||
<span className="text-xs text-muted-foreground ml-1">{unit}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox={`0 0 ${w} ${height}`} className="w-full" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id={`grad-${label}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={color} stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d={areaD} fill={`url(#grad-${label})`} />
|
||||
<path d={pathD} fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Analytics() {
|
||||
const [hours, setHours] = useState(6);
|
||||
|
||||
const { data: metrics = [] } = useQuery<MetricEntry[]>({
|
||||
queryKey: ["analytics", hours],
|
||||
queryFn: () =>
|
||||
fetch(`/api/analytics?hours=${hours}`).then((r) => r.json()),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null;
|
||||
|
||||
const ranges = [
|
||||
{ label: "1h", value: 1 },
|
||||
{ label: "6h", value: 6 },
|
||||
{ label: "24h", value: 24 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Server Analytics</CardTitle>
|
||||
<CardDescription>
|
||||
{metrics.length} data points
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-lg w-fit shrink-0">
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setHours(r.value)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition ${
|
||||
hours === r.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Sparkline
|
||||
data={metrics.map((m) => m.tps)}
|
||||
color="#4ade80"
|
||||
max={22}
|
||||
label="TPS"
|
||||
unit=""
|
||||
currentValue={latest ? latest.tps.toFixed(1) : "-"}
|
||||
/>
|
||||
<Sparkline
|
||||
data={metrics.map((m) => m.ramUsedMB)}
|
||||
color="#60a5fa"
|
||||
label="RAM"
|
||||
unit="MB"
|
||||
currentValue={
|
||||
latest ? `${(latest.ramUsedMB / 1024).toFixed(1)} GB` : "-"
|
||||
}
|
||||
/>
|
||||
<Sparkline
|
||||
data={metrics.map((m) => m.cpuPercent)}
|
||||
color="#f59e0b"
|
||||
max={100}
|
||||
label="CPU"
|
||||
unit="%"
|
||||
currentValue={latest ? latest.cpuPercent.toFixed(0) : "-"}
|
||||
/>
|
||||
<Sparkline
|
||||
data={metrics.map((m) => m.playersOnline)}
|
||||
color="#a78bfa"
|
||||
label="Players"
|
||||
unit=""
|
||||
currentValue={latest ? latest.playersOnline.toString() : "0"}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
192
components/BackupManager.tsx
Normal file
192
components/BackupManager.tsx
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type Backup = {
|
||||
name: string;
|
||||
size: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function BackupManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
const { data: backups = [] } = useQuery<Backup[]>({
|
||||
queryKey: ["backups"],
|
||||
queryFn: () => fetch("/api/backups").then((r) => r.json()),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const createBackup = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch("/api/backups", { method: "POST" });
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResult({ ok: true, message: data.message || "Backup created" });
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setResult({ ok: false, message: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const restore = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
setConfirmRestore(null);
|
||||
const res = await fetch("/api/backups/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResult({ ok: data.success, message: data.message });
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setResult({ ok: false, message: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteBackup = useMutation({
|
||||
mutationFn: async (name: string) => {
|
||||
await fetch("/api/backups", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["backups"] });
|
||||
},
|
||||
});
|
||||
|
||||
const isBusy = createBackup.isPending || restore.isPending;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>World Backups</CardTitle>
|
||||
<CardDescription>
|
||||
Auto-backup every 6 hours. {backups.length} backup(s) stored.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => createBackup.mutate()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{createBackup.isPending ? "Creating..." : "Backup Now"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{result && (
|
||||
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
|
||||
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
|
||||
{result.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{restore.isPending && (
|
||||
<Alert className="border-blue-500/20 bg-blue-500/5">
|
||||
<AlertDescription className="text-blue-300 flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Restoring world backup... Server will restart. This may take a minute.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No backups yet. Click "Backup Now" to create one.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1 max-h-[300px] overflow-y-auto">
|
||||
{backups.map((b) => (
|
||||
<li
|
||||
key={b.name}
|
||||
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{b.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(b.createdAt).toLocaleString()} — {b.size}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
||||
{confirmRestore === b.name ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => restore.mutate(b.name)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9"
|
||||
>
|
||||
Confirm Restore
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
className="text-xs h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<a
|
||||
href={`/api/backups/download?name=${encodeURIComponent(b.name)}`}
|
||||
className="sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
<Button size="sm" variant="ghost" className="text-xs h-9">
|
||||
Download
|
||||
</Button>
|
||||
</a>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRestore(b.name)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteBackup.mutate(b.name)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
148
components/ChatBridge.tsx
Normal file
148
components/ChatBridge.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type ChatMessage = {
|
||||
time: string;
|
||||
type: "chat" | "join" | "leave" | "death" | "server";
|
||||
player: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function ChatBridge() {
|
||||
const queryClient = useQueryClient();
|
||||
const chatRef = useRef<HTMLDivElement>(null);
|
||||
const [message, setMessage] = useState("");
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const { data: messages = [] } = useQuery<ChatMessage[]>({
|
||||
queryKey: ["chat"],
|
||||
queryFn: () => fetch("/api/chat?lines=50").then((r) => r.json()),
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: async (msg: string) => {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: msg }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to send");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage("");
|
||||
autoScrollRef.current = true;
|
||||
setTimeout(() => queryClient.invalidateQueries({ queryKey: ["chat"] }), 500);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (chatRef.current && autoScrollRef.current) {
|
||||
chatRef.current.scrollTop = chatRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!chatRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = chatRef.current;
|
||||
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 60;
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const msg = message.trim();
|
||||
if (!msg) return;
|
||||
send.mutate(msg);
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
chat: "text-foreground",
|
||||
join: "text-emerald-400",
|
||||
leave: "text-amber-400",
|
||||
death: "text-red-400",
|
||||
server: "text-blue-400",
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Chat Bridge</CardTitle>
|
||||
<CardDescription>
|
||||
Live in-game chat — auto-refreshes every 3 seconds
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* Chat window */}
|
||||
<div
|
||||
ref={chatRef}
|
||||
onScroll={handleScroll}
|
||||
className="h-[250px] sm:h-[300px] overflow-y-auto rounded-lg border border-border bg-background p-3 space-y-0.5"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No chat messages yet
|
||||
</p>
|
||||
) : (
|
||||
messages.map((msg, i) => (
|
||||
<div key={`${msg.time}-${i}`} className="flex gap-2 text-sm leading-relaxed min-w-0">
|
||||
<span className="text-muted-foreground text-xs shrink-0 mt-0.5 font-mono">
|
||||
{msg.time}
|
||||
</span>
|
||||
{msg.type === "chat" ? (
|
||||
<span className={`${typeColors.chat} break-words min-w-0`}>
|
||||
<strong><{msg.player}></strong> {msg.message}
|
||||
</span>
|
||||
) : msg.type === "join" ? (
|
||||
<span className={`${typeColors.join} break-words`}>
|
||||
{msg.player} joined the game
|
||||
</span>
|
||||
) : msg.type === "leave" ? (
|
||||
<span className={`${typeColors.leave} break-words`}>
|
||||
{msg.player} left the game
|
||||
</span>
|
||||
) : msg.type === "death" ? (
|
||||
<span className={`${typeColors.death} break-words`}>
|
||||
{msg.player} {msg.message}
|
||||
</span>
|
||||
) : (
|
||||
<span className={`${typeColors.server} break-words`}>
|
||||
[Server] {msg.message}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Send message */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Send a message as [Server]..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSend();
|
||||
}}
|
||||
maxLength={256}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSend} disabled={!message.trim() || send.isPending}>
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
15
components/ClientOnly.tsx
Normal file
15
components/ClientOnly.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export function ClientOnly({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
75
components/DownloadCard.tsx
Normal file
75
components/DownloadCard.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function DownloadCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-base">Join the Server</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<a
|
||||
href="/download/installer"
|
||||
className={cn(buttonVariants({ size: "lg" }), "w-full min-h-[44px]")}
|
||||
>
|
||||
Windows (.bat)
|
||||
</a>
|
||||
<a
|
||||
href="/download/installer-linux"
|
||||
className={cn(buttonVariants({ variant: "outline", size: "lg" }), "w-full min-h-[44px]")}
|
||||
>
|
||||
Linux (.sh)
|
||||
</a>
|
||||
</div>
|
||||
<a
|
||||
href="/download/modpack"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "secondary", size: "lg" }),
|
||||
"w-full min-h-[44px]"
|
||||
)}
|
||||
>
|
||||
Download Mods Only (.zip)
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">How to install</h3>
|
||||
<ol className="list-decimal pl-5 space-y-1.5 text-muted-foreground text-sm">
|
||||
<li>
|
||||
Make sure you have{" "}
|
||||
<strong className="text-foreground">Minecraft Java Edition</strong>{" "}
|
||||
installed
|
||||
</li>
|
||||
<li>
|
||||
Download the{" "}
|
||||
<strong className="text-foreground">Installer</strong> for your OS
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Windows:</strong> double-click
|
||||
the .bat file.{" "}
|
||||
<strong className="text-foreground">Linux:</strong>{" "}
|
||||
run <code className="text-xs bg-muted px-1 py-0.5 rounded">bash install-modpack.sh</code>
|
||||
</li>
|
||||
<li>
|
||||
Open Minecraft, select{" "}
|
||||
<strong className="text-foreground">Forge 1.20.1</strong>
|
||||
</li>
|
||||
<li>
|
||||
Go to <strong className="text-foreground">Multiplayer</strong> and
|
||||
add{" "}
|
||||
<strong className="text-foreground">
|
||||
minecraft.hurkicorgi.com
|
||||
</strong>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
108
components/LogViewer.tsx
Normal file
108
components/LogViewer.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export function LogViewer() {
|
||||
const logRef = useRef<HTMLPreElement>(null);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lines, setLines] = useState(100);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
const { data, refetch, isFetching, isError, error } = useQuery<{ logs: string }>({
|
||||
queryKey: ["logs", lines],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/logs?lines=${lines}`);
|
||||
if (!res.ok) throw new Error(`Failed to load logs (${res.status})`);
|
||||
return res.json();
|
||||
},
|
||||
enabled: hasLoaded,
|
||||
refetchInterval: autoRefresh ? 3000 : false,
|
||||
});
|
||||
|
||||
// Auto-scroll only if user is near the bottom
|
||||
useEffect(() => {
|
||||
if (logRef.current && autoScrollRef.current) {
|
||||
logRef.current.scrollTop = logRef.current.scrollHeight;
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!logRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = logRef.current;
|
||||
autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 80;
|
||||
};
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
setHasLoaded(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<CardTitle>Console Logs</CardTitle>
|
||||
<CardDescription>Server output from journalctl</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={lines}
|
||||
onChange={(e) => {
|
||||
setLines(Number(e.target.value));
|
||||
setTimeout(() => refetch(), 50);
|
||||
}}
|
||||
className="h-10 rounded-md border border-input bg-muted px-2 text-sm text-foreground focus:outline-none"
|
||||
>
|
||||
<option value={50}>50 lines</option>
|
||||
<option value={100}>100 lines</option>
|
||||
<option value={200}>200 lines</option>
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={autoRefresh ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setAutoRefresh(!autoRefresh);
|
||||
if (!autoRefresh) autoScrollRef.current = true;
|
||||
}}
|
||||
className={`text-xs ${autoRefresh ? "animate-pulse" : ""}`}
|
||||
>
|
||||
{autoRefresh ? "Live" : "Auto"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching ? "..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre
|
||||
ref={logRef}
|
||||
onScroll={handleScroll}
|
||||
className={`rounded-lg border bg-background p-3 sm:p-4 h-[300px] sm:h-[450px] overflow-y-auto font-mono text-xs leading-relaxed whitespace-pre-wrap break-all ${
|
||||
isError ? "border-red-500/30 text-red-300" : "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{isError
|
||||
? `Failed to load logs: ${error instanceof Error ? error.message : "unknown error"}`
|
||||
: data?.logs || (isFetching ? "Loading logs..." : "No logs available.")}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
57
components/ModList.tsx
Normal file
57
components/ModList.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Mod = {
|
||||
modId: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
filename: string;
|
||||
size: string;
|
||||
};
|
||||
|
||||
export function ModList() {
|
||||
const { data: mods = [] } = useQuery<Mod[]>({
|
||||
queryKey: ["mods"],
|
||||
queryFn: () => fetch("/api/mods").then((r) => r.json()),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-base">
|
||||
Installed Mods ({mods.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="">
|
||||
<ul className="max-h-[350px] sm:max-h-[400px] overflow-y-auto -mx-1">
|
||||
{mods.map((mod, i) => (
|
||||
<li
|
||||
key={mod.filename}
|
||||
className={`flex justify-between items-center px-3 py-2.5 rounded-md ${
|
||||
i % 2 === 1 ? "bg-muted/50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{mod.displayName}
|
||||
</span>
|
||||
{mod.version && (
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0 hidden sm:inline-flex">
|
||||
{mod.version}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-3 tabular-nums">
|
||||
{mod.size}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
844
components/ModManager.tsx
Normal file
844
components/ModManager.tsx
Normal file
|
|
@ -0,0 +1,844 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { CheckCircle2, XCircle, Circle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────
|
||||
|
||||
type ModSide = "client" | "server" | "both";
|
||||
|
||||
type ModMeta = {
|
||||
modId: string;
|
||||
displayName: string;
|
||||
version: string;
|
||||
filename: string;
|
||||
size: string;
|
||||
side: ModSide;
|
||||
};
|
||||
|
||||
type SearchResult = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon_url: string;
|
||||
downloads: number;
|
||||
project_id: string;
|
||||
};
|
||||
|
||||
type ModDownload = {
|
||||
projectId: string;
|
||||
versionId: string;
|
||||
title: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
isDependency: boolean;
|
||||
alreadyInstalled: boolean;
|
||||
side: ModSide;
|
||||
};
|
||||
|
||||
type ResolveResult = {
|
||||
toInstall: ModDownload[];
|
||||
skipped: ModDownload[];
|
||||
conflicts: string[];
|
||||
};
|
||||
|
||||
type SnapshotInfo = {
|
||||
name: string;
|
||||
dirName: string;
|
||||
createdAt: string;
|
||||
modCount: number;
|
||||
mods: string[];
|
||||
};
|
||||
|
||||
type WizardStep = "idle" | "searching" | "reviewing" | "installing";
|
||||
|
||||
type TimelineStep = {
|
||||
id: string;
|
||||
label: string;
|
||||
status: "pending" | "active" | "done" | "error";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const INSTALL_STEPS_SERVER: Pick<TimelineStep, "id" | "label">[] = [
|
||||
{ id: "snapshot", label: "Create snapshot" },
|
||||
{ id: "download", label: "Download mods" },
|
||||
{ id: "restart", label: "Restart server" },
|
||||
{ id: "health", label: "Verify server" },
|
||||
{ id: "modpack", label: "Update modpack" },
|
||||
];
|
||||
|
||||
const INSTALL_STEPS_CLIENT: Pick<TimelineStep, "id" | "label">[] = [
|
||||
{ id: "snapshot", label: "Create snapshot" },
|
||||
{ id: "download", label: "Download mods" },
|
||||
{ id: "modpack", label: "Update modpack" },
|
||||
];
|
||||
|
||||
// ── Side Badge ──────────────────────────────────────────────
|
||||
|
||||
const sideConfig = {
|
||||
client: { label: "Client", className: "border-purple-500/30 text-purple-400" },
|
||||
server: { label: "Server", className: "border-orange-500/30 text-orange-400" },
|
||||
both: { label: "Both", className: "border-green-500/30 text-green-400" },
|
||||
} as const;
|
||||
|
||||
function SideBadge({ side }: { side: ModSide }) {
|
||||
const config = sideConfig[side];
|
||||
return (
|
||||
<Badge variant="outline" className={`text-xs px-1.5 py-0 ${config.className}`}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ───────────────────────────────────────────────
|
||||
|
||||
export function ModManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [step, setStep] = useState<WizardStep>("idle");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Map<string, SearchResult>>(new Map());
|
||||
const [resolved, setResolved] = useState<ResolveResult | null>(null);
|
||||
const [installStatus, setInstallStatus] = useState("");
|
||||
const [timelineSteps, setTimelineSteps] = useState<TimelineStep[]>([]);
|
||||
const [newlyInstalled, setNewlyInstalled] = useState<Set<string>>(new Set());
|
||||
const [installResult, setInstallResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
rolledBack?: boolean;
|
||||
} | null>(null);
|
||||
const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
|
||||
const [confirmRestore, setConfirmRestore] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
// Installed mods
|
||||
const { data: mods = [] } = useQuery<ModMeta[]>({
|
||||
queryKey: ["mods"],
|
||||
queryFn: () => fetch("/api/mods").then((r) => r.json()),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Search (debounced)
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(searchQuery);
|
||||
}, 300);
|
||||
return () => { if (debounceRef.current) clearTimeout(debounceRef.current); };
|
||||
}, [searchQuery]);
|
||||
|
||||
const { data: searchResults = [], isFetching: isSearching } = useQuery<SearchResult[]>({
|
||||
queryKey: ["mod-search", debouncedQuery],
|
||||
queryFn: () =>
|
||||
fetch(`/api/mods/search?q=${encodeURIComponent(debouncedQuery)}`).then((r) =>
|
||||
r.json()
|
||||
),
|
||||
enabled: debouncedQuery.length >= 2 && step === "searching",
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Snapshots
|
||||
const { data: snapshots = [] } = useQuery<SnapshotInfo[]>({
|
||||
queryKey: ["snapshots"],
|
||||
queryFn: () => fetch("/api/snapshots").then((r) => r.json()),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Resolve dependencies
|
||||
const resolve = useMutation({
|
||||
mutationFn: async () => {
|
||||
const projectIds = Array.from(selected.keys());
|
||||
const titles: Record<string, string> = {};
|
||||
selected.forEach((v, k) => (titles[k] = v.title));
|
||||
|
||||
const res = await fetch("/api/mods/resolve", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ projectIds, titles }),
|
||||
});
|
||||
return (await res.json()) as ResolveResult;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResolved(data);
|
||||
setStep("reviewing");
|
||||
},
|
||||
});
|
||||
|
||||
// Batch install (SSE streaming)
|
||||
const install = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!resolved) throw new Error("No resolved mods");
|
||||
|
||||
const modsToInstall = resolved.toInstall.filter((m) => !m.alreadyInstalled);
|
||||
const snapshotName = `before-${modsToInstall.map((m) => m.title).join("-").slice(0, 40)}`;
|
||||
const hasServerMods = modsToInstall.some((m) => m.side !== "client");
|
||||
|
||||
// Initialize timeline
|
||||
const steps = (hasServerMods ? INSTALL_STEPS_SERVER : INSTALL_STEPS_CLIENT)
|
||||
.map((s) => ({ ...s, status: "pending" as const }));
|
||||
setTimelineSteps(steps);
|
||||
setInstallStatus("Starting installation...");
|
||||
|
||||
const res = await fetch("/api/mods/batch-install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mods: modsToInstall, snapshotName }),
|
||||
});
|
||||
|
||||
if (!res.body) throw new Error("No response stream");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let finalResult: { success: boolean; message: string; installed?: string[]; rolledBack?: boolean } | null = null;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const frames = buffer.split("\n\n");
|
||||
buffer = frames.pop()!;
|
||||
|
||||
for (const frame of frames) {
|
||||
if (!frame.trim()) continue;
|
||||
const eventMatch = frame.match(/^event:\s*(\w+)/m);
|
||||
const dataMatch = frame.match(/^data:\s*(.+)/m);
|
||||
if (!eventMatch || !dataMatch) continue;
|
||||
|
||||
const event = eventMatch[1];
|
||||
const data = JSON.parse(dataMatch[1]);
|
||||
|
||||
if (event === "step") {
|
||||
setTimelineSteps((prev) => {
|
||||
const exists = prev.some((s) => s.id === data.id);
|
||||
if (exists) {
|
||||
return prev.map((s) =>
|
||||
s.id === data.id ? { ...s, status: data.status, message: data.message } : s
|
||||
);
|
||||
}
|
||||
// Dynamically add rollback step
|
||||
return [...prev, { id: data.id, label: "Rollback", status: data.status, message: data.message }];
|
||||
});
|
||||
setInstallStatus(data.message);
|
||||
} else if (event === "done") {
|
||||
finalResult = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResult) throw new Error("Stream ended without result");
|
||||
return finalResult;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setInstallStatus("");
|
||||
setInstallResult(data);
|
||||
if (data.success) {
|
||||
setStep("idle");
|
||||
setSelected(new Map());
|
||||
setResolved(null);
|
||||
setTimelineSteps([]);
|
||||
if (data.installed?.length) {
|
||||
setNewlyInstalled(new Set(data.installed));
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setInstallStatus("");
|
||||
setTimelineSteps([]);
|
||||
setInstallResult({ success: false, message: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
// Remove mod
|
||||
const removeMod = useMutation({
|
||||
mutationFn: async (filename: string) => {
|
||||
setConfirmRemove(null);
|
||||
const res = await fetch("/api/mods/remove", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setInstallResult(data);
|
||||
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Restore snapshot
|
||||
const restoreSnap = useMutation({
|
||||
mutationFn: async (dirName: string) => {
|
||||
setConfirmRestore(null);
|
||||
const res = await fetch("/api/snapshots/restore", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dirName }),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setInstallResult(data);
|
||||
queryClient.invalidateQueries({ queryKey: ["mods"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||
},
|
||||
});
|
||||
|
||||
// Delete snapshot
|
||||
const deleteSnap = useMutation({
|
||||
mutationFn: async (dirName: string) => {
|
||||
await fetch("/api/snapshots", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dirName }),
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["snapshots"] });
|
||||
},
|
||||
});
|
||||
|
||||
const toggleSelect = useCallback((result: SearchResult) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.has(result.project_id)) {
|
||||
next.delete(result.project_id);
|
||||
} else {
|
||||
next.set(result.project_id, result);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isBusy = install.isPending || removeMod.isPending || restoreSnap.isPending;
|
||||
|
||||
const formatDownloads = (n: number) => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return n.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ── Mod Manager Card ──────────────────────────────── */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Mod Manager</CardTitle>
|
||||
<CardDescription>
|
||||
Search Modrinth, auto-resolve dependencies, install with rollback safety
|
||||
</CardDescription>
|
||||
</div>
|
||||
{step === "idle" && (
|
||||
<Button onClick={() => setStep("searching")} disabled={isBusy}>
|
||||
Add Mods
|
||||
</Button>
|
||||
)}
|
||||
{step !== "idle" && step !== "installing" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setStep("idle");
|
||||
setSelected(new Map());
|
||||
setResolved(null);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Step indicator */}
|
||||
{step !== "idle" && (
|
||||
<ol className="flex items-center gap-2 text-xs">
|
||||
{[
|
||||
{ key: "searching", label: "Search" },
|
||||
{ key: "reviewing", label: "Review" },
|
||||
{ key: "installing", label: "Install" },
|
||||
].map((s, i, arr) => {
|
||||
const currentIdx = arr.findIndex((x) => x.key === step);
|
||||
const thisIdx = i;
|
||||
const state =
|
||||
thisIdx < currentIdx ? "done" : thisIdx === currentIdx ? "active" : "pending";
|
||||
return (
|
||||
<li key={s.key} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[10px] font-semibold ${
|
||||
state === "active"
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: state === "done"
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-300"
|
||||
: "border-border text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{thisIdx + 1}
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
state === "active"
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
{i < arr.length - 1 && (
|
||||
<span className="w-6 h-px bg-border mx-1" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{/* Result feedback */}
|
||||
{installResult && !isBusy && step === "idle" && (
|
||||
<Alert
|
||||
className={
|
||||
installResult.success
|
||||
? "border-emerald-500/20 bg-emerald-500/5"
|
||||
: "border-red-500/20 bg-red-500/5"
|
||||
}
|
||||
>
|
||||
<AlertDescription
|
||||
className={installResult.success ? "text-emerald-300" : "text-red-300"}
|
||||
>
|
||||
{installResult.message}
|
||||
{installResult.rolledBack && (
|
||||
<span className="block text-amber-300 mt-1 text-xs">
|
||||
Changes were automatically rolled back.
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ── Step 1: Search & Select ─────────────────── */}
|
||||
{step === "searching" && (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
placeholder="Search mods on Modrinth..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Selected queue */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Array.from(selected.values()).map((s) => (
|
||||
<Badge
|
||||
key={s.project_id}
|
||||
variant="secondary"
|
||||
className="gap-1 cursor-pointer hover:bg-destructive/20"
|
||||
onClick={() => toggleSelect(s)}
|
||||
>
|
||||
{s.title}
|
||||
<span className="text-muted-foreground">x</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search results */}
|
||||
{isSearching && (
|
||||
<p className="text-sm text-muted-foreground">Searching...</p>
|
||||
)}
|
||||
|
||||
{debouncedQuery.length >= 2 && (
|
||||
<ul className="space-y-1 max-h-[350px] overflow-y-auto">
|
||||
{searchResults.map((result) => {
|
||||
const isSelected = selected.has(result.project_id);
|
||||
return (
|
||||
<li
|
||||
key={result.project_id}
|
||||
onClick={() => toggleSelect(result)}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition ${
|
||||
isSelected
|
||||
? "bg-primary/10 border border-primary/30"
|
||||
: "bg-muted/50 hover:bg-muted border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{result.icon_url ? (
|
||||
<img
|
||||
src={result.icon_url}
|
||||
alt=""
|
||||
className="w-10 h-10 rounded-md shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-md bg-muted shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{result.title}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{formatDownloads(result.downloads)} downloads
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{result.description}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Badge variant="default" className="shrink-0 text-xs">
|
||||
Selected
|
||||
</Badge>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => resolve.mutate()}
|
||||
disabled={resolve.isPending}
|
||||
>
|
||||
{resolve.isPending
|
||||
? "Resolving dependencies..."
|
||||
: `Next (${selected.size} selected)`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 2: Review & Validate ───────────────── */}
|
||||
{step === "reviewing" && resolved && (
|
||||
<div className="space-y-4">
|
||||
{/* Conflicts */}
|
||||
{resolved.conflicts.length > 0 && (
|
||||
<Alert className="border-red-500/20 bg-red-500/5">
|
||||
<AlertDescription className="text-red-300">
|
||||
<strong>Issues found:</strong>
|
||||
<ul className="list-disc pl-4 mt-1 space-y-0.5">
|
||||
{resolved.conflicts.map((c, i) => (
|
||||
<li key={i}>{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Mods to install */}
|
||||
{resolved.toInstall.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2">
|
||||
Will be installed ({resolved.toInstall.length})
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{resolved.toInstall.map((mod) => (
|
||||
<li
|
||||
key={mod.projectId}
|
||||
className="px-3 py-2 rounded-md bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">
|
||||
{mod.title}
|
||||
</span>
|
||||
<SideBadge side={mod.side} />
|
||||
{mod.isDependency && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1.5 py-0 border-blue-500/30 text-blue-300"
|
||||
>
|
||||
dependency
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{mod.filename}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skipped (already installed) */}
|
||||
{resolved.skipped.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-2 text-muted-foreground">
|
||||
Already installed (skipped)
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{resolved.skipped.map((mod) => (
|
||||
<li
|
||||
key={mod.projectId}
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md bg-muted/30 opacity-60"
|
||||
>
|
||||
<span className="text-sm">{mod.title}</span>
|
||||
<SideBadge side={mod.side} />
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0">
|
||||
installed
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-between">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setStep("searching");
|
||||
setResolved(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setStep("installing");
|
||||
install.mutate();
|
||||
}}
|
||||
disabled={
|
||||
resolved.toInstall.length === 0 ||
|
||||
resolved.conflicts.length > 0
|
||||
}
|
||||
>
|
||||
Install {resolved.toInstall.length} mod(s)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Step 3: Installing (Timeline) ─────────── */}
|
||||
{step === "installing" && timelineSteps.length > 0 && (
|
||||
<div className="space-y-1.5 py-2">
|
||||
{timelineSteps.map((s) => (
|
||||
<div key={s.id} className="flex items-start gap-3 px-1">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
{s.status === "done" && (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-300" />
|
||||
)}
|
||||
{s.status === "active" && (
|
||||
<span className="flex h-4 w-4 items-center justify-center">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
{s.status === "error" && (
|
||||
<XCircle className="h-4 w-4 text-red-300" />
|
||||
)}
|
||||
{s.status === "pending" && (
|
||||
<Circle className="h-4 w-4 text-muted-foreground/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className={`text-sm font-medium ${
|
||||
s.status === "done" ? "text-emerald-300" :
|
||||
s.status === "active" ? "text-blue-300" :
|
||||
s.status === "error" ? "text-red-300" :
|
||||
"text-muted-foreground/60"
|
||||
}`}>
|
||||
{s.label}
|
||||
</p>
|
||||
{s.message && s.status === "active" && (
|
||||
<p className="text-xs text-muted-foreground">{s.message}</p>
|
||||
)}
|
||||
{s.message && s.status === "error" && (
|
||||
<p className="text-xs text-red-300/80">{s.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-xs text-muted-foreground px-1 pt-1">
|
||||
Do not close this page.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Installed mods list ─────────────────────── */}
|
||||
{step === "idle" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">
|
||||
Installed Mods ({mods.length})
|
||||
</h3>
|
||||
<ul className="max-h-[400px] overflow-y-auto space-y-1">
|
||||
{mods.map((mod) => (
|
||||
<li
|
||||
key={mod.filename}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">
|
||||
{mod.displayName}
|
||||
</span>
|
||||
{mod.version && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{mod.version}
|
||||
</Badge>
|
||||
)}
|
||||
<SideBadge side={mod.side} />
|
||||
{newlyInstalled.has(mod.filename) && (
|
||||
<Badge variant="outline" className="text-xs px-1.5 py-0 border-emerald-500/30 text-emerald-300">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{mod.filename} — {mod.size}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{confirmRemove === mod.filename ? (
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => removeMod.mutate(mod.filename)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRemove(null)}
|
||||
className="text-xs h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRemove(mod.filename)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition shrink-0"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ── Snapshots Card ────────────────────────────────── */}
|
||||
{step === "idle" && snapshots.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Snapshots</CardTitle>
|
||||
<CardDescription>
|
||||
Restore a previous mod configuration
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-1">
|
||||
{snapshots.map((snap) => (
|
||||
<li
|
||||
key={snap.dirName}
|
||||
className="flex items-center justify-between px-3 py-2.5 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{snap.name}</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs px-1.5 py-0"
|
||||
>
|
||||
{snap.modCount} mods
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(snap.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
||||
{confirmRestore === snap.dirName ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => restoreSnap.mutate(snap.dirName)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9"
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRestore(null)}
|
||||
className="text-xs h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setConfirmRestore(snap.dirName)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteSnap.mutate(snap.dirName)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/Navbar.tsx
Normal file
40
components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-card">
|
||||
<div className="max-w-5xl mx-auto flex items-center justify-between px-3 sm:px-6 py-2.5 sm:py-3">
|
||||
<Link href="/" className="font-bold text-primary text-base sm:text-lg tracking-tight">
|
||||
HurkiCorgi MC
|
||||
</Link>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{session ? (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" render={<Link href="/admin" />}>
|
||||
Admin
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" render={<Link href="/login" />}>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
281
components/PlayerManager.tsx
Normal file
281
components/PlayerManager.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type PlayerData = {
|
||||
ops: { name: string; uuid: string; level: number }[];
|
||||
whitelist: { name: string; uuid: string }[];
|
||||
banned: { name: string; uuid: string; reason: string }[];
|
||||
};
|
||||
|
||||
type Tab = "ops" | "whitelist" | "banned";
|
||||
|
||||
export function PlayerManager() {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState<Tab>("ops");
|
||||
const [playerName, setPlayerName] = useState("");
|
||||
const [banReason, setBanReason] = useState("");
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
const { data = { ops: [], whitelist: [], banned: [] } } = useQuery<PlayerData>({
|
||||
queryKey: ["players"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/players");
|
||||
if (!res.ok) throw new Error("Failed to fetch players");
|
||||
return res.json();
|
||||
},
|
||||
staleTime: 10_000,
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: async (params: { action: string; player: string; reason?: string }) => {
|
||||
const res = await fetch("/api/players", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setResult({ ok: true, message: data.response || "Done" });
|
||||
setPlayerName("");
|
||||
setBanReason("");
|
||||
queryClient.invalidateQueries({ queryKey: ["players"] });
|
||||
},
|
||||
onError: (err) => {
|
||||
setResult({ ok: false, message: err.message });
|
||||
},
|
||||
});
|
||||
|
||||
const tabs: { key: Tab; label: string; count: number }[] = [
|
||||
{ key: "ops", label: "Operators", count: data.ops.length },
|
||||
{ key: "whitelist", label: "Whitelist", count: data.whitelist.length },
|
||||
{ key: "banned", label: "Banned", count: data.banned.length },
|
||||
];
|
||||
|
||||
const MC_NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
|
||||
const trimmed = playerName.trim();
|
||||
const nameValid = MC_NAME_RE.test(trimmed);
|
||||
const nameError =
|
||||
trimmed.length === 0
|
||||
? null
|
||||
: !nameValid
|
||||
? "3–16 chars, letters/numbers/underscores only"
|
||||
: null;
|
||||
|
||||
const handleAction = (act: string, player?: string) => {
|
||||
const name = player || trimmed;
|
||||
if (!name) return;
|
||||
if (!player && !nameValid) return;
|
||||
action.mutate({ action: act, player: name, reason: banReason || undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Player Management</CardTitle>
|
||||
<CardDescription>
|
||||
Manage operators, whitelist, and bans via RCON
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 bg-muted p-1 rounded-lg">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => { setTab(t.key); setResult(null); }}
|
||||
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition min-h-[40px] ${
|
||||
tab === t.key
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{t.count > 0 && (
|
||||
<Badge variant="secondary" className="ml-1.5 text-xs px-1.5 py-0">
|
||||
{t.count}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add player input */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Player name"
|
||||
value={playerName}
|
||||
onChange={(e) => setPlayerName(e.target.value)}
|
||||
aria-invalid={!!nameError}
|
||||
maxLength={16}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (tab === "ops") handleAction("op");
|
||||
else if (tab === "whitelist") handleAction("whitelist add");
|
||||
else if (tab === "banned") handleAction("ban");
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
{nameError && (
|
||||
<p className="text-xs text-red-300 mt-1">{nameError}</p>
|
||||
)}
|
||||
</div>
|
||||
{tab === "banned" && (
|
||||
<Input
|
||||
placeholder="Reason (optional)"
|
||||
value={banReason}
|
||||
onChange={(e) => setBanReason(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (tab === "ops") handleAction("op");
|
||||
else if (tab === "whitelist") handleAction("whitelist add");
|
||||
else if (tab === "banned") handleAction("ban");
|
||||
}}
|
||||
disabled={!nameValid || action.isPending}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{tab === "ops" ? "Add OP" : tab === "whitelist" ? "Add" : "Ban"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{result && (
|
||||
<Alert className={result.ok ? "border-emerald-500/20 bg-emerald-500/5" : "border-red-500/20 bg-red-500/5"}>
|
||||
<AlertDescription className={result.ok ? "text-emerald-300" : "text-red-300"}>
|
||||
{result.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Player lists */}
|
||||
{tab === "ops" && (
|
||||
<ul className="space-y-1">
|
||||
{data.ops.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground py-2 text-center">No operators</li>
|
||||
)}
|
||||
{data.ops.map((p) => (
|
||||
<li
|
||||
key={p.uuid || p.name}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={`https://mc-heads.net/avatar/${p.name}/24`}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded shrink-0"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||
<Badge variant="secondary" className="text-xs px-1.5 py-0 shrink-0">
|
||||
Lv{p.level}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAction("deop", p.name)}
|
||||
disabled={action.isPending}
|
||||
className="text-xs h-9 shrink-0 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Deop
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "whitelist" && (
|
||||
<ul className="space-y-1">
|
||||
{data.whitelist.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground py-2 text-center">Whitelist is empty</li>
|
||||
)}
|
||||
{data.whitelist.map((p) => (
|
||||
<li
|
||||
key={p.uuid || p.name}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<img
|
||||
src={`https://mc-heads.net/avatar/${p.name}/24`}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded shrink-0"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAction("whitelist remove", p.name)}
|
||||
disabled={action.isPending}
|
||||
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{tab === "banned" && (
|
||||
<ul className="space-y-1">
|
||||
{data.banned.length === 0 && (
|
||||
<li className="text-sm text-muted-foreground py-2 text-center">No banned players</li>
|
||||
)}
|
||||
{data.banned.map((p) => (
|
||||
<li
|
||||
key={p.uuid || p.name}
|
||||
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={`https://mc-heads.net/avatar/${p.name}/24`}
|
||||
alt=""
|
||||
className="w-6 h-6 rounded shrink-0"
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">{p.name}</span>
|
||||
</div>
|
||||
{p.reason && (
|
||||
<p className="text-xs text-muted-foreground truncate ml-8">{p.reason}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleAction("pardon", p.name)}
|
||||
disabled={action.isPending}
|
||||
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition"
|
||||
>
|
||||
Unban
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
287
components/ServerControls.tsx
Normal file
287
components/ServerControls.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
type ServerStatus = {
|
||||
online: boolean;
|
||||
starting?: boolean;
|
||||
players: { online: number; max: number };
|
||||
version?: string;
|
||||
motd?: string;
|
||||
};
|
||||
|
||||
type Action = "start" | "stop" | "restart";
|
||||
|
||||
const ACTION_LABEL: Record<Action, string> = {
|
||||
start: "Start",
|
||||
stop: "Stop",
|
||||
restart: "Restart",
|
||||
};
|
||||
|
||||
export function ServerControls() {
|
||||
const queryClient = useQueryClient();
|
||||
const [confirm, setConfirm] = useState<Action | null>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<ServerStatus>({
|
||||
queryKey: ["status"],
|
||||
queryFn: () => fetch("/api/status").then((r) => r.json()),
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const action = useMutation({
|
||||
mutationFn: async (act: Action) => {
|
||||
const res = await fetch(`/api/server/${act}`, { method: "POST" });
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Request failed");
|
||||
}
|
||||
return { ...(await res.json()), act };
|
||||
},
|
||||
onSuccess: () => {
|
||||
setTimeout(
|
||||
() => queryClient.invalidateQueries({ queryKey: ["status"] }),
|
||||
3000
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const isOnline = status?.online ?? false;
|
||||
const lastAction = action.data?.act as Action | undefined;
|
||||
|
||||
const trigger = (act: Action) => {
|
||||
if (act === "start") {
|
||||
action.mutate(act);
|
||||
} else {
|
||||
setConfirm(act);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRun = () => {
|
||||
if (confirm) {
|
||||
action.mutate(confirm);
|
||||
setConfirm(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle>Server Controls</CardTitle>
|
||||
<CardDescription className="hidden sm:block">Manage the Minecraft server process</CardDescription>
|
||||
</div>
|
||||
{isLoading || !status ? (
|
||||
<Skeleton className="h-5 w-20" />
|
||||
) : (
|
||||
<StatusBadge status={statusFromServer(status)} />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3">
|
||||
<div className="rounded-lg bg-muted p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">Players</p>
|
||||
{isLoading || !status ? (
|
||||
<Skeleton className="h-7 w-12 mx-auto" />
|
||||
) : (
|
||||
<p className="text-xl font-bold tabular-nums">
|
||||
{isOnline ? `${status.players.online}/${status.players.max}` : "-"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">Version</p>
|
||||
{isLoading || !status ? (
|
||||
<Skeleton className="h-5 w-16 mx-auto mt-1" />
|
||||
) : (
|
||||
<p className="text-sm font-semibold mt-1">{status.version || "-"}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-muted p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground mb-0.5">Address</p>
|
||||
<p className="text-xs font-mono font-semibold mt-1">
|
||||
minecraft.hurkicorgi.com
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation */}
|
||||
{confirm && (
|
||||
<Alert className="border-amber-500/30 bg-amber-500/5">
|
||||
<AlertDescription className="text-amber-300 flex items-center justify-between gap-3 flex-wrap">
|
||||
<span>
|
||||
{confirm === "stop"
|
||||
? "Stop the server? Players will be disconnected."
|
||||
: "Restart the server? Players will be disconnected briefly."}
|
||||
</span>
|
||||
<span className="flex gap-1">
|
||||
<Button size="sm" variant="destructive" onClick={confirmRun}>
|
||||
Confirm {ACTION_LABEL[confirm]}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => trigger("start")}
|
||||
disabled={action.isPending || isOnline || !!confirm}
|
||||
className="w-full border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200 disabled:opacity-40"
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => trigger("stop")}
|
||||
disabled={action.isPending || !isOnline || !!confirm}
|
||||
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-40"
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => trigger("restart")}
|
||||
disabled={action.isPending || !isOnline || !!confirm}
|
||||
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 hover:text-amber-200 disabled:opacity-40"
|
||||
>
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
{action.isPending && (
|
||||
<Alert>
|
||||
<AlertDescription className="text-muted-foreground flex items-center gap-2">
|
||||
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
|
||||
Sending {action.variables ? ACTION_LABEL[action.variables] : ""} command...
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{action.isSuccess && !action.isPending && (
|
||||
<Alert className="border-emerald-500/20 bg-emerald-500/5">
|
||||
<AlertDescription className="text-emerald-300">
|
||||
{lastAction ? `${ACTION_LABEL[lastAction]} command sent.` : "Command sent."} Status updates in a few seconds.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{action.isError && (
|
||||
<Alert className="border-red-500/20 bg-red-500/5">
|
||||
<AlertDescription className="text-red-300">
|
||||
{action.error.message}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Scheduled restart */}
|
||||
<ScheduledRestart />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduledRestart() {
|
||||
const { data: schedule } = useQuery<{ enabled: boolean; hour: number; minute: number }>({
|
||||
queryKey: ["schedule"],
|
||||
queryFn: () => fetch("/api/schedule").then((r) => r.json()),
|
||||
staleTime: 30_000,
|
||||
initialData: { enabled: false, hour: 4, minute: 0 },
|
||||
});
|
||||
|
||||
const [hour, setHour] = useState<number | null>(null);
|
||||
const [minute, setMinute] = useState<number | null>(null);
|
||||
|
||||
const h = hour ?? schedule.hour;
|
||||
const m = minute ?? schedule.minute;
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async (params: { enabled: boolean; hour: number; minute: number }) => {
|
||||
const res = await fetch("/api/schedule", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Scheduled Restart</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedule.enabled
|
||||
? `Daily at ${pad(schedule.hour)}:${pad(schedule.minute)} server time — warns players before restarting`
|
||||
: "Set a time for daily auto-restart with player warnings (uses server's local time)"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={h}
|
||||
onChange={(e) => setHour(parseInt(e.target.value))}
|
||||
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
<option key={i} value={i}>
|
||||
{pad(i)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-muted-foreground font-bold">:</span>
|
||||
<select
|
||||
value={m}
|
||||
onChange={(e) => setMinute(parseInt(e.target.value))}
|
||||
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
|
||||
>
|
||||
{[0, 15, 30, 45].map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{pad(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-muted-foreground">server time</span>
|
||||
{schedule.enabled ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => update.mutate({ enabled: false, hour: h, minute: m })}
|
||||
disabled={update.isPending}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => update.mutate({ enabled: true, hour: h, minute: m })}
|
||||
disabled={update.isPending}
|
||||
>
|
||||
Enable
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
components/StatusBadge.tsx
Normal file
40
components/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
type Status = "online" | "starting" | "offline";
|
||||
|
||||
const config = {
|
||||
online: {
|
||||
label: "Online",
|
||||
className: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
|
||||
dot: "bg-emerald-400 shadow-[0_0_6px_var(--color-emerald-400)]",
|
||||
pulse: false,
|
||||
},
|
||||
starting: {
|
||||
label: "Starting",
|
||||
className: "border-amber-500/30 bg-amber-500/10 text-amber-300",
|
||||
dot: "bg-amber-400",
|
||||
pulse: true,
|
||||
},
|
||||
offline: {
|
||||
label: "Offline",
|
||||
className: "border-red-500/30 bg-red-500/10 text-red-300",
|
||||
dot: "bg-red-400",
|
||||
pulse: false,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function StatusBadge({ status }: { status: Status }) {
|
||||
const c = config[status];
|
||||
return (
|
||||
<Badge variant="outline" className={`gap-1.5 ${c.className}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${c.dot} ${c.pulse ? "animate-pulse" : ""}`} />
|
||||
{c.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusFromServer(s: { online: boolean; starting?: boolean }): Status {
|
||||
if (s.online) return "online";
|
||||
if (s.starting) return "starting";
|
||||
return "offline";
|
||||
}
|
||||
102
components/StatusCard.tsx
Normal file
102
components/StatusCard.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
|
||||
|
||||
type ServerStatus = {
|
||||
online: boolean;
|
||||
starting?: boolean;
|
||||
players: { online: number; max: number };
|
||||
version?: string;
|
||||
motd?: string;
|
||||
};
|
||||
|
||||
export function StatusCard() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<ServerStatus>({
|
||||
queryKey: ["status"],
|
||||
queryFn: () => fetch("/api/status").then((r) => r.json()),
|
||||
refetchInterval: 15000,
|
||||
});
|
||||
|
||||
const copyIP = () => {
|
||||
navigator.clipboard.writeText("minecraft.hurkicorgi.com");
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-0">
|
||||
<CardTitle className="text-base">Server Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Status */}
|
||||
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-2">
|
||||
Status
|
||||
</p>
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-5 w-20 mx-auto" />
|
||||
) : (
|
||||
<StatusBadge status={statusFromServer(data)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Players */}
|
||||
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Players
|
||||
</p>
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-8 w-16 mx-auto" />
|
||||
) : (
|
||||
<p className="text-2xl font-bold tabular-nums">
|
||||
{data.online ? `${data.players.online}/${data.players.max}` : "0"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Version
|
||||
</p>
|
||||
{isLoading || !data ? (
|
||||
<Skeleton className="h-5 w-20 mx-auto" />
|
||||
) : (
|
||||
<p className="text-sm font-semibold">{data.version || "-"}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connect */}
|
||||
<div className="rounded-lg bg-muted p-3 sm:p-4 text-center">
|
||||
<p className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Connect
|
||||
</p>
|
||||
<button
|
||||
onClick={copyIP}
|
||||
className="w-full rounded-md border border-dashed border-border px-2 py-2 hover:border-primary/50 transition cursor-pointer min-h-[44px]"
|
||||
>
|
||||
<p className="font-mono text-xs font-semibold text-foreground">
|
||||
minecraft.hurkicorgi.com
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{copied ? (
|
||||
<span className="text-emerald-300">Copied!</span>
|
||||
) : (
|
||||
"tap to copy"
|
||||
)}
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
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-heading 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 }
|
||||
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 }
|
||||
58
components/ui/button.tsx
Normal file
58
components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 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",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
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 w-full min-w-0 overflow-x-clip flex-col gap-4 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-3 sm:px-4 min-w-0 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(
|
||||
"font-heading 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-3 sm:px-4 min-w-0 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,
|
||||
}
|
||||
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 }
|
||||
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 }
|
||||
10
components/ui/skeleton.tsx
Normal file
10
components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted-foreground/10", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 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 }
|
||||
Loading…
Add table
Add a link
Reference in a new issue