refactor(fe): overhaul — split types, type-safe WS, remove dead deps/code

- Remove unused deps: gsap, @tanstack/react-query, three, r3f, drei, autoprefixer
- Split types from shared/api/client.ts into entities/{guild,voice,media,ui,recording,dashboard}
- Type-safe WebSocket handlers using WsEventMap — 0 'as' casts in App.tsx
- Replace gsap with framer-motion in AuthOverlay
- Remove dead code: useMascotSummary, gsapCardHover, live/components/index barrel
- Fix bare console calls → use createLogger from @bete/shared/logger
- Clean up unused imports and variables (aiVariant, etc.)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-16 22:44:28 +07:00
co-authored by Claude
parent 9af2d7d4dd
commit 752d144dc0
45 changed files with 535 additions and 1314 deletions
+86 -698
View File
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -16,24 +16,18 @@
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@react-three/drei": "^9.6.1",
"@react-three/fiber": "^9.6.1",
"@tanstack/react-query": "^5.100.14",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.4.0", "framer-motion": "^12.4.0",
"gsap": "^3.12.7",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0"
"three": "^0.174.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "latest", "@biomejs/biome": "latest",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@types/three": "^0.184.1",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"postcss": "^8.5.14", "postcss": "^8.5.14",
+19 -38
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { ActiveSpeaker } from "./entities/voice/types.js";
import { DashboardPanel } from "./features/dashboard"; import { DashboardPanel } from "./features/dashboard";
import { LivePanel } from "./features/live"; import { LivePanel } from "./features/live";
import { useMediaControl } from "./features/live/hooks/useMediaControl"; import { useMediaControl } from "./features/live/hooks/useMediaControl";
@@ -9,12 +10,7 @@ import {
mergeMessages, mergeMessages,
useMessages, useMessages,
} from "./features/messages/hooks/useMessages"; } from "./features/messages/hooks/useMessages";
import { import { getAppConfig } from "./shared/api/client";
type ActiveSpeaker,
getAppConfig,
type MediaState,
type MessageRecord,
} from "./shared/api/client";
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback"; import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit"; import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
import { useUIState } from "./shared/hooks/useUIState"; import { useUIState } from "./shared/hooks/useUIState";
@@ -74,29 +70,20 @@ export default function App() {
onBinary: (d) => audio.handleIncomingBinary(d), onBinary: (d) => audio.handleIncomingBinary(d),
onUserState: (users) => onUserState: (users) =>
setActiveSpeakers( setActiveSpeakers(
(users as (ActiveSpeaker & { heardAt?: number })[]).map((u) => ({ users.map((u) => ({
...u, ...u,
heardAt: Date.now(), heardAt: Date.now(),
})), })),
), ),
onVoiceActiveUser: (data) => { onVoiceActiveUser: (data) => {
const d = data as { if (data.userId) audio.registerUserId(data.userId);
userId?: string;
id?: string;
username: string;
avatar: string;
speaking: boolean;
};
if (d.userId) audio.registerUserId(d.userId);
setActiveSpeakers((prev) => setActiveSpeakers((prev) =>
updateSpeakerList( updateSpeakerList(prev, {
prev, userId: data.userId,
d as Partial<ActiveSpeaker> & { username: data.username,
userId?: string; avatar: data.avatar,
id?: string; speaking: data.speaking,
speaking: boolean; }),
},
),
); );
}, },
onVoiceRecordingStarted: () => onVoiceRecordingStarted: () =>
@@ -104,25 +91,19 @@ export default function App() {
onVoiceRecordingStopped: () => onVoiceRecordingStopped: () =>
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")), window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
onMessageCreated: (m) => onMessageCreated: (m) =>
messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])), messages.setMessages((prev) => mergeMessages(prev, [m])),
onMessageUpdated: (m) => { onMessageUpdated: (m) =>
const d = m as Partial<MessageRecord> & { id: string };
messages.setMessages((prev) => messages.setMessages((prev) =>
prev.map((i) => (i.id === d.id ? { ...i, ...d } : i)), prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
); ),
}, onMessageDeleted: (m) =>
onMessageDeleted: (m) => {
const d = m as { id: string };
messages.setMessages((prev) => messages.setMessages((prev) =>
prev.map((i) => prev.map((i) =>
i.id === d.id ? { ...i, type: "deleted" as const } : i, i.id === m.id ? { ...i, type: "deleted" as const } : i,
), ),
); ),
}, onMessageAnalyzed: (msg) => {
onMessageAnalyzed: (m) => {
const msg = m as MessageRecord;
messages.setMessages((prev) => mergeMessages(prev, [msg])); messages.setMessages((prev) => mergeMessages(prev, [msg]));
// Show toast for moderation alerts (flagged)
const status = msg.ai_status; const status = msg.ai_status;
if (status === "flagged") { if (status === "flagged") {
const username = msg.username || msg.user_id || "unknown"; const username = msg.username || msg.user_id || "unknown";
@@ -144,7 +125,7 @@ export default function App() {
messages messages
.fetchMessages(monitorGuildId || undefined) .fetchMessages(monitorGuildId || undefined)
.catch(() => undefined), .catch(() => undefined),
onMediaState: (state) => media.setMediaState(state as MediaState), onMediaState: (state) => media.setMediaState(state),
onVoiceRecordingUploaded: (d) => onVoiceRecordingUploaded: (d) =>
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("voice_recording_uploaded", { detail: d }), new CustomEvent("voice_recording_uploaded", { detail: d }),
@@ -0,0 +1,75 @@
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: Array<{
channel_id: string;
channel_name: string | null;
message_count: number;
}>;
moderation_overview: {
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;
}
export interface DashboardUserDetail extends DashboardUser {
last_analyzed_at: number | null;
clean_message_streak: number | null;
total_infractions: number | null;
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
}>;
}
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 extends DashboardChannel {
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
username: string | null;
}>;
}
export interface ChatResponse {
response?: string;
}
@@ -0,0 +1,19 @@
export interface Guild {
id: string;
name: string;
icon: string | null;
}
export interface Channel {
id: string;
name: string;
type?: string;
parentId?: string | null;
}
export interface GuildVoiceEntry {
guildId: string;
channelId: string;
channelName: string;
connectedAt: number;
}
@@ -0,0 +1,17 @@
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}
@@ -17,13 +17,3 @@ export interface MessageMetadata {
threadName?: string; threadName?: string;
}; };
} }
export function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
const parsed = JSON.parse(value) as MessageMetadata;
return parsed;
} catch {
return {};
}
}
@@ -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: "pending" | "uploaded" | "failed";
upload_error: string | null;
transcription?: string | null;
created_at: number;
uploaded_at: number | null;
}
export interface VoiceRecordingListResponse {
items: VoiceRecording[];
nextCursor: string | null;
hasMore: boolean;
}
@@ -0,0 +1,18 @@
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
selectedAnalyticsGuild?: string;
selectedAnalyticsChannel?: string;
activeTab?: "live" | "messages" | "dashboard";
isListening?: boolean;
isStreaming?: boolean;
}
export type DashboardTab = "live" | "messages" | "dashboard";
export interface AppConfig {
monitorGuildId: string | null;
}
@@ -0,0 +1,17 @@
import type { GuildVoiceEntry } from "../guild/types";
export interface VoiceStatus {
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}
+10 -10
View File
@@ -1,7 +1,7 @@
import { motion } from "framer-motion";
import { Lock } from "lucide-react"; import { Lock } from "lucide-react";
import { useEffect, useState } from "react"; import { useState } from "react";
import { login } from "../../shared/api/client"; import { login } from "../../shared/api/client.js";
import { useGsapTransition } from "../../shared/hooks/useGsapTransition";
import { import {
Button, Button,
Card, Card,
@@ -20,11 +20,6 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { pageRef, animateIn } = useGsapTransition("auth");
useEffect(() => {
animateIn();
}, [animateIn]);
const handleSubmit = async (e: { preventDefault: () => void }) => { const handleSubmit = async (e: { preventDefault: () => void }) => {
e.preventDefault(); e.preventDefault();
@@ -42,7 +37,12 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
}; };
return ( return (
<div ref={pageRef} className="flex items-center justify-center p-4"> <motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="flex items-center justify-center p-4"
>
<Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10"> <Card className="w-full max-w-md border-primary/30 shadow-lg shadow-primary/10">
<CardHeader className="text-center"> <CardHeader className="text-center">
<div className="mx-auto mb-4 flex items-center justify-center"> <div className="mx-auto mb-4 flex items-center justify-center">
@@ -77,6 +77,6 @@ export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</div> </motion.div>
); );
} }
@@ -1,5 +1,5 @@
import { Hash } from "lucide-react"; import { Hash } from "lucide-react";
import type { DashboardChannelDetail } from "../../../shared/api/client"; import type { DashboardChannelDetail } from "../../../entities/dashboard/types.js";
import { ProfileDetail } from "../../../shared/ui"; import { ProfileDetail } from "../../../shared/ui";
interface ChannelProfileDetailProps { interface ChannelProfileDetailProps {
@@ -1,5 +1,5 @@
import { Hash } from "lucide-react"; import { Hash } from "lucide-react";
import type { DashboardChannel } from "../../../shared/api/client"; import type { DashboardChannel } from "../../../entities/dashboard/types.js";
import type { SummaryItem } from "../../../shared/ui"; import type { SummaryItem } from "../../../shared/ui";
import { SummaryList } from "../../../shared/ui"; import { SummaryList } from "../../../shared/ui";
@@ -10,7 +10,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { useUIState } from "../../../shared/hooks/useUIState"; import { useUIState } from "../../../shared/hooks/useUIState.js";
import { cn } from "../../../shared/lib/utils"; import { cn } from "../../../shared/lib/utils";
import { import {
Card, Card,
@@ -168,7 +168,7 @@ export function DashboardStatsContent() {
</p> </p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{stats.top_channels.map((ch, i) => ( {stats.top_channels.map((ch) => (
<div <div
key={ch.channel_id} key={ch.channel_id}
className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm" className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm"
@@ -1,5 +1,5 @@
import { User } from "lucide-react"; import { User } from "lucide-react";
import type { DashboardUserDetail } from "../../../shared/api/client"; import type { DashboardUserDetail } from "../../../entities/dashboard/types.js";
import { ProfileDetail } from "../../../shared/ui"; import { ProfileDetail } from "../../../shared/ui";
interface UserProfileDetailProps { interface UserProfileDetailProps {
@@ -1,5 +1,5 @@
import { User } from "lucide-react"; import { User } from "lucide-react";
import type { DashboardUser } from "../../../shared/api/client"; import type { DashboardUser } from "../../../entities/dashboard/types.js";
import type { SummaryItem } from "../../../shared/ui"; import type { SummaryItem } from "../../../shared/ui";
import { SummaryList } from "../../../shared/ui"; import { SummaryList } from "../../../shared/ui";
@@ -1,20 +1,18 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { DashboardStats } from "../../../entities/dashboard/types.js";
import { import {
type DashboardChannel,
type DashboardChannelDetail,
type DashboardStats,
type DashboardUser,
type DashboardUserDetail,
getDashboardChannelDetail, getDashboardChannelDetail,
getDashboardStats, getDashboardStats,
getDashboardUserDetail, getDashboardUserDetail,
listDashboardChannels, listDashboardChannels,
listDashboardUsers, listDashboardUsers,
} from "../../../shared/api/client"; } from "../../../shared/api/client.js";
import { useItemDetail } from "../../../shared/hooks/useItemDetail"; import { useItemDetail } from "../../../shared/hooks/useItemDetail";
import { usePaginatedList } from "../../../shared/hooks/usePaginatedList"; import { usePaginatedList } from "../../../shared/hooks/usePaginatedList";
const logger = console; import { createLogger } from "../../../shared/lib/logger.js";
const logger = createLogger("use-dashboard");
/** /**
* Fetch dashboard aggregate stats. * Fetch dashboard aggregate stats.
@@ -33,7 +31,7 @@ export function useDashboardStats() {
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : "Failed to load stats"; const msg = e instanceof Error ? e.message : "Failed to load stats";
setError(msg); setError(msg);
logger.error("[useDashboardStats]", msg); logger.error("[useDashboardStats]", { error: msg });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -1,4 +1,4 @@
import type { ActiveSpeaker } from "../../../shared/api/client"; import type { ActiveSpeaker } from "../../../entities/voice/types.js";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
interface ActiveSpeakersProps { interface ActiveSpeakersProps {
@@ -1,5 +1,5 @@
import { MonitorUp, Music2 } from "lucide-react"; import { MonitorUp, Music2 } from "lucide-react";
import type { MediaItem } from "../../../shared/api/client"; import type { MediaItem } from "../../../entities/media/types.js";
import { Badge } from "../../../shared/ui"; import { Badge } from "../../../shared/ui";
interface NowPlayingProps { interface NowPlayingProps {
@@ -2,7 +2,7 @@
import { Download, Mic, Trash2 } from "lucide-react"; import { Download, Mic, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { VoiceRecording } from "../../../shared/api/client"; import type { VoiceRecording } from "../../../entities/recording/types.js";
import { deleteRecording, listRecordings } from "../../../shared/api/client"; import { deleteRecording, listRecordings } from "../../../shared/api/client";
import { formatBytes, formatDate } from "../../../shared/lib/utils"; import { formatBytes, formatDate } from "../../../shared/lib/utils";
import { Badge, Button, Skeleton } from "../../../shared/ui"; import { Badge, Button, Skeleton } from "../../../shared/ui";
@@ -1,5 +1,6 @@
import { Headphones, Radio } from "lucide-react"; import { Headphones, Radio } from "lucide-react";
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client"; import type { Channel, Guild } from "../../../entities/guild/types.js";
import type { VoiceStatus } from "../../../entities/voice/types.js";
import { Button, Select } from "../../../shared/ui"; import { Button, Select } from "../../../shared/ui";
import { MicLevelMeter } from "./MicLevelMeter"; import { MicLevelMeter } from "./MicLevelMeter";
@@ -1,11 +0,0 @@
// ─── Live feature barrel export ─────────────────────────────────────────────
export { ActiveSpeakers } from "./ActiveSpeakers";
export { AudioVisualizer } from "./AudioVisualizer";
export { MicLevelMeter } from "./MicLevelMeter";
export { MusicSubPanel } from "./MusicSubPanel";
export { NowPlaying } from "./NowPlaying";
export { RecordingsSubPanel } from "./RecordingsSubPanel";
export { ScreenSubPanel } from "./ScreenSubPanel";
export { VoiceConnectionCard } from "./VoiceConnectionCard";
export { WaveformPlayer } from "./WaveformPlayer";
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { MediaState } from "../../../shared/api/client"; import type { MediaState } from "../../../entities/media/types.js";
import { import {
getMediaStatus, getMediaStatus,
queueMedia, queueMedia,
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { Channel, Guild, VoiceStatus } from "../../../shared/api/client"; import type { Channel, Guild } from "../../../entities/guild/types.js";
import type { VoiceStatus } from "../../../entities/voice/types.js";
import { import {
connectVoice, connectVoice,
disconnectVoice, disconnectVoice,
@@ -2,13 +2,9 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Mic, MonitorUp, Music2 } from "lucide-react"; import { Mic, MonitorUp, Music2 } from "lucide-react";
import type { import type { Channel, Guild } from "../../entities/guild/types.js";
ActiveSpeaker, import type { MediaState } from "../../entities/media/types.js";
Channel, import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
Guild,
MediaState,
VoiceStatus,
} from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import { import {
Card, Card,
@@ -1,5 +1,5 @@
import { parseMetadata } from "../../../entities/message/types"; import type { MessageRecord } from "../../../entities/message/types.js";
import type { MessageRecord } from "../../../shared/api/client"; import { parseMetadata } from "../../../shared/lib/utils.js";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
interface ImageItem { interface ImageItem {
@@ -73,7 +73,7 @@ export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
return ( return (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{images.map((image, index) => { {images.map((image) => {
// Stable key using message.id + url // Stable key using message.id + url
const stableKey = `${image.message.id}-${image.kind}-${image.url}`; const stableKey = `${image.message.id}-${image.kind}-${image.url}`;
return ( return (
@@ -9,8 +9,8 @@ import {
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { Fragment, useMemo, useState } from "react"; import { Fragment, useMemo, useState } from "react";
import { parseMetadata } from "../../../entities/message/types"; import type { MessageRecord } from "../../../entities/message/types.js";
import type { MessageRecord } from "../../../shared/api/client"; import { parseMetadata } from "../../../shared/lib/utils.js";
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui"; import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
@@ -72,12 +72,6 @@ function parseStringList(value?: string | null): string[] {
} }
} }
function aiVariant(status: string) {
if (status === "clean") return "success";
if (status === "flagged" || status === "error") return "destructive";
return "secondary";
}
function severityColor(severity: string) { function severityColor(severity: string) {
switch (severity) { switch (severity) {
case "critical": case "critical":
@@ -1,6 +1,6 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef } from "react";
import type { MessageRecord } from "../../../shared/api/client"; import type { MessageRecord } from "../../../entities/message/types.js";
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
import { ScrollArea } from "../../../shared/ui"; import { ScrollArea } from "../../../shared/ui";
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage"; import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
@@ -1,5 +1,5 @@
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import type { MessageRecord } from "../../../shared/api/client"; import type { MessageRecord } from "../../../entities/message/types.js";
import { import {
listMessages, listMessages,
reanalyzeErrorBatch, reanalyzeErrorBatch,
@@ -1,7 +1,8 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Filter, RotateCw, Search, X } from "lucide-react"; import { Filter, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { type MessageRecord, request } from "../../shared/api/client"; import type { MessageRecord } from "../../shared/api/client";
import { request } from "../../shared/api/client";
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger"; import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
import { import {
Badge, Badge,
+3 -17
View File
@@ -1,21 +1,9 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { ToastProvider } from "./shared/ui"; import { ToastProvider } from "./shared/ui";
import "./styles.css"; import "./styles.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // data stays fresh for 30s — no refetch within this window
gcTime: 5 * 60_000, // keep unused data in cache for 5 minutes
refetchOnWindowFocus: false, // avoid spamming the API on tab switches
retry: 2,
},
},
});
const root = document.getElementById("root"); const root = document.getElementById("root");
if (!root) { if (!root) {
@@ -24,10 +12,8 @@ if (!root) {
ReactDOM.createRoot(root).render( ReactDOM.createRoot(root).render(
<React.StrictMode> <React.StrictMode>
<QueryClientProvider client={queryClient}> <ToastProvider>
<ToastProvider> <App />
<App /> </ToastProvider>
</ToastProvider>
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
); );
+52 -175
View File
@@ -1,6 +1,34 @@
// ─── Shared HTTP client — all API endpoints in one file ────────────────────── // ─── Shared HTTP client — all API endpoints in one file ──────────────────────
import type { MessageRecord, PageResult } from "@bete/shared"; import type { MessageRecord, PageResult } from "@bete/shared";
import type {
ChatResponse,
DashboardChannel,
DashboardChannelDetail,
DashboardStats,
DashboardUser,
DashboardUserDetail,
} from "../../entities/dashboard/types.js";
import type {
Channel,
Guild,
GuildVoiceEntry,
} from "../../entities/guild/types.js";
import type {
MediaItem,
MediaMode,
MediaState,
} from "../../entities/media/types.js";
import type {
VoiceRecording,
VoiceRecordingListResponse,
} from "../../entities/recording/types.js";
import type {
AppConfig,
DashboardTab,
UIState,
} from "../../entities/ui/types.js";
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
import { createLogger } from "../lib/logger.js"; import { createLogger } from "../lib/logger.js";
const logger = createLogger("api"); const logger = createLogger("api");
@@ -85,86 +113,31 @@ export function getAPIURL(): string {
return BE_API_URL; return BE_API_URL;
} }
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Re-exports ──────────────────────────────────────────────────────────────
export type { MessageRecord, PageResult }; export type {
ActiveSpeaker,
export interface Guild { AppConfig,
id: string; Channel,
name: string; ChatResponse,
icon: string | null; DashboardChannel,
} DashboardChannelDetail,
DashboardStats,
export interface Channel { DashboardTab,
id: string; DashboardUser,
name: string; DashboardUserDetail,
type?: string; Guild,
parentId?: string | null; GuildVoiceEntry,
} MediaItem,
MediaMode,
export interface GuildVoiceEntry { MediaState,
guildId: string; MessageRecord,
channelId: string; PageResult,
channelName: string; UIState,
connectedAt: number; VoiceRecording,
} VoiceRecordingListResponse,
VoiceStatus,
export interface VoiceStatus { };
connected: boolean;
activeGuildId: string | null;
activeChannelId: string | null;
activeChannelName: string | null;
connections: GuildVoiceEntry[];
}
export interface ActiveSpeaker {
id?: string;
userId?: string;
username: string;
avatar: string;
speaking: boolean;
}
export type MediaMode = "music" | "screen";
export interface MediaItem {
id?: string;
source: string;
title: string;
mode?: "music" | "screen";
durationMs?: number | null;
thumbnailUrl?: string | null;
}
export interface MediaState {
playing: boolean;
musicVolume: number;
current: MediaItem | null;
queue: MediaItem[];
}
export interface UIState {
selectedGuild?: string;
selectedVoiceGuild?: string;
selectedVoiceChannel?: string;
selectedTextGuild?: string;
selectedTextChannel?: string;
selectedAnalyticsGuild?: string;
selectedAnalyticsChannel?: string;
activeTab?: "live" | "messages" | "dashboard";
isListening?: boolean;
isStreaming?: boolean;
}
export interface AppConfig {
monitorGuildId: string | null;
}
export interface ChatResponse {
response?: string;
}
export type DashboardTab = "live" | "messages" | "dashboard";
// ─── Messages ──────────────────────────────────────────────────────────────── // ─── Messages ────────────────────────────────────────────────────────────────
@@ -275,30 +248,6 @@ export function setMediaVolume(volume: number): Promise<MediaState> {
// ─── Recordings ────────────────────────────────────────────────────────────── // ─── Recordings ──────────────────────────────────────────────────────────────
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: "pending" | "uploaded" | "failed";
upload_error: string | null;
transcription?: string | null;
created_at: number;
uploaded_at: number | null;
}
export interface VoiceRecordingListResponse {
items: VoiceRecording[];
nextCursor: string | null;
hasMore: boolean;
}
export function listRecordings(params?: { export function listRecordings(params?: {
limit?: number; limit?: number;
cursor?: string; cursor?: string;
@@ -325,55 +274,6 @@ export function login(password: string): Promise<{ ok: boolean }> {
// ─── Dashboard ───────────────────────────────────────────────────────────────── // ─── Dashboard ─────────────────────────────────────────────────────────────────
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: Array<{
channel_id: string;
channel_name: string | null;
message_count: number;
}>;
moderation_overview: {
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;
}
export interface DashboardUserDetail extends DashboardUser {
last_analyzed_at: number | null;
clean_message_streak: number | null;
total_infractions: number | null;
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
}>;
}
export function getDashboardStats(): Promise<DashboardStats> { export function getDashboardStats(): Promise<DashboardStats> {
return request<DashboardStats>("/api/dashboard/stats"); return request<DashboardStats>("/api/dashboard/stats");
} }
@@ -399,29 +299,6 @@ export function getDashboardUserDetail(
// ─── Dashboard Channels ───────────────────────────────────────────────────────── // ─── Dashboard Channels ─────────────────────────────────────────────────────────
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 extends DashboardChannel {
clean_count: number;
recent_messages: Array<{
id: string;
content: string;
channel_id: string;
created_at: number;
ai_status: string | null;
username: string | null;
}>;
}
export function listDashboardChannels( export function listDashboardChannels(
params: { params: {
limit?: number; limit?: number;
@@ -1,6 +1,7 @@
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ── // ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { getAPIURL } from "../api/client.js"; import { getAPIURL } from "../api/client.js";
// note: this hook only uses API functions, not entity types
import { createLogger } from "../lib/logger"; import { createLogger } from "../lib/logger";
const SAMPLE_RATE = 24000; const SAMPLE_RATE = 24000;
@@ -1,123 +0,0 @@
import gsap from "gsap";
import { useCallback, useEffect, useRef } from "react";
function prefersReducedMotion(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function useGsapTransition(tabKey: string) {
const pageRef = useRef<HTMLDivElement>(null);
const ctxRef = useRef<gsap.Context | null>(null);
const animateIn = useCallback(() => {
// Kill any previously recorded animations to prevent conflicts
ctxRef.current?.kill();
const instant = prefersReducedMotion();
const scope = pageRef.current ?? undefined;
const ctx = gsap.context(() => {
const tl = gsap.timeline();
// Page container: fade-in + slide-up (400ms ease-out)
if (pageRef.current) {
tl.fromTo(
pageRef.current,
{ opacity: 0, y: 20 },
{
opacity: 1,
y: 0,
duration: instant ? 0 : 0.4,
ease: "power2.out",
},
);
}
// Stagger children with data-stagger attribute
const staggerEls =
pageRef.current?.querySelectorAll<HTMLElement>("[data-stagger]");
if (staggerEls && staggerEls.length > 0) {
tl.fromTo(
staggerEls,
{ opacity: 0, y: 15 },
{
opacity: 1,
y: 0,
duration: instant ? 0 : 0.3,
stagger: instant ? 0 : 0.05,
ease: "power2.out",
},
"-=0.1",
);
}
}, scope);
ctxRef.current = ctx;
}, [tabKey]);
const animateOut = useCallback((): Promise<void> => {
// Kill any previously recorded animations to prevent conflicts
ctxRef.current?.kill();
return new Promise<void>((resolve) => {
const instant = prefersReducedMotion();
const scope = pageRef.current ?? undefined;
const ctx = gsap.context(() => {
const tl = gsap.timeline({
onComplete: () => {
resolve();
},
});
if (pageRef.current) {
// Page container: fade-out + slide-down (300ms ease-in)
tl.to(pageRef.current, {
opacity: 0,
y: 20,
duration: instant ? 0 : 0.3,
ease: "power2.in",
});
} else {
// No element to animate — resolve immediately
resolve();
}
}, scope);
ctxRef.current = ctx;
});
}, [tabKey]);
// Cleanup all recorded animations on unmount
useEffect(() => {
return () => {
ctxRef.current?.kill();
};
}, []);
return { pageRef, animateIn, animateOut };
}
function gsapCardHover() {
return {
onMouseEnter: (e: React.MouseEvent<HTMLElement>) => {
gsap.to(e.currentTarget, {
y: -4,
boxShadow: "0 8px 25px rgba(0,0,0,0.15)",
duration: 0.2,
ease: "power2.out",
overwrite: "auto",
});
},
onMouseLeave: (e: React.MouseEvent<HTMLElement>) => {
gsap.to(e.currentTarget, {
y: 0,
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
duration: 0.2,
ease: "power2.out",
overwrite: "auto",
});
},
};
}
@@ -1,5 +1,5 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import type { ChatResponse } from "../api/client"; import type { ChatResponse } from "../../entities/dashboard/types.js";
import { request } from "../api/client"; import { request } from "../api/client";
import { createLogger } from "../lib/logger"; import { createLogger } from "../lib/logger";
@@ -1,125 +0,0 @@
import { useEffect, useState } from "react";
import type { MessageRecord } from "../api/client";
/**
* useMascotSummary Generates AI-powered summary/insights from recent messages
* Used by mascot's floating chat bubble to display conversation insights
*/
interface UseMascotSummaryOptions {
messages: MessageRecord[];
enabled?: boolean;
}
const summaryPrompts = [
"📊 Diskusi sangat aktif dengan {count} pesan",
"💬 Topik populer: {topic} ({percentage}%)",
"👥 Partisipan utama: {users}",
"⏰ Aktivitas puncak: {time}",
"🔥 Buzz level: {level}",
"💡 Insight: {insight}",
];
function generateInsight(messages: MessageRecord[]): string {
if (messages.length === 0) {
return "Menunggu pesan...";
}
const totalMessages = messages.length;
const recentMessages = messages.slice(-10);
// Hitung user yang berbeda
const uniqueUsers = new Set(recentMessages.map((m) => m.user_id)).size;
// Hitung average panjang pesan
const avgLength = Math.round(
recentMessages.reduce((sum, m) => sum + (m.content?.length || 0), 0) /
recentMessages.length,
);
// Tentukan tipe percakapan
let insight = "";
if (avgLength > 150) {
insight = "Diskusi mendalam sedang berlangsung";
} else if (avgLength > 80) {
insight = "Percakapan normal dan interaktif";
} else {
insight = "Chat cepat dan ringkas";
}
// Tambah info partisipan
if (uniqueUsers > 5) {
insight += `${uniqueUsers} orang aktif`;
}
// Tambah info volume
if (totalMessages > 50) {
insight += " • Volume tinggi 🔥";
} else if (totalMessages > 20) {
insight += " • Percakapan aktif";
}
return insight;
}
function extractTopics(messages: MessageRecord[]): string {
if (messages.length === 0) return "Tidak ada topik";
// Extract keywords dari recent messages
const recentMessages = messages.slice(-15);
const content = recentMessages
.map((m) => m.content?.toLowerCase() || "")
.join(" ");
// Simple keyword extraction
const keywords = [
{ word: "voice", label: "Voice" },
{ word: "recording", label: "Recording" },
{ word: "audio", label: "Audio" },
{ word: "chat", label: "Chat" },
{ word: "message", label: "Message" },
{ word: "user", label: "User" },
];
for (const { word, label } of keywords) {
if (content.includes(word)) {
return label;
}
}
return "Umum";
}
export function useMascotSummary({
messages,
enabled = true,
}: UseMascotSummaryOptions): string {
const [summary, setSummary] = useState<string>("");
useEffect(() => {
if (!enabled || messages.length === 0) {
setSummary("");
return;
}
// Generate summary berdasarkan messages
const insight = generateInsight(messages);
setSummary(insight);
// Rotate summary setiap 5 detik
const interval = setInterval(() => {
setSummary((prev) => {
if (prev.includes("aktif")) {
return `📈 Total: ${messages.length} pesan`;
} else if (prev.includes("Total")) {
return generateInsight(messages);
}
return prev;
});
}, 5000);
return () => clearInterval(interval);
}, [messages, enabled]);
return summary;
}
@@ -1,5 +1,5 @@
import { useCallback } from "react"; import { useCallback } from "react";
import type { UIState } from "../api/client"; import type { UIState } from "../../entities/ui/types.js";
import { uiStateValidator, useLocalStorage } from "./useLocalStorage"; import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
export function useUIState() { export function useUIState() {
+22
View File
@@ -16,3 +16,25 @@ export function formatBytes(bytes: number): string {
export function formatDate(value: number): string { export function formatDate(value: number): string {
return new Date(value).toLocaleString(); return new Date(value).toLocaleString();
} }
export interface MessageMetadata {
stickers?: Array<{ name?: string; url?: string }>;
attachments?: Array<{ name: string; url: string; contentType?: string }>;
embeds?: Array<{ title?: string; image?: string; thumbnail?: string }>;
channel?: {
channelId: string;
channelName?: string;
threadId?: string;
threadName?: string;
};
}
export function parseMetadata(value: string | null): MessageMetadata {
if (!value) return {};
try {
const parsed = JSON.parse(value) as MessageMetadata;
return parsed;
} catch {
return {};
}
}
@@ -1,5 +1,5 @@
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import type { DashboardTab } from "../api/client"; import type { DashboardTab } from "../../entities/ui/types.js";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [ const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
@@ -1,4 +1,5 @@
// ─── Toast notification system ────────────────────────────────────────────── // ─── Toast notification system ──────────────────────────────────────────────
// (no entity type imports needed — only uses string/ReactNode)
import { import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
+123 -48
View File
@@ -1,6 +1,14 @@
// ─── WebSocket singleton with reconnect, typed events, and observable status ─ // ─── WebSocket singleton with reconnect, typed events, and observable status ─
import type {
AttachmentRecord,
MessageRecord,
VoiceRecordingUploadData,
} from "@bete/shared";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { MediaState } from "../../entities/media/types.js";
import { createLogger } from "../lib/logger.js"; import { createLogger } from "../lib/logger.js";
import type { ActiveSpeakerData } from "./events.js";
const logger = createLogger("socket"); const logger = createLogger("socket");
@@ -23,30 +31,48 @@ function computeBackoff(attempt: number): number {
export interface WsHandlers { export interface WsHandlers {
onBinary?: BinaryHandler; onBinary?: BinaryHandler;
onMessageCreated?: (data: unknown) => void; onMessageCreated?: (data: MessageRecord) => void;
onMessageUpdated?: (data: unknown) => void; onMessageUpdated?: (
onMessageDeleted?: (data: unknown) => void; data: MessageRecord & { edited_content?: string | null },
onMessageAnalyzed?: (data: unknown) => void; ) => void;
onAttachmentCreated?: (data: unknown) => void; onMessageDeleted?: (data: {
onAttachmentUploaded?: (data: unknown) => void; id: string;
onUserState?: (users: unknown[]) => void; channel_id?: string;
onUiState?: (state: unknown) => void; deleted_at: number;
onMediaState?: (state: unknown) => void; }) => void;
onVoiceRecordingStarted?: (data: unknown) => void; onMessageAnalyzed?: (data: MessageRecord) => void;
onVoiceRecordingStopped?: (data: unknown) => void; onAttachmentCreated?: (data: AttachmentRecord) => void;
onVoiceRecordingUploaded?: (data: unknown) => void; onAttachmentUploaded?: (data: AttachmentRecord) => void;
onVoicePcmData?: (data: unknown) => void; onUserState?: (users: ActiveSpeakerData[]) => void;
onVoiceActiveUser?: (data: unknown) => void; onUiState?: (state: Record<string, unknown>) => void;
onReactionAdded?: (data: unknown) => void; onMediaState?: (state: MediaState) => void;
onReactionRemoved?: (data: unknown) => void; onVoiceRecordingStarted?: (data: Record<string, unknown>) => void;
onThreadCreated?: (data: unknown) => void; onVoiceRecordingStopped?: (data: {
onThreadDeleted?: (data: unknown) => void; guild_id: string;
onThreadUpdated?: (data: unknown) => void; session_id: string;
onChannelTopicUpdated?: (data: unknown) => void; duration_ms: number;
onPresenceUpdated?: (data: unknown) => void; participants: number;
onGuildMemberAdded?: (data: unknown) => void; segment_count: number;
onGuildMemberRemoved?: (data: unknown) => void; status: string;
onVoiceAnalyzed?: (data: unknown) => void; stopped_at: number;
}) => void;
onVoiceRecordingUploaded?: (data: VoiceRecordingUploadData) => void;
onVoicePcmData?: (data: {
userId: string;
pcm: string;
metadata?: Record<string, unknown>;
}) => void;
onVoiceActiveUser?: (data: ActiveSpeakerData) => void;
onReactionAdded?: (data: Record<string, unknown>) => void;
onReactionRemoved?: (data: Record<string, unknown>) => void;
onThreadCreated?: (data: Record<string, unknown>) => void;
onThreadDeleted?: (data: Record<string, unknown>) => void;
onThreadUpdated?: (data: Record<string, unknown>) => void;
onChannelTopicUpdated?: (data: Record<string, unknown>) => void;
onPresenceUpdated?: (data: Record<string, unknown>) => void;
onGuildMemberAdded?: (data: Record<string, unknown>) => void;
onGuildMemberRemoved?: (data: Record<string, unknown>) => void;
onVoiceAnalyzed?: (data: Record<string, unknown>) => void;
} }
let _wsInstance: WebSocket | null = null; let _wsInstance: WebSocket | null = null;
@@ -110,76 +136,125 @@ function doConnect(): WebSocket {
for (const h of _listeners) { for (const h of _listeners) {
switch (msg.type) { switch (msg.type) {
case "message_created": case "message_created":
if (msg.data !== undefined) h.onMessageCreated?.(msg.data); if (msg.data !== undefined)
h.onMessageCreated?.(msg.data as MessageRecord);
break; break;
case "message_updated": case "message_updated":
if (msg.data !== undefined) h.onMessageUpdated?.(msg.data); if (msg.data !== undefined)
h.onMessageUpdated?.(
msg.data as MessageRecord & { edited_content?: string | null },
);
break; break;
case "message_deleted": case "message_deleted":
if (msg.data !== undefined) h.onMessageDeleted?.(msg.data); if (msg.data !== undefined)
h.onMessageDeleted?.(
msg.data as {
id: string;
channel_id?: string;
deleted_at: number;
},
);
break; break;
case "message_analyzed": case "message_analyzed":
if (msg.data !== undefined) h.onMessageAnalyzed?.(msg.data); if (msg.data !== undefined)
h.onMessageAnalyzed?.(msg.data as MessageRecord);
break; break;
case "attachment_created": case "attachment_created":
if (msg.data !== undefined) h.onAttachmentCreated?.(msg.data); if (msg.data !== undefined)
h.onAttachmentCreated?.(msg.data as AttachmentRecord);
break; break;
case "attachment_uploaded": case "attachment_uploaded":
if (msg.data !== undefined) h.onAttachmentUploaded?.(msg.data); if (msg.data !== undefined)
h.onAttachmentUploaded?.(msg.data as AttachmentRecord);
break; break;
case "user_state": case "user_state":
h.onUserState?.((msg.users as unknown[]) || []); h.onUserState?.(
(msg.users as unknown as ActiveSpeakerData[]) || [],
);
break; break;
case "ui_state": case "ui_state":
h.onUiState?.(msg.state); h.onUiState?.(msg.state as Record<string, unknown>);
break; break;
case "media_state": case "media_state":
h.onMediaState?.(msg.state); h.onMediaState?.(msg.state as MediaState);
break; break;
case "voice_recording_started": case "voice_recording_started":
if (msg.data !== undefined) h.onVoiceRecordingStarted?.(msg.data); if (msg.data !== undefined)
h.onVoiceRecordingStarted?.(msg.data as Record<string, unknown>);
break; break;
case "voice_recording_stopped": case "voice_recording_stopped":
if (msg.data !== undefined) h.onVoiceRecordingStopped?.(msg.data); if (msg.data !== undefined)
h.onVoiceRecordingStopped?.(
msg.data as {
guild_id: string;
session_id: string;
duration_ms: number;
participants: number;
segment_count: number;
status: string;
stopped_at: number;
},
);
break; break;
case "voice_recording_uploaded": case "voice_recording_uploaded":
if (msg.data !== undefined) h.onVoiceRecordingUploaded?.(msg.data); if (msg.data !== undefined)
h.onVoiceRecordingUploaded?.(
msg.data as VoiceRecordingUploadData,
);
break; break;
case "voice_pcm_data": case "voice_pcm_data":
if (msg.data !== undefined) h.onVoicePcmData?.(msg.data); if (msg.data !== undefined)
h.onVoicePcmData?.(
msg.data as {
userId: string;
pcm: string;
metadata?: Record<string, unknown>;
},
);
break; break;
case "voice_active_user": case "voice_active_user":
if (msg.data !== undefined) h.onVoiceActiveUser?.(msg.data); if (msg.data !== undefined)
h.onVoiceActiveUser?.(msg.data as ActiveSpeakerData);
break; break;
case "voice_analyzed": case "voice_analyzed":
if (msg.data !== undefined) h.onVoiceAnalyzed?.(msg.data); if (msg.data !== undefined)
h.onVoiceAnalyzed?.(msg.data as Record<string, unknown>);
break; break;
case "reaction_added": case "reaction_added":
if (msg.data !== undefined) h.onReactionAdded?.(msg.data); if (msg.data !== undefined)
h.onReactionAdded?.(msg.data as Record<string, unknown>);
break; break;
case "reaction_removed": case "reaction_removed":
if (msg.data !== undefined) h.onReactionRemoved?.(msg.data); if (msg.data !== undefined)
h.onReactionRemoved?.(msg.data as Record<string, unknown>);
break; break;
case "thread_created": case "thread_created":
if (msg.data !== undefined) h.onThreadCreated?.(msg.data); if (msg.data !== undefined)
h.onThreadCreated?.(msg.data as Record<string, unknown>);
break; break;
case "thread_deleted": case "thread_deleted":
if (msg.data !== undefined) h.onThreadDeleted?.(msg.data); if (msg.data !== undefined)
h.onThreadDeleted?.(msg.data as Record<string, unknown>);
break; break;
case "thread_updated": case "thread_updated":
if (msg.data !== undefined) h.onThreadUpdated?.(msg.data); if (msg.data !== undefined)
h.onThreadUpdated?.(msg.data as Record<string, unknown>);
break; break;
case "channel_topic_updated": case "channel_topic_updated":
if (msg.data !== undefined) h.onChannelTopicUpdated?.(msg.data); if (msg.data !== undefined)
h.onChannelTopicUpdated?.(msg.data as Record<string, unknown>);
break; break;
case "presence_updated": case "presence_updated":
if (msg.data !== undefined) h.onPresenceUpdated?.(msg.data); if (msg.data !== undefined)
h.onPresenceUpdated?.(msg.data as Record<string, unknown>);
break; break;
case "guild_member_added": case "guild_member_added":
if (msg.data !== undefined) h.onGuildMemberAdded?.(msg.data); if (msg.data !== undefined)
h.onGuildMemberAdded?.(msg.data as Record<string, unknown>);
break; break;
case "guild_member_removed": case "guild_member_removed":
if (msg.data !== undefined) h.onGuildMemberRemoved?.(msg.data); if (msg.data !== undefined)
h.onGuildMemberRemoved?.(msg.data as Record<string, unknown>);
break; break;
case "analysis_queue_status": case "analysis_queue_status":
// monitoring-only — no UI action needed // monitoring-only — no UI action needed
@@ -1,10 +1,8 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { import type { MessageRecord } from "../entities/message/types.js";
DashboardTab, import type { DashboardTab } from "../entities/ui/types.js";
MessageRecord, import type { VoiceStatus } from "../entities/voice/types.js";
VoiceStatus,
} from "../shared/api/client";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger"; import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import type { WsStatus } from "../shared/ws/socket"; import type { WsStatus } from "../shared/ws/socket";
import { Header } from "./Header"; import { Header } from "./Header";
+2 -1
View File
@@ -1,6 +1,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Wifi, WifiOff } from "lucide-react"; import { Wifi, WifiOff } from "lucide-react";
import type { DashboardTab, VoiceStatus } from "../shared/api/client"; import type { DashboardTab } from "../entities/ui/types.js";
import type { VoiceStatus } from "../entities/voice/types.js";
import { fadeSlideUp } from "../shared/hooks/useFramerStagger"; import { fadeSlideUp } from "../shared/hooks/useFramerStagger";
import { cn } from "../shared/lib/utils"; import { cn } from "../shared/lib/utils";
import { Badge } from "../shared/ui"; import { Badge } from "../shared/ui";
+2 -1
View File
@@ -1,6 +1,7 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
import type { DashboardTab, MessageRecord } from "../shared/api/client"; import type { MessageRecord } from "../entities/message/types.js";
import type { DashboardTab } from "../entities/ui/types.js";
import { useMascotChat } from "../shared/hooks/useMascotChat"; import { useMascotChat } from "../shared/hooks/useMascotChat";
import { cn } from "../shared/lib/utils"; import { cn } from "../shared/lib/utils";
import { MascotChatbot } from "./mascot/MascotChatbot"; import { MascotChatbot } from "./mascot/MascotChatbot";
@@ -1,8 +1,11 @@
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react"; import { Maximize2, MessageCircle, Minimize2, Send, X } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { createLogger } from "../../shared/lib/logger.js";
import { cn } from "../../shared/lib/utils"; import { cn } from "../../shared/lib/utils";
const logger = createLogger("mascot-chat");
export interface ChatMessage { export interface ChatMessage {
id: string; id: string;
role: "user" | "mascot"; role: "user" | "mascot";
@@ -84,7 +87,9 @@ export function MascotChatbot({
setMessages((prev) => [...prev, mascotMessage]); setMessages((prev) => [...prev, mascotMessage]);
} catch (error) { } catch (error) {
console.error("Error sending message:", error); logger.error("Error sending message", {
error: error instanceof Error ? error.message : String(error),
});
const errorMessage: ChatMessage = { const errorMessage: ChatMessage = {
id: `mascot-error-${Date.now()}`, id: `mascot-error-${Date.now()}`,
role: "mascot", role: "mascot",