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
+13
View File
@@ -0,0 +1,13 @@
import { ApiError, api } from "./client";
export async function login(password: string): Promise<boolean> {
try {
const resp = await api.post<{ ok: boolean }>("/api/auth/login", {
password,
});
return resp.ok;
} catch (err) {
if (err instanceof ApiError) return false;
throw err;
}
}
+63
View File
@@ -0,0 +1,63 @@
export class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
}
}
function getBaseUrl(): string {
if (typeof window === "undefined") return "";
const protocol = window.location.protocol.replace(":", "");
const host = window.location.host;
// In dev, Next.js proxy can be configured, but default to same-host assumption
return `${protocol}://${host}`;
}
function getAuthHeader(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("admin-password");
}
export async function apiRequest<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const url = `${getBaseUrl()}${path}`;
const password = getAuthHeader();
const headers: Record<string, string> = {};
if (password) {
headers["X-Admin-Password"] = password;
}
if (body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status >= 400) {
const text = await response.text().catch(() => "");
throw new ApiError(text || `HTTP ${response.status}`, response.status);
}
// Handle 204 No Content (e.g., DELETE)
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export const api = {
get: <T>(path: string) => apiRequest<T>("GET", path),
post: <T>(path: string, body?: unknown) => apiRequest<T>("POST", path, body),
delete: <T>(path: string) => apiRequest<T>("DELETE", path),
};
+6
View File
@@ -0,0 +1,6 @@
import type { AppConfig } from "@/lib/types";
import { api } from "./client";
export const configApi = {
get: () => api.get<AppConfig>("/api/config"),
};
@@ -0,0 +1,38 @@
import type {
DashboardChannelDetail,
DashboardStats,
DashboardUserDetail,
PaginatedChannels,
PaginatedUsers,
} from "@/lib/types";
import { api } from "./client";
export const dashboardApi = {
getStats: () => api.get<DashboardStats>("/api/dashboard/stats"),
listUsers: (limit?: number, cursor?: string, search?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
if (search) params.set("search", search);
const qs = params.toString();
return api.get<PaginatedUsers>(`/api/dashboard/users${qs ? `?${qs}` : ""}`);
},
getUserDetail: (userId: string) =>
api.get<DashboardUserDetail>(`/api/dashboard/users/${userId}`),
listChannels: (limit?: number, search?: string, guildId?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (search) params.set("search", search);
if (guildId) params.set("guild_id", guildId);
const qs = params.toString();
return api.get<PaginatedChannels>(
`/api/dashboard/channels${qs ? `?${qs}` : ""}`,
);
},
getChannelDetail: (channelId: string) =>
api.get<DashboardChannelDetail>(`/api/dashboard/channels/${channelId}`),
};
+9
View File
@@ -0,0 +1,9 @@
export { login } from "./auth";
export { ApiError, api, apiRequest } from "./client";
export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
export { mascotApi } from "./mascot";
export { messagesApi } from "./messages";
export { recordingsApi } from "./recordings";
export { uiStateApi } from "./ui-state";
export { voiceApi } from "./voice";
+11
View File
@@ -0,0 +1,11 @@
import type { ChatHistoryMessage, MascotChatResponse } from "@/lib/types";
import { api } from "./client";
export const mascotApi = {
send: (message: string) =>
api.post<MascotChatResponse>("/api/mascot/chat", { message }),
getHistory: () => api.get<ChatHistoryMessage[]>("/api/mascot/chat/history"),
clearHistory: () => api.delete<{ ok: boolean }>("/api/mascot/chat/history"),
};
+76
View File
@@ -0,0 +1,76 @@
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { api } from "./client";
export const messagesApi = {
list: (
guildId: string,
limit?: number,
channelId?: string,
cursor?: string,
) => {
const params = new URLSearchParams({ guildId });
if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId);
if (cursor) params.set("cursor", cursor);
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
`/api/messages?${params}`,
);
},
getByChannel: (channelId: string, limit?: number, cursor?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
`/api/messages/${channelId}${qs ? `?${qs}` : ""}`,
);
},
getDetail: (id: string) =>
api.get<MessageRecord>(`/api/messages/detail/${id}`),
getImages: (guildId: string, limit?: number) => {
const params = new URLSearchParams({ guildId });
if (limit) params.set("limit", String(limit));
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
`/api/messages/images?${params}`,
);
},
getAttachments: (channelId: string, limit?: number, cursor?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<{ data: AttachmentRecord[]; nextCursor: string | null }>(
`/api/messages/${channelId}/attachments${qs ? `?${qs}` : ""}`,
);
},
getReview: (limit?: number, channelId?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId);
return api.get<{ results: MessageRecord[]; limit: number; cursor: null }>(
`/api/review?${params}`,
);
},
reanalyze: (id: string) =>
api.post<{ ok: boolean }>(`/api/messages/${id}/reanalyze`, {}),
reanalyzeBatch: (guildId?: string, channelId?: string) =>
api.post<{ ok: boolean; count: number }>("/api/messages/reanalyze-batch", {
guildId,
channelId,
}),
search: (query: string, limit?: number) => {
const params = new URLSearchParams({ q: query });
if (limit) params.set("limit", String(limit));
return api.get<{ results: MessageRecord[] }>(
`/api/analysis/search?${params}`,
);
},
};
@@ -0,0 +1,21 @@
import type { PaginatedRecordings } from "@/lib/types";
import { api } from "./client";
export const recordingsApi = {
list: (
limit?: number,
channelId?: string,
userId?: string,
cursor?: string,
) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId);
if (userId) params.set("userId", userId);
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<PaginatedRecordings>(`/api/recordings${qs ? `?${qs}` : ""}`);
},
delete: (id: string) => api.delete<{ ok: boolean }>(`/api/recordings/${id}`),
};
@@ -0,0 +1,8 @@
import type { UiState } from "@/lib/types";
import { api } from "./client";
export const uiStateApi = {
get: () => api.get<UiState>("/api/ui-state"),
save: (state: UiState) => api.post<{ ok: boolean }>("/api/ui-state", state),
};
+30
View File
@@ -0,0 +1,30 @@
import type { Channel, Guild, MediaState, VoiceStatus } from "@/lib/types";
import { api } from "./client";
export const voiceApi = {
// Guilds
getGuilds: () => api.get<Guild[]>("/api/guilds"),
getTextChannels: (guildId: string) =>
api.get<Channel[]>(`/api/guilds/${guildId}/channels`),
getVoiceChannels: (guildId: string) =>
api.get<Channel[]>(`/api/guilds/${guildId}/voice-channels`),
// Voice connection
getStatus: () => api.get<VoiceStatus>("/api/voice/status"),
connect: (guildId: string, channelId: string) =>
api.post<VoiceStatus>("/api/voice/connect", { guildId, channelId }),
disconnect: () => api.post<VoiceStatus>("/api/voice/disconnect", {}),
sendCommand: (command: string) =>
api.post<{ success: boolean; command: string }>("/api/voice/command", {
command,
}),
// Media
getMediaStatus: () => api.get<MediaState>("/api/media/status"),
mediaQueue: (source: string, mode: string) =>
api.post<MediaState>("/api/media/queue", { source, mode }),
mediaSkip: () => api.post<MediaState>("/api/media/skip", {}),
mediaStop: () => api.post<MediaState>("/api/media/stop", {}),
mediaVolume: (volume: number) =>
api.post<MediaState>("/api/media/volume", { volume }),
};