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,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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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}`),
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
Reference in New Issue
Block a user