2026-06-09 10:16:04 +07:00
|
|
|
import { useEffect, useMemo, useState } from "react";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { AuthOverlay } from "./features/auth";
|
2026-06-13 11:24:42 +07:00
|
|
|
import { DashboardPanel } from "./features/dashboard";
|
2026-06-01 21:44:29 +07:00
|
|
|
import { LivePanel } from "./features/live";
|
|
|
|
|
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
|
|
|
|
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
|
|
|
|
import { MessagesPanel } from "./features/messages";
|
2026-06-02 12:17:51 +07:00
|
|
|
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
|
2026-06-01 21:44:29 +07:00
|
|
|
import {
|
|
|
|
|
mergeMessages,
|
|
|
|
|
useMessages,
|
|
|
|
|
} from "./features/messages/hooks/useMessages";
|
|
|
|
|
import {
|
|
|
|
|
type ActiveSpeaker,
|
|
|
|
|
getAppConfig,
|
|
|
|
|
type MediaState,
|
|
|
|
|
type MessageRecord,
|
|
|
|
|
} from "./shared/api/client";
|
|
|
|
|
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
|
|
|
|
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
|
|
|
|
import { useUIState } from "./shared/hooks/useUIState";
|
|
|
|
|
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
|
|
|
|
import { useDashboardSocket } from "./shared/ws/socket";
|
|
|
|
|
import { DashboardLayout } from "./widgets/DashboardLayout";
|
|
|
|
|
|
|
|
|
|
export default function App() {
|
|
|
|
|
const { uiState, patchUIState } = useUIState();
|
|
|
|
|
const voice = useVoiceControl();
|
|
|
|
|
const media = useMediaControl();
|
|
|
|
|
const messages = useMessages();
|
|
|
|
|
const [activeSpeakers, setActiveSpeakers] = useState<ActiveSpeaker[]>([]);
|
|
|
|
|
const [isAuthenticated, setIsAuthenticated] = useState(
|
|
|
|
|
!!localStorage.getItem("admin-password"),
|
|
|
|
|
);
|
|
|
|
|
const [monitorGuildId, setMonitorGuildId] = useState("");
|
|
|
|
|
|
|
|
|
|
const audio = useAudioPlayback();
|
2026-06-12 21:28:05 +07:00
|
|
|
const activeTab = uiState.activeTab || "messages";
|
2026-06-01 21:44:29 +07:00
|
|
|
const selectedVoiceGuild =
|
|
|
|
|
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
2026-06-02 11:02:54 +07:00
|
|
|
|
|
|
|
|
// Resolve monitor guild name from the full guild list (has real names now)
|
|
|
|
|
const monitorGuildName = useMemo(
|
2026-06-01 21:44:29 +07:00
|
|
|
() =>
|
|
|
|
|
monitorGuildId
|
2026-06-02 11:02:54 +07:00
|
|
|
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
|
|
|
|
|
: null,
|
2026-06-01 21:44:29 +07:00
|
|
|
[monitorGuildId, voice.guilds],
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-13 14:24:09 +07:00
|
|
|
// Update speaker list from incremental voice_active_user events
|
|
|
|
|
const updateSpeakerList = (
|
|
|
|
|
prev: ActiveSpeaker[],
|
|
|
|
|
data: Partial<ActiveSpeaker> & { userId?: string; id?: string; speaking: boolean },
|
|
|
|
|
): ActiveSpeaker[] => {
|
|
|
|
|
const key = data.userId ?? data.id;
|
|
|
|
|
if (!key) return prev;
|
|
|
|
|
const idx = prev.findIndex(
|
|
|
|
|
(s) => (s.userId ?? s.id) === key,
|
|
|
|
|
);
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
const next = [...prev];
|
|
|
|
|
next[idx] = { ...next[idx], ...data };
|
|
|
|
|
return next;
|
|
|
|
|
}
|
|
|
|
|
return [...prev, data as ActiveSpeaker];
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-01 21:44:29 +07:00
|
|
|
const socket = useDashboardSocket({
|
2026-06-09 10:16:04 +07:00
|
|
|
onVoicePcmData: (d) =>
|
|
|
|
|
audio.handleIncomingPcm(d as { userId: string; pcm: string }),
|
2026-06-01 21:44:29 +07:00
|
|
|
onUserState: (users) => setActiveSpeakers(users as ActiveSpeaker[]),
|
2026-06-13 14:24:09 +07:00
|
|
|
onVoiceActiveUser: (data) =>
|
|
|
|
|
setActiveSpeakers((prev) =>
|
|
|
|
|
updateSpeakerList(
|
|
|
|
|
prev,
|
|
|
|
|
data as Partial<ActiveSpeaker> & {
|
|
|
|
|
userId?: string;
|
|
|
|
|
id?: string;
|
|
|
|
|
speaking: boolean;
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
onVoiceRecordingStarted: () =>
|
|
|
|
|
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
|
|
|
|
onVoiceRecordingStopped: () =>
|
|
|
|
|
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
2026-06-01 21:44:29 +07:00
|
|
|
onMessageCreated: (m) =>
|
|
|
|
|
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])),
|
|
|
|
|
onMessageUpdated: (m) => {
|
|
|
|
|
const d = m as Partial<MessageRecord> & { id: string };
|
|
|
|
|
messages.setMessages((prev) =>
|
|
|
|
|
prev.map((i) => (i.id === d.id ? { ...i, ...d } : i)),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
onMessageDeleted: (m) => {
|
|
|
|
|
const d = m as { id: string };
|
|
|
|
|
messages.setMessages((prev) =>
|
|
|
|
|
prev.map((i) =>
|
|
|
|
|
i.id === d.id ? { ...i, type: "deleted" as const } : i,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
2026-06-02 12:17:51 +07:00
|
|
|
onMessageAnalyzed: (m) => {
|
|
|
|
|
const msg = m as MessageRecord;
|
|
|
|
|
messages.setMessages((prev) => mergeMessages(prev, [msg]));
|
2026-06-03 01:13:47 +07:00
|
|
|
// Show toast for moderation alerts (flagged)
|
2026-06-02 12:17:51 +07:00
|
|
|
const status = msg.ai_status;
|
2026-06-03 01:13:47 +07:00
|
|
|
if (status === "flagged") {
|
2026-06-02 12:17:51 +07:00
|
|
|
const username = msg.username || msg.user_id || "unknown";
|
|
|
|
|
const severity = msg.ai_severity || "";
|
|
|
|
|
const categories = msg.ai_categories || "";
|
2026-06-03 03:21:20 +07:00
|
|
|
const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI";
|
2026-06-02 12:17:51 +07:00
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent("moderation_alert", {
|
|
|
|
|
detail: { type: status, username, severity, categories, brief },
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-06-01 21:44:29 +07:00
|
|
|
onAttachmentUploaded: () =>
|
2026-06-02 11:02:54 +07:00
|
|
|
messages
|
2026-06-03 20:53:47 +07:00
|
|
|
.fetchMessages(monitorGuildId || undefined)
|
2026-06-02 11:02:54 +07:00
|
|
|
.catch(() => undefined),
|
2026-06-09 16:56:44 +07:00
|
|
|
onAttachmentCreated: () =>
|
|
|
|
|
messages
|
|
|
|
|
.fetchMessages(monitorGuildId || undefined)
|
|
|
|
|
.catch(() => undefined),
|
2026-06-01 21:44:29 +07:00
|
|
|
onMediaState: (state) => media.setMediaState(state as MediaState),
|
|
|
|
|
onVoiceRecordingUploaded: (d) =>
|
|
|
|
|
window.dispatchEvent(
|
|
|
|
|
new CustomEvent("voice_recording_uploaded", { detail: d }),
|
|
|
|
|
),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const transmit = useAudioTransmit(socket.socketRef);
|
|
|
|
|
|
2026-06-02 11:02:54 +07:00
|
|
|
// Load app config on mount
|
2026-06-01 21:44:29 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
getAppConfig()
|
|
|
|
|
.then((c) => {
|
|
|
|
|
if (c.monitorGuildId) {
|
|
|
|
|
setMonitorGuildId(c.monitorGuildId);
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.catch(() => undefined);
|
2026-06-02 11:02:54 +07:00
|
|
|
}, []);
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-02 11:02:54 +07:00
|
|
|
// Load voice channels when guild changes (Live tab)
|
2026-06-01 21:44:29 +07:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (selectedVoiceGuild)
|
|
|
|
|
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
|
|
|
|
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
2026-06-02 11:02:54 +07:00
|
|
|
|
2026-06-03 20:31:23 +07:00
|
|
|
// Auto-fetch messages for the monitor guild
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (monitorGuildId)
|
2026-06-09 10:16:04 +07:00
|
|
|
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
2026-06-02 11:02:54 +07:00
|
|
|
}, [monitorGuildId, messages.fetchMessages]);
|
2026-06-01 21:44:29 +07:00
|
|
|
|
2026-06-02 11:02:54 +07:00
|
|
|
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
2026-06-01 21:44:29 +07:00
|
|
|
useEffect(() => {
|
2026-06-02 11:02:54 +07:00
|
|
|
if (!monitorGuildId) return;
|
2026-06-01 21:44:29 +07:00
|
|
|
const interval = setInterval(() => {
|
2026-06-03 20:53:47 +07:00
|
|
|
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
2026-06-02 11:02:54 +07:00
|
|
|
}, 15_000);
|
2026-06-01 21:44:29 +07:00
|
|
|
return () => clearInterval(interval);
|
2026-06-03 20:53:47 +07:00
|
|
|
}, [monitorGuildId, messages.fetchMessages]);
|
2026-06-01 21:44:29 +07:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DashboardLayout
|
|
|
|
|
activeTab={activeTab}
|
|
|
|
|
wsStatus={socket.status}
|
|
|
|
|
voiceStatus={voice.voiceStatus}
|
|
|
|
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
2026-06-03 14:17:21 +07:00
|
|
|
recentMessages={messages.messages}
|
2026-06-03 15:01:34 +07:00
|
|
|
guildId={monitorGuildId}
|
2026-06-09 10:16:04 +07:00
|
|
|
channelId={
|
|
|
|
|
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
|
|
|
|
}
|
2026-06-01 21:44:29 +07:00
|
|
|
>
|
|
|
|
|
{activeTab === "live" ? (
|
|
|
|
|
!isAuthenticated ? (
|
|
|
|
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
|
|
|
|
) : (
|
|
|
|
|
<LivePanel
|
|
|
|
|
guilds={voice.guilds}
|
|
|
|
|
voiceChannels={voice.voiceChannels}
|
|
|
|
|
selectedGuild={selectedVoiceGuild}
|
|
|
|
|
selectedChannel={uiState.selectedVoiceChannel || ""}
|
|
|
|
|
status={voice.voiceStatus}
|
|
|
|
|
voiceLoading={voice.loading}
|
|
|
|
|
activeSpeakers={activeSpeakers}
|
|
|
|
|
levels={audio.levels}
|
|
|
|
|
isListening={audio.isListening}
|
|
|
|
|
isStreaming={transmit.isStreaming}
|
2026-06-13 14:49:47 +07:00
|
|
|
micLevel={transmit.micLevel}
|
2026-06-01 21:44:29 +07:00
|
|
|
mediaState={media.mediaState}
|
|
|
|
|
mediaLoading={media.loading}
|
|
|
|
|
onGuildChange={(id) =>
|
|
|
|
|
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
|
|
|
|
}
|
|
|
|
|
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
|
|
|
|
onJoin={() =>
|
|
|
|
|
voice.joinVoice(
|
|
|
|
|
selectedVoiceGuild,
|
|
|
|
|
uiState.selectedVoiceChannel || "",
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
onDisconnect={() => voice.leaveVoice()}
|
|
|
|
|
onListenToggle={audio.toggleListening}
|
|
|
|
|
onStreamingToggle={transmit.toggle}
|
|
|
|
|
onQueueMusic={(s) => media.enqueue(s, "music")}
|
|
|
|
|
onStartScreen={(s) => media.enqueue(s, "screen")}
|
|
|
|
|
onSkip={media.skip}
|
|
|
|
|
onStop={media.stop}
|
|
|
|
|
onVolumeChange={media.setVolume}
|
|
|
|
|
/>
|
|
|
|
|
)
|
2026-06-13 11:24:42 +07:00
|
|
|
) : activeTab === "dashboard" ? (
|
2026-06-12 21:28:05 +07:00
|
|
|
!isAuthenticated ? (
|
|
|
|
|
<AuthOverlay onAuthenticated={() => setIsAuthenticated(true)} />
|
|
|
|
|
) : (
|
2026-06-13 11:24:42 +07:00
|
|
|
<DashboardPanel />
|
2026-06-12 21:28:05 +07:00
|
|
|
)
|
2026-06-09 10:16:04 +07:00
|
|
|
) : (
|
2026-06-01 21:44:29 +07:00
|
|
|
<MessagesPanel
|
2026-06-02 11:02:54 +07:00
|
|
|
guildName={monitorGuildName}
|
2026-06-01 21:44:29 +07:00
|
|
|
messages={messages.messages}
|
|
|
|
|
onReanalyze={messages.reanalyze}
|
2026-06-03 00:17:51 +07:00
|
|
|
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
2026-06-01 21:44:29 +07:00
|
|
|
onLoadMore={messages.loadMore}
|
|
|
|
|
hasMore={messages.hasMore}
|
|
|
|
|
loadingMore={messages.loadingMore}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<MobileTabBar
|
|
|
|
|
activeTab={activeTab}
|
|
|
|
|
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
|
|
|
|
/>
|
2026-06-02 12:17:51 +07:00
|
|
|
<ModerationAlertListener />
|
2026-06-01 21:44:29 +07:00
|
|
|
</DashboardLayout>
|
|
|
|
|
);
|
|
|
|
|
}
|