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
+144
View File
@@ -0,0 +1,144 @@
import type { WsEvent, WsStatus } from "./types";
type WsEventCallback = (event: WsEvent) => void;
function getWsUrl(): string {
if (typeof window === "undefined") return "ws://localhost:3001/ws";
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const host = window.location.host;
return `${protocol}://${host}/ws`;
}
export class WsConnection {
private ws: WebSocket | null = null;
private url: string;
private reconnectAttempt = 0;
private maxReconnectAttempts = 20;
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
private _status: WsStatus = "disconnected";
private statusListeners: Array<(status: WsStatus) => void> = [];
private eventListeners: Array<WsEventCallback> = [];
private destroyed = false;
constructor(url?: string) {
this.url = url ?? getWsUrl();
}
get status(): WsStatus {
return this._status;
}
onStatusChange(listener: (status: WsStatus) => void): () => void {
this.statusListeners.push(listener);
return () => {
this.statusListeners = this.statusListeners.filter((l) => l !== listener);
};
}
onEvent(listener: WsEventCallback): () => void {
this.eventListeners.push(listener);
return () => {
this.eventListeners = this.eventListeners.filter((l) => l !== listener);
};
}
connect(): void {
if (this.destroyed) return;
if (this._status === "connected" || this._status === "connecting") return;
this.setStatus("connecting");
try {
this.ws = new WebSocket(this.url);
} catch (_err) {
this.setStatus("error");
this.scheduleReconnect();
return;
}
this.ws.onopen = () => {
this.reconnectAttempt = 0;
this.setStatus("connected");
};
this.ws.onclose = () => {
this.setStatus("disconnected");
this.scheduleReconnect();
};
this.ws.onerror = () => {
this.setStatus("error");
};
this.ws.onmessage = (msg: MessageEvent) => {
if (typeof msg.data === "string") {
this.dispatchEvent({ type: "text", data: msg.data });
} else if (msg.data instanceof ArrayBuffer) {
this.dispatchEvent({ type: "binary", data: msg.data });
} else if (msg.data instanceof Blob) {
// Blob — convert to ArrayBuffer
msg.data.arrayBuffer().then((buffer) => {
this.dispatchEvent({ type: "binary", data: buffer });
});
}
};
}
disconnect(): void {
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
if (this.ws) {
this.ws.onclose = null; // prevent reconnect
this.ws.close();
this.ws = null;
}
this.setStatus("disconnected");
}
destroy(): void {
this.destroyed = true;
this.disconnect();
this.statusListeners = [];
this.eventListeners = [];
}
sendText(text: string): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(text);
}
}
sendBinary(data: ArrayBufferLike): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(data);
}
}
private setStatus(status: WsStatus): void {
if (this._status === status) return;
this._status = status;
this.statusListeners.forEach((l) => l(status));
}
private dispatchEvent(event: WsEvent): void {
this.eventListeners.forEach((l) => l(event));
}
private scheduleReconnect(): void {
if (this.destroyed || this.reconnectAttempt >= this.maxReconnectAttempts)
return;
// Full-jitter exponential backoff: min(1000 * 2^attempt, 30000) * (0.5 + random * 0.5)
const base = Math.min(1000 * 2 ** this.reconnectAttempt, 30000);
const jitter = 0.5 + Math.random() * 0.5;
const delay = Math.floor(base * jitter);
this.reconnectAttempt++;
this.reconnectTimeout = setTimeout(() => {
this.connect();
}, delay);
}
}
+165
View File
@@ -0,0 +1,165 @@
"use client";
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { WsConnection } from "./connection";
import type { PcmChunk, WsEventHandler, WsEventType, WsStatus } from "./types";
interface WsContextValue {
status: WsStatus;
connect: () => void;
disconnect: () => void;
sendText: (text: string) => void;
sendBinary: (data: ArrayBufferLike) => void;
/** Subscribe to a typed WS event. Returns unsubscribe function. */
on: <E extends WsEventType>(
eventType: E,
handler: WsEventHandler<E>,
) => () => void;
/** Subscribe to binary PCM events. Returns unsubscribe function. */
onPcm: (handler: (chunk: PcmChunk) => void) => () => void;
}
const WsContext = createContext<WsContextValue | null>(null);
/** FNV-1a 32-bit hash matching the backend's hashUserId function */
function _hashUserId(userId: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < userId.length; i++) {
hash ^= userId.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return hash >>> 0;
}
export function WsProvider({
children,
url,
}: {
children: ReactNode;
url?: string;
}) {
const connRef = useRef<WsConnection | null>(null);
const [status, setStatus] = useState<WsStatus>("disconnected");
// Event handler registry — Ref so listeners survive re-renders without reconnect
// Using unknown as internal store; typed at the subscribe interface
const handlersRef = useRef<Record<string, Set<(data: unknown) => void>>>({});
const pcmHandlersRef = useRef<Set<(chunk: PcmChunk) => void>>(new Set());
const handleJsonEvent = useCallback((json: string) => {
try {
const parsed = JSON.parse(json);
const eventType = parsed.type as string;
const data = parsed.data ?? parsed.state ?? parsed;
const handlers = handlersRef.current;
const eventHandlers = handlers[eventType as WsEventType];
if (eventHandlers && eventHandlers.size > 0) {
eventHandlers.forEach((h) => h(data));
}
} catch {
// ignore parse errors
}
}, []);
const handleBinaryEvent = useCallback((buffer: ArrayBuffer) => {
if (buffer.byteLength < 4 || pcmHandlersRef.current.size === 0) return;
const view = new DataView(buffer);
const userIdHash = view.getUint32(0, true);
const samples = new Int16Array(buffer, 4);
const chunk: PcmChunk = { userIdHash, samples };
pcmHandlersRef.current.forEach((h) => h(chunk));
}, []);
useEffect(() => {
const conn = new WsConnection(url);
connRef.current = conn;
const unsubStatus = conn.onStatusChange(setStatus);
const unsubEvent = conn.onEvent((event) => {
if (event.type === "text") {
handleJsonEvent(event.data);
} else {
handleBinaryEvent(event.data);
}
});
conn.connect();
return () => {
conn.destroy();
connRef.current = null;
unsubStatus();
unsubEvent();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url, handleBinaryEvent, handleJsonEvent]);
const subscribe = useCallback(
<E extends WsEventType>(_eventType: E, handler: WsEventHandler<E>) => {
const eventType = _eventType as string;
if (!handlersRef.current[eventType]) {
handlersRef.current[eventType] = new Set();
}
handlersRef.current[eventType].add(handler as (data: unknown) => void);
return () => {
handlersRef.current[eventType]?.delete(
handler as (data: unknown) => void,
);
};
},
[],
);
const subscribePcm = useCallback((handler: (chunk: PcmChunk) => void) => {
pcmHandlersRef.current.add(handler);
return () => {
pcmHandlersRef.current.delete(handler);
};
}, []);
const connect = useCallback(() => connRef.current?.connect(), []);
const disconnect = useCallback(() => connRef.current?.disconnect(), []);
const sendText = useCallback(
(text: string) => connRef.current?.sendText(text),
[],
);
const sendBinary = useCallback(
(data: ArrayBufferLike) => connRef.current?.sendBinary(data),
[],
);
return (
<WsContext.Provider
value={{
status,
connect,
disconnect,
sendText,
sendBinary,
on: subscribe,
onPcm: subscribePcm,
}}
>
{children}
</WsContext.Provider>
);
}
export function useWebSocket(): WsContextValue {
const ctx = useContext(WsContext);
if (!ctx) {
throw new Error("useWebSocket must be used within a WsProvider");
}
return ctx;
}
+68
View File
@@ -0,0 +1,68 @@
import type {
ActiveSpeaker,
MediaState,
MessageRecord,
VoiceRecording,
} from "@/lib/types";
// ── Connection Status ──────────────────────────────────────
export type WsStatus = "disconnected" | "connecting" | "connected" | "error";
// ── Raw Events (from WebSocket) ────────────────────────────
export type WsEvent = WsTextEvent | WsBinaryEvent;
export interface WsTextEvent {
type: "text";
data: string;
}
export interface WsBinaryEvent {
type: "binary";
data: ArrayBuffer;
}
// ── Typed Event Map ───────────────────────────────────────
export interface WsEventMap {
message_created: MessageRecord;
message_updated: MessageRecord;
message_deleted: string; // message ID
message_analyzed: MessageRecord;
attachment_created: unknown;
attachment_uploaded: unknown;
voice_recording_started: unknown;
voice_recording_stopped: unknown;
voice_recording_uploaded: VoiceRecording;
voice_active_user: ActiveSpeaker;
voice_pcm_data: { userId: string; pcm: string };
voice_analyzed: unknown;
analysis_queue_status: unknown;
reaction_added: unknown;
reaction_removed: unknown;
thread_created: unknown;
thread_deleted: unknown;
thread_updated: unknown;
channel_topic_updated: unknown;
presence_updated: unknown;
guild_member_added: unknown;
guild_member_removed: unknown;
media_state: MediaState;
user_state: unknown;
ui_state: unknown;
heartbeat: unknown;
}
export type WsEventType = keyof WsEventMap;
export type WsEventHandler<E extends WsEventType = WsEventType> = (
data: WsEventMap[E],
) => void;
// ── Binary PCM ─────────────────────────────────────────────
export interface PcmChunk {
userIdHash: number;
samples: Int16Array;
}