🐛 fix: align dashboard with secured APIs

This commit is contained in:
2026-08-13 03:29:33 +07:00
parent e90dc8382b
commit 3f09e330be
47 changed files with 1280 additions and 397 deletions
+31 -8
View File
@@ -1,11 +1,15 @@
"use client";
import type * as React from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { FadeIn, Stagger, useShake } from "@/components/motion";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/cn";
type AuthFormCardProps = {
title: string;
description: string;
children: React.ReactNode;
leading?: React.ReactNode;
className?: string;
headerClassName?: string;
contentClassName?: string;
@@ -15,26 +19,45 @@ export function AuthFormCard({
title,
description,
children,
leading,
className,
headerClassName,
contentClassName,
}: AuthFormCardProps) {
return (
<Card className={cn("w-full max-w-md", className)}>
<CardHeader className={cn("space-y-3", headerClassName)}>
<CardTitle className="text-3xl tracking-[-0.035em]">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className={contentClassName}>{children}</CardContent>
</Card>
<FadeIn y={20} x={12} duration={0.65} className="flex w-full justify-center">
<Card className={cn("w-full max-w-md gap-0 overflow-hidden py-6", className)}>
<header className={cn("px-5 pb-6 sm:px-6", headerClassName)}>
<div className={cn(leading && "flex items-start gap-3.5")}>
{leading}
<div className="min-w-0">
<h2 className="text-[1.75rem] leading-none font-black tracking-[-0.045em]">
{title}
</h2>
<p className="mt-1.5 max-w-[34ch] text-sm leading-5 text-muted-foreground">
{description}
</p>
</div>
</div>
</header>
<CardContent className={contentClassName}>
<Stagger y={10} stagger={0.05} delay={0.12} selector=":scope > *, :scope > form > *">
{children}
</Stagger>
</CardContent>
</Card>
</FadeIn>
);
}
export function FormError({ message }: { message?: string | null }) {
const ref = useShake(message);
if (!message) return null;
return (
<div
ref={ref}
role="alert"
className="border-l-4 border-destructive bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
+81 -10
View File
@@ -1,22 +1,80 @@
"use client";
import { useRef } from "react";
import { cn } from "@/lib/cn";
import { gsap, motionDuration, motionEase, prefersReducedMotion, useGSAP } from "@/lib/motion";
type BrandMarkProps = {
className?: string;
/**
* Renders the mark for placement on a filled `primary` panel: the block reads
* in the panel's foreground colour instead of the brand green.
*/
inverted?: boolean;
};
export function BrandMark({ className, inverted = false }: BrandMarkProps) {
/**
* An isometric block on a 24-unit grid — three faces, no interior detail.
*
* The mark ships as small as 32px, so it is drawn with only the silhouette and
* the three face tones that separate it. Colour comes from `currentColor` plus
* two `color-mix` shades of it, so the block follows `text-primary` /
* `text-primary-foreground` and works on the sidebar, the light dashboard, and
* the filled primary panel from a single asset.
*/
function BrandGlyph({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
{/* Left face — mixed toward black for the shadowed side. */}
<path
d="M2.5 7 12 12.2 12 22.4 2.5 17.2Z"
fill="color-mix(in oklch, currentColor 62%, black)"
/>
{/* Right face — the mid tone. */}
<path
d="M21.5 7 12 12.2 12 22.4 21.5 17.2Z"
fill="color-mix(in oklch, currentColor 80%, black)"
/>
{/* Top face — full strength, so the mark keys off `currentColor` exactly. */}
<path d="M12 1.6 21.5 7 12 12.2 2.5 7Z" fill="currentColor" />
</svg>
);
}
export function BrandMark({ className, inverted }: BrandMarkProps) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el || prefersReducedMotion()) return;
const enter = () =>
gsap.to(el, { y: -2, duration: motionDuration.fast, ease: motionEase.out });
const leave = () =>
gsap.to(el, { y: 0, duration: motionDuration.fast, ease: motionEase.out });
el.addEventListener("pointerenter", enter);
el.addEventListener("pointerleave", leave);
return () => {
el.removeEventListener("pointerenter", enter);
el.removeEventListener("pointerleave", leave);
};
},
{ scope: ref }
);
return (
<div
ref={ref}
className={cn(
"grid size-11 shrink-0 place-items-center border font-mono text-lg font-black",
inverted
? "border-foreground bg-foreground text-background"
: "border-sidebar-primary bg-sidebar-primary text-sidebar-primary-foreground",
"relative grid size-11 shrink-0 place-items-center will-change-transform",
inverted ? "text-primary-foreground" : "text-primary",
className
)}
aria-hidden="true"
>
M
<BrandGlyph className="size-full" />
</div>
);
}
@@ -27,15 +85,28 @@ type BrandLockupProps = BrandMarkProps & {
textClassName?: string;
};
export function BrandLockup({ subtitle, compact, className, textClassName }: BrandLockupProps) {
export function BrandLockup({
subtitle,
compact,
className,
textClassName,
inverted,
}: BrandLockupProps) {
return (
<div className={cn("flex items-center gap-3", className)}>
<BrandMark className={compact ? "size-9 text-sm" : undefined} />
<BrandMark className={compact ? "size-9" : undefined} inverted={inverted} />
<div className={textClassName}>
<p className={cn("font-black uppercase tracking-[0.14em]", compact ? "text-base" : "text-lg")}>
<p
className={cn(
"font-black uppercase tracking-[0.14em]",
compact ? "text-base" : "text-lg"
)}
>
Minikura
</p>
<p className="font-mono text-[9px] uppercase tracking-[0.2em] text-current/45">{subtitle}</p>
<p className="font-mono text-[9px] uppercase tracking-[0.2em] text-current/45">
{subtitle}
</p>
</div>
</div>
);
+52 -7
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { BrandLockup } from "@/components/brand";
import { FadeIn } from "@/components/motion";
import { FullScreenLoader } from "@/components/page-layout";
import { ThemeToggle } from "@/components/theme-toggle";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -38,6 +39,7 @@ type NavigationGroup = {
icon: LucideIcon;
label: string;
context: string;
adminOnly?: boolean;
}>;
};
@@ -45,14 +47,34 @@ const navigation: NavigationGroup[] = [
{
label: "Operations",
items: [
{ href: "/dashboard/users", icon: Users, label: "Users", context: "Identity" },
{
href: "/dashboard/users",
icon: Users,
label: "Users",
context: "Identity",
adminOnly: true,
},
{ href: "/dashboard/servers", icon: Server, label: "Servers", context: "Workloads" },
{ href: "/dashboard/topology", icon: GitGraph, label: "Network", context: "Topology" },
{
href: "/dashboard/topology",
icon: GitGraph,
label: "Network",
context: "Topology",
adminOnly: true,
},
],
},
{
label: "Kubernetes",
items: [{ href: "/dashboard/k8s", icon: Network, label: "Resources", context: "Cluster" }],
items: [
{
href: "/dashboard/k8s",
icon: Network,
label: "Resources",
context: "Cluster",
adminOnly: true,
},
],
},
];
@@ -64,10 +86,29 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (!isPending && !session?.user) {
router.replace("/login");
return;
}
}, [session, isPending, router]);
if (
!isPending &&
session?.user.role !== "admin" &&
(pathname === "/dashboard/users" ||
pathname.startsWith("/dashboard/topology") ||
pathname.startsWith("/dashboard/k8s") ||
pathname.startsWith("/dashboard/servers/create") ||
pathname.startsWith("/dashboard/servers/edit"))
) {
router.replace("/dashboard/servers");
}
}, [session, isPending, pathname, router]);
if (isPending || !session?.user) return <FullScreenLoader />;
const isAdmin = session.user.role === "admin";
const visibleNavigation = navigation
.map((group) => ({
...group,
items: group.items.filter((item) => isAdmin || !item.adminOnly),
}))
.filter((group) => group.items.length > 0);
const handleSignOut = async () => {
await signOut();
@@ -80,7 +121,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
.map((n) => n[0])
.join("")
.toUpperCase() || "U";
const currentPage = navigation
const currentPage = visibleNavigation
.flatMap((group) => group.items)
.find((item) => pathname === item.href || pathname.startsWith(`${item.href}/`));
@@ -102,7 +143,7 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
/>
</SidebarHeader>
<SidebarContent className="py-4">
{navigation.map((group) => (
{visibleNavigation.map((group) => (
<SidebarGroup key={group.label} className="px-3">
<SidebarGroupLabel className="font-mono text-[9px] uppercase tracking-[0.2em]">
{group.label}
@@ -164,7 +205,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) {
</span>
<ThemeToggle className="ml-auto" />
</header>
<main className="min-w-0 flex-1 overflow-auto p-4 sm:p-6 lg:p-8">{children}</main>
<main className="min-w-0 flex-1 overflow-auto p-4 sm:p-6 lg:p-8">
<FadeIn key={pathname} y={10} duration={0.4}>
{children}
</FadeIn>
</main>
</SidebarInset>
</SidebarProvider>
);
+145
View File
@@ -0,0 +1,145 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { useRef } from "react";
import { cn } from "@/lib/cn";
import { gsap, motionDuration, motionEase, prefersReducedMotion, useGSAP } from "@/lib/motion";
type FadeInProps = ComponentProps<"div"> & {
delay?: number;
duration?: number;
y?: number;
x?: number;
};
export function FadeIn({
children,
className,
delay = 0,
duration = motionDuration.base,
y = 16,
x = 0,
...props
}: FadeInProps) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el) return;
if (prefersReducedMotion()) {
gsap.set(el, { autoAlpha: 1, x: 0, y: 0 });
return;
}
gsap.fromTo(
el,
{ autoAlpha: 0, x, y },
{ autoAlpha: 1, x: 0, y: 0, delay, duration, ease: motionEase.out }
);
},
{ scope: ref }
);
return (
<div ref={ref} className={className} {...props}>
{children}
</div>
);
}
type StaggerProps = {
children: ReactNode;
className?: string;
delay?: number;
stagger?: number;
duration?: number;
y?: number;
selector?: string;
};
export function Stagger({
children,
className,
delay = 0,
stagger = 0.06,
duration = motionDuration.base,
y = 14,
selector = ":scope > *",
}: StaggerProps) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const root = ref.current;
if (!root) return;
const items = root.querySelectorAll(selector);
if (!items.length) return;
if (prefersReducedMotion()) {
gsap.set(items, { autoAlpha: 1, y: 0 });
return;
}
gsap.fromTo(
items,
{ autoAlpha: 0, y },
{ autoAlpha: 1, y: 0, delay, duration, stagger, ease: motionEase.out }
);
},
{ scope: ref }
);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
type HoverLiftProps = {
children: ReactNode;
className?: string;
y?: number;
};
export function HoverLift({ children, className, y = -3 }: HoverLiftProps) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el || prefersReducedMotion()) return;
const enter = () => gsap.to(el, { y, duration: motionDuration.fast, ease: motionEase.out });
const leave = () =>
gsap.to(el, { y: 0, duration: motionDuration.fast, ease: motionEase.out });
el.addEventListener("pointerenter", enter);
el.addEventListener("pointerleave", leave);
return () => {
el.removeEventListener("pointerenter", enter);
el.removeEventListener("pointerleave", leave);
};
},
{ scope: ref }
);
return (
<div ref={ref} className={cn("will-change-transform", className)}>
{children}
</div>
);
}
export function useShake(trigger: string | null | undefined) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el || !trigger || prefersReducedMotion()) return;
gsap.fromTo(el, { x: -6 }, { x: 0, duration: 0.42, ease: "elastic.out(1, 0.4)" });
},
{ dependencies: [trigger], scope: ref }
);
return ref;
}
+96 -16
View File
@@ -1,6 +1,11 @@
import { Loader2 } from "lucide-react";
"use client";
import { useRef } from "react";
import type * as React from "react";
import { FadeIn } from "@/components/motion";
import { BrandMark } from "@/components/brand";
import { cn } from "@/lib/cn";
import { gsap, prefersReducedMotion, useGSAP } from "@/lib/motion";
type PageHeaderProps = {
eyebrow: string;
@@ -24,17 +29,19 @@ export function PageHeader({
className,
}: PageHeaderProps) {
return (
<header className={cn("page-heading", className)}>
<div className={cn(leading && "flex items-center gap-4")}>
{leading}
<div>
<span className="page-eyebrow">{eyebrow}</span>
<h1 className="page-title">{title}</h1>
{description && <p className="page-description">{description}</p>}
<FadeIn y={12} duration={0.45}>
<header className={cn("page-heading", className)}>
<div className={cn(leading && "flex items-center gap-4")}>
{leading}
<div>
<span className="page-eyebrow">{eyebrow}</span>
<h1 className="page-title">{title}</h1>
{description && <p className="page-description">{description}</p>}
</div>
</div>
</div>
{actions}
</header>
{actions}
</header>
</FadeIn>
);
}
@@ -58,7 +65,9 @@ export function StatePanel({
...props
}: StatePanelProps) {
return (
<div
<FadeIn
y={10}
duration={0.4}
className={cn(
"flex min-h-48 items-center justify-center border border-dashed bg-card/60 p-6 text-center",
className
@@ -67,7 +76,7 @@ export function StatePanel({
>
<div className="flex max-w-lg flex-col items-center gap-2">
{loading ? (
<Loader2 className="mb-2 size-7 animate-spin text-muted-foreground" />
<LoaderMark className="mb-2" />
) : (
icon && <div className="mb-2 text-muted-foreground">{icon}</div>
)}
@@ -75,16 +84,87 @@ export function StatePanel({
{description && <div className="text-sm text-muted-foreground">{description}</div>}
{action && <div className="mt-3">{action}</div>}
</div>
</FadeIn>
);
}
function LoaderMark({ className }: { className?: string }) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const el = ref.current;
if (!el || prefersReducedMotion()) return;
gsap.to(el, {
rotate: 180,
duration: 1.1,
repeat: -1,
yoyo: true,
ease: "power1.inOut",
});
},
{ scope: ref }
);
return (
<div ref={ref} className={cn("text-muted-foreground", className)}>
<BrandMark className="size-8 text-sm" />
</div>
);
}
export function FullScreenLoader({ label }: { label?: string }) {
const rootRef = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const root = rootRef.current;
if (!root || prefersReducedMotion()) return;
const mark = root.querySelector("[data-loader-mark]");
const ring = root.querySelector("[data-loader-ring]");
const copy = root.querySelector("[data-loader-copy]");
gsap.fromTo(
[mark, copy],
{ autoAlpha: 0, y: 8 },
{ autoAlpha: 1, y: 0, duration: 0.45, stagger: 0.08, ease: "power3.out" }
);
gsap.to(mark, {
scale: 1.06,
duration: 0.9,
repeat: -1,
yoyo: true,
ease: "sine.inOut",
});
gsap.to(ring, {
rotate: 360,
duration: 2.4,
repeat: -1,
ease: "none",
});
},
{ scope: rootRef }
);
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-sidebar text-sidebar-foreground">
<Loader2 className="size-8 animate-spin text-sidebar-primary" />
<div
ref={rootRef}
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-sidebar text-sidebar-foreground"
>
<div className="relative grid size-16 place-items-center">
<span
data-loader-ring=""
className="absolute inset-0 border border-sidebar-primary/35 border-t-sidebar-primary"
/>
<div data-loader-mark="">
<BrandMark className="size-11" />
</div>
</div>
{label && (
<span className="font-mono text-[10px] font-bold uppercase tracking-widest text-sidebar-foreground/50">
<span
data-loader-copy=""
className="font-mono text-[10px] font-bold uppercase tracking-widest text-sidebar-foreground/50"
>
{label}
</span>
)}
+23 -12
View File
@@ -1,4 +1,7 @@
"use client";
import type * as React from "react";
import { FadeIn } from "@/components/motion";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { cn } from "@/lib/cn";
@@ -21,19 +24,27 @@ export function SectionCard({
...props
}: SectionCardProps) {
return (
<Card className={className} {...props}>
<CardHeader className="border-b">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{icon}
<CardTitle>{title}</CardTitle>
<FadeIn y={14} duration={0.5}>
<Card
className={cn(
"transition-[border-color,box-shadow] duration-200 hover:border-foreground/40 hover:shadow-[4px_4px_0_color-mix(in_oklch,var(--foreground)_12%,transparent)]",
className
)}
{...props}
>
<CardHeader className="border-b">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
{icon}
<CardTitle>{title}</CardTitle>
</div>
{headerAction}
</div>
{headerAction}
</div>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className={contentClassName}>{children}</CardContent>
</Card>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent className={contentClassName}>{children}</CardContent>
</Card>
</FadeIn>
);
}
@@ -23,7 +23,11 @@ export function AdvancedPanel({
return (
<TabsContent value="advanced" className="space-y-4 mt-4">
<div className="grid grid-cols-3 gap-4">
<Field id="timezone" label="Timezone" tooltip="Timezone for server logs and scheduling (e.g., America/New_York)">
<Field
id="timezone"
label="Timezone"
tooltip="Timezone for server logs and scheduling (e.g., America/New_York)"
>
<Input
id="timezone"
value={formData.timezone}
@@ -11,7 +11,11 @@ export function ModsPanel({ formData, updateField }: ServerFormPanelProps) {
{formData.type === "CUSTOM" && (
<FormNotice>Mods/plugins automation is intended for Vanilla/Paper workflows.</FormNotice>
)}
<Field id="plugins" label="Plugins" tooltip="Comma-separated list of plugin URLs or filenames">
<Field
id="plugins"
label="Plugins"
tooltip="Comma-separated list of plugin URLs or filenames"
>
<Textarea
id="plugins"
value={formData.plugins || ""}
@@ -30,12 +30,18 @@ export function NetworkPanel({ formData, updateField }: ServerFormPanelProps) {
max="65535"
/>
</Field>
<Field id="serviceType" label="Service Type" tooltip="How the server is exposed in Kubernetes">
<Field
id="serviceType"
label="Service Type"
tooltip="How the server is exposed in Kubernetes"
>
<Select
value={formData.serviceType}
onValueChange={(value) => updateField("serviceType", value as ServiceType)}
>
<SelectTrigger id="serviceType"><SelectValue /></SelectTrigger>
<SelectTrigger id="serviceType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="CLUSTER_IP">ClusterIP (Internal Only)</SelectItem>
<SelectItem value="NODE_PORT">NodePort (External Access)</SelectItem>
@@ -51,7 +51,11 @@ export function PerformancePanel({ formData, updateField }: ServerFormPanelProps
rows={3}
/>
</Field>
<Field id="jvmXxOpts" label="JVM -XX Options" tooltip="Space-separated -XX JVM flags for advanced tuning">
<Field
id="jvmXxOpts"
label="JVM -XX Options"
tooltip="Space-separated -XX JVM flags for advanced tuning"
>
<Textarea
id="jvmXxOpts"
value={formData.jvmXxOpts || ""}
@@ -60,7 +64,11 @@ export function PerformancePanel({ formData, updateField }: ServerFormPanelProps
rows={2}
/>
</Field>
<Field id="jvmDdOpts" label="JVM -D System Properties" tooltip="Comma-separated key=value pairs for system properties">
<Field
id="jvmDdOpts"
label="JVM -D System Properties"
tooltip="Comma-separated key=value pairs for system properties"
>
<Textarea
id="jvmDdOpts"
value={formData.jvmDdOpts || ""}
@@ -10,7 +10,11 @@ export function ResourcesPanel({ formData, updateField }: ServerFormPanelProps)
{formData.type === "CUSTOM" && (
<FormNotice>Resource pack settings may not apply to custom jars.</FormNotice>
)}
<Field id="resourcePack" label="Resource Pack URL" tooltip="URL or path to a resource pack ZIP file">
<Field
id="resourcePack"
label="Resource Pack URL"
tooltip="URL or path to a resource pack ZIP file"
>
<Input
id="resourcePack"
value={formData.resourcePack || ""}
@@ -18,7 +22,11 @@ export function ResourcesPanel({ formData, updateField }: ServerFormPanelProps)
placeholder="https://example.com/resourcepack.zip"
/>
</Field>
<Field id="resourcePackSha1" label="Resource Pack SHA1" tooltip="SHA1 checksum of the resource pack for verification">
<Field
id="resourcePackSha1"
label="Resource Pack SHA1"
tooltip="SHA1 checksum of the resource pack for verification"
>
<Input
id="resourcePackSha1"
value={formData.resourcePackSha1 || ""}
@@ -34,7 +42,11 @@ export function ResourcesPanel({ formData, updateField }: ServerFormPanelProps)
>
Enforce Resource Pack
</CheckboxField>
<Field id="serverIcon" label="Server Icon URL" tooltip="URL or path to a server icon image (PNG, 64x64 recommended)">
<Field
id="serverIcon"
label="Server Icon URL"
tooltip="URL or path to a server icon image (PNG, 64x64 recommended)"
>
<Input
id="serverIcon"
value={formData.serverIcon || ""}
@@ -106,8 +106,13 @@ export function ServerPanel({ formData, updateField }: ServerFormPanelProps) {
<div className="grid grid-cols-2 gap-4">
<Field id="mode" label="Game Mode">
<Select value={formData.mode} onValueChange={(value) => updateField("mode", toMode(value))}>
<SelectTrigger id="mode"><SelectValue /></SelectTrigger>
<Select
value={formData.mode}
onValueChange={(value) => updateField("mode", toMode(value))}
>
<SelectTrigger id="mode">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="survival">Survival</SelectItem>
<SelectItem value="creative">Creative</SelectItem>
@@ -121,7 +126,9 @@ export function ServerPanel({ formData, updateField }: ServerFormPanelProps) {
value={formData.difficulty}
onValueChange={(value) => updateField("difficulty", toDifficulty(value))}
>
<SelectTrigger id="difficulty"><SelectValue /></SelectTrigger>
<SelectTrigger id="difficulty">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="peaceful">Peaceful</SelectItem>
<SelectItem value="easy">Easy</SelectItem>
@@ -156,19 +163,39 @@ export function ServerPanel({ formData, updateField }: ServerFormPanelProps) {
</div>
<div className="space-y-3">
<CheckboxField id="pvp" checked={formData.pvp} onCheckedChange={(value) => updateField("pvp", value)}>
<CheckboxField
id="pvp"
checked={formData.pvp}
onCheckedChange={(value) => updateField("pvp", value)}
>
Enable PvP (Player vs Player)
</CheckboxField>
<CheckboxField id="onlineMode" checked={formData.onlineMode} onCheckedChange={(value) => updateField("onlineMode", value)}>
<CheckboxField
id="onlineMode"
checked={formData.onlineMode}
onCheckedChange={(value) => updateField("onlineMode", value)}
>
Online Mode (Requires authenticated Minecraft accounts)
</CheckboxField>
<CheckboxField id="allowFlight" checked={formData.allowFlight} onCheckedChange={(value) => updateField("allowFlight", value)}>
<CheckboxField
id="allowFlight"
checked={formData.allowFlight}
onCheckedChange={(value) => updateField("allowFlight", value)}
>
Allow Flight
</CheckboxField>
<CheckboxField id="enableCommandBlock" checked={formData.enableCommandBlock} onCheckedChange={(value) => updateField("enableCommandBlock", value)}>
<CheckboxField
id="enableCommandBlock"
checked={formData.enableCommandBlock}
onCheckedChange={(value) => updateField("enableCommandBlock", value)}
>
Enable Command Blocks
</CheckboxField>
<CheckboxField id="hardcore" checked={formData.hardcore} onCheckedChange={(value) => updateField("hardcore", value)}>
<CheckboxField
id="hardcore"
checked={formData.hardcore}
onCheckedChange={(value) => updateField("hardcore", value)}
>
Hardcore Mode (Permanent Death)
</CheckboxField>
</div>
+1 -1
View File
@@ -92,7 +92,7 @@ export interface ServerFormData {
export type UpdateServerField = <K extends keyof ServerFormData>(
key: K,
value: ServerFormData[K],
value: ServerFormData[K]
) => void;
export interface ServerFormPanelProps {
@@ -45,7 +45,9 @@ export function WorldPanel({ formData, updateField }: ServerFormPanelProps) {
value={formData.levelType || "default"}
onValueChange={(value) => updateField("levelType", value === "default" ? "" : value)}
>
<SelectTrigger id="levelType"><SelectValue placeholder="Default" /></SelectTrigger>
<SelectTrigger id="levelType">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="flat">Flat/Superflat</SelectItem>
@@ -98,13 +100,25 @@ export function WorldPanel({ formData, updateField }: ServerFormPanelProps) {
</Field>
</div>
<div className="space-y-3">
<CheckboxField id="spawnAnimals" checked={formData.spawnAnimals} onCheckedChange={(value) => updateField("spawnAnimals", value)}>
<CheckboxField
id="spawnAnimals"
checked={formData.spawnAnimals}
onCheckedChange={(value) => updateField("spawnAnimals", value)}
>
Spawn Animals
</CheckboxField>
<CheckboxField id="spawnMonsters" checked={formData.spawnMonsters} onCheckedChange={(value) => updateField("spawnMonsters", value)}>
<CheckboxField
id="spawnMonsters"
checked={formData.spawnMonsters}
onCheckedChange={(value) => updateField("spawnMonsters", value)}
>
Spawn Monsters
</CheckboxField>
<CheckboxField id="spawnNpcs" checked={formData.spawnNpcs} onCheckedChange={(value) => updateField("spawnNpcs", value)}>
<CheckboxField
id="spawnNpcs"
checked={formData.spawnNpcs}
onCheckedChange={(value) => updateField("spawnNpcs", value)}
>
Spawn NPCs (Villagers)
</CheckboxField>
</div>
@@ -76,7 +76,7 @@ export function ConnectionInfoCell({ serverId, type }: ConnectionInfoCellProps)
aria-label="Copy connection string"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
<Check className="h-3 w-3 text-success" />
) : (
<Copy className="h-3 w-3" />
)}
+27 -22
View File
@@ -11,14 +11,14 @@ type ServerTableProps =
| {
type: "normal";
servers: NormalServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}
| {
type: "proxy";
servers: ReverseProxyServer[];
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
};
function RowActions({
@@ -29,27 +29,32 @@ function RowActions({
}: {
id: string;
kind: string;
onEdit: (id: string) => void;
onDelete: (id: string) => void;
onEdit?: (id: string) => void;
onDelete?: (id: string) => void;
}) {
if (!onEdit && !onDelete) return null;
return (
<TableActions>
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
aria-label={`Edit ${kind} ${id}`}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
aria-label={`Delete ${kind} ${id}`}
>
<Trash2 />
</Button>
{onEdit && (
<Button
variant="ghost"
size="icon"
onClick={() => onEdit(id)}
aria-label={`Edit ${kind} ${id}`}
>
<Pencil />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="icon"
onClick={() => onDelete(id)}
aria-label={`Delete ${kind} ${id}`}
>
<Trash2 />
</Button>
)}
</TableActions>
);
}
+48 -2
View File
@@ -1,5 +1,9 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { useRef } from "react";
import { cn } from "@/lib/cn";
import { gsap, motionEase, prefersReducedMotion, useGSAP } from "@/lib/motion";
export type StatItem = {
label: string;
@@ -15,8 +19,45 @@ export function StatStrip({
items: readonly StatItem[];
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useGSAP(
() => {
const root = ref.current;
if (!root) return;
const cells = root.querySelectorAll("[data-stat-cell]");
if (prefersReducedMotion()) {
gsap.set(cells, { autoAlpha: 1, y: 0 });
return;
}
gsap.fromTo(
cells,
{ autoAlpha: 0, y: 12 },
{ autoAlpha: 1, y: 0, duration: 0.45, stagger: 0.05, ease: motionEase.out }
);
for (const valueEl of root.querySelectorAll<HTMLElement>("[data-stat-value]")) {
const raw = valueEl.dataset.statValue;
const numeric = raw !== undefined ? Number(raw) : Number.NaN;
if (!Number.isFinite(numeric)) continue;
const state = { value: 0 };
gsap.to(state, {
value: numeric,
duration: 0.7,
ease: motionEase.out,
onUpdate: () => {
valueEl.textContent = String(Math.round(state.value));
},
});
}
},
{ scope: ref, dependencies: [items.map((item) => `${item.label}:${item.value}`).join("|")] }
);
return (
<div
ref={ref}
className={cn(
"grid divide-x divide-border border bg-card shadow-[2px_2px_0_color-mix(in_oklch,var(--foreground)_8%,transparent)]",
className
@@ -24,7 +65,7 @@ export function StatStrip({
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
>
{items.map(({ label, value, icon: Icon, tone = "default" }) => (
<div key={label} className="min-w-20 px-3 py-2.5 sm:min-w-28 sm:px-4">
<div key={label} data-stat-cell="" className="min-w-20 px-3 py-2.5 sm:min-w-28 sm:px-4">
<div className="mb-1.5 flex items-center gap-1.5 text-muted-foreground">
{Icon && (
<Icon
@@ -39,7 +80,12 @@ export function StatStrip({
{label}
</span>
</div>
<strong className="block text-xl leading-none tabular-nums sm:text-2xl">{value}</strong>
<strong
data-stat-value={typeof value === "number" ? value : undefined}
className="block text-xl leading-none tabular-nums sm:text-2xl"
>
{value}
</strong>
</div>
))}
</div>
+4 -2
View File
@@ -6,11 +6,13 @@ import { cn } from "@/lib/cn";
const statusConfig = {
success: {
icon: CheckCircle2,
className: "border-success/35 bg-success/12 text-success-foreground",
className:
"border-success/35 bg-success/12 text-success-foreground dark:bg-success/15 dark:text-success",
},
warning: {
icon: AlertCircle,
className: "border-warning/40 bg-warning/14 text-warning-foreground",
className:
"border-warning/40 bg-warning/14 text-warning-foreground dark:bg-warning/15 dark:text-warning",
},
error: { icon: XCircle, className: "border-destructive/35 bg-destructive/10 text-destructive" },
neutral: { icon: CircleDashed, className: "border-border bg-muted/60 text-muted-foreground" },
+5 -3
View File
@@ -203,13 +203,15 @@ export function Terminal({
<div className="relative w-full h-full">
<div className="absolute top-2 right-2 flex items-center gap-2 z-10">
{connected && (
<div className="flex items-center gap-2 bg-green-500/20 text-green-500 text-xs px-2 py-1 rounded">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
<div className="flex items-center gap-2 rounded bg-success/20 px-2 py-1 text-xs text-success">
<div className="h-2 w-2 animate-pulse rounded-full bg-success" />
Connected
</div>
)}
{error && (
<div className="bg-red-500/20 text-red-500 text-xs px-2 py-1 rounded">{error}</div>
<div className="rounded bg-destructive/20 px-2 py-1 text-xs text-destructive">
{error}
</div>
)}
<button
type="button"
@@ -85,7 +85,7 @@ function ServerDetails({ metadata }: { metadata: ServerMetadata }) {
<DetailRow label="Behind Proxies" value={connectedProxies.length.toString()} />
<IdentifierList
items={connectedProxies}
className="text-sm bg-blue-50 border border-blue-200"
className="text-sm bg-info/12 border border-info/30"
/>
</DetailSection>
)}
@@ -129,7 +129,7 @@ function ProxyDetails({ metadata }: { metadata: ProxyMetadata }) {
{connectedServers.length > 0 && (
<IdentifierList
items={connectedServers}
className="text-sm bg-green-50 border border-green-200"
className="text-sm bg-success/12 border border-success/30"
/>
)}
</DetailSection>
@@ -166,7 +166,7 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) {
<p className="text-sm font-medium mb-2">Server Pods:</p>
<IdentifierList
items={serverPods}
className="text-xs bg-green-50 border border-green-200"
className="text-xs bg-success/12 border border-success/30"
withMargin={false}
/>
</div>
@@ -177,7 +177,7 @@ function K8sNodeDetails({ metadata }: { metadata: K8sNodeMetadata }) {
<p className="text-sm font-medium mb-2">Proxy Pods:</p>
<IdentifierList
items={proxyPods}
className="text-xs bg-blue-50 border border-blue-200"
className="text-xs bg-info/12 border border-info/30"
withMargin={false}
/>
</div>
@@ -204,7 +204,7 @@ function KubernetesWorkloadDetails({
</div>
<IdentifierList
items={k8sNodes}
className="text-xs bg-blue-50 border border-blue-200 ml-6"
className="text-xs bg-info/12 border border-info/30 ml-6"
withMargin={false}
/>
</div>
@@ -58,7 +58,7 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
];
return (
<div className="flex min-w-[280px] max-w-[calc(100vw-5rem)] flex-col gap-3 rounded-sm border-2 border-foreground bg-card/95 p-3 shadow-[5px_5px_0_color-mix(in_oklch,var(--foreground)_16%,transparent)] backdrop-blur-sm sm:min-w-[350px] sm:p-4">
<div className="flex w-[calc(100vw-4.5rem)] max-w-[350px] flex-col gap-3 rounded-sm border-2 border-foreground bg-card/95 p-3 shadow-[5px_5px_0_color-mix(in_oklch,var(--foreground)_16%,transparent)] backdrop-blur-sm sm:w-[350px] sm:p-4">
<div className="grid grid-cols-3 gap-2">
{stats.map(({ label, value, icon: Icon }) => (
<div key={label} className="flex flex-col items-center border bg-muted/50 p-2">
@@ -100,7 +100,7 @@ export function TopologyToolbar({ filters, onFiltersChange, metadata }: Topology
Filters
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="start">
<PopoverContent className="w-[calc(100vw-3rem)] max-w-80" align="start">
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-3">Show/Hide</h4>
@@ -12,8 +12,8 @@ export function K8sNodeComponent({ data, selected }: NodeProps) {
return (
<TopologyNodeCard
selected={selected}
icon={<Box className="h-4 w-4 text-blue-600" />}
iconClassName="bg-blue-500/10"
icon={<Box className="h-4 w-4 text-info" />}
iconClassName="bg-info/10"
title={node.name || "Unknown"}
description="Kubernetes Node"
health={health}
@@ -35,7 +35,7 @@ export function K8sNodeComponent({ data, selected }: NodeProps) {
<CompactRow
label="Total Pods"
className="text-xs bg-muted/50 rounded px-2 py-1.5"
valueClassName="font-semibold text-blue-600"
valueClassName="font-semibold text-info"
>
{podCount}
</CompactRow>
@@ -55,7 +55,7 @@ export function K8sNodeComponent({ data, selected }: NodeProps) {
<CompactRow
label="CPU"
icon={<Cpu className="h-3 w-3" />}
valueClassName="font-semibold text-xs text-blue-600"
valueClassName="font-semibold text-xs text-info"
>
{metrics.cpuUsage}
</CompactRow>
@@ -64,7 +64,7 @@ export function K8sNodeComponent({ data, selected }: NodeProps) {
<CompactRow
label="Memory"
icon={<HardDrive className="h-3 w-3" />}
valueClassName="font-semibold text-xs text-blue-600"
valueClassName="font-semibold text-xs text-info"
>
{metrics.memoryUsage}
</CompactRow>
@@ -15,8 +15,8 @@ export function ProxyNode({ data, selected }: NodeProps) {
return (
<TopologyNodeCard
selected={selected}
icon={<Globe className="h-4 w-4 text-blue-500" />}
iconClassName="bg-blue-500/10"
icon={<Globe className="h-4 w-4 text-info" />}
iconClassName="bg-info/10"
title={proxy.id}
description={proxy.description}
health={health}
@@ -37,7 +37,7 @@ export function ProxyNode({ data, selected }: NodeProps) {
className="text-xs bg-muted/50 rounded px-2 py-1.5"
valueClassName={cn(
"font-semibold",
readyPods === podCount ? "text-green-600" : "text-yellow-600"
readyPods === podCount ? "text-success" : "text-warning"
)}
>
{readyPods}/{podCount}
@@ -80,7 +80,7 @@ export function ProxyNode({ data, selected }: NodeProps) {
<div className="space-y-1 text-[11px] pt-1 border-t">
<CompactRow
label="Restarts"
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
valueClassName={restartCount > 0 ? "text-warning" : "text-success"}
>
{restartCount}
</CompactRow>
@@ -90,7 +90,7 @@ export function ProxyNode({ data, selected }: NodeProps) {
{pods[0].ip}
</CompactRow>
)}
<CompactRow label="Routing To" valueClassName="font-semibold text-blue-600">
<CompactRow label="Routing To" valueClassName="font-semibold text-info">
{connectedServers.length} servers
</CompactRow>
{k8sNodes.length > 0 && (
@@ -43,7 +43,7 @@ export function ServerNode({ data, selected }: NodeProps) {
className="text-xs bg-muted/50 rounded px-2 py-1.5"
valueClassName={cn(
"font-semibold",
readyPods === podCount ? "text-green-600" : "text-yellow-600"
readyPods === podCount ? "text-success" : "text-warning"
)}
>
{readyPods}/{podCount}
@@ -77,7 +77,7 @@ export function ServerNode({ data, selected }: NodeProps) {
<div className="space-y-1 text-[11px] pt-1 border-t">
<CompactRow
label="Restarts"
valueClassName={restartCount > 0 ? "text-yellow-600" : "text-green-600"}
valueClassName={restartCount > 0 ? "text-warning" : "text-success"}
>
{restartCount}
</CompactRow>
@@ -93,7 +93,7 @@ export function ServerNode({ data, selected }: NodeProps) {
{(k8sNodes.length > 0 || connectedProxies.length > 0) && (
<div className="space-y-1 text-[11px] pt-1 border-t">
{connectedProxies.length > 0 && (
<CompactRow label="Exposed By" valueClassName="font-semibold text-blue-600">
<CompactRow label="Exposed By" valueClassName="font-semibold text-info">
{connectedProxies.length} proxies
</CompactRow>
)}
@@ -54,7 +54,7 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
}, []);
return (
<div className="h-[calc(100vh-250px)] min-h-[520px] w-full overflow-hidden rounded-sm border-2 border-foreground bg-background shadow-[6px_6px_0_color-mix(in_oklch,var(--foreground)_14%,transparent)]">
<div className="h-[70vh] min-h-[420px] w-full overflow-hidden rounded-sm border-2 border-foreground bg-background shadow-[6px_6px_0_color-mix(in_oklch,var(--foreground)_14%,transparent)] sm:h-[calc(100vh-250px)] sm:min-h-[520px]">
<ReactFlow
nodes={nodes}
edges={edges}
@@ -93,18 +93,18 @@ export function TopologyCanvas({ graph }: TopologyCanvasProps) {
showZoom
showFitView
showInteractive
className="border bg-card/95 shadow-md backdrop-blur-sm"
className="hidden border bg-card/95 shadow-md backdrop-blur-sm sm:block"
/>
<MiniMap
nodeColor={(node: any) => {
const data = node.data as TopologyNodeData;
const colors = {
healthy: "#22c55e",
degraded: "#eab308",
unhealthy: "#ef4444",
unknown: "#94a3b8",
healthy: "var(--success)",
degraded: "var(--warning)",
unhealthy: "var(--destructive)",
unknown: "var(--muted-foreground)",
};
return colors[data.status] || "#94a3b8";
return colors[data.status] || "var(--muted-foreground)";
}}
maskColor="color-mix(in oklch, var(--foreground) 8%, transparent)"
className="border bg-card/95 shadow-md backdrop-blur-sm"
@@ -17,9 +17,10 @@ const healthLabels: Record<HealthStatus, string> = {
};
const solidHealthClasses: Record<Exclude<HealthStatus, "unknown">, string> = {
healthy: "bg-green-500 hover:bg-green-600",
degraded: "bg-yellow-500 hover:bg-yellow-600",
unhealthy: "bg-red-500 hover:bg-red-600",
healthy: "border-transparent bg-success text-success-foreground hover:bg-success/90",
degraded: "border-transparent bg-warning text-warning-foreground hover:bg-warning/90",
unhealthy:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/90",
};
interface HealthBadgeProps {
@@ -39,9 +40,9 @@ export function HealthBadge({
if (appearance === "summary") {
const indicatorClasses = {
healthy: "bg-green-500",
degraded: "bg-yellow-500",
unhealthy: "bg-red-500",
healthy: "bg-success",
degraded: "bg-warning",
unhealthy: "bg-destructive",
unknown: "bg-muted-foreground",
}[status];
@@ -156,7 +157,7 @@ interface MetricRowProps {
export function MetricRow({ label, icon, usage, limit }: MetricRowProps) {
return (
<CompactRow label={label} icon={icon} valueClassName="text-xs">
{usage && <span className="text-blue-600">{usage} / </span>}
{usage && <span className="text-info">{usage} / </span>}
<span className="text-muted-foreground">{limit}</span>
</CompactRow>
);
@@ -207,7 +208,7 @@ export function CopyableCode({ value, title = "Copy address" }: CopyableCodeProp
onClick={handleCopy}
title={copied ? "Copied!" : title}
>
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
{copied ? <Check className="h-3 w-3 text-success" /> : <Copy className="h-3 w-3" />}
</Button>
</div>
);
+1 -1
View File
@@ -13,7 +13,7 @@ const badgeVariants = cva(
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
+1 -1
View File
@@ -12,7 +12,7 @@ const buttonVariants = cva(
default:
"border-foreground bg-primary text-primary-foreground shadow-[3px_3px_0_var(--foreground)] hover:-translate-y-0.5 hover:shadow-[4px_4px_0_var(--foreground)]",
destructive:
"border-destructive bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20",
"border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20",
outline: "border-border bg-card text-foreground hover:border-foreground hover:bg-accent",
secondary: "border-border bg-secondary text-secondary-foreground hover:border-foreground",
ghost: "text-current hover:bg-accent hover:text-accent-foreground",
+7 -1
View File
@@ -59,7 +59,13 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="card-content" className={cn("px-5 sm:px-6", className)} {...props} />;
return (
<div
data-slot="card-content"
className={cn("px-5 sm:px-6 [.border-b+&]:pt-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+1 -1
View File
@@ -444,7 +444,7 @@ const sidebarMenuButtonVariants = cva(
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]",
},
size: {
default: "h-8 text-sm",