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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
Reference in New Issue
Block a user