mirror of
https://github.com/YuzuZensai/Minikura.git
synced 2026-09-14 03:09:50 +00:00
🐛 fix: align dashboard with secured APIs
This commit is contained in:
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user