From 3f09e330bea191d5c87b30222caa60b5b23684d2 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 13 Aug 2026 03:29:33 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20align=20dashboard=20with?= =?UTF-8?q?=20secured=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/app/bootstrap/page.tsx | 133 +++++++----- apps/web/app/dashboard/k8s/page.tsx | 2 +- apps/web/app/dashboard/page.tsx | 2 +- .../web/app/dashboard/servers/create/page.tsx | 10 +- .../app/dashboard/servers/edit/[id]/page.tsx | 39 ++-- apps/web/app/dashboard/servers/page.tsx | 21 +- apps/web/app/dashboard/topology/page.tsx | 36 +++- apps/web/app/dashboard/users/page.tsx | 165 +++++++++++---- apps/web/app/globals.css | 94 +++++---- apps/web/app/login/page.tsx | 196 ++++++++++++------ apps/web/components/auth/auth-form-card.tsx | 39 +++- apps/web/components/brand.tsx | 91 +++++++- apps/web/components/dashboard-layout.tsx | 59 +++++- apps/web/components/motion.tsx | 145 +++++++++++++ apps/web/components/page-layout.tsx | 112 ++++++++-- apps/web/components/section-card.tsx | 35 ++-- .../components/server-form/advanced-panel.tsx | 6 +- .../web/components/server-form/mods-panel.tsx | 6 +- .../components/server-form/network-panel.tsx | 10 +- .../server-form/performance-panel.tsx | 12 +- .../server-form/resources-panel.tsx | 18 +- .../components/server-form/server-panel.tsx | 43 +++- apps/web/components/server-form/types.ts | 2 +- .../components/server-form/world-panel.tsx | 22 +- .../servers/connection-info-cell.tsx | 2 +- apps/web/components/servers/server-table.tsx | 49 +++-- apps/web/components/stat-strip.tsx | 50 ++++- apps/web/components/status-badge.tsx | 6 +- apps/web/components/terminal.tsx | 8 +- .../topology/controls/node-details-panel.tsx | 10 +- .../topology/controls/topology-toolbar.tsx | 4 +- .../components/topology/nodes/k8s-node.tsx | 10 +- .../components/topology/nodes/proxy-node.tsx | 10 +- .../components/topology/nodes/server-node.tsx | 6 +- .../components/topology/topology-canvas.tsx | 14 +- .../topology/topology-primitives.tsx | 17 +- apps/web/components/ui/badge.tsx | 2 +- apps/web/components/ui/button.tsx | 2 +- apps/web/components/ui/card.tsx | 8 +- apps/web/components/ui/sidebar.tsx | 2 +- apps/web/hooks/use-server-list.ts | 12 +- apps/web/hooks/use-topology-data.ts | 126 ++++++++--- apps/web/lib/api-helpers.ts | 5 +- apps/web/lib/motion.ts | 26 +++ apps/web/lib/topology-utils.ts | 2 +- apps/web/package.json | 2 + bun.lock | 6 + 47 files changed, 1280 insertions(+), 397 deletions(-) create mode 100644 apps/web/components/motion.tsx create mode 100644 apps/web/lib/motion.ts diff --git a/apps/web/app/bootstrap/page.tsx b/apps/web/app/bootstrap/page.tsx index 2ec2a68..cb706db 100644 --- a/apps/web/app/bootstrap/page.tsx +++ b/apps/web/app/bootstrap/page.tsx @@ -2,21 +2,23 @@ import { ArrowRight, Check } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; +import { useEffect, useRef, useState } from "react"; import { AuthFormCard, FormError } from "@/components/auth/auth-form-card"; import { BrandMark } from "@/components/brand"; import { FullScreenLoader } from "@/components/page-layout"; import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { api } from "@/lib/api-client"; +import { gsap, motionEase, prefersReducedMotion, useGSAP } from "@/lib/motion"; export default function BootstrapPage() { const router = useRouter(); const [loading, setLoading] = useState(false); const [checkingStatus, setCheckingStatus] = useState(true); const [error, setError] = useState(""); + const stageRef = useRef(null); useEffect(() => { const checkStatus = async () => { @@ -35,6 +37,27 @@ export default function BootstrapPage() { checkStatus(); }, [router]); + useGSAP( + () => { + const root = stageRef.current; + if (!root || prefersReducedMotion()) return; + + gsap.fromTo( + root, + { y: 28, autoAlpha: 0 }, + { y: 0, autoAlpha: 1, duration: 0.65, ease: motionEase.out } + ); + + const panelItems = root.querySelectorAll("[data-boot-item]"); + gsap.fromTo( + panelItems, + { y: 16, autoAlpha: 0 }, + { y: 0, autoAlpha: 1, duration: 0.5, stagger: 0.07, delay: 0.12, ease: motionEase.out } + ); + }, + { scope: stageRef } + ); + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); @@ -85,10 +108,16 @@ export default function BootstrapPage() { return (
-
-
- -
+
+
+
+
+ +
+
System bootstrap / 01

Build your command center. @@ -98,13 +127,13 @@ export default function BootstrapPage() {

-

+

Admin authority

-

+

Secure session

-

+

Ready in one step

@@ -116,49 +145,49 @@ export default function BootstrapPage() { headerClassName="px-7 sm:px-10" contentClassName="px-7 sm:px-10" > -
-
- - -
-
- - -
-
- - -
-
- - -
- - - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
diff --git a/apps/web/app/dashboard/k8s/page.tsx b/apps/web/app/dashboard/k8s/page.tsx index 2314ea3..19826d5 100644 --- a/apps/web/app/dashboard/k8s/page.tsx +++ b/apps/web/app/dashboard/k8s/page.tsx @@ -150,7 +150,7 @@ export default function K8sResourcesPage() { {pageHeader} } + icon={} description={ <>

Ensure the operator is running with a valid Kubernetes configuration.

diff --git a/apps/web/app/dashboard/page.tsx b/apps/web/app/dashboard/page.tsx index 05a3770..e63579f 100644 --- a/apps/web/app/dashboard/page.tsx +++ b/apps/web/app/dashboard/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function DashboardPage() { - redirect("/dashboard/users"); + redirect("/dashboard/servers"); } diff --git a/apps/web/app/dashboard/servers/create/page.tsx b/apps/web/app/dashboard/servers/create/page.tsx index 1c0874e..15c93b4 100644 --- a/apps/web/app/dashboard/servers/create/page.tsx +++ b/apps/web/app/dashboard/servers/create/page.tsx @@ -56,11 +56,11 @@ export default function CreateServerPage() { title="Server Configuration" description="Complete configuration for the itzg/minecraft-server workload." > - router.push("/dashboard/servers")} - submitLabel="Create Server" - /> + router.push("/dashboard/servers")} + submitLabel="Create Server" + /> ); diff --git a/apps/web/app/dashboard/servers/edit/[id]/page.tsx b/apps/web/app/dashboard/servers/edit/[id]/page.tsx index 87e0f95..4f5eb2b 100644 --- a/apps/web/app/dashboard/servers/edit/[id]/page.tsx +++ b/apps/web/app/dashboard/servers/edit/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import type { NormalServer, UpdateServerRequest } from "@minikura/api"; +import type { NormalServer, ReverseProxyServer, UpdateServerRequest } from "@minikura/api"; import { ArrowLeft } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -19,6 +19,7 @@ export default function EditServerPage() { const [loading, setLoading] = useState(true); const [serverData, setServerData] = useState(null); + const [resourceKind, setResourceKind] = useState<"server" | "proxy">("server"); const [error, setError] = useState(null); useEffect(() => { @@ -32,6 +33,7 @@ export default function EditServerPage() { const server = servers.find((s) => s.id === serverId); if (server) { setServerData(server); + setResourceKind("server"); setLoading(false); return; } @@ -39,10 +41,11 @@ export default function EditServerPage() { const proxyResponse = await getReverseProxyApi().get(); if (proxyResponse.data) { - const proxies = proxyResponse.data as unknown as NormalServer[]; + const proxies = proxyResponse.data as unknown as ReverseProxyServer[]; const proxy = proxies.find((p) => p.id === serverId); if (proxy) { - setServerData(proxy); + setServerData(proxy as unknown as NormalServer); + setResourceKind("proxy"); setLoading(false); return; } @@ -66,7 +69,18 @@ export default function EditServerPage() { const payload: UpdateServerRequest = toCommonServerRequestFields(data); - const response = await api.api.servers({ id: serverId }).patch(payload); + const response = + resourceKind === "server" + ? await api.api.servers({ id: serverId }).patch(payload) + : await getReverseProxyApi()({ id: serverId }).patch({ + description: payload.description, + listen_port: payload.listen_port, + service_type: payload.service_type, + node_port: payload.node_port, + memory: payload.memory, + cpu_request: payload.cpu_request, + cpu_limit: payload.cpu_limit, + }); if (response.error) { const errorMsg = @@ -95,10 +109,7 @@ export default function EditServerPage() { tone="error" className="min-h-[50vh]" action={ - @@ -127,12 +138,12 @@ export default function EditServerPage() { title="Server Configuration" description="Modify settings for your Minecraft server" > - router.push("/dashboard/servers")} - submitLabel="Save Changes" - /> + router.push("/dashboard/servers")} + submitLabel="Save Changes" + /> ); diff --git a/apps/web/app/dashboard/servers/page.tsx b/apps/web/app/dashboard/servers/page.tsx index 6548c93..1b47329 100644 --- a/apps/web/app/dashboard/servers/page.tsx +++ b/apps/web/app/dashboard/servers/page.tsx @@ -9,9 +9,12 @@ import { ResourceSection } from "@/components/section-card"; import { ServerTable } from "@/components/servers/server-table"; import { Button } from "@/components/ui/button"; import { useServerList } from "@/hooks/use-server-list"; +import { useSession } from "@/lib/auth-client"; export default function ServersPage() { const router = useRouter(); + const { data: session } = useSession(); + const isAdmin = session?.user.role === "admin"; const { normalServers, reverseProxies, loading, error, deleteServer } = useServerList(); const [deleteTarget, setDeleteTarget] = useState<{ id: string; @@ -34,10 +37,12 @@ export default function ServersPage() { title="Servers" description="Provision Minecraft runtimes and route traffic through edge proxies." actions={ - + isAdmin ? ( + + ) : undefined } /> @@ -59,8 +64,8 @@ export default function ServersPage() { router.push(`/dashboard/servers/edit/${id}`)} - onDelete={(id) => setDeleteTarget({ id, type: "normal" })} + onEdit={isAdmin ? (id) => router.push(`/dashboard/servers/edit/${id}`) : undefined} + onDelete={isAdmin ? (id) => setDeleteTarget({ id, type: "normal" }) : undefined} /> @@ -76,8 +81,8 @@ export default function ServersPage() { router.push(`/dashboard/servers/edit/${id}`)} - onDelete={(id) => setDeleteTarget({ id, type: "proxy" })} + onEdit={isAdmin ? (id) => router.push(`/dashboard/servers/edit/${id}`) : undefined} + onDelete={isAdmin ? (id) => setDeleteTarget({ id, type: "proxy" }) : undefined} /> diff --git a/apps/web/app/dashboard/topology/page.tsx b/apps/web/app/dashboard/topology/page.tsx index 7cf2d99..e29fb71 100644 --- a/apps/web/app/dashboard/topology/page.tsx +++ b/apps/web/app/dashboard/topology/page.tsx @@ -1,12 +1,13 @@ "use client"; -import { Network } from "lucide-react"; +import { Network, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { PageHeader, PageShell, StatePanel } from "@/components/page-layout"; import { TopologyCanvas } from "@/components/topology/topology-canvas"; import { useTopologyData } from "@/hooks/use-topology-data"; export default function TopologyPage() { - const { graph, loading, error } = useTopologyData(); + const { graph, loading, error, refreshing, refresh } = useTopologyData(); const header = ( {header} {loading ? ( - - ) : error ? ( + + ) : error && !graph ? ( } tone="error" - className="h-[calc(100vh-250px)]" + className="h-[70vh] sm:h-[calc(100vh-250px)]" + action={} /> ) : !graph || graph.nodes.length === 0 ? ( ) : ( - +
+ {error && ( +
+ Refresh failed: {error}. Showing the last successful topology. + +
+ )} + +
)} ); diff --git a/apps/web/app/dashboard/users/page.tsx b/apps/web/app/dashboard/users/page.tsx index cceb9d1..bed2c42 100644 --- a/apps/web/app/dashboard/users/page.tsx +++ b/apps/web/app/dashboard/users/page.tsx @@ -1,7 +1,8 @@ "use client"; import { Ban, CheckCircle, Edit, ShieldCheck, Trash2, UserRoundCheck, Users } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { getErrorMessage } from "@minikura/shared/errors"; +import { useCallback, useEffect, useRef, useState } from "react"; import { ConfirmDialog } from "@/components/confirm-dialog"; import { DataTable, type DataTableColumn } from "@/components/data-table"; import { PageHeader, PageShell, StatePanel } from "@/components/page-layout"; @@ -36,12 +37,27 @@ type User = { name: string; email: string; role: string; - createdAt: Date; + createdAt: Date | string; emailVerified: boolean; isSuspended: boolean; - suspendedUntil: Date | null; + banned: boolean; + suspendedUntil: Date | string | null; }; +function formatDateTime(value: Date | string): string { + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + timeZoneName: "short", + }).format(new Date(value)); +} + +function localDateTimeMinimum(): string { + const now = new Date(Date.now() + 60_000); + const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000); + return local.toISOString().slice(0, 16); +} + export default function UsersPage() { const { data: session } = useSession(); const [users, setUsers] = useState([]); @@ -49,16 +65,23 @@ export default function UsersPage() { const [editingUser, setEditingUser] = useState(null); const [suspendingUser, setSuspendingUser] = useState(null); const [deleteUser, setDeleteUser] = useState(null); + const [error, setError] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + const fetchSequence = useRef(0); const fetchUsers = useCallback(async () => { + const sequence = ++fetchSequence.current; + setLoading(true); + setError(null); try { const { data, error } = await api.api.users.get(); - if (!error && data) { - setUsers(data); - } - } catch (_error) { + if (error) throw error; + if (!data) throw new Error("The user directory returned no data"); + if (sequence === fetchSequence.current) setUsers(data); + } catch (requestError) { + if (sequence === fetchSequence.current) setError(getErrorMessage(requestError)); } finally { - setLoading(false); + if (sequence === fetchSequence.current) setLoading(false); } }, []); @@ -72,19 +95,32 @@ export default function UsersPage() { const formData = new FormData(e.currentTarget); const name = formData.get("name") as string; - const role = formData.get("role") as string; + const role = + editingUser.id === session?.user?.id + ? editingUser.role + : String(formData.get("role") || editingUser.role); + if (editingUser.id === session?.user?.id && role !== "admin") { + setError("You cannot remove your own administrator access."); + return; + } + + setPendingAction(`edit:${editingUser.id}`); + setError(null); try { const { error } = await api.api.users({ id: editingUser.id }).patch({ name, role: role as "admin" | "user", }); - if (!error) { - await fetchUsers(); - setEditingUser(null); - } - } catch (_error) {} + if (error) throw error; + setEditingUser(null); + await fetchUsers(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setPendingAction(null); + } }; const handleSuspend = async (e: React.FormEvent) => { @@ -94,46 +130,81 @@ export default function UsersPage() { const formData = new FormData(e.currentTarget); const suspendedUntil = formData.get("suspendedUntil") as string; + if (suspendingUser.id === session?.user?.id) { + setError("You cannot suspend your own account."); + return; + } + + const suspensionDate = suspendedUntil ? new Date(suspendedUntil) : null; + if ( + suspensionDate && + (Number.isNaN(suspensionDate.getTime()) || suspensionDate <= new Date()) + ) { + setError("Suspension end time must be in the future."); + return; + } + + setPendingAction(`suspend:${suspendingUser.id}`); + setError(null); try { const { error } = await getUserApi(suspendingUser.id).suspension.patch({ isSuspended: true, - suspendedUntil: suspendedUntil || null, + suspendedUntil: suspensionDate?.toISOString() || null, }); - if (!error) { - await fetchUsers(); - setSuspendingUser(null); - } - } catch (_error) {} + if (error) throw error; + setSuspendingUser(null); + await fetchUsers(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setPendingAction(null); + } }; const handleUnsuspend = async (userId: string) => { + setPendingAction(`unsuspend:${userId}`); + setError(null); try { const { error } = await getUserApi(userId).suspension.patch({ isSuspended: false, suspendedUntil: null, }); - if (!error) { - await fetchUsers(); - } - } catch (_error) {} + if (error) throw error; + await fetchUsers(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setPendingAction(null); + } }; const handleDelete = async () => { if (!deleteUser) return; + if (deleteUser.id === session?.user?.id) { + setError("You cannot delete your own account."); + setDeleteUser(null); + return; + } + setPendingAction(`delete:${deleteUser.id}`); + setError(null); try { const { error } = await api.api.users({ id: deleteUser.id }).delete(); - if (!error) { - await fetchUsers(); - setDeleteUser(null); - } - } catch (_error) {} + if (error) throw error; + setDeleteUser(null); + await fetchUsers(); + } catch (requestError) { + setError(getErrorMessage(requestError)); + } finally { + setPendingAction(null); + } }; const isUserSuspended = (user: User): boolean => { + if (user.banned) return true; if (!user.isSuspended) return false; if (user.suspendedUntil && new Date(user.suspendedUntil) <= new Date()) { return false; @@ -162,8 +233,8 @@ export default function UsersPage() { cell: (user) => isUserSuspended(user) ? ( - Suspended - {user.suspendedUntil && ` until ${new Date(user.suspendedUntil).toLocaleDateString()}`} + {user.banned ? "Banned" : "Suspended"} + {!user.banned && user.suspendedUntil && ` until ${formatDateTime(user.suspendedUntil)}`} ) : ( @@ -187,15 +258,17 @@ export default function UsersPage() { - {isUserSuspended(user) ? ( + {user.banned ? null : isUserSuspended(user) ? ( + + )} + {loading ? ( ) : ( @@ -278,7 +364,11 @@ export default function UsersPage() {
- @@ -293,7 +383,9 @@ export default function UsersPage() { - + @@ -315,6 +407,7 @@ export default function UsersPage() { id="suspendedUntil" name="suspendedUntil" type="datetime-local" + min={localDateTimeMinimum()} placeholder="Leave empty for indefinite suspension" />

@@ -326,7 +419,7 @@ export default function UsersPage() { - diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index bc86069..8d38e1e 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -26,10 +26,13 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --color-success: var(--success); --color-success-foreground: var(--success-foreground); --color-warning: var(--warning); --color-warning-foreground: var(--warning-foreground); + --color-info: var(--info); + --color-info-foreground: var(--info-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -69,10 +72,13 @@ --accent: oklch(0.88 0.04 112); --accent-foreground: oklch(0.2 0.028 110); --destructive: oklch(0.57 0.205 28); + --destructive-foreground: oklch(0.99 0.005 28); --success: oklch(0.59 0.155 132); --success-foreground: oklch(0.28 0.09 132); --warning: oklch(0.72 0.15 76); --warning-foreground: oklch(0.34 0.09 66); + --info: oklch(0.52 0.12 230); + --info-foreground: oklch(0.3 0.07 230); --border: oklch(0.79 0.018 90); --input: oklch(0.76 0.02 90); --ring: oklch(0.68 0.19 125); @@ -93,41 +99,44 @@ } .dark { - --background: oklch(0.16 0.018 75); - --foreground: oklch(0.94 0.012 95); - --card: oklch(0.21 0.018 75); - --card-foreground: oklch(0.94 0.012 95); - --popover: oklch(0.19 0.018 75); - --popover-foreground: oklch(0.94 0.012 95); - --primary: oklch(0.75 0.205 125); - --primary-foreground: oklch(0.15 0.025 125); - --secondary: oklch(0.26 0.02 80); - --secondary-foreground: oklch(0.94 0.012 95); - --muted: oklch(0.24 0.016 80); - --muted-foreground: oklch(0.72 0.018 90); - --accent: oklch(0.28 0.04 112); - --accent-foreground: oklch(0.92 0.03 110); - --destructive: oklch(0.68 0.19 28); - --success: oklch(0.72 0.155 132); - --success-foreground: oklch(0.22 0.06 132); - --warning: oklch(0.78 0.14 76); - --warning-foreground: oklch(0.28 0.08 66); - --border: oklch(0.35 0.02 80); - --input: oklch(0.32 0.02 80); - --ring: oklch(0.75 0.205 125); - --chart-1: oklch(0.75 0.205 125); - --chart-2: oklch(0.66 0.16 205); - --chart-3: oklch(0.72 0.17 80); - --chart-4: oklch(0.62 0.16 50); - --chart-5: oklch(0.58 0.14 300); - --sidebar: oklch(0.13 0.018 75); - --sidebar-foreground: oklch(0.91 0.015 95); - --sidebar-primary: oklch(0.75 0.205 125); - --sidebar-primary-foreground: oklch(0.15 0.025 125); - --sidebar-accent: oklch(0.22 0.022 80); - --sidebar-accent-foreground: oklch(0.96 0.01 95); - --sidebar-border: oklch(0.28 0.022 80); - --sidebar-ring: oklch(0.75 0.205 125); + --background: oklch(0.175 0.012 152); + --foreground: oklch(0.93 0.014 105); + --card: oklch(0.215 0.012 152); + --card-foreground: oklch(0.93 0.014 105); + --popover: oklch(0.2 0.012 152); + --popover-foreground: oklch(0.93 0.014 105); + --primary: oklch(0.79 0.165 128); + --primary-foreground: oklch(0.18 0.04 128); + --secondary: oklch(0.255 0.014 152); + --secondary-foreground: oklch(0.9 0.012 110); + --muted: oklch(0.24 0.012 152); + --muted-foreground: oklch(0.7 0.02 130); + --accent: oklch(0.27 0.03 140); + --accent-foreground: oklch(0.93 0.03 120); + --destructive: oklch(0.72 0.16 25); + --destructive-foreground: oklch(0.98 0.01 25); + --success: oklch(0.76 0.13 148); + --success-foreground: oklch(0.2 0.05 148); + --warning: oklch(0.82 0.12 88); + --warning-foreground: oklch(0.24 0.06 80); + --info: oklch(0.76 0.1 220); + --info-foreground: oklch(0.86 0.04 220); + --border: oklch(0.3 0.016 150); + --input: oklch(0.28 0.016 150); + --ring: oklch(0.79 0.165 128); + --chart-1: oklch(0.79 0.165 128); + --chart-2: oklch(0.72 0.12 210); + --chart-3: oklch(0.8 0.12 88); + --chart-4: oklch(0.7 0.12 55); + --chart-5: oklch(0.68 0.1 300); + --sidebar: oklch(0.135 0.014 155); + --sidebar-foreground: oklch(0.9 0.012 110); + --sidebar-primary: oklch(0.79 0.165 128); + --sidebar-primary-foreground: oklch(0.18 0.04 128); + --sidebar-accent: oklch(0.21 0.02 150); + --sidebar-accent-foreground: oklch(0.95 0.01 110); + --sidebar-border: oklch(0.24 0.016 150); + --sidebar-ring: oklch(0.79 0.165 128); color-scheme: dark; } @@ -146,6 +155,11 @@ linear-gradient(to bottom, color-mix(in oklch, var(--border) 22%, transparent) 1px, transparent 1px); background-size: 32px 32px; } + .dark body { + background-image: + linear-gradient(to right, color-mix(in oklch, var(--border) 12%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in oklch, var(--border) 12%, transparent) 1px, transparent 1px); + } h1, h2, h3 { @@ -185,6 +199,16 @@ background-size: 36px 36px; } + .auth-scanlines { + background: repeating-linear-gradient( + to bottom, + transparent 0, + transparent 2px, + color-mix(in oklch, white 6%, transparent) 3px + ); + mix-blend-mode: overlay; + } + .server-form [data-slot="tabs-content"] { @apply border border-border bg-background/65 p-4 sm:p-6; } diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 8c840fe..14a4ae1 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -2,15 +2,111 @@ import { ArrowRight } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import { Button } from "@/components/ui/button"; +import { useEffect, useRef, useState } from "react"; import { AuthFormCard, FormError } from "@/components/auth/auth-form-card"; -import { BrandLockup } from "@/components/brand"; +import { BrandLockup, BrandMark } from "@/components/brand"; import { FullScreenLoader } from "@/components/page-layout"; import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient, signIn, useSession } from "@/lib/auth-client"; +import { gsap, motionEase, prefersReducedMotion, useGSAP } from "@/lib/motion"; + +function LoginHero() { + const heroRef = useRef(null); + + useGSAP( + () => { + const root = heroRef.current; + if (!root || prefersReducedMotion()) return; + + const bg = root.querySelector("[data-hero-bg]"); + const brand = root.querySelector("[data-hero-brand]"); + const chip = root.querySelector("[data-hero-chip]"); + const lines = root.querySelectorAll("[data-hero-line]"); + const copy = root.querySelector("[data-hero-copy]"); + const foot = root.querySelector("[data-hero-foot]"); + const rail = root.querySelector("[data-hero-rail]"); + + const tl = gsap.timeline({ defaults: { ease: motionEase.out } }); + tl.from(bg, { scale: 1.12, duration: 1.2, ease: "power2.out" }, 0) + .from(rail, { scaleY: 0, duration: 0.6, transformOrigin: "top" }, 0.05) + .from(brand, { y: -12, autoAlpha: 0, duration: 0.45 }, 0.08) + .from(chip, { y: 14, autoAlpha: 0, duration: 0.4 }, 0.16) + .from(lines, { yPercent: 110, duration: 0.7, stagger: 0.08, ease: motionEase.snap }, 0.2) + .from(copy, { y: 14, autoAlpha: 0, duration: 0.45 }, 0.42) + .from(foot, { autoAlpha: 0, duration: 0.35 }, 0.55); + + gsap.to(bg, { + scale: 1.06, + duration: 18, + ease: "none", + yoyo: true, + repeat: -1, + delay: 1.2, + }); + }, + { scope: heroRef } + ); + + return ( +

+
+
+
+
+ +
+ +
+ +
+ + + Minecraft Control Plane + +

+ + + Play more + + + + + Operate less + + +

+

+ Spin up servers, route players, and run the whole network from one console. +

+
+ +

+ Built for people who actually run servers +

+
+ ); +} export default function LoginPage() { const router = useRouter(); @@ -47,7 +143,9 @@ export default function LoginPage() { }); if (!refreshedSession.data?.user) { - setError("Signed in, but the session cookie was not accepted. Check the web and API URLs."); + setError( + "Signed in, but the session cookie was not accepted. Check the web and API URLs." + ); return; } @@ -64,67 +162,45 @@ export default function LoginPage() { return (
-
-
- - -
- - Minecraft operations platform - -

- Orchestrate -
- Every World. -

-

- Provision servers, manage proxy routes, and monitor Kubernetes workloads from a single - operational interface. -

-
- -

- Server and cluster administration -

-
+
- + } + className="border-2 border-foreground shadow-[8px_8px_0_color-mix(in_oklch,var(--foreground)_18%,transparent)]" headerClassName="border-b" - contentClassName="pt-2" > -
-
- - -
-
- - -
- - - +
+
+ + +
+
+ + +
+ + +
diff --git a/apps/web/components/auth/auth-form-card.tsx b/apps/web/components/auth/auth-form-card.tsx index e4beecb..06a2f08 100644 --- a/apps/web/components/auth/auth-form-card.tsx +++ b/apps/web/components/auth/auth-form-card.tsx @@ -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 ( - - - {title} - {description} - - {children} - + + +
+
+ {leading} +
+

+ {title} +

+

+ {description} +

+
+
+
+ + + {children} + + +
+
); } export function FormError({ message }: { message?: string | null }) { + const ref = useShake(message); + if (!message) return null; return (
diff --git a/apps/web/components/brand.tsx b/apps/web/components/brand.tsx index 8c6f515..95a237f 100644 --- a/apps/web/components/brand.tsx +++ b/apps/web/components/brand.tsx @@ -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 ( + + ); +} + +export function BrandMark({ className, inverted }: BrandMarkProps) { + const ref = useRef(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 ( ); } @@ -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 (
- +
-

+

Minikura

-

{subtitle}

+

+ {subtitle} +

); diff --git a/apps/web/components/dashboard-layout.tsx b/apps/web/components/dashboard-layout.tsx index f89e75f..67f7f96 100644 --- a/apps/web/components/dashboard-layout.tsx +++ b/apps/web/components/dashboard-layout.tsx @@ -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 ; + 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 }) { /> - {navigation.map((group) => ( + {visibleNavigation.map((group) => ( {group.label} @@ -164,7 +205,11 @@ export function DashboardLayout({ children }: { children: React.ReactNode }) { -
{children}
+
+ + {children} + +
); diff --git a/apps/web/components/motion.tsx b/apps/web/components/motion.tsx new file mode 100644 index 0000000..1486480 --- /dev/null +++ b/apps/web/components/motion.tsx @@ -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(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 ( +
+ {children} +
+ ); +} + +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(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 ( +
+ {children} +
+ ); +} + +type HoverLiftProps = { + children: ReactNode; + className?: string; + y?: number; +}; + +export function HoverLift({ children, className, y = -3 }: HoverLiftProps) { + const ref = useRef(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 ( +
+ {children} +
+ ); +} + +export function useShake(trigger: string | null | undefined) { + const ref = useRef(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; +} diff --git a/apps/web/components/page-layout.tsx b/apps/web/components/page-layout.tsx index ebb36db..15596a9 100644 --- a/apps/web/components/page-layout.tsx +++ b/apps/web/components/page-layout.tsx @@ -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 ( -
-
- {leading} -
- {eyebrow} -

{title}

- {description &&

{description}

} + +
+
+ {leading} +
+ {eyebrow} +

{title}

+ {description &&

{description}

} +
-
- {actions} -
+ {actions} + + ); } @@ -58,7 +65,9 @@ export function StatePanel({ ...props }: StatePanelProps) { return ( -
{loading ? ( - + ) : ( icon &&
{icon}
)} @@ -75,16 +84,87 @@ export function StatePanel({ {description &&
{description}
} {action &&
{action}
}
+ + ); +} + +function LoaderMark({ className }: { className?: string }) { + const ref = useRef(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 ( +
+
); } export function FullScreenLoader({ label }: { label?: string }) { + const rootRef = useRef(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 ( -
- +
+
+ +
+ +
+
{label && ( - + {label} )} diff --git a/apps/web/components/section-card.tsx b/apps/web/components/section-card.tsx index e35b00c..2b54208 100644 --- a/apps/web/components/section-card.tsx +++ b/apps/web/components/section-card.tsx @@ -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 ( - - -
-
- {icon} - {title} + + + +
+
+ {icon} + {title} +
+ {headerAction}
- {headerAction} -
- {description && {description}} - - {children} - + {description && {description}} + + {children} + + ); } diff --git a/apps/web/components/server-form/advanced-panel.tsx b/apps/web/components/server-form/advanced-panel.tsx index ffd3199..bd53d89 100644 --- a/apps/web/components/server-form/advanced-panel.tsx +++ b/apps/web/components/server-form/advanced-panel.tsx @@ -23,7 +23,11 @@ export function AdvancedPanel({ return (
- + Mods/plugins automation is intended for Vanilla/Paper workflows. )} - +