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:
hurkicorgi 2026-04-13 00:46:58 -06:00
commit dd69c17c3b
77 changed files with 7007 additions and 0 deletions

177
components/Analytics.tsx Normal file
View 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>
);
}

View 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 &quot;Backup Now&quot; 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
View 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>&lt;{msg.player}&gt;</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
View 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}</>;
}

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

View 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
? "316 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>
);
}

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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 }

View 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 }

View 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
View 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 }