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 }),
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { login } from "@/lib/api";
|
||||
|
||||
interface AuthContextValue {
|
||||
authenticated: boolean;
|
||||
loading: boolean;
|
||||
login: (password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const password = localStorage.getItem("admin-password");
|
||||
if (password) {
|
||||
// Verify stored password still works
|
||||
login(password)
|
||||
.then((ok) => {
|
||||
setAuthenticated(ok);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("admin-password");
|
||||
setLoading(false);
|
||||
});
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLogin = useCallback(async (password: string) => {
|
||||
const ok = await login(password);
|
||||
if (ok) {
|
||||
localStorage.setItem("admin-password", password);
|
||||
setAuthenticated(true);
|
||||
}
|
||||
return ok;
|
||||
}, []);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem("admin-password");
|
||||
setAuthenticated(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
authenticated,
|
||||
loading,
|
||||
login: handleLogin,
|
||||
logout: handleLogout,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { configApi } from "@/lib/api";
|
||||
|
||||
export interface AppConfig {
|
||||
monitorGuildId: string | null;
|
||||
webserverPort?: number;
|
||||
nodeEnv?: string;
|
||||
}
|
||||
|
||||
export function useAppConfig() {
|
||||
const [config, setConfig] = useState<AppConfig | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
configApi
|
||||
.get()
|
||||
.then((cfg) => {
|
||||
setConfig({
|
||||
monitorGuildId: cfg.monitor_guild_id ?? null,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// silent — config fetch is not critical
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return { config, loading };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { MessageRecord } from "./message";
|
||||
|
||||
export interface DashboardStats {
|
||||
total_messages: number;
|
||||
total_users: number;
|
||||
total_flagged: number;
|
||||
total_clean: number;
|
||||
total_warned: number;
|
||||
total_error: number;
|
||||
total_voice_recordings: number;
|
||||
total_profiles: number;
|
||||
today_messages: number;
|
||||
today_flagged: number;
|
||||
active_users_24h: number;
|
||||
top_channels: TopChannel[];
|
||||
moderation_overview: ModerationOverview;
|
||||
}
|
||||
|
||||
export interface TopChannel {
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ModerationOverview {
|
||||
pending: number;
|
||||
processing: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface DashboardUser {
|
||||
user_id: string;
|
||||
username?: string | null;
|
||||
avatar_url?: string | null;
|
||||
profile_summary?: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
last_message_at?: number | null;
|
||||
trust_score?: number | null;
|
||||
clean_message_streak?: number;
|
||||
}
|
||||
|
||||
export interface DashboardUserDetail extends DashboardUser {
|
||||
last_analyzed_at?: number | null;
|
||||
clean_message_streak: number;
|
||||
total_infractions: number;
|
||||
clean_count: number;
|
||||
recent_messages: MessageRecord[];
|
||||
}
|
||||
|
||||
export interface DashboardChannel {
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
guild_id?: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
last_message_at?: number | null;
|
||||
culture_summary?: string | null;
|
||||
last_analyzed_at?: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardChannelDetail {
|
||||
channel_id: string;
|
||||
channel_name?: string | null;
|
||||
guild_id?: string | null;
|
||||
total_messages: number;
|
||||
flagged_count: number;
|
||||
last_message_at?: number | null;
|
||||
culture_summary?: string | null;
|
||||
last_analyzed_at?: number | null;
|
||||
clean_count: number;
|
||||
recent_messages: MessageRecord[];
|
||||
}
|
||||
|
||||
export interface PaginatedUsers {
|
||||
data: DashboardUser[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedChannels {
|
||||
data: DashboardChannel[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export interface Guild {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
type?: string | null; // "voice" | "text"
|
||||
parent_id?: string | null;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
monitor_guild_id?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export * from "./dashboard";
|
||||
export * from "./guild";
|
||||
export * from "./media";
|
||||
export * from "./message";
|
||||
export * from "./recording";
|
||||
export * from "./ui";
|
||||
export * from "./voice";
|
||||
@@ -0,0 +1,17 @@
|
||||
export type MediaMode = "music" | "screen";
|
||||
|
||||
export interface MediaItem {
|
||||
id?: string | null;
|
||||
source: string;
|
||||
title?: string | null;
|
||||
mode?: MediaMode | null;
|
||||
durationMs?: number | null;
|
||||
thumbnailUrl?: string | null;
|
||||
}
|
||||
|
||||
export interface MediaState {
|
||||
playing: boolean;
|
||||
musicVolume: number;
|
||||
current?: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// ── AI Moderation Types ──────────────────────────────────────
|
||||
|
||||
export type AiStatus =
|
||||
| "pending"
|
||||
| "processing"
|
||||
| "clean"
|
||||
| "warn"
|
||||
| "flagged"
|
||||
| "error";
|
||||
|
||||
export type AiSeverity = "none" | "low" | "medium" | "high" | "critical";
|
||||
|
||||
export type AiRecommendedAction =
|
||||
| "none"
|
||||
| "monitor"
|
||||
| "warn"
|
||||
| "review"
|
||||
| "delete"
|
||||
| "escalate";
|
||||
|
||||
// ── Embeds & Metadata ────────────────────────────────────────
|
||||
|
||||
export interface EmbedMedia {
|
||||
url: string;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface EmbedInfo {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
url?: string | null;
|
||||
color?: number | null;
|
||||
image?: EmbedMedia | null;
|
||||
thumbnail?: EmbedMedia | null;
|
||||
author?: { name?: string; url?: string; icon_url?: string } | null;
|
||||
footer?: { text: string; icon_url?: string } | null;
|
||||
fields?: Array<{ name: string; value: string; inline?: boolean }>;
|
||||
}
|
||||
|
||||
export interface StickerInfo {
|
||||
name?: string | null;
|
||||
url?: string | null;
|
||||
}
|
||||
|
||||
export interface AttachmentRef {
|
||||
name: string;
|
||||
url: string;
|
||||
contentType?: string | null;
|
||||
}
|
||||
|
||||
export interface ChannelRef {
|
||||
channelId: string;
|
||||
channelName?: string | null;
|
||||
threadId?: string | null;
|
||||
threadName?: string | null;
|
||||
}
|
||||
|
||||
export interface ReferenceInfo {
|
||||
messageId?: string | null;
|
||||
channelId?: string | null;
|
||||
guildId?: string | null;
|
||||
type?: string | null;
|
||||
content?: string | null;
|
||||
repliedUsername?: string | null;
|
||||
repliedUserId?: string | null;
|
||||
}
|
||||
|
||||
export interface MessageMetadata {
|
||||
stickers?: StickerInfo[] | null;
|
||||
attachments?: AttachmentRef[] | null;
|
||||
embeds?: EmbedInfo[] | null;
|
||||
channel?: ChannelRef | null;
|
||||
reference?: ReferenceInfo | null;
|
||||
}
|
||||
|
||||
// ── Message Record ──────────────────────────────────────────
|
||||
|
||||
export interface MessageRecord {
|
||||
id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id?: string | null;
|
||||
reference_message_id?: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url?: string | null;
|
||||
content: string;
|
||||
edited_content?: string | null;
|
||||
type: string; // "text" | "edited" | "deleted"
|
||||
is_reply?: boolean | null;
|
||||
is_forward?: boolean | null;
|
||||
is_crosspost?: boolean | null;
|
||||
metadata?: string | null; // JSON string of MessageMetadata
|
||||
created_at: number;
|
||||
edited_at?: number | null;
|
||||
deleted_at?: number | null;
|
||||
ai_status?: AiStatus | null;
|
||||
ai_severity?: AiSeverity | null;
|
||||
ai_confidence?: number | null;
|
||||
ai_moderation_flags?: string | null; // JSON string array
|
||||
ai_moderation_score?: number | null;
|
||||
ai_analysis?: string | null;
|
||||
ai_categories?: string | null; // JSON string array
|
||||
ai_recommended_action?: AiRecommendedAction | null;
|
||||
ai_error?: string | null;
|
||||
ai_analyzed_at?: number | null;
|
||||
}
|
||||
|
||||
// ── Pagination ──────────────────────────────────────────────
|
||||
|
||||
export interface PageResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
// ── Attachment ──────────────────────────────────────────────
|
||||
|
||||
export interface AttachmentRecord {
|
||||
id: string;
|
||||
message_id: string;
|
||||
guild_id: string;
|
||||
channel_id: string;
|
||||
thread_id?: string | null;
|
||||
user_id: string;
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
discord_url: string;
|
||||
uploaded_url?: string | null;
|
||||
upload_status: "pending" | "uploaded" | "failed";
|
||||
upload_error?: string | null;
|
||||
created_at: number;
|
||||
uploaded_at?: number | null;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export interface VoiceRecording {
|
||||
id: string;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar_url?: string | null;
|
||||
guild_id?: string | null;
|
||||
channel_id?: string | null;
|
||||
channel_name?: string | null;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
download_url?: string | null;
|
||||
upload_status: string;
|
||||
upload_error?: string | null;
|
||||
transcription?: string | null;
|
||||
created_at: number;
|
||||
uploaded_at?: number | null;
|
||||
}
|
||||
|
||||
export interface PaginatedRecordings {
|
||||
items: VoiceRecording[];
|
||||
nextCursor: string | null;
|
||||
hasMore: boolean;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type DashboardTab = "messages" | "live" | "dashboard";
|
||||
|
||||
export interface UiState {
|
||||
selected_guild?: string | null;
|
||||
selected_voice_guild?: string | null;
|
||||
selected_voice_channel?: string | null;
|
||||
selected_text_guild?: string | null;
|
||||
selected_text_channel?: string | null;
|
||||
active_tab?: DashboardTab | null;
|
||||
is_listening?: boolean | null;
|
||||
is_streaming?: boolean | null;
|
||||
}
|
||||
|
||||
export interface MascotChatResponse {
|
||||
response: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface ChatHistoryMessage {
|
||||
role: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface GuildVoiceEntry {
|
||||
guildId: string;
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
export interface VoiceStatus {
|
||||
connected: boolean;
|
||||
activeGuildId?: string | null;
|
||||
activeChannelId?: string | null;
|
||||
activeChannelName?: string | null;
|
||||
connections: GuildVoiceEntry[];
|
||||
}
|
||||
|
||||
export interface ActiveSpeaker {
|
||||
id?: string | null;
|
||||
user_id: string;
|
||||
username: string;
|
||||
avatar?: string | null;
|
||||
speaking: boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user