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:
Generated
+86
-698
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user