feat: migrate Leptos frontend to Next.js 16 (React 19)
Deploy to VPS / deploy (push) Failing after 42s
Deploy to VPS / deploy (push) Failing after 42s
Complete migration from services/frontend.old/ (Leptos 0.7 WASM + Rust) to services/frontend/ (Next.js 16 static export + TypeScript + Tailwind v4). Summary: - Port all shared types (message, guild, voice, media, dashboard, recording, ui) - Build fetch-based API client covering all 30+ backend endpoints - WebSocket client with auto-reconnect (exponential backoff, 20 attempts) - React context provider for WS with typed event subscription (22 event types) - Login page with localStorage auth + auto-redirect - Dashboard layout with sidebar, header (WS status + theme toggle) - Messages: feed, search, images tab, review tab, channel filter, detail modal - Live: voice connection, music player, recordings, mic transmit, active speakers - Dashboard: stats, user list, channel list, detail views - Mascot chatbot with history + clear - uiStateApi persistence for selected tab - Add static export config, update deploy scripts and CI
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useRef } from "react";
|
||||
import { uiStateApi } from "@/lib/api";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { MobileTabBar } from "@/components/layout/mobile-tab-bar";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { MascotChatbot } from "@/features/mascot/mascot-chatbot";
|
||||
import { AuthProvider, useAuth } from "@/lib/hooks/use-auth";
|
||||
import { WsProvider } from "@/lib/ws/context";
|
||||
|
||||
function DashboardGuard({ children }: { children: React.ReactNode }) {
|
||||
const { authenticated, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !authenticated) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [authenticated, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticated) return null;
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function DashboardShell({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const restored = useRef(false);
|
||||
|
||||
const activeTab = (searchParams.get("tab") ?? "messages") as
|
||||
| "messages"
|
||||
| "live"
|
||||
| "dashboard";
|
||||
|
||||
// Restore persisted tab on mount (only if no explicit tab in URL)
|
||||
useEffect(() => {
|
||||
if (restored.current) return;
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam) {
|
||||
restored.current = true;
|
||||
return; // explicit tab in URL — don't override
|
||||
}
|
||||
uiStateApi
|
||||
.get()
|
||||
.then((state) => {
|
||||
restored.current = true;
|
||||
const savedTab = state.active_tab;
|
||||
if (savedTab && savedTab !== activeTab) {
|
||||
router.replace(`/dashboard?tab=${savedTab}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
restored.current = true;
|
||||
});
|
||||
}, [searchParams, activeTab, router]);
|
||||
|
||||
// Persist tab changes
|
||||
useEffect(() => {
|
||||
if (!restored.current) return;
|
||||
uiStateApi.save({ active_tab: activeTab }).catch(() => {});
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Sidebar activeTab={activeTab} />
|
||||
<div className="md:pl-56 flex flex-col min-h-screen">
|
||||
<Header />
|
||||
<main className="flex-1 p-4 md:p-6 pb-20 md:pb-6">{children}</main>
|
||||
</div>
|
||||
<MobileTabBar activeTab={activeTab} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DashboardGuard>
|
||||
<WsProvider>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="size-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</Suspense>
|
||||
<MascotChatbot />
|
||||
</WsProvider>
|
||||
</DashboardGuard>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { DashboardPanel } from "@/features/dashboard/dashboard-panel";
|
||||
import { LivePanel } from "@/features/live/live-panel";
|
||||
import { MessagesPanel } from "@/features/messages/messages-panel";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const tab = searchParams.get("tab") ?? "messages";
|
||||
|
||||
switch (tab) {
|
||||
case "live":
|
||||
return <LivePanel />;
|
||||
case "dashboard":
|
||||
return <DashboardPanel />;
|
||||
default:
|
||||
return <MessagesPanel />;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,130 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--font-heading: var(--font-sans);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Bete — Discord Moderation Dashboard",
|
||||
description: "Live Discord monitoring and AI moderation dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
try {
|
||||
const theme = localStorage.getItem('theme') || 'dark';
|
||||
document.documentElement.classList.add(theme);
|
||||
} catch(e) {}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, Shield } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { login } from "@/lib/api";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checking, setChecking] = useState(true);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem("admin-password");
|
||||
if (stored) {
|
||||
login(stored)
|
||||
.then((ok) => {
|
||||
if (ok) router.replace("/dashboard");
|
||||
else setChecking(false);
|
||||
})
|
||||
.catch(() => setChecking(false));
|
||||
} else {
|
||||
setChecking(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const ok = await login(password);
|
||||
if (ok) {
|
||||
localStorage.setItem("admin-password", password);
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setError("Invalid password");
|
||||
}
|
||||
} catch {
|
||||
setError("Connection failed. Is the backend running?");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Shield className="size-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Bete</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Discord Moderation Dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium leading-none"
|
||||
>
|
||||
Admin Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter admin password"
|
||||
className="flex h-9 w-full rounded-lg border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !password}
|
||||
className="inline-flex w-full items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{loading ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
|
||||
{loading ? "Signing in…" : "Sign in"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user