feat: migrate Leptos frontend to Next.js 16 (React 19)
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:
asepharyana
2026-07-26 11:27:21 +07:00
parent cbbe939fad
commit 5e01ec0806
165 changed files with 5035 additions and 16410 deletions
@@ -0,0 +1,63 @@
"use client";
import { Moon, Sun, Wifi, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { useWebSocket } from "@/lib/ws/context";
export function Header() {
const { status } = useWebSocket();
const [theme, setTheme] = useState<"light" | "dark">("dark");
useEffect(() => {
const stored = localStorage.getItem("theme") as "light" | "dark" | null;
if (stored) setTheme(stored);
}, []);
const toggleTheme = () => {
const next = theme === "dark" ? "light" : "dark";
setTheme(next);
localStorage.setItem("theme", next);
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.add(next);
};
return (
<header className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-background px-4 md:px-6">
<div className="flex-1" />
{/* Connection status */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{status === "connected" ? (
<>
<Wifi className="size-3 text-green-500" />
<span className="hidden sm:inline">Connected</span>
</>
) : status === "connecting" ? (
<>
<Wifi className="size-3 text-yellow-500" />
<span className="hidden sm:inline">Connecting</span>
</>
) : (
<>
<WifiOff className="size-3 text-destructive" />
<span className="hidden sm:inline">Disconnected</span>
</>
)}
</div>
{/* Theme toggle */}
<button
type="button"
onClick={toggleTheme}
className="inline-flex size-8 items-center justify-center rounded-lg border hover:bg-muted transition-colors"
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun className="size-4" />
) : (
<Moon className="size-4" />
)}
</button>
</header>
);
}
@@ -0,0 +1,35 @@
"use client";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import { useRouter } from "next/navigation";
const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function MobileTabBar({ activeTab }: { activeTab: TabId }) {
const router = useRouter();
return (
<nav className="md:hidden fixed bottom-0 inset-x-0 z-10 border-t bg-background">
<div className="flex">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => router.push(`/dashboard?tab=${id}`)}
data-active={activeTab === id ? "" : undefined}
className="flex-1 flex flex-col items-center gap-0.5 py-2 text-xs font-medium text-muted-foreground data-[active]:text-primary transition-colors"
>
<Icon className="size-5" />
{label}
</button>
))}
</div>
</nav>
);
}
@@ -0,0 +1,59 @@
"use client";
import { LayoutDashboard, LogOut, MessageSquare, Radio } from "lucide-react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/hooks/use-auth";
const tabs = [
{ id: "messages", label: "Messages", icon: MessageSquare },
{ id: "live", label: "Live", icon: Radio },
{ id: "dashboard", label: "Dashboard", icon: LayoutDashboard },
] as const;
type TabId = (typeof tabs)[number]["id"];
export function Sidebar({ activeTab }: { activeTab: TabId }) {
const { logout } = useAuth();
const router = useRouter();
const handleTabClick = (tabId: TabId) => {
router.push(`/dashboard?tab=${tabId}`);
};
return (
<aside className="hidden md:flex md:w-56 md:flex-col md:fixed md:inset-y-0 border-r bg-sidebar">
<div className="flex h-14 items-center gap-2 border-b px-4">
<div className="size-7 rounded-full bg-primary/10 flex items-center justify-center">
<Radio className="size-4 text-primary" />
</div>
<span className="font-semibold text-sm">Bete</span>
</div>
<nav className="flex-1 space-y-1 p-3">
{tabs.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => handleTabClick(id)}
data-active={activeTab === id ? "" : undefined}
className="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground data-[active]:bg-sidebar-accent data-[active]:text-sidebar-accent-foreground"
>
<Icon className="size-4 shrink-0" />
{label}
</button>
))}
</nav>
<div className="border-t p-3">
<button
type="button"
onClick={logout}
className="w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground transition-colors"
>
<LogOut className="size-4 shrink-0" />
Sign out
</button>
</div>
</aside>
);
}