chore: remove React frontend, rename frontend-leptos to frontend
This commit is contained in:
@@ -1,300 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { ActiveSpeaker } from "./entities/voice/types.js";
|
||||
import { AuthOverlay } from "./features/auth";
|
||||
import { DashboardPanel } from "./features/dashboard";
|
||||
import { LivePanel } from "./features/live";
|
||||
import { useMediaControl } from "./features/live/hooks/useMediaControl";
|
||||
import { useVoiceControl } from "./features/live/hooks/useVoiceControl";
|
||||
import { MessagesPanel } from "./features/messages";
|
||||
import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener";
|
||||
import {
|
||||
mergeMessages,
|
||||
useMessages,
|
||||
} from "./features/messages/hooks/useMessages";
|
||||
import { getAppConfig } from "./shared/api/client";
|
||||
import { useAudioPlayback } from "./shared/hooks/useAudioPlayback";
|
||||
import { useAudioTransmit } from "./shared/hooks/useAudioTransmit";
|
||||
import { useTheme } from "./shared/hooks/useTheme";
|
||||
import { useUIState } from "./shared/hooks/useUIState";
|
||||
import { MobileTabBar } from "./shared/ui/MobileTabBar";
|
||||
import { useDashboardSocket } from "./shared/ws/socket";
|
||||
import { DashboardLayout } from "./widgets/DashboardLayout";
|
||||
|
||||
export default function App() {
|
||||
const { uiState, patchUIState } = useUIState();
|
||||
const [authenticated, setAuthenticated] = useState(() => {
|
||||
return sessionStorage.getItem("admin-password") !== null;
|
||||
});
|
||||
useTheme();
|
||||
|
||||
const handleAuthenticated = useCallback(() => {
|
||||
setAuthenticated(true);
|
||||
}, []);
|
||||
|
||||
const voice = useVoiceControl();
|
||||
const media = useMediaControl();
|
||||
const messages = useMessages();
|
||||
const [activeSpeakers, setActiveSpeakers] = useState<
|
||||
(ActiveSpeaker & { heardAt?: number })[]
|
||||
>([]);
|
||||
const [monitorGuildId, setMonitorGuildId] = useState("");
|
||||
|
||||
const audio = useAudioPlayback();
|
||||
const isPublicDashboard = import.meta.env.VITE_DASHBOARD_IS_PUBLIC === "true";
|
||||
const activeTab = uiState.activeTab || "messages";
|
||||
|
||||
// Reset persisted tab to "messages" once on mount if not authenticated
|
||||
// Prevents localStorage carryover from a prior session on the Live tab
|
||||
useEffect(() => {
|
||||
if (!authenticated && !isPublicDashboard && uiState.activeTab === "live") {
|
||||
patchUIState({ activeTab: "messages" });
|
||||
}
|
||||
// Run only on mount
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
const selectedVoiceGuild =
|
||||
uiState.selectedVoiceGuild || uiState.selectedGuild || "";
|
||||
|
||||
// Resolve monitor guild name from the full guild list (has real names now)
|
||||
const monitorGuildName = useMemo(
|
||||
() =>
|
||||
monitorGuildId
|
||||
? (voice.guilds.find((g) => g.id === monitorGuildId)?.name ?? null)
|
||||
: null,
|
||||
[monitorGuildId, voice.guilds],
|
||||
);
|
||||
|
||||
// Update speaker list from incremental voice_active_user events
|
||||
const updateSpeakerList = (
|
||||
prev: (ActiveSpeaker & { heardAt?: number })[],
|
||||
data: Partial<ActiveSpeaker> & {
|
||||
userId?: string;
|
||||
id?: string;
|
||||
speaking: boolean;
|
||||
},
|
||||
): (ActiveSpeaker & { heardAt?: number })[] => {
|
||||
const key = data.userId ?? data.id;
|
||||
if (!key) return prev;
|
||||
const now = Date.now();
|
||||
const idx = prev.findIndex((s) => (s.userId ?? s.id) === key);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], ...data, heardAt: now };
|
||||
return next;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{ ...data, heardAt: now } as ActiveSpeaker & { heardAt?: number },
|
||||
];
|
||||
};
|
||||
|
||||
const socket = useDashboardSocket({
|
||||
onBinary: (d) => audio.handleIncomingBinary(d),
|
||||
onUserState: (users) =>
|
||||
setActiveSpeakers(
|
||||
users.map((u) => ({
|
||||
...u,
|
||||
heardAt: Date.now(),
|
||||
})),
|
||||
),
|
||||
onVoiceActiveUser: (data) => {
|
||||
if (data.userId) audio.registerUserId(data.userId);
|
||||
setActiveSpeakers((prev) =>
|
||||
updateSpeakerList(prev, {
|
||||
userId: data.userId,
|
||||
username: data.username,
|
||||
avatar: data.avatar,
|
||||
speaking: data.speaking,
|
||||
}),
|
||||
);
|
||||
},
|
||||
onVoiceRecordingStarted: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onVoiceRecordingStopped: () =>
|
||||
window.dispatchEvent(new CustomEvent("voice_recording_uploaded")),
|
||||
onMessageCreated: (m) =>
|
||||
messages.setMessages((prev) => mergeMessages(prev, [m])),
|
||||
onMessageUpdated: (m) =>
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) => (i.id === m.id ? { ...i, ...m } : i)),
|
||||
),
|
||||
onMessageDeleted: (m) =>
|
||||
messages.setMessages((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === m.id ? { ...i, type: "deleted" as const } : i,
|
||||
),
|
||||
),
|
||||
onMessageAnalyzed: (msg) => {
|
||||
messages.setMessages((prev) => mergeMessages(prev, [msg]));
|
||||
const status = msg.ai_status;
|
||||
if (status === "flagged") {
|
||||
const username = msg.username || msg.user_id || "unknown";
|
||||
const severity = msg.ai_severity || "";
|
||||
const categories = msg.ai_categories || "";
|
||||
const brief = msg.ai_analysis?.slice(0, 80) ?? "Message flagged by AI";
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("moderation_alert", {
|
||||
detail: { type: status, username, severity, categories, brief },
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onAttachmentUploaded: () =>
|
||||
messages
|
||||
.fetchMessages(monitorGuildId || undefined)
|
||||
.catch(() => undefined),
|
||||
onAttachmentCreated: () =>
|
||||
messages
|
||||
.fetchMessages(monitorGuildId || undefined)
|
||||
.catch(() => undefined),
|
||||
onMediaState: (state) => media.setMediaState(state),
|
||||
onVoiceRecordingUploaded: (d) =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("voice_recording_uploaded", { detail: d }),
|
||||
),
|
||||
});
|
||||
|
||||
const transmit = useAudioTransmit(socket.socketRef);
|
||||
|
||||
// Load app config on mount
|
||||
useEffect(() => {
|
||||
getAppConfig()
|
||||
.then((c) => {
|
||||
if (c.monitorGuildId) {
|
||||
setMonitorGuildId(c.monitorGuildId);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
// Load voice channels when guild changes (Live tab)
|
||||
useEffect(() => {
|
||||
if (selectedVoiceGuild)
|
||||
voice.loadVoiceChannels(selectedVoiceGuild).catch(() => undefined);
|
||||
}, [selectedVoiceGuild, voice.loadVoiceChannels]);
|
||||
|
||||
// Auto-fetch messages for the monitor guild
|
||||
useEffect(() => {
|
||||
if (monitorGuildId)
|
||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||
}, [monitorGuildId, messages.fetchMessages]);
|
||||
|
||||
// Periodic refetch — keeps dashboard in sync even if WS events missed
|
||||
useEffect(() => {
|
||||
if (!monitorGuildId) return;
|
||||
const interval = setInterval(() => {
|
||||
messages.fetchMessages(monitorGuildId).catch(() => undefined);
|
||||
}, 15_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [monitorGuildId, messages.fetchMessages]);
|
||||
|
||||
// Stale speaker pruning — remove speakers not heard from in 30s
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveSpeakers((prev) => {
|
||||
const now = Date.now();
|
||||
const pruned = prev.filter(
|
||||
(s) => s.speaking || (s.heardAt && now - s.heardAt < 30_000),
|
||||
);
|
||||
return pruned.length < prev.length ? pruned : prev;
|
||||
});
|
||||
}, 30_000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Push-to-Talk — hold Space to transmit
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
e.target instanceof HTMLInputElement ||
|
||||
e.target instanceof HTMLTextAreaElement ||
|
||||
e.target instanceof HTMLSelectElement
|
||||
)
|
||||
return;
|
||||
if (e.code === "Space" && !transmit.isStreaming && e.repeat === false) {
|
||||
e.preventDefault();
|
||||
transmit.startTransmit().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (e.code === "Space" && transmit.isStreaming) {
|
||||
transmit.stopTransmit();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, [transmit]);
|
||||
|
||||
return (
|
||||
<DashboardLayout
|
||||
activeTab={activeTab}
|
||||
wsStatus={socket.status}
|
||||
voiceStatus={voice.voiceStatus}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
recentMessages={messages.messages}
|
||||
guildId={monitorGuildId}
|
||||
channelId={
|
||||
uiState.selectedTextChannel || uiState.selectedVoiceChannel || undefined
|
||||
}
|
||||
>
|
||||
{activeTab === "live" && !authenticated && !isPublicDashboard ? (
|
||||
<AuthOverlay onAuthenticated={handleAuthenticated} />
|
||||
) : activeTab === "live" ? (
|
||||
<LivePanel
|
||||
guilds={voice.guilds}
|
||||
voiceChannels={voice.voiceChannels}
|
||||
selectedGuild={selectedVoiceGuild}
|
||||
selectedChannel={uiState.selectedVoiceChannel || ""}
|
||||
micLevel={0}
|
||||
status={voice.voiceStatus}
|
||||
voiceLoading={voice.loading}
|
||||
activeSpeakers={activeSpeakers}
|
||||
levels={audio.levels}
|
||||
isListening={audio.isListening}
|
||||
isStreaming={transmit.isStreaming}
|
||||
mediaState={media.mediaState}
|
||||
mediaLoading={media.loading}
|
||||
onGuildChange={(id) =>
|
||||
patchUIState({ selectedVoiceGuild: id, selectedVoiceChannel: "" })
|
||||
}
|
||||
onChannelChange={(id) => patchUIState({ selectedVoiceChannel: id })}
|
||||
onJoin={() =>
|
||||
voice.joinVoice(
|
||||
selectedVoiceGuild,
|
||||
uiState.selectedVoiceChannel || "",
|
||||
)
|
||||
}
|
||||
onDisconnect={() => voice.leaveVoice()}
|
||||
onListenToggle={audio.toggleListening}
|
||||
onStreamingToggle={transmit.toggle}
|
||||
onQueueMusic={(s) => media.enqueue(s, "music")}
|
||||
onStartScreen={(s) => media.enqueue(s, "screen")}
|
||||
onSkip={media.skip}
|
||||
onStop={media.stop}
|
||||
onVolumeChange={media.setVolume}
|
||||
/>
|
||||
) : activeTab === "dashboard" ? (
|
||||
<DashboardPanel />
|
||||
) : (
|
||||
<MessagesPanel
|
||||
guildName={monitorGuildName}
|
||||
messages={messages.messages}
|
||||
onReanalyze={messages.reanalyze}
|
||||
onReanalyzeAllErrors={messages.reanalyzeAllErrors}
|
||||
onLoadMore={messages.loadMore}
|
||||
hasMore={messages.hasMore}
|
||||
loadingMore={messages.loadingMore}
|
||||
/>
|
||||
)}
|
||||
<MobileTabBar
|
||||
activeTab={activeTab}
|
||||
onTabChange={(tab) => patchUIState({ activeTab: tab })}
|
||||
/>
|
||||
<ModerationAlertListener />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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[];
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
export type {
|
||||
AIRecommendedAction,
|
||||
AISeverity,
|
||||
AIStatus,
|
||||
MessageRecord,
|
||||
PageResult,
|
||||
} from "@bete/shared";
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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,82 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Lock } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { login } from "../../shared/api/client.js";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
} from "../../shared/ui";
|
||||
|
||||
interface AuthOverlayProps {
|
||||
onAuthenticated: () => void;
|
||||
}
|
||||
|
||||
export function AuthOverlay({ onAuthenticated }: AuthOverlayProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: { preventDefault: () => void }) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(password);
|
||||
sessionStorage.setItem("admin-password", password);
|
||||
onAuthenticated();
|
||||
} catch {
|
||||
setError("Invalid password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex items-center justify-center">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<Lock className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>Admin Access Required</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the admin password to access Voice and Media controls.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={loading || !password}
|
||||
>
|
||||
{loading ? "Authenticating..." : "Unlock Controls"}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import type { DashboardChannelDetail } from "../../../entities/dashboard/types.js";
|
||||
import { ProfileDetail } from "../../../shared/ui";
|
||||
|
||||
interface ChannelProfileDetailProps {
|
||||
detail: DashboardChannelDetail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function ChannelProfileDetail({
|
||||
detail,
|
||||
loading,
|
||||
error,
|
||||
onBack,
|
||||
onRefetch,
|
||||
}: ChannelProfileDetailProps) {
|
||||
if (!detail && !loading && !error) return null;
|
||||
|
||||
return (
|
||||
<ProfileDetail
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={onRefetch}
|
||||
onBack={onBack}
|
||||
icon={<Hash className="h-8 w-8" />}
|
||||
title={detail ? `#${detail.channel_name ?? detail.channel_id}` : ""}
|
||||
subtitle={detail?.channel_id}
|
||||
summaryLabel="AI Channel Summary"
|
||||
summaryText={detail?.culture_summary ?? undefined}
|
||||
lastAnalyzedLabel={
|
||||
detail?.last_analyzed_at
|
||||
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
stats={{
|
||||
totalLabel: "Total Messages",
|
||||
totalValue: detail?.total_messages ?? 0,
|
||||
cleanLabel: "Clean",
|
||||
cleanValue: detail?.clean_count ?? 0,
|
||||
flaggedLabel: "Flagged",
|
||||
flaggedValue: detail?.flagged_count ?? 0,
|
||||
}}
|
||||
messages={
|
||||
detail?.recent_messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
created_at: new Date(msg.created_at).toISOString(),
|
||||
ai_status: msg.ai_status,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import type { DashboardChannel } from "../../../entities/dashboard/types.js";
|
||||
import type { SummaryItem } from "../../../shared/ui";
|
||||
import { SummaryList } from "../../../shared/ui";
|
||||
|
||||
interface ChannelSummaryListProps {
|
||||
channels: DashboardChannel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
onRefetch: () => void;
|
||||
onSelectChannel: (channelId: string) => void;
|
||||
}
|
||||
|
||||
export function ChannelSummaryList({
|
||||
channels,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
onSearchChange,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
onRefetch,
|
||||
onSelectChannel,
|
||||
}: ChannelSummaryListProps) {
|
||||
const items: SummaryItem[] = channels.map((ch) => ({
|
||||
id: ch.channel_id,
|
||||
label: `#${ch.channel_name ?? ch.channel_id}`,
|
||||
subtitle: ch.flagged_count > 0 ? `${ch.flagged_count} flagged` : undefined,
|
||||
summaryText: ch.culture_summary ?? `${ch.total_messages} messages`,
|
||||
onClick: () => onSelectChannel(ch.channel_id),
|
||||
}));
|
||||
|
||||
return (
|
||||
<SummaryList
|
||||
items={items}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchValue={search}
|
||||
onSearchChange={onSearchChange}
|
||||
onRetry={onRefetch}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={onLoadMore}
|
||||
loadingMore={loading}
|
||||
renderIcon={() => (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<Hash className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
emptyMessage="No channels found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||
import { useUIState } from "../../../shared/hooks/useUIState.js";
|
||||
import { cn } from "../../../shared/lib/utils";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Skeleton,
|
||||
StatusBadge,
|
||||
} from "../../../shared/ui";
|
||||
import { useDashboardStats } from "../hooks/useDashboard";
|
||||
|
||||
export function DashboardStatsContent() {
|
||||
const { stats, loading, error, refetch } = useDashboardStats();
|
||||
const { patchUIState } = useUIState();
|
||||
|
||||
if (loading) {
|
||||
return <StatsSkeleton />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
||||
<AlertCircle className="h-10 w-10 text-destructive" />
|
||||
<p className="text-sm">{error}</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-4 py-2 text-sm font-medium hover:bg-accent transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-20 text-muted-foreground">
|
||||
<BarChart3 className="h-10 w-10" />
|
||||
<p className="text-sm">No data available yet.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Total Messages",
|
||||
value: stats.total_messages.toLocaleString(),
|
||||
icon: MessageSquare,
|
||||
color: "text-primary",
|
||||
bg: "bg-primary/10",
|
||||
},
|
||||
{
|
||||
title: "Today's Messages",
|
||||
value: stats.today_messages.toLocaleString(),
|
||||
icon: MessageSquare,
|
||||
color: "text-success",
|
||||
bg: "bg-success-soft",
|
||||
},
|
||||
{
|
||||
title: "Total Users",
|
||||
value: stats.total_users.toLocaleString(),
|
||||
icon: Users,
|
||||
color: "text-primary",
|
||||
bg: "bg-primary-soft",
|
||||
},
|
||||
{
|
||||
title: "Active Users (24h)",
|
||||
value: stats.active_users_24h.toLocaleString(),
|
||||
icon: UserCheck,
|
||||
color: "text-tertiary",
|
||||
bg: "bg-tertiary-soft",
|
||||
},
|
||||
{
|
||||
title: "Flagged",
|
||||
value: stats.total_flagged.toLocaleString(),
|
||||
icon: ShieldAlert,
|
||||
color: "text-destructive",
|
||||
bg: "bg-destructive/10",
|
||||
},
|
||||
{
|
||||
title: "Clean",
|
||||
value: stats.total_clean.toLocaleString(),
|
||||
icon: ShieldAlert,
|
||||
color: "text-success",
|
||||
bg: "bg-success-soft",
|
||||
},
|
||||
{
|
||||
title: "Voice Recordings",
|
||||
value: stats.total_voice_recordings.toLocaleString(),
|
||||
icon: Mic,
|
||||
color: "text-info",
|
||||
bg: "bg-info-soft",
|
||||
onClick: () => patchUIState({ activeTab: "live" }),
|
||||
},
|
||||
{
|
||||
title: "AI Profiles",
|
||||
value: stats.total_profiles.toLocaleString(),
|
||||
icon: Users,
|
||||
color: "text-warning",
|
||||
bg: "bg-warning-soft",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="grid gap-6"
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{/* Summary cards grid */}
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<Card
|
||||
key={card.title}
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
card.onClick &&
|
||||
"cursor-pointer transition-colors hover:bg-accent/50",
|
||||
)}
|
||||
onClick={card.onClick}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
{card.title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold tracking-tight">
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className={cn("rounded-xl p-2.5", card.bg)}>
|
||||
<card.icon className={cn("h-5 w-5", card.color)} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
{/* Top channels */}
|
||||
<motion.div variants={cardItem}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Top Channels</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats.top_channels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No channel data yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{stats.top_channels.map((ch) => (
|
||||
<div
|
||||
key={ch.channel_id}
|
||||
className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
#{ch.channel_name ?? ch.channel_id}
|
||||
</span>
|
||||
<span className="ml-2 shrink-0 font-medium">
|
||||
{ch.message_count.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{/* Moderation overview */}
|
||||
<motion.div variants={cardItem}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Moderation Queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<StatusBadge status="pending" />
|
||||
<StatusBadge status="processing" />
|
||||
<StatusBadge status="error" />
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-3 mt-4">
|
||||
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-muted-foreground">
|
||||
{stats.moderation_overview.pending}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Pending</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-warning">
|
||||
{stats.moderation_overview.processing}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Processing</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p className="text-2xl font-bold text-destructive">
|
||||
{stats.moderation_overview.error}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Errors</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatsSkeleton() {
|
||||
return (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-7 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { User } from "lucide-react";
|
||||
import type { DashboardUserDetail } from "../../../entities/dashboard/types.js";
|
||||
import { ProfileDetail } from "../../../shared/ui";
|
||||
|
||||
interface UserProfileDetailProps {
|
||||
detail: DashboardUserDetail | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export function UserProfileDetail({
|
||||
detail,
|
||||
loading,
|
||||
error,
|
||||
onBack,
|
||||
onRefetch,
|
||||
}: UserProfileDetailProps) {
|
||||
if (!detail && !loading && !error) return null;
|
||||
|
||||
const icon = detail?.avatar_url ? (
|
||||
<img
|
||||
src={detail.avatar_url}
|
||||
alt=""
|
||||
className="h-16 w-16 rounded-full object-cover ring-2 ring-border"
|
||||
/>
|
||||
) : (
|
||||
<User className="h-8 w-8" />
|
||||
);
|
||||
|
||||
return (
|
||||
<ProfileDetail
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={onRefetch}
|
||||
onBack={onBack}
|
||||
icon={icon}
|
||||
title={detail?.username ?? detail?.user_id ?? ""}
|
||||
subtitle={detail?.user_id}
|
||||
summaryLabel="AI Profile Summary"
|
||||
summaryText={detail?.profile_summary ?? undefined}
|
||||
lastAnalyzedLabel={
|
||||
detail?.last_analyzed_at
|
||||
? `Last analyzed: ${new Date(detail.last_analyzed_at).toLocaleString()}`
|
||||
: undefined
|
||||
}
|
||||
stats={{
|
||||
totalLabel: "Total Messages",
|
||||
totalValue: detail?.total_messages ?? 0,
|
||||
cleanLabel: "Clean",
|
||||
cleanValue: detail?.clean_count ?? 0,
|
||||
flaggedLabel: "Flagged",
|
||||
flaggedValue: detail?.flagged_count ?? 0,
|
||||
}}
|
||||
messages={
|
||||
detail?.recent_messages.map((msg) => ({
|
||||
id: msg.id,
|
||||
content: msg.content,
|
||||
created_at: new Date(msg.created_at).toISOString(),
|
||||
ai_status: msg.ai_status,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { User } from "lucide-react";
|
||||
import type { DashboardUser } from "../../../entities/dashboard/types.js";
|
||||
import type { SummaryItem } from "../../../shared/ui";
|
||||
import { SummaryList } from "../../../shared/ui";
|
||||
|
||||
interface UserSummaryListProps {
|
||||
users: DashboardUser[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
search: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onLoadMore: () => void;
|
||||
hasMore: boolean;
|
||||
onRefetch: () => void;
|
||||
onSelectUser: (userId: string) => void;
|
||||
}
|
||||
|
||||
export function UserSummaryList({
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
search,
|
||||
onSearchChange,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
onRefetch,
|
||||
onSelectUser,
|
||||
}: UserSummaryListProps) {
|
||||
const items: SummaryItem[] = users.map((u) => ({
|
||||
id: u.user_id,
|
||||
label: u.username ?? u.user_id,
|
||||
subtitle: u.trust_score !== null ? `Trust: ${u.trust_score}` : undefined,
|
||||
summaryText: u.profile_summary ?? `${u.total_messages} messages`,
|
||||
onClick: () => onSelectUser(u.user_id),
|
||||
}));
|
||||
|
||||
const avatarMap = new Map(users.map((u) => [u.user_id, u.avatar_url]));
|
||||
|
||||
return (
|
||||
<SummaryList
|
||||
items={items}
|
||||
loading={loading}
|
||||
error={error}
|
||||
searchValue={search}
|
||||
onSearchChange={onSearchChange}
|
||||
onRetry={onRefetch}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={onLoadMore}
|
||||
loadingMore={loading}
|
||||
renderIcon={(item) => {
|
||||
const avatarUrl = avatarMap.get(item.id);
|
||||
return avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted">
|
||||
<User className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
emptyMessage="No users found."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { DashboardStats } from "../../../entities/dashboard/types.js";
|
||||
import {
|
||||
getDashboardChannelDetail,
|
||||
getDashboardStats,
|
||||
getDashboardUserDetail,
|
||||
listDashboardChannels,
|
||||
listDashboardUsers,
|
||||
} from "../../../shared/api/client.js";
|
||||
import { useItemDetail } from "../../../shared/hooks/useItemDetail";
|
||||
import { usePaginatedList } from "../../../shared/hooks/usePaginatedList";
|
||||
|
||||
import { createLogger } from "../../../shared/lib/logger.js";
|
||||
|
||||
const logger = createLogger("use-dashboard");
|
||||
|
||||
/**
|
||||
* Fetch dashboard aggregate stats.
|
||||
*/
|
||||
export function useDashboardStats() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDashboardStats();
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Failed to load stats";
|
||||
setError(msg);
|
||||
logger.error("[useDashboardStats]", { error: msg });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch().catch(() => undefined);
|
||||
}, [fetch]);
|
||||
|
||||
return { stats, loading, error, refetch: fetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch paginated user list with optional search.
|
||||
*/
|
||||
export function useDashboardUsers() {
|
||||
const paginated = usePaginatedList(
|
||||
(params) =>
|
||||
listDashboardUsers({
|
||||
limit: params.limit,
|
||||
search: params.search,
|
||||
cursor: params.cursor,
|
||||
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
|
||||
"",
|
||||
);
|
||||
|
||||
return {
|
||||
users: paginated.data,
|
||||
loading: paginated.loading,
|
||||
error: paginated.error,
|
||||
search: paginated.search,
|
||||
setSearch: paginated.setSearch,
|
||||
loadMore: paginated.loadMore,
|
||||
hasMore: paginated.hasMore,
|
||||
refetch: paginated.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single user detail by userId.
|
||||
*/
|
||||
export function useDashboardUserDetail(userId: string | null) {
|
||||
const { data, loading, error, refetch } = useItemDetail(
|
||||
(_guildId, entityId) => getDashboardUserDetail(entityId),
|
||||
"",
|
||||
userId,
|
||||
"user",
|
||||
);
|
||||
|
||||
return { detail: data, loading, error, refetch };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch paginated channel list with optional search.
|
||||
*/
|
||||
export function useDashboardChannels() {
|
||||
const paginated = usePaginatedList(
|
||||
(params) =>
|
||||
listDashboardChannels({
|
||||
limit: params.limit,
|
||||
search: params.search,
|
||||
guild_id: params.guildId,
|
||||
cursor: params.cursor,
|
||||
}).then((r) => ({ data: r.data, nextCursor: r.nextCursor })),
|
||||
"",
|
||||
);
|
||||
|
||||
return {
|
||||
channels: paginated.data,
|
||||
loading: paginated.loading,
|
||||
error: paginated.error,
|
||||
search: paginated.search,
|
||||
setSearch: paginated.setSearch,
|
||||
loadMore: paginated.loadMore,
|
||||
hasMore: paginated.hasMore,
|
||||
refetch: paginated.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single channel detail by channelId.
|
||||
*/
|
||||
export function useDashboardChannelDetail(channelId: string | null) {
|
||||
const { data, loading, error, refetch } = useItemDetail(
|
||||
(_guildId, entityId) => getDashboardChannelDetail(entityId),
|
||||
"",
|
||||
channelId,
|
||||
"channel",
|
||||
);
|
||||
|
||||
return { detail: data, loading, error, refetch };
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN DashboardPanel — Statistics Hub for Guild Moderation Watcher
|
||||
* Menampilkan overview komunitas dengan IMPHNEN approachable modernism.
|
||||
* Tiga tab: Stats (ringkasan), Users (profil pengguna), Channels (kanal).
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Hash,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { ChannelProfileDetail } from "./components/ChannelProfileDetail";
|
||||
import { ChannelSummaryList } from "./components/ChannelSummaryList";
|
||||
import { DashboardStatsContent } from "./components/DashboardStats";
|
||||
import { UserProfileDetail } from "./components/UserProfileDetail";
|
||||
import { UserSummaryList } from "./components/UserSummaryList";
|
||||
import {
|
||||
useDashboardChannelDetail,
|
||||
useDashboardChannels,
|
||||
useDashboardUserDetail,
|
||||
useDashboardUsers,
|
||||
} from "./hooks/useDashboard";
|
||||
|
||||
export function DashboardPanel() {
|
||||
const [activeTab, setActiveTab] = useState("stats");
|
||||
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const {
|
||||
users,
|
||||
loading: usersLoading,
|
||||
error: usersError,
|
||||
search: userSearch,
|
||||
setSearch: setUserSearch,
|
||||
loadMore: loadMoreUsers,
|
||||
hasMore: hasMoreUsers,
|
||||
refetch: refetchUsers,
|
||||
} = useDashboardUsers();
|
||||
const {
|
||||
detail: userDetail,
|
||||
loading: userDetailLoading,
|
||||
error: userDetailError,
|
||||
refetch: refetchUserDetail,
|
||||
} = useDashboardUserDetail(selectedUserId);
|
||||
const {
|
||||
channels,
|
||||
loading: channelsLoading,
|
||||
error: channelsError,
|
||||
search: channelSearch,
|
||||
setSearch: setChannelSearch,
|
||||
loadMore: loadMoreChannels,
|
||||
hasMore: hasMoreChannels,
|
||||
refetch: refetchChannels,
|
||||
} = useDashboardChannels();
|
||||
const {
|
||||
detail: channelDetail,
|
||||
loading: channelDetailLoading,
|
||||
error: channelDetailError,
|
||||
refetch: refetchChannelDetail,
|
||||
} = useDashboardChannelDetail(selectedChannelId);
|
||||
|
||||
// Show user detail view
|
||||
if (selectedUserId) {
|
||||
return (
|
||||
<UserProfileDetail
|
||||
detail={userDetail}
|
||||
loading={userDetailLoading}
|
||||
error={userDetailError}
|
||||
onBack={() => {
|
||||
setSelectedUserId(null);
|
||||
}}
|
||||
onRefetch={refetchUserDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Show channel detail view
|
||||
if (selectedChannelId) {
|
||||
return (
|
||||
<ChannelProfileDetail
|
||||
detail={channelDetail}
|
||||
loading={channelDetailLoading}
|
||||
error={channelDetailError}
|
||||
onBack={() => {
|
||||
setSelectedChannelId(null);
|
||||
}}
|
||||
onRefetch={refetchChannelDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* ── Page Header ───────────────────────────────────────────────── */}
|
||||
<div className="mb-6">
|
||||
<h2 className="typo-headline-md text-[#1a1a1a]">
|
||||
Dashboard Guild
|
||||
</h2>
|
||||
<p className="typo-body-md text-[#666666] mt-1">
|
||||
Pantau statistik, profil pengguna, dan aktivitas kanal komunitas
|
||||
IMPHNEN secara real-time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ────────────────────────────────────────────────────────── */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="mb-6 bg-[#f5f5f5] p-1 rounded-lg inline-flex">
|
||||
<TabsTrigger value="stats" className="flex items-center gap-1.5">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
<span>Statistik</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="users" className="flex items-center gap-1.5">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>Pengguna</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="flex items-center gap-1.5">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>Kanal</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="stats">
|
||||
<DashboardStatsContent />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="users">
|
||||
<UserSummaryList
|
||||
users={users}
|
||||
loading={usersLoading}
|
||||
error={usersError}
|
||||
search={userSearch}
|
||||
onSearchChange={setUserSearch}
|
||||
onLoadMore={loadMoreUsers}
|
||||
hasMore={hasMoreUsers}
|
||||
onRefetch={refetchUsers}
|
||||
onSelectUser={setSelectedUserId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="channels">
|
||||
<ChannelSummaryList
|
||||
channels={channels}
|
||||
loading={channelsLoading}
|
||||
error={channelsError}
|
||||
search={channelSearch}
|
||||
onSearchChange={setChannelSearch}
|
||||
onLoadMore={loadMoreChannels}
|
||||
hasMore={hasMoreChannels}
|
||||
onRefetch={refetchChannels}
|
||||
onSelectChannel={setSelectedChannelId}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ActiveSpeaker } from "../../../entities/voice/types.js";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
|
||||
interface ActiveSpeakersProps {
|
||||
speakers: ActiveSpeaker[];
|
||||
}
|
||||
|
||||
export function ActiveSpeakers({ speakers }: ActiveSpeakersProps) {
|
||||
if (speakers.length === 0) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{speakers.map((s) => {
|
||||
const key = s.userId ?? s.id ?? `speaker-${s.username}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card p-3"
|
||||
>
|
||||
<img
|
||||
src={s.avatar}
|
||||
alt=""
|
||||
className="h-8 w-8 rounded-full object-cover ring-2 ring-primary/30"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{s.username}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
s.speaking ? "bg-emerald-500" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
s.speaking ? "text-success" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{s.speaking ? "Speaking" : "Silent"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface AudioVisualizerProps {
|
||||
levels: number[];
|
||||
}
|
||||
|
||||
export function AudioVisualizer({ levels }: AudioVisualizerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 128 * dpr;
|
||||
canvas.style.height = "128px";
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const width = canvas.width / dpr;
|
||||
const height = canvas.height / dpr;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const barWidth = width / levels.length;
|
||||
const maxBarHeight = height * 0.85;
|
||||
|
||||
const root = getComputedStyle(document.documentElement);
|
||||
const primaryColor = root.getPropertyValue("--primary").trim() || "#23a1eb";
|
||||
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, height);
|
||||
gradient.addColorStop(0, primaryColor);
|
||||
gradient.addColorStop(1, primaryColor);
|
||||
|
||||
for (let i = 0; i < levels.length; i++) {
|
||||
const level = levels[i];
|
||||
const barHeight = Math.min(maxBarHeight, level * maxBarHeight);
|
||||
const x = i * barWidth;
|
||||
const y = height - barHeight;
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
|
||||
const radius = barWidth * 0.4;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + barWidth - radius, y);
|
||||
ctx.quadraticCurveTo(x + barWidth, y, x + barWidth, y + radius);
|
||||
ctx.lineTo(x + barWidth, height);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [levels]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative w-full">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={0}
|
||||
height={0}
|
||||
className="w-full rounded-lg bg-primary/5"
|
||||
style={{ height: "128px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// ─── Mic level meter — vertical bar showing outgoing audio RMS level ─────────
|
||||
|
||||
interface MicLevelMeterProps {
|
||||
level: number; // 0-1
|
||||
}
|
||||
|
||||
export function MicLevelMeter({ level }: MicLevelMeterProps) {
|
||||
const pct = Math.round(level * 100);
|
||||
|
||||
// Color gradient: green <-> yellow <-> red
|
||||
const hue = 120 - level * 120; // 120 (green) -> 0 (red)
|
||||
const bg = `hsl(${hue}, 80%, 45%)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-6 w-24 overflow-hidden rounded-full bg-muted"
|
||||
role="meter"
|
||||
aria-valuenow={pct}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Microphone level"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full transition-[width,background-color] duration-75 ease-linear"
|
||||
style={{ width: `${pct}%`, backgroundColor: bg }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Music2, SkipForward, Square, Volume2, VolumeX } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
|
||||
interface MusicSubPanelProps {
|
||||
volume: number;
|
||||
onVolumeChange: (v: number) => void;
|
||||
onQueue: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function MusicSubPanel({
|
||||
volume,
|
||||
onVolumeChange,
|
||||
onQueue,
|
||||
onSkip,
|
||||
onStop,
|
||||
loading,
|
||||
}: MusicSubPanelProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const safeVolume = Number.isFinite(volume)
|
||||
? Math.max(0, Math.min(1, volume))
|
||||
: 1;
|
||||
const [draftVolume, setDraftVolume] = useState(Math.round(safeVolume * 100));
|
||||
const [muted, setMuted] = useState(false);
|
||||
const prevVolumeRef = useRef(safeVolume);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Proper debounce: setTimeout instead of setInterval polling
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
const normalized = draftVolume / 100;
|
||||
if (Math.abs(normalized - safeVolume) >= 0.001)
|
||||
onVolumeChange(normalized);
|
||||
}, 200);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [draftVolume, safeVolume, onVolumeChange]);
|
||||
|
||||
const handleMute = useCallback(() => {
|
||||
if (muted) {
|
||||
// Unmute: restore previous volume
|
||||
const restore = prevVolumeRef.current;
|
||||
setDraftVolume(Math.round(restore * 100));
|
||||
onVolumeChange(restore);
|
||||
setMuted(false);
|
||||
} else {
|
||||
// Mute: save current, set to 0
|
||||
prevVolumeRef.current = safeVolume;
|
||||
setDraftVolume(0);
|
||||
onVolumeChange(0);
|
||||
setMuted(true);
|
||||
}
|
||||
}, [muted, safeVolume, onVolumeChange]);
|
||||
|
||||
const submit = () => {
|
||||
const t = source.trim();
|
||||
if (!t) return;
|
||||
onQueue(t);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder="YouTube URL, Spotify track, or search terms"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMute}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{muted ? (
|
||||
<VolumeX className="h-4 w-4" />
|
||||
) : (
|
||||
<Volume2 className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={draftVolume}
|
||||
onChange={(e) => {
|
||||
setDraftVolume(Number(e.target.value));
|
||||
if (muted) setMuted(false);
|
||||
}}
|
||||
className="h-2 w-full cursor-pointer accent-primary"
|
||||
/>
|
||||
<span className="w-10 shrink-0 text-right text-sm tabular-nums text-muted-foreground">
|
||||
{draftVolume}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<Music2 className="mr-1.5 h-4 w-4" /> Queue
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { MonitorUp, Music2 } from "lucide-react";
|
||||
import type { MediaItem } from "../../../entities/media/types.js";
|
||||
import { Badge } from "../../../shared/ui";
|
||||
|
||||
interface NowPlayingProps {
|
||||
current: MediaItem | null;
|
||||
queue: MediaItem[];
|
||||
}
|
||||
|
||||
export function NowPlaying({ current, queue }: NowPlayingProps) {
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
{current.mode === "screen" ? (
|
||||
<MonitorUp className="h-5 w-5" />
|
||||
) : (
|
||||
<Music2 className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{current.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{current.source}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={current.mode === "screen" ? "warning" : "success"}>
|
||||
{current.mode ?? "music"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="mb-2 text-sm font-medium">Queue ({queue.length})</div>
|
||||
<div className="space-y-1.5">
|
||||
{queue.map((item, i) => (
|
||||
<div
|
||||
key={`${item.source}-${i}`}
|
||||
className="flex items-center gap-3 rounded-lg border-l-2 border-l-primary border-border bg-card p-2.5 text-sm"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">{item.title}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{item.source}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// ─── Recordings Sub-Panel ──
|
||||
|
||||
import { Download, Mic, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VoiceRecording } from "../../../entities/recording/types.js";
|
||||
import { deleteRecording, listRecordings } from "../../../shared/api/client";
|
||||
import { formatBytes, formatDate } from "../../../shared/lib/utils";
|
||||
import { Badge, Button, Skeleton } from "../../../shared/ui";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
import { WaveformPlayer } from "./WaveformPlayer";
|
||||
|
||||
export function RecordingsSubPanel() {
|
||||
const [recordings, setRecordings] = useState<VoiceRecording[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRecordings = useCallback(
|
||||
async (opts?: { signal?: AbortSignal }) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await listRecordings({ limit: 50 });
|
||||
if (!opts?.signal?.aborted) {
|
||||
setRecordings(data.items);
|
||||
setNextCursor(data.nextCursor);
|
||||
setHasMore(data.hasMore);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!opts?.signal?.aborted)
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
if (!opts?.signal?.aborted) setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!nextCursor || loadingMore) return;
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
const data = await listRecordings({ limit: 50, cursor: nextCursor });
|
||||
setRecordings((prev) => [...prev, ...data.items]);
|
||||
setNextCursor(data.nextCursor);
|
||||
setHasMore(data.hasMore);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [nextCursor, loadingMore]);
|
||||
|
||||
useEffect(() => {
|
||||
const ab = new AbortController();
|
||||
loadRecordings({ signal: ab.signal });
|
||||
const handler = () => loadRecordings();
|
||||
window.addEventListener("voice_recording_uploaded", handler);
|
||||
return () => {
|
||||
ab.abort();
|
||||
window.removeEventListener("voice_recording_uploaded", handler);
|
||||
};
|
||||
}, [loadRecordings]);
|
||||
|
||||
const handleDelete = useCallback(async (id: string) => {
|
||||
if (!confirm("Delete this recording?")) return;
|
||||
setDeletingIds((prev) => new Set(prev).add(id));
|
||||
try {
|
||||
await deleteRecording(id);
|
||||
setRecordings((prev) => prev.filter((r) => r.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setDeletingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 rounded-xl border border-border bg-card p-4"
|
||||
>
|
||||
<Skeleton className="h-10 w-10 rounded-xl" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-3 w-64" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-destructive p-6 text-center text-sm text-destructive">
|
||||
{error}
|
||||
<div className="mt-2">
|
||||
<Button size="sm" variant="outline" onClick={() => loadRecordings()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (recordings.length === 0) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map((rec) => (
|
||||
<div key={rec.id} className="rounded-xl border border-[#e0e0e0] bg-white">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Mic className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{rec.filename}</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
|
||||
<span>{rec.username}</span>
|
||||
<span>·</span>
|
||||
<span>{rec.channel_name ?? rec.channel_id ?? "unknown"}</span>
|
||||
<span>·</span>
|
||||
<span>{formatDate(rec.created_at)}</span>
|
||||
<span>·</span>
|
||||
<span>{formatBytes(rec.size_bytes)}</span>
|
||||
</div>
|
||||
{rec.upload_error && (
|
||||
<div className="mt-1 text-xs text-destructive">
|
||||
{rec.upload_error}
|
||||
</div>
|
||||
)}
|
||||
{rec.transcription && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground italic">
|
||||
{rec.transcription}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={deletingIds.has(rec.id)}
|
||||
onClick={() => handleDelete(rec.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Badge
|
||||
variant={
|
||||
rec.upload_status === "uploaded"
|
||||
? "success"
|
||||
: rec.upload_status === "failed"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{rec.upload_status}
|
||||
</Badge>
|
||||
{rec.download_url && (
|
||||
<a
|
||||
href={rec.download_url}
|
||||
download={rec.filename}
|
||||
className="rounded-lg bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{rec.download_url && (
|
||||
<div className="-mt-2 px-4 pb-4">
|
||||
<WaveformPlayer
|
||||
downloadUrl={rec.download_url}
|
||||
filename={rec.filename}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loadingMore}
|
||||
onClick={loadMore}
|
||||
>
|
||||
{loadingMore ? "Loading..." : "Load More"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { MonitorUp, SkipForward, Square } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button, Input } from "../../../shared/ui";
|
||||
|
||||
interface ScreenSubPanelProps {
|
||||
onStart: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export function ScreenSubPanel({
|
||||
onStart,
|
||||
onSkip,
|
||||
onStop,
|
||||
loading,
|
||||
}: ScreenSubPanelProps) {
|
||||
const [source, setSource] = useState("");
|
||||
const submit = () => {
|
||||
const t = source.trim();
|
||||
if (!t) return;
|
||||
onStart(t);
|
||||
setSource("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-4">
|
||||
<Input
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
placeholder="Screen share URL or local file path"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={loading || !source.trim()} onClick={submit}>
|
||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Start
|
||||
</Button>
|
||||
<Button variant="secondary" disabled={loading} onClick={onSkip}>
|
||||
<SkipForward className="mr-1.5 h-4 w-4" /> Skip
|
||||
</Button>
|
||||
<Button variant="destructive" disabled={loading} onClick={onStop}>
|
||||
<Square className="mr-1.5 h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { Headphones, Radio } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../../entities/guild/types.js";
|
||||
import type { VoiceStatus } from "../../../entities/voice/types.js";
|
||||
import { Button, Select } from "../../../shared/ui";
|
||||
import { MicLevelMeter } from "./MicLevelMeter";
|
||||
|
||||
interface VoiceConnectionCardProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
micLevel: number;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
}
|
||||
|
||||
export function VoiceConnectionCard({
|
||||
guilds,
|
||||
voiceChannels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
status,
|
||||
voiceLoading,
|
||||
isListening,
|
||||
isStreaming,
|
||||
micLevel,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
onDisconnect,
|
||||
onListenToggle,
|
||||
onStreamingToggle,
|
||||
}: VoiceConnectionCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm">
|
||||
<div className="p-6">
|
||||
<h3 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
|
||||
<Radio className="h-5 w-5 text-primary" /> Voice Bridge
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Join a Discord voice channel, listen, and transmit audio.
|
||||
</p>
|
||||
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">Guild</label>
|
||||
<Select
|
||||
value={selectedGuild}
|
||||
onChange={(e) => onGuildChange(e.target.value)}
|
||||
placeholder="Select guild"
|
||||
options={guilds.map((g) => ({ value: g.id, label: g.name }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
Voice Channel
|
||||
</label>
|
||||
<Select
|
||||
value={selectedChannel}
|
||||
onChange={(e) => onChannelChange(e.target.value)}
|
||||
placeholder="Select voice channel"
|
||||
options={voiceChannels.map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
disabled={!selectedGuild || !selectedChannel || voiceLoading}
|
||||
onClick={onJoin}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{status.connected ? "Reconnect" : "Join Voice"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!status.connected || voiceLoading}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
<Button
|
||||
variant={isListening ? "secondary" : "outline"}
|
||||
onClick={onListenToggle}
|
||||
>
|
||||
<Headphones className="mr-1.5 h-4 w-4" />{" "}
|
||||
{isListening ? "Stop Listening" : "Listen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isStreaming ? "secondary" : "outline"}
|
||||
onClick={onStreamingToggle}
|
||||
>
|
||||
<Radio className="mr-1.5 h-4 w-4" />{" "}
|
||||
{isStreaming ? "Stop Transmit" : "Transmit"}
|
||||
</Button>
|
||||
{isStreaming && (
|
||||
<div className="flex items-center gap-2 pl-1">
|
||||
<span className="animate-pulse rounded-full bg-emerald-500 px-2 py-0.5 text-xs font-medium text-white">
|
||||
Hold Space
|
||||
</span>
|
||||
<MicLevelMeter level={micLevel} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
// ─── Waveform Player — audio visualizer with seekable waveform bars ──────────
|
||||
|
||||
import { Pause, Play } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createLogger } from "../../../shared/lib/logger";
|
||||
|
||||
const logger = createLogger("waveform-player");
|
||||
|
||||
const BAR_COUNT = 64;
|
||||
const SAMPLE_RATE = 24000;
|
||||
|
||||
interface WaveformPlayerProps {
|
||||
downloadUrl: string;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function WaveformPlayer({ downloadUrl, filename }: WaveformPlayerProps) {
|
||||
const [playing, setPlaying] = useState(false);
|
||||
const [peaks, setPeaks] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const startTimeRef = useRef(0);
|
||||
const startOffsetRef = useRef(0);
|
||||
const rafRef = useRef<number>(0);
|
||||
const decodedRef = useRef<AudioBuffer | null>(null);
|
||||
const durationRef = useRef(0);
|
||||
|
||||
// Decode audio on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const ctx = new AudioContext();
|
||||
audioContextRef.current = ctx;
|
||||
|
||||
fetch(downloadUrl)
|
||||
.then((res) => {
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.arrayBuffer();
|
||||
})
|
||||
.then((buf) => ctx.decodeAudioData(buf))
|
||||
.then((audioBuffer) => {
|
||||
if (cancelled) return;
|
||||
decodedRef.current = audioBuffer;
|
||||
durationRef.current = audioBuffer.duration;
|
||||
|
||||
// Compute waveform peaks
|
||||
const channel = audioBuffer.getChannelData(0);
|
||||
const samplesPerBar = Math.floor(channel.length / BAR_COUNT);
|
||||
const peakValues: number[] = [];
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
let max = 0;
|
||||
const start = i * samplesPerBar;
|
||||
const end = Math.min(start + samplesPerBar, channel.length);
|
||||
for (let j = start; j < end; j++) {
|
||||
const abs = Math.abs(channel[j]);
|
||||
if (abs > max) max = abs;
|
||||
}
|
||||
// Clamp so silent sections still show a tiny bar
|
||||
peakValues.push(Math.max(0.01, max));
|
||||
}
|
||||
setPeaks(peakValues);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to decode audio", { error: msg });
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
ctx.close();
|
||||
};
|
||||
}, [downloadUrl]);
|
||||
|
||||
// Draw waveform on canvas whenever peaks change or while playing
|
||||
const drawWaveform = useCallback(
|
||||
(progress = 0) => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = 64 * dpr;
|
||||
canvas.style.height = "64px";
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, 64);
|
||||
|
||||
if (peaks.length === 0) return;
|
||||
|
||||
const barWidth = rect.width / peaks.length;
|
||||
const barGap = Math.max(1, barWidth * 0.15);
|
||||
const barActualWidth = barWidth - barGap;
|
||||
const progressPixel = rect.width * progress;
|
||||
|
||||
for (let i = 0; i < peaks.length; i++) {
|
||||
const x = i * barWidth;
|
||||
const height = Math.max(2, peaks[i] * 50);
|
||||
const y = 32 - height / 2;
|
||||
|
||||
// Color: played vs unplayed
|
||||
const isPlayed = x + barWidth <= progressPixel;
|
||||
ctx.fillStyle = isPlayed ? "#23a1eb" : "#334155";
|
||||
ctx.fillRect(x, y, barActualWidth, height);
|
||||
}
|
||||
},
|
||||
[peaks],
|
||||
);
|
||||
|
||||
// Initial draw when peaks change
|
||||
useEffect(() => {
|
||||
drawWaveform();
|
||||
}, [drawWaveform]);
|
||||
|
||||
// Animation loop while playing
|
||||
useEffect(() => {
|
||||
if (!playing || !decodedRef.current) return;
|
||||
|
||||
const tick = () => {
|
||||
if (!audioContextRef.current) return;
|
||||
const elapsed =
|
||||
audioContextRef.current.currentTime - startTimeRef.current;
|
||||
const progress = (elapsed + startOffsetRef.current) / durationRef.current;
|
||||
drawWaveform(Math.min(1, Math.max(0, progress)));
|
||||
|
||||
if (progress >= 1) {
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [playing, drawWaveform]);
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
const ctx = audioContextRef.current;
|
||||
const buffer = decodedRef.current;
|
||||
if (!ctx || !buffer) return;
|
||||
|
||||
if (playing) {
|
||||
// Pause
|
||||
if (sourceRef.current) {
|
||||
startOffsetRef.current += ctx.currentTime - startTimeRef.current;
|
||||
sourceRef.current.stop();
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
setPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resume / start
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start(0, startOffsetRef.current);
|
||||
startTimeRef.current = ctx.currentTime;
|
||||
sourceRef.current = source;
|
||||
setPlaying(true);
|
||||
|
||||
source.onended = () => {
|
||||
if (sourceRef.current === source) {
|
||||
setPlaying(false);
|
||||
sourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [playing]);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!decodedRef.current) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const progress = Math.max(0, Math.min(1, x / rect.width));
|
||||
const offset = progress * durationRef.current;
|
||||
|
||||
const ctx = audioContextRef.current;
|
||||
if (ctx && sourceRef.current) {
|
||||
sourceRef.current.stop();
|
||||
sourceRef.current.disconnect();
|
||||
}
|
||||
|
||||
startOffsetRef.current = offset;
|
||||
startTimeRef.current = ctx?.currentTime ?? 0;
|
||||
drawWaveform(progress);
|
||||
|
||||
if (playing && ctx) {
|
||||
const buffer = decodedRef.current;
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(ctx.destination);
|
||||
source.start(0, offset);
|
||||
startTimeRef.current = ctx.currentTime;
|
||||
sourceRef.current = source;
|
||||
source.onended = () => {
|
||||
if (sourceRef.current === source) {
|
||||
setPlaying(false);
|
||||
sourceRef.current = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
[playing, drawWaveform],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <div className="h-16 w-full animate-pulse rounded-md bg-muted" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-16 w-full rounded-md bg-destructive/10 flex items-center justify-center text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (peaks.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePlay}
|
||||
className="shrink-0 rounded-full bg-primary p-1.5 text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{playing ? (
|
||||
<Pause className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Play className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex-1 cursor-pointer"
|
||||
onClick={handleSeek}
|
||||
role="slider"
|
||||
aria-label={`Playback seek for ${filename}`}
|
||||
tabIndex={0}
|
||||
>
|
||||
<canvas ref={canvasRef} className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { MediaState } from "../../../entities/media/types.js";
|
||||
import {
|
||||
getMediaStatus,
|
||||
queueMedia,
|
||||
setMediaVolume,
|
||||
skipMedia,
|
||||
stopMedia,
|
||||
} from "../../../shared/api/client";
|
||||
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
|
||||
import { createLogger } from "../../../shared/lib/logger.js";
|
||||
|
||||
const logger = createLogger("use-media-control");
|
||||
|
||||
const emptyMediaState: MediaState = {
|
||||
playing: false,
|
||||
musicVolume: 1,
|
||||
current: null,
|
||||
queue: [],
|
||||
};
|
||||
|
||||
export function useMediaControl() {
|
||||
const [mediaState, setMediaState] = useState<MediaState>(emptyMediaState);
|
||||
const { loading, error, execute, clearError } = useAsyncAction();
|
||||
|
||||
const refreshMedia = useCallback(async () => {
|
||||
const state = await getMediaStatus();
|
||||
setMediaState(state);
|
||||
return state;
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
async (source: string, mode: "music" | "screen") => {
|
||||
const result = await execute(() => queueMedia(source, mode));
|
||||
if (result) {
|
||||
setMediaState(result);
|
||||
logger.info("Media queued", { source, mode });
|
||||
} else {
|
||||
logger.error("Failed to queue media", { source, mode });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[execute],
|
||||
);
|
||||
|
||||
const skip = useCallback(async () => {
|
||||
const result = await execute(() => skipMedia());
|
||||
if (result) {
|
||||
setMediaState(result);
|
||||
logger.info("Media skipped");
|
||||
} else {
|
||||
logger.error("Failed to skip media");
|
||||
}
|
||||
return result;
|
||||
}, [execute]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
const result = await execute(() => stopMedia());
|
||||
if (result) {
|
||||
setMediaState(result);
|
||||
logger.info("Media stopped");
|
||||
} else {
|
||||
logger.error("Failed to stop media");
|
||||
}
|
||||
return result;
|
||||
}, [execute]);
|
||||
|
||||
const setVolume = useCallback(
|
||||
async (volume: number) => {
|
||||
clearError();
|
||||
try {
|
||||
const state = await setMediaVolume(volume);
|
||||
setMediaState(state);
|
||||
logger.info("Volume set", { volume });
|
||||
return state;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to set volume", { volume, error: message });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[clearError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
refreshMedia().catch((err) =>
|
||||
logger.error("Failed to refresh media state on mount", {
|
||||
error: String(err),
|
||||
}),
|
||||
);
|
||||
}, [refreshMedia]);
|
||||
|
||||
return {
|
||||
mediaState,
|
||||
setMediaState,
|
||||
loading,
|
||||
error,
|
||||
refreshMedia,
|
||||
enqueue,
|
||||
skip,
|
||||
stop,
|
||||
setVolume,
|
||||
};
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Channel, Guild } from "../../../entities/guild/types.js";
|
||||
import type { VoiceStatus } from "../../../entities/voice/types.js";
|
||||
import {
|
||||
connectVoice,
|
||||
disconnectVoice,
|
||||
getGuilds,
|
||||
getTextChannels,
|
||||
getVoiceChannels,
|
||||
getVoiceStatus,
|
||||
} from "../../../shared/api/client";
|
||||
import { useAsyncAction } from "../../../shared/hooks/useAsyncAction.js";
|
||||
import { createLogger } from "../../../shared/lib/logger.js";
|
||||
|
||||
const logger = createLogger("use-voice-control");
|
||||
|
||||
export function useVoiceControl() {
|
||||
const [guilds, setGuilds] = useState<Guild[]>([]);
|
||||
const [voiceChannels, setVoiceChannels] = useState<Channel[]>([]);
|
||||
const [textChannels, setTextChannels] = useState<Channel[]>([]);
|
||||
const [voiceStatus, setVoiceStatus] = useState<VoiceStatus>({
|
||||
connected: false,
|
||||
activeGuildId: null,
|
||||
activeChannelId: null,
|
||||
activeChannelName: null,
|
||||
connections: [],
|
||||
});
|
||||
const { loading, error, execute, clearError } = useAsyncAction();
|
||||
|
||||
const refreshGuilds = useCallback(async () => {
|
||||
clearError();
|
||||
const nextGuilds = await getGuilds();
|
||||
setGuilds(nextGuilds);
|
||||
return nextGuilds;
|
||||
}, [clearError]);
|
||||
|
||||
const refreshVoiceStatus = useCallback(async () => {
|
||||
const status = await getVoiceStatus();
|
||||
setVoiceStatus(status);
|
||||
return status;
|
||||
}, []);
|
||||
|
||||
const loadVoiceChannels = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setVoiceChannels([]);
|
||||
return [];
|
||||
}
|
||||
const channels = await getVoiceChannels(guildId);
|
||||
setVoiceChannels(channels);
|
||||
return channels;
|
||||
}, []);
|
||||
|
||||
const loadTextTargets = useCallback(async (guildId: string) => {
|
||||
if (!guildId) {
|
||||
setTextChannels([]);
|
||||
return [];
|
||||
}
|
||||
const channels = await getTextChannels(guildId);
|
||||
setTextChannels(channels);
|
||||
return channels;
|
||||
}, []);
|
||||
|
||||
const joinVoice = useCallback(
|
||||
async (guildId: string, channelId: string) => {
|
||||
const result = await execute(() => connectVoice(guildId, channelId));
|
||||
if (result) {
|
||||
setVoiceStatus(result);
|
||||
logger.info("Connected to voice", { guildId, channelId });
|
||||
} else {
|
||||
logger.error("Failed to connect to voice", { guildId, channelId });
|
||||
}
|
||||
return result;
|
||||
},
|
||||
[execute],
|
||||
);
|
||||
|
||||
const leaveVoice = useCallback(async () => {
|
||||
const result = await execute(() => disconnectVoice());
|
||||
if (result) {
|
||||
setVoiceStatus(result);
|
||||
logger.info("Disconnected from voice");
|
||||
} else {
|
||||
logger.error("Failed to disconnect from voice");
|
||||
}
|
||||
return result;
|
||||
}, [execute]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshGuilds().catch((err) =>
|
||||
logger.error("Failed to refresh guilds on mount", { error: String(err) }),
|
||||
);
|
||||
refreshVoiceStatus().catch((err) =>
|
||||
logger.error("Failed to refresh voice status on mount", {
|
||||
error: String(err),
|
||||
}),
|
||||
);
|
||||
}, [refreshGuilds, refreshVoiceStatus]);
|
||||
|
||||
return {
|
||||
guilds,
|
||||
voiceChannels,
|
||||
textChannels,
|
||||
voiceStatus,
|
||||
loading,
|
||||
error,
|
||||
refreshGuilds,
|
||||
refreshVoiceStatus,
|
||||
loadVoiceChannels,
|
||||
loadTextTargets,
|
||||
joinVoice,
|
||||
leaveVoice,
|
||||
};
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// ─── Live Panel — thin composition layer ────────────────────────────────────
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Mic, MonitorUp, Music2 } from "lucide-react";
|
||||
import type { Channel, Guild } from "../../entities/guild/types.js";
|
||||
import type { MediaState } from "../../entities/media/types.js";
|
||||
import type { ActiveSpeaker, VoiceStatus } from "../../entities/voice/types.js";
|
||||
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { ActiveSpeakers } from "./components/ActiveSpeakers";
|
||||
import { AudioVisualizer } from "./components/AudioVisualizer";
|
||||
import { MusicSubPanel } from "./components/MusicSubPanel";
|
||||
import { NowPlaying } from "./components/NowPlaying";
|
||||
import { RecordingsSubPanel } from "./components/RecordingsSubPanel";
|
||||
import { ScreenSubPanel } from "./components/ScreenSubPanel";
|
||||
import { VoiceConnectionCard } from "./components/VoiceConnectionCard";
|
||||
|
||||
interface LivePanelProps {
|
||||
guilds: Guild[];
|
||||
voiceChannels: Channel[];
|
||||
selectedGuild: string;
|
||||
selectedChannel: string;
|
||||
status: VoiceStatus;
|
||||
voiceLoading: boolean;
|
||||
activeSpeakers: ActiveSpeaker[];
|
||||
levels: number[];
|
||||
isListening: boolean;
|
||||
isStreaming: boolean;
|
||||
micLevel: number;
|
||||
mediaState: MediaState;
|
||||
mediaLoading: boolean;
|
||||
onGuildChange: (id: string) => void;
|
||||
onChannelChange: (id: string) => void;
|
||||
onJoin: () => void;
|
||||
onDisconnect: () => void;
|
||||
onListenToggle: () => void;
|
||||
onStreamingToggle: () => void;
|
||||
onQueueMusic: (source: string) => void;
|
||||
onStartScreen: (source: string) => void;
|
||||
onSkip: () => void;
|
||||
onStop: () => void;
|
||||
onVolumeChange: (v: number) => void;
|
||||
}
|
||||
|
||||
export function LivePanel({
|
||||
guilds,
|
||||
voiceChannels,
|
||||
selectedGuild,
|
||||
selectedChannel,
|
||||
status,
|
||||
voiceLoading,
|
||||
activeSpeakers,
|
||||
levels,
|
||||
isListening,
|
||||
isStreaming,
|
||||
micLevel,
|
||||
mediaState,
|
||||
mediaLoading,
|
||||
onGuildChange,
|
||||
onChannelChange,
|
||||
onJoin,
|
||||
onDisconnect,
|
||||
onListenToggle,
|
||||
onStreamingToggle,
|
||||
onQueueMusic,
|
||||
onStartScreen,
|
||||
onSkip,
|
||||
onStop,
|
||||
onVolumeChange,
|
||||
}: LivePanelProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
className="grid gap-6"
|
||||
>
|
||||
<motion.div variants={cardItem}>
|
||||
<VoiceConnectionCard
|
||||
guilds={guilds}
|
||||
voiceChannels={voiceChannels}
|
||||
selectedGuild={selectedGuild}
|
||||
selectedChannel={selectedChannel}
|
||||
status={status}
|
||||
voiceLoading={voiceLoading}
|
||||
isListening={isListening}
|
||||
isStreaming={isStreaming}
|
||||
micLevel={micLevel}
|
||||
onGuildChange={onGuildChange}
|
||||
onChannelChange={onChannelChange}
|
||||
onJoin={onJoin}
|
||||
onDisconnect={onDisconnect}
|
||||
onListenToggle={onListenToggle}
|
||||
onStreamingToggle={onStreamingToggle}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="grid gap-6 xl:grid-cols-[1fr_320px]"
|
||||
>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Live Audio</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AudioVisualizer levels={levels} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Active Speakers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ActiveSpeakers speakers={activeSpeakers} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={cardItem}>
|
||||
<NowPlaying current={mediaState.current} queue={mediaState.queue} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={cardItem}>
|
||||
<Tabs defaultValue="music">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="music">
|
||||
<Music2 className="mr-1.5 h-4 w-4" /> Music
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screen">
|
||||
<MonitorUp className="mr-1.5 h-4 w-4" /> Screen Share
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="recordings">
|
||||
<Mic className="mr-1.5 h-4 w-4" /> Recordings
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="music">
|
||||
<MusicSubPanel
|
||||
volume={mediaState.musicVolume}
|
||||
onVolumeChange={onVolumeChange}
|
||||
onQueue={onQueueMusic}
|
||||
onSkip={onSkip}
|
||||
onStop={onStop}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="screen">
|
||||
<ScreenSubPanel
|
||||
onStart={onStartScreen}
|
||||
onSkip={onSkip}
|
||||
onStop={onStop}
|
||||
loading={mediaLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="recordings">
|
||||
<RecordingsSubPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||
import { parseMetadata } from "../../../shared/lib/utils.js";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
|
||||
interface ImageItem {
|
||||
url: string;
|
||||
title: string;
|
||||
kind: "attachment" | "embed" | "sticker";
|
||||
message: MessageRecord;
|
||||
}
|
||||
|
||||
function kindBadge(kind: ImageItem["kind"]): string {
|
||||
switch (kind) {
|
||||
case "sticker":
|
||||
return "bg-primary/10 text-primary border-primary/20";
|
||||
case "attachment":
|
||||
return "bg-primary-soft text-primary border-primary/30";
|
||||
case "embed":
|
||||
return "bg-tertiary-soft text-tertiary border-tertiary/20";
|
||||
}
|
||||
}
|
||||
|
||||
export function ImageGrid({ messages }: { messages: MessageRecord[] }) {
|
||||
const images: ImageItem[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const metadata = parseMetadata(message.metadata);
|
||||
|
||||
// Stickers
|
||||
for (const sticker of metadata.stickers ?? []) {
|
||||
if (sticker.url) {
|
||||
images.push({
|
||||
url: sticker.url,
|
||||
title: sticker.name || "sticker",
|
||||
kind: "sticker",
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
for (const attachment of metadata.attachments ?? []) {
|
||||
if (
|
||||
attachment.url &&
|
||||
(attachment.contentType?.startsWith("image/") ||
|
||||
/\.(png|jpe?g|gif|webp)$/i.test(attachment.name))
|
||||
) {
|
||||
images.push({
|
||||
url: attachment.url,
|
||||
title: attachment.name,
|
||||
kind: "attachment",
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Embed images
|
||||
for (const embed of metadata.embeds ?? []) {
|
||||
for (const imgUrl of [embed.image, embed.thumbnail].filter(Boolean)) {
|
||||
images.push({
|
||||
url: imgUrl as string,
|
||||
title: embed.title || "embed image",
|
||||
kind: "embed",
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (images.length === 0) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{images.map((image) => {
|
||||
// Stable key using message.id + url
|
||||
const stableKey = `${image.message.id}-${image.kind}-${image.url}`;
|
||||
return (
|
||||
<a
|
||||
key={stableKey}
|
||||
href={image.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="group overflow-hidden rounded-xl border border-primary/20 bg-white shadow-sm transition-all hover:border-primary/40 hover:shadow-md"
|
||||
>
|
||||
<div className="relative aspect-video overflow-hidden">
|
||||
{image.kind === "sticker" ? (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.title}
|
||||
className="h-full w-full object-contain bg-muted/30 p-2 transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.title}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={`absolute right-2 top-2 rounded-full border px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider shadow-sm backdrop-blur ${kindBadge(image.kind)}`}
|
||||
>
|
||||
{image.kind}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="truncate text-sm font-medium">{image.title}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 overflow-hidden rounded-full ring-1 ring-primary/30">
|
||||
<img
|
||||
src={
|
||||
image.message.avatar_url ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||
}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{image.message.username}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,561 +0,0 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Forward,
|
||||
Hash,
|
||||
Image as ImageIcon,
|
||||
MessageCircle,
|
||||
Pencil,
|
||||
Reply,
|
||||
RotateCw,
|
||||
Smile,
|
||||
Trash2,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||
import { parseMetadata } from "../../../shared/lib/utils.js";
|
||||
import { getMessageById } from "../../../shared/api/client.js";
|
||||
import { Badge, Button, Skeleton, StatusBadge } from "../../../shared/ui";
|
||||
|
||||
const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g;
|
||||
|
||||
function renderContentWithCustomEmojis(content: string): React.ReactNode {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const regex = new RegExp(CUSTOM_EMOJI_REGEX.source, "g");
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(content.slice(lastIndex, match.index));
|
||||
}
|
||||
const [, animated, name, id] = match;
|
||||
const ext = animated ? "gif" : "png";
|
||||
const url = `https://cdn.discordapp.com/emojis/${id}.${ext}?size=128`;
|
||||
parts.push(
|
||||
<img
|
||||
key={`${id}-${match.index}`}
|
||||
src={url}
|
||||
alt={name}
|
||||
className="inline-block h-[22px] w-[22px] align-middle object-contain"
|
||||
loading="lazy"
|
||||
draggable={false}
|
||||
title={`:${name}:`}
|
||||
/>,
|
||||
);
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
if (lastIndex < content.length) {
|
||||
parts.push(content.slice(lastIndex));
|
||||
}
|
||||
if (parts.length === 0) return content;
|
||||
return <Fragment>{parts}</Fragment>;
|
||||
}
|
||||
|
||||
// ─── Props ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface MessageCardProps {
|
||||
messages: MessageRecord[];
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseStringList(value?: string | null): string[] {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
function severityColor(severity: string) {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
return "bg-destructive-soft text-destructive border-destructive/20";
|
||||
case "high":
|
||||
return "bg-warning-soft text-warning border-warning/20";
|
||||
case "medium":
|
||||
return "bg-warning-soft text-warning border-warning/20";
|
||||
case "low":
|
||||
return "bg-info-soft text-info border-info/20";
|
||||
default:
|
||||
return "bg-muted text-muted-foreground border-border";
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Single message row inside a group ───────────────────────────────────────
|
||||
|
||||
function MessageRow({
|
||||
message,
|
||||
onReanalyze,
|
||||
}: {
|
||||
message: MessageRecord;
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
}) {
|
||||
const metadata = useMemo(
|
||||
() => parseMetadata(message.metadata),
|
||||
[message.metadata],
|
||||
);
|
||||
const displayContent = message.edited_content ?? message.content;
|
||||
const aiStatus = message.ai_status ?? "pending";
|
||||
const categories = useMemo(() => {
|
||||
const list = parseStringList(
|
||||
message.ai_categories ?? message.ai_moderation_flags,
|
||||
);
|
||||
return list.filter((c) => c !== "analysis_incomplete");
|
||||
}, [message.ai_categories, message.ai_moderation_flags]);
|
||||
const confidence =
|
||||
message.ai_confidence ?? message.ai_moderation_score ?? null;
|
||||
const [isReanalyzing, setIsReanalyzing] = useState(false);
|
||||
|
||||
// ── Fetch referenced message content for replies if not in metadata ──
|
||||
const referenceMeta = metadata.reference;
|
||||
const [fetchedRefContent, setFetchedRefContent] = useState<{
|
||||
username: string;
|
||||
content: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
message.is_reply &&
|
||||
referenceMeta?.messageId &&
|
||||
!referenceMeta?.content &&
|
||||
!message.deleted_at
|
||||
) {
|
||||
getMessageById(referenceMeta.messageId)
|
||||
.then((refMsg) => {
|
||||
if (refMsg) {
|
||||
setFetchedRefContent({
|
||||
username: refMsg.username,
|
||||
content: refMsg.content,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Referenced message might not exist in our DB
|
||||
});
|
||||
}
|
||||
}, [message.is_reply, referenceMeta?.messageId, referenceMeta?.content, message.deleted_at]);
|
||||
|
||||
const analysisSummary = useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
if (categories.length > 0) {
|
||||
parts.push(categories.slice(0, 3).join(", "));
|
||||
if (categories.length > 3) parts.push(`+${categories.length - 3} more`);
|
||||
}
|
||||
if (message.ai_severity && message.ai_severity !== "none") {
|
||||
parts.push(message.ai_severity);
|
||||
}
|
||||
if (confidence != null) {
|
||||
parts.push(`${Math.round(confidence * 100)}% confidence`);
|
||||
}
|
||||
if (parts.length === 0) return "View AI analysis";
|
||||
return parts.join(" · ");
|
||||
}, [categories, message.ai_severity, confidence]);
|
||||
|
||||
const stickers = metadata.stickers ?? [];
|
||||
const attachments = metadata.attachments ?? [];
|
||||
const imageAttachments = attachments.filter(
|
||||
(a) =>
|
||||
a.contentType?.startsWith("image/") ||
|
||||
/\.(png|jpe?g|gif|webp)$/i.test(a.name),
|
||||
);
|
||||
const videoAttachments = attachments.filter(
|
||||
(a) =>
|
||||
a.contentType?.startsWith("video/") ||
|
||||
/\.(mp4|webm|mov|mkv|avi)$/i.test(a.name),
|
||||
);
|
||||
const hasImages = imageAttachments.length > 0;
|
||||
const hasVideos = videoAttachments.length > 0;
|
||||
|
||||
/** Hide the fallback text ("[Attachment: ...]", "[Sticker: ...]", "[Embed]") when the actual media IS already shown visually. */
|
||||
const isFallbackText =
|
||||
/^\[(Attachment|Sticker):/i.test(displayContent) ||
|
||||
/^\[Embed\]/i.test(displayContent);
|
||||
const shouldShowContent = displayContent && !isFallbackText;
|
||||
|
||||
const handleReanalyze = async () => {
|
||||
setIsReanalyzing(true);
|
||||
try {
|
||||
await onReanalyze(message.id);
|
||||
} finally {
|
||||
setIsReanalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Reference context (reply / forward / crosspost) ─────────────────
|
||||
const renderReferenceIndicator = () => {
|
||||
// Use fetched content if metadata doesn't have it
|
||||
const effectiveRepliedUsername =
|
||||
referenceMeta?.repliedUsername ?? fetchedRefContent?.username ?? null;
|
||||
const effectiveRepliedContent =
|
||||
referenceMeta?.content ?? fetchedRefContent?.content ?? null;
|
||||
|
||||
if (message.is_reply) {
|
||||
return (
|
||||
<div className="flex items-start gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-muted-foreground/20 pl-2.5 py-1 hover:border-primary/40 transition-colors">
|
||||
<Reply className="h-3 w-3 mt-0.5 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium text-foreground/60">
|
||||
Replying to{" "}
|
||||
{effectiveRepliedUsername
|
||||
? `@${effectiveRepliedUsername}`
|
||||
: "a message"}
|
||||
</span>
|
||||
{effectiveRepliedContent && (
|
||||
<span className="block truncate max-w-[400px] text-ellipsis text-[11px] text-muted-foreground/50 mt-0.5">
|
||||
{effectiveRepliedContent}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.is_forward) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-warning/40 pl-2.5 py-1">
|
||||
<Forward className="h-3 w-3 shrink-0 text-warning" />
|
||||
<span className="font-medium text-warning/70">Forwarded</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.is_crosspost) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2 text-[12px] text-muted-foreground/70 border-l-2 border-info/40 pl-2.5 py-1">
|
||||
<MessageCircle className="h-3 w-3 shrink-0 text-info" />
|
||||
<span className="font-medium text-info/70">Crossposted</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const referenceIndicator = renderReferenceIndicator();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Row header: time + edit/delete indicators + AI badges */}
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground/70"
|
||||
title={new Date(message.created_at).toLocaleString()}
|
||||
>
|
||||
{formatTime(message.created_at)}
|
||||
</span>
|
||||
{message.edited_at && (
|
||||
<span className="flex items-center gap-0.5 text-[11px] text-muted-foreground/70">
|
||||
<Pencil className="h-2.5 w-2.5" /> edited
|
||||
</span>
|
||||
)}
|
||||
{message.deleted_at && (
|
||||
<span className="flex items-center gap-0.5 text-[11px] text-destructive/70">
|
||||
<Trash2 className="h-2.5 w-2.5" /> deleted
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<StatusBadge status={aiStatus} className="text-[10px] px-1.5 py-0">
|
||||
{aiStatus === "clean" && <CheckCircle2 className="h-3 w-3" />}
|
||||
{aiStatus === "flagged" && <AlertCircle className="h-3 w-3" />}
|
||||
{aiStatus === "error" && <AlertCircle className="h-3 w-3" />}
|
||||
</StatusBadge>
|
||||
{message.ai_severity && message.ai_severity !== "none" && (
|
||||
<Badge
|
||||
className={`text-[10px] px-1.5 py-0 ${severityColor(message.ai_severity)}`}
|
||||
>
|
||||
{message.ai_severity}
|
||||
</Badge>
|
||||
)}
|
||||
{confidence != null && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 tabular-nums"
|
||||
>
|
||||
{Math.round(confidence * 100)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reference context: reply / forward / crosspost */}
|
||||
{referenceIndicator}
|
||||
|
||||
{/* Content — hidden when it's just an "[Attachment: ...]" fallback and the image is shown below */}
|
||||
{shouldShowContent ? (
|
||||
<p
|
||||
className={`whitespace-pre-wrap break-words text-sm leading-6 ${
|
||||
message.deleted_at
|
||||
? "text-muted-foreground/60"
|
||||
: "text-foreground/90"
|
||||
}`}
|
||||
>
|
||||
{renderContentWithCustomEmojis(displayContent)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Stickers */}
|
||||
{stickers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stickers.map((sticker) => (
|
||||
<div
|
||||
key={sticker.name || sticker.url}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
{sticker.url ? (
|
||||
<img
|
||||
src={sticker.url}
|
||||
alt={sticker.name || "sticker"}
|
||||
className="h-12 w-12 rounded-lg border border-border object-contain bg-muted/50"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg border border-border bg-muted/50">
|
||||
<Smile className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached images */}
|
||||
{hasImages && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{imageAttachments.slice(0, 4).map((img) => (
|
||||
<a
|
||||
key={img.url}
|
||||
href={img.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 overflow-hidden rounded-lg border border-border"
|
||||
>
|
||||
<img
|
||||
src={img.url}
|
||||
alt={img.name}
|
||||
className="h-16 w-16 object-cover transition-transform hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
{imageAttachments.length > 4 && (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
|
||||
+{imageAttachments.length - 4}
|
||||
<ImageIcon className="ml-0.5 h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attached videos */}
|
||||
{hasVideos && (
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{videoAttachments.slice(0, 4).map((vid) => (
|
||||
<video
|
||||
key={vid.url}
|
||||
src={vid.url}
|
||||
controls
|
||||
className="h-28 w-48 shrink-0 rounded-lg border border-border object-cover bg-black"
|
||||
preload="metadata"
|
||||
/>
|
||||
))}
|
||||
{videoAttachments.length > 4 && (
|
||||
<div className="flex h-28 w-16 items-center justify-center rounded-lg border border-border bg-muted text-[11px] text-muted-foreground">
|
||||
+{videoAttachments.length - 4}
|
||||
<Video className="ml-0.5 h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Categories */}
|
||||
{categories.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categories.map((category) => (
|
||||
<Badge key={category} variant="secondary" className="text-[10px]">
|
||||
{category}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Analysis — always expanded */}
|
||||
{message.ai_analysis ? (
|
||||
<div
|
||||
className={`rounded-lg border-l-[3px] px-3 py-2 ${
|
||||
aiStatus === "flagged"
|
||||
? "border-l-tertiary bg-tertiary/5"
|
||||
: "border-l-success bg-success-soft"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-[11px]">
|
||||
<span className="mt-0.5 shrink-0">
|
||||
{aiStatus === "flagged" ? "🚨" : "ℹ️"}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="block font-medium text-foreground/70 mb-1">
|
||||
{analysisSummary}
|
||||
</span>
|
||||
<div className="text-[12px] text-muted-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{message.ai_analysis}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* AI Error */}
|
||||
{message.ai_error ? (
|
||||
<div className="rounded-lg bg-tertiary/5 px-3 py-2 text-[12px] text-tertiary">
|
||||
AI error: {message.ai_error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Re-analyze button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={aiStatus === "error" ? "destructive" : "outline"}
|
||||
onClick={handleReanalyze}
|
||||
disabled={aiStatus === "pending" || isReanalyzing}
|
||||
className="text-[11px] h-7 px-2.5"
|
||||
>
|
||||
<RotateCw
|
||||
className={`h-3 w-3 ${isReanalyzing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{isReanalyzing ? "Reanalyzing..." : "Re-analyze"}
|
||||
</Button>
|
||||
{aiStatus === "error" && (
|
||||
<span className="text-[11px] text-tertiary/70">
|
||||
Click to retry analysis
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Group card: one card per user group ─────────────────────────────────────
|
||||
|
||||
export function MessageCard({ messages, onReanalyze }: MessageCardProps) {
|
||||
const firstMsg = messages[0];
|
||||
const hasMultiple = messages.length > 1;
|
||||
const meta = useMemo(
|
||||
() => parseMetadata(firstMsg.metadata),
|
||||
[firstMsg.metadata],
|
||||
);
|
||||
const channelMeta = meta.channel;
|
||||
const locationLabel = useMemo(() => {
|
||||
if (channelMeta?.threadName) {
|
||||
return `# ${channelMeta.channelName || "unknown"} › ${channelMeta.threadName}`;
|
||||
}
|
||||
if (channelMeta?.channelName) {
|
||||
return `# ${channelMeta.channelName}`;
|
||||
}
|
||||
return null;
|
||||
}, [channelMeta]);
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group rounded-xl border bg-card shadow-sm transition-all hover:border-primary/30 hover:shadow-md ${
|
||||
firstMsg.deleted_at ? "border-destructive/20 opacity-60" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 p-4">
|
||||
{/* Avatar — only for first message */}
|
||||
<img
|
||||
src={
|
||||
firstMsg.avatar_url ??
|
||||
"https://cdn.discordapp.com/embed/avatars/0.png"
|
||||
}
|
||||
alt=""
|
||||
className="h-10 w-10 shrink-0 rounded-full object-cover ring-2 ring-primary/30"
|
||||
/>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Group header: username + location + timestamp */}
|
||||
<div className="flex items-baseline gap-2 mb-2">
|
||||
<span className="font-semibold text-sm text-foreground">
|
||||
{firstMsg.username || firstMsg.user_id}
|
||||
</span>
|
||||
{locationLabel && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground/50 bg-muted/50 px-1.5 py-0.5 rounded-full">
|
||||
<Hash className="h-2.5 w-2.5" />
|
||||
{locationLabel}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[11px] text-muted-foreground/60"
|
||||
title={new Date(firstMsg.created_at).toLocaleString()}
|
||||
>
|
||||
{formatTimeAgo(firstMsg.created_at)}
|
||||
{hasMultiple && ` · ${messages.length} messages`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Message rows — divided by separator when multiple */}
|
||||
<div
|
||||
className={
|
||||
hasMultiple ? "divide-y divide-border/30 space-y-2.5" : ""
|
||||
}
|
||||
>
|
||||
{messages.map((msg, idx) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={hasMultiple && idx > 0 ? "pt-2.5" : ""}
|
||||
>
|
||||
<MessageRow message={msg} onReanalyze={onReanalyze} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Skeleton ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function MessageCardSkeleton() {
|
||||
return (
|
||||
<article className="rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||
<div className="flex gap-3">
|
||||
<Skeleton className="h-10 w-10 shrink-0 rounded-full" />
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<div className="flex gap-2">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||
import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger";
|
||||
import { ScrollArea } from "../../../shared/ui";
|
||||
import { EmptyStateMascot } from "../../../widgets/mascot/MascotImage";
|
||||
import { MessageCard, MessageCardSkeleton } from "./MessageCard";
|
||||
|
||||
export interface MessageFeedProps {
|
||||
messages: MessageRecord[];
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
emptyText?: string;
|
||||
loading?: boolean;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
}
|
||||
|
||||
/** Messages from the same user within 5 minutes are visually grouped. */
|
||||
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
||||
|
||||
interface MessageGroup {
|
||||
messages: MessageRecord[];
|
||||
}
|
||||
|
||||
function groupMessages(messages: MessageRecord[]): MessageGroup[] {
|
||||
const groups: MessageGroup[] = [];
|
||||
for (const msg of messages) {
|
||||
const lastGroup = groups[groups.length - 1];
|
||||
if (
|
||||
lastGroup &&
|
||||
lastGroup.messages[0].user_id === msg.user_id &&
|
||||
lastGroup.messages[lastGroup.messages.length - 1].created_at -
|
||||
msg.created_at <
|
||||
GROUP_WINDOW_MS
|
||||
) {
|
||||
lastGroup.messages.push(msg);
|
||||
} else {
|
||||
groups.push({ messages: [msg] });
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function MessageFeed({
|
||||
messages,
|
||||
onReanalyze,
|
||||
emptyText: _emptyText,
|
||||
loading,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
}: MessageFeedProps) {
|
||||
// IntersectionObserver for infinite scroll — fires when sentinel becomes visible
|
||||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onLoadMore || !hasMore) return;
|
||||
const el = sentinelRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0]?.isIntersecting) onLoadMore();
|
||||
},
|
||||
{ rootMargin: "400px" },
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [onLoadMore, hasMore]);
|
||||
|
||||
const groupedMessages = useMemo(() => groupMessages(messages), [messages]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<MessageCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
return <EmptyStateMascot />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-3">
|
||||
<motion.div
|
||||
className="space-y-3"
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
{groupedMessages.map((group) => (
|
||||
<motion.div key={group.messages[0].id} variants={cardItem}>
|
||||
<MessageCard messages={group.messages} onReanalyze={onReanalyze} />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Infinite-scroll sentinel */}
|
||||
{hasMore && (
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
className="flex items-center justify-center py-4"
|
||||
>
|
||||
{loadingMore ? (
|
||||
<MessageCardSkeleton />
|
||||
) : (
|
||||
<div className="h-2 w-2 rounded-full bg-primary/40" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
// ─── Moderation alert toast listener ───────────────────────────────────────
|
||||
// Listens for "moderation_alert" custom events dispatched from WebSocket
|
||||
// message_analyzed handler, and shows toast notifications for flagged
|
||||
// messages so moderators don't miss important alerts.
|
||||
import { useEffect } from "react";
|
||||
import { useToast } from "../../../shared/ui";
|
||||
|
||||
interface AlertDetail {
|
||||
type: "flagged";
|
||||
username: string;
|
||||
severity: string;
|
||||
categories: string;
|
||||
brief: string;
|
||||
}
|
||||
|
||||
function severityToToastType(
|
||||
severity: string,
|
||||
): "error" | "warning" | "info" | "success" {
|
||||
switch (severity) {
|
||||
case "critical":
|
||||
case "high":
|
||||
return "error";
|
||||
case "medium":
|
||||
return "warning";
|
||||
case "low":
|
||||
return "info";
|
||||
default:
|
||||
return "warning";
|
||||
}
|
||||
}
|
||||
|
||||
export function ModerationAlertListener() {
|
||||
const { addToast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { username, severity, categories, brief } = (
|
||||
e as CustomEvent<AlertDetail>
|
||||
).detail;
|
||||
|
||||
const sevLabel = severity ? `[${severity}]` : "";
|
||||
const catLabel = categories
|
||||
? ` — ${categories.split(",").slice(0, 2).join(", ")}`
|
||||
: "";
|
||||
|
||||
addToast(
|
||||
`🚨 ${username} ${sevLabel}${catLabel}: ${brief}`,
|
||||
severityToToastType(severity),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("moderation_alert", handler);
|
||||
return () => window.removeEventListener("moderation_alert", handler);
|
||||
}, [addToast]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { MessageRecord } from "../../../entities/message/types.js";
|
||||
import {
|
||||
listMessages,
|
||||
reanalyzeErrorBatch,
|
||||
reanalyzeMessage,
|
||||
} from "../../../shared/api/client";
|
||||
import { createLogger } from "../../../shared/lib/logger.js";
|
||||
|
||||
const logger = createLogger("use-messages");
|
||||
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function mergeMessages(
|
||||
current: MessageRecord[],
|
||||
incoming: MessageRecord[],
|
||||
): MessageRecord[] {
|
||||
const byId = new Map(current.map((message) => [message.id, message]));
|
||||
for (const message of incoming) {
|
||||
byId.set(message.id, { ...byId.get(message.id), ...message });
|
||||
}
|
||||
return Array.from(byId.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id),
|
||||
);
|
||||
}
|
||||
|
||||
export function useMessages() {
|
||||
const [messages, setMessages] = useState<MessageRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const currentGuild = useRef<string | null>(null);
|
||||
|
||||
const fetchMessages = useCallback(async (guildId?: string) => {
|
||||
if (!guildId) {
|
||||
setMessages([]);
|
||||
setCursor(null);
|
||||
setHasMore(false);
|
||||
return [];
|
||||
}
|
||||
currentGuild.current = guildId;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await listMessages({
|
||||
guildId,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
if (currentGuild.current === guildId) {
|
||||
setMessages(result.data);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
}
|
||||
return result.data;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError(message);
|
||||
logger.error("Failed to fetch messages", { guildId, error: message });
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (!cursor || !currentGuild.current || loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await listMessages({
|
||||
guildId: currentGuild.current,
|
||||
cursor,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
setMessages((prev) => [...prev, ...result.data]);
|
||||
setCursor(result.nextCursor);
|
||||
setHasMore(!!result.nextCursor);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to load more messages", { error: message });
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [cursor, loadingMore]);
|
||||
|
||||
const reanalyze = useCallback(async (id: string): Promise<void> => {
|
||||
// Capture prior state inside the functional updater so we don't need
|
||||
// `messages` as a useCallback dependency (avoids stale closure churn).
|
||||
let saved: MessageRecord | undefined;
|
||||
|
||||
setMessages((prev) => {
|
||||
saved = prev.find((m) => m.id === id);
|
||||
return prev.map((message) =>
|
||||
message.id === id
|
||||
? {
|
||||
...message,
|
||||
ai_status: "pending" as const,
|
||||
ai_error: null,
|
||||
ai_analysis: null,
|
||||
}
|
||||
: message,
|
||||
);
|
||||
});
|
||||
|
||||
try {
|
||||
await reanalyzeMessage(id);
|
||||
} catch (err) {
|
||||
// HTTP failed — revert the optimistic update so the UI stays truthful.
|
||||
if (saved) {
|
||||
const snapshot = saved;
|
||||
setMessages((prev) =>
|
||||
prev.map((message) => (message.id === id ? snapshot : message)),
|
||||
);
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to reanalyze message", { id, error: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reanalyzeAllErrors = useCallback(async (): Promise<number> => {
|
||||
// Optimistically mark all error messages as pending
|
||||
setMessages((prev) =>
|
||||
prev.map((message) =>
|
||||
message.ai_status === "error"
|
||||
? {
|
||||
...message,
|
||||
ai_status: "pending" as const,
|
||||
ai_error: null,
|
||||
ai_analysis: null,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
);
|
||||
try {
|
||||
const { count } = await reanalyzeErrorBatch({
|
||||
guildId: currentGuild.current ?? undefined,
|
||||
});
|
||||
logger.info("Reanalyze all errors complete", { count });
|
||||
return count;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to reanalyze error batch", { error: message });
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
setMessages,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
fetchMessages,
|
||||
reanalyze,
|
||||
reanalyzeAllErrors,
|
||||
loadMore,
|
||||
hasMore,
|
||||
};
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Filter, RotateCw, Search, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MessageRecord } from "../../shared/api/client";
|
||||
import { request } from "../../shared/api/client";
|
||||
import { cardItem, cardStagger } from "../../shared/hooks/useFramerStagger";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "../../shared/ui";
|
||||
import { ImageGrid } from "./components/ImageGrid";
|
||||
import { MessageFeed } from "./components/MessageFeed";
|
||||
|
||||
interface MessagesPanelProps {
|
||||
guildName: string | null;
|
||||
messages: MessageRecord[];
|
||||
onReanalyze: (id: string) => Promise<void>;
|
||||
onReanalyzeAllErrors?: () => Promise<number>;
|
||||
onLoadMore?: () => void;
|
||||
hasMore?: boolean;
|
||||
loadingMore?: boolean;
|
||||
}
|
||||
|
||||
type AiFilter = "all" | "analyzed" | "clean" | "flagged" | "error" | "pending";
|
||||
|
||||
export function MessagesPanel({
|
||||
guildName,
|
||||
messages,
|
||||
onReanalyze,
|
||||
onReanalyzeAllErrors,
|
||||
onLoadMore,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
}: MessagesPanelProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<MessageRecord[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [aiFilter, setAiFilter] = useState<AiFilter>("analyzed");
|
||||
const [viewTab, setViewTab] = useState<"all" | "images">("all");
|
||||
const [retryingAll, setRetryingAll] = useState(false);
|
||||
const [retriedCount, setRetriedCount] = useState<number | null>(null);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
setShowSearch(false);
|
||||
return;
|
||||
}
|
||||
setIsSearching(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ q: searchQuery, limit: "50" });
|
||||
const data = await request<{ results: MessageRecord[] }>(
|
||||
`/api/analysis/search?${params}`,
|
||||
);
|
||||
setSearchResults(data.results || []);
|
||||
setShowSearch(true);
|
||||
} catch {
|
||||
setSearchResults([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const base = showSearch ? searchResults : messages;
|
||||
return {
|
||||
total: base.length,
|
||||
clean: base.filter((m) => m.ai_status === "clean").length,
|
||||
flagged: base.filter((m) => m.ai_status === "flagged").length,
|
||||
error: base.filter((m) => m.ai_status === "error").length,
|
||||
pending: base.filter((m) => m.ai_status === "pending" || !m.ai_status)
|
||||
.length,
|
||||
deleted: base.filter((m) => m.deleted_at).length,
|
||||
edited: base.filter((m) => m.edited_at).length,
|
||||
};
|
||||
}, [messages, searchResults, showSearch]);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
const base = showSearch ? searchResults : messages;
|
||||
if (aiFilter === "all") return base;
|
||||
return base.filter((m) => {
|
||||
const status = m.ai_status ?? "pending";
|
||||
if (aiFilter === "analyzed")
|
||||
return status !== "pending" && status !== null && status !== undefined;
|
||||
if (aiFilter === "pending")
|
||||
return status === "pending" || status === null || status === undefined;
|
||||
return status === aiFilter;
|
||||
});
|
||||
}, [messages, searchResults, showSearch, aiFilter]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="grid gap-6"
|
||||
variants={cardStagger}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
>
|
||||
<motion.div variants={cardItem}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-primary">Messages</CardTitle>
|
||||
{guildName && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Monitoring all text channels in{" "}
|
||||
<span className="font-medium text-foreground">{guildName}</span>
|
||||
</p>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Messages are automatically captured from all text channels in the
|
||||
monitored guild. Real-time updates arrive via WebSocket.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
{stats.total > 0 && (
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs border-primary/40 text-primary"
|
||||
>
|
||||
{stats.total} total{hasMore && !showSearch ? "+" : ""}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-success-soft text-success border-success/20"
|
||||
>
|
||||
{stats.clean} clean
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-primary/10 text-primary border-primary/20"
|
||||
>
|
||||
{stats.flagged} flagged
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-warning-soft text-warning border-warning/20"
|
||||
>
|
||||
{stats.error} error
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs text-muted-foreground border-border"
|
||||
>
|
||||
{stats.pending} pending
|
||||
</Badge>
|
||||
{stats.deleted > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs bg-destructive-soft text-destructive border-destructive/20"
|
||||
>
|
||||
{stats.deleted} deleted
|
||||
</Badge>
|
||||
)}
|
||||
{stats.edited > 0 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{stats.edited} edited
|
||||
</Badge>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="flex flex-wrap items-center gap-2"
|
||||
>
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-primary" />
|
||||
<Input
|
||||
className="pl-9 rounded-full focus-visible:ring-primary"
|
||||
placeholder="Search message content..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
disabled={isSearching}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={isSearching || !searchQuery.trim()}
|
||||
size="sm"
|
||||
className="rounded-xl"
|
||||
>
|
||||
{isSearching ? "Searching..." : "Search"}
|
||||
</Button>
|
||||
{showSearch && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowSearch(false);
|
||||
setSearchResults([]);
|
||||
setSearchQuery("");
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" /> Clear
|
||||
</Button>
|
||||
)}
|
||||
{stats.error > 0 && onReanalyzeAllErrors && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={retryingAll}
|
||||
onClick={async () => {
|
||||
setRetryingAll(true);
|
||||
setRetriedCount(null);
|
||||
try {
|
||||
const count = await onReanalyzeAllErrors();
|
||||
setRetriedCount(count);
|
||||
} finally {
|
||||
setRetryingAll(false);
|
||||
}
|
||||
}}
|
||||
className="rounded-xl bg-destructive/10 text-destructive hover:bg-destructive/20 border-destructive/20"
|
||||
>
|
||||
<RotateCw
|
||||
className={`mr-1.5 h-3.5 w-3.5 ${retryingAll ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{retryingAll ? "Retrying..." : `Retry All Errors (${stats.error})`}
|
||||
</Button>
|
||||
)}
|
||||
{retriedCount !== null && (
|
||||
<span className="text-xs text-success">
|
||||
{retriedCount} message{retriedCount !== 1 ? "s" : ""} queued for
|
||||
re-analysis
|
||||
</span>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<Filter className="h-4 w-4 text-primary" />
|
||||
{(
|
||||
[
|
||||
"all",
|
||||
"analyzed",
|
||||
"clean",
|
||||
"flagged",
|
||||
"error",
|
||||
"pending",
|
||||
] as AiFilter[]
|
||||
).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setAiFilter(f)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-all ${
|
||||
aiFilter === f
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||
}`}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<motion.div
|
||||
variants={cardItem}
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Found {searchResults.length} result
|
||||
{searchResults.length !== 1 ? "s" : ""}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div variants={cardItem}>
|
||||
<Tabs
|
||||
value={viewTab}
|
||||
onValueChange={(v) => setViewTab(v as "all" | "images")}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
{showSearch
|
||||
? `Search (${filteredMessages.length})`
|
||||
: `All (${filteredMessages.length})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="images">Images</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="all">
|
||||
<MessageFeed
|
||||
messages={filteredMessages}
|
||||
onReanalyze={onReanalyze}
|
||||
emptyText={
|
||||
showSearch
|
||||
? "No messages found matching your search."
|
||||
: "No captures yet."
|
||||
}
|
||||
onLoadMore={showSearch ? undefined : onLoadMore}
|
||||
hasMore={showSearch ? false : hasMore}
|
||||
loadingMore={loadingMore}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="images">
|
||||
<ImageGrid messages={filteredMessages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import { ToastProvider } from "./shared/ui";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -1,350 +0,0 @@
|
||||
// ─── Shared HTTP client — all API endpoints in one file ──────────────────────
|
||||
|
||||
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";
|
||||
|
||||
const logger = createLogger("api");
|
||||
|
||||
const BE_API_URL = import.meta.env.VITE_BE_API_URL || "http://localhost:3001";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15000;
|
||||
|
||||
class ApiError extends Error {
|
||||
code: string;
|
||||
statusCode: number;
|
||||
|
||||
constructor(code: string, message: string, statusCode: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache admin password in memory — read from sessionStorage once on first call
|
||||
let _cachedPassword: string | null = null;
|
||||
|
||||
function getAdminPassword(): string | null {
|
||||
if (_cachedPassword === null) {
|
||||
try {
|
||||
_cachedPassword = sessionStorage.getItem("admin-password");
|
||||
} catch {
|
||||
_cachedPassword = null;
|
||||
}
|
||||
}
|
||||
return _cachedPassword;
|
||||
}
|
||||
|
||||
function buildSearchParams(
|
||||
params: Record<string, string | number | undefined | null>,
|
||||
): URLSearchParams {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value != null && value !== "") {
|
||||
sp.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
timeoutMs?: number,
|
||||
): Promise<T> {
|
||||
const password = getAdminPassword();
|
||||
const url = path.startsWith("http") ? path : `${BE_API_URL}${path}`;
|
||||
const signal = AbortSignal.timeout(timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
logger.debug("Request", { method: init?.method ?? "GET", url });
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(password ? { "X-Admin-Password": password } : {}),
|
||||
},
|
||||
signal,
|
||||
...init,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
let code = "REQUEST_FAILED";
|
||||
try {
|
||||
const body = (await res.json()) as { error?: string; message?: string };
|
||||
if (body.message) message = body.message;
|
||||
if (body.error) code = body.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
logger.error("Request failed", { url, status: res.status, code, message });
|
||||
throw new ApiError(code, message, res.status);
|
||||
}
|
||||
|
||||
const result = (await res.json()) as T;
|
||||
logger.debug("Response", { url, status: res.status });
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getAPIURL(): string {
|
||||
return BE_API_URL;
|
||||
}
|
||||
|
||||
// ─── Re-exports ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type {
|
||||
ActiveSpeaker,
|
||||
AppConfig,
|
||||
Channel,
|
||||
ChatResponse,
|
||||
DashboardChannel,
|
||||
DashboardChannelDetail,
|
||||
DashboardStats,
|
||||
DashboardTab,
|
||||
DashboardUser,
|
||||
DashboardUserDetail,
|
||||
Guild,
|
||||
GuildVoiceEntry,
|
||||
MediaItem,
|
||||
MediaMode,
|
||||
MediaState,
|
||||
MessageRecord,
|
||||
PageResult,
|
||||
UIState,
|
||||
VoiceRecording,
|
||||
VoiceRecordingListResponse,
|
||||
VoiceStatus,
|
||||
};
|
||||
|
||||
// ─── Messages ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function listMessages(params: {
|
||||
guildId: string;
|
||||
channelId?: string;
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<PageResult<MessageRecord>> {
|
||||
const sp = buildSearchParams({
|
||||
guildId: params.guildId,
|
||||
limit: params.limit ?? 100,
|
||||
channelId: params.channelId,
|
||||
cursor: params.cursor,
|
||||
});
|
||||
return request<PageResult<MessageRecord>>(`/api/messages?${sp}`);
|
||||
}
|
||||
|
||||
export function listReview(
|
||||
params: URLSearchParams,
|
||||
): Promise<PageResult<MessageRecord>> {
|
||||
return request<PageResult<MessageRecord>>(`/api/review?${params}`);
|
||||
}
|
||||
|
||||
export function reanalyzeMessage(id: string): Promise<void> {
|
||||
return request<void>(`/api/messages/${id}/reanalyze`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function getMessageById(
|
||||
id: string,
|
||||
): Promise<MessageRecord | null> {
|
||||
return request<MessageRecord | null>(`/api/messages/detail/${id}`);
|
||||
}
|
||||
|
||||
export function reanalyzeErrorBatch(opts: {
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
messageIds?: string[];
|
||||
}): Promise<{ ok: boolean; count: number }> {
|
||||
return request<{ ok: boolean; count: number }>(
|
||||
"/api/messages/reanalyze-batch",
|
||||
{ method: "POST", body: JSON.stringify(opts) },
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Guilds / Config ─────────────────────────────────────────────────────────
|
||||
|
||||
export function getGuilds(): Promise<Guild[]> {
|
||||
return request<Guild[]>("/api/guilds");
|
||||
}
|
||||
|
||||
export function getAppConfig(): Promise<AppConfig> {
|
||||
return request<AppConfig>("/api/config");
|
||||
}
|
||||
|
||||
// ─── Voice ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getVoiceChannels(guildId: string): Promise<Channel[]> {
|
||||
return request<Channel[]>(`/api/guilds/${guildId}/voice-channels`);
|
||||
}
|
||||
|
||||
export function getTextChannels(guildId: string): Promise<Channel[]> {
|
||||
return request<Channel[]>(`/api/guilds/${guildId}/channels`);
|
||||
}
|
||||
|
||||
export function getVoiceStatus(): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>("/api/voice/status");
|
||||
}
|
||||
|
||||
export function connectVoice(
|
||||
guildId: string,
|
||||
channelId: string,
|
||||
): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>("/api/voice/connect", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ guildId, channelId }),
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectVoice(): Promise<VoiceStatus> {
|
||||
return request<VoiceStatus>("/api/voice/disconnect", { method: "POST" });
|
||||
}
|
||||
|
||||
// ─── Media ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getMediaStatus(): Promise<MediaState> {
|
||||
return request<MediaState>("/api/media/status");
|
||||
}
|
||||
|
||||
export function queueMedia(
|
||||
source: string,
|
||||
mode: "music" | "screen",
|
||||
): Promise<MediaState> {
|
||||
return request<MediaState>("/api/media/queue", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source, mode }),
|
||||
});
|
||||
}
|
||||
|
||||
export function skipMedia(): Promise<MediaState> {
|
||||
return request<MediaState>("/api/media/skip", { method: "POST" });
|
||||
}
|
||||
|
||||
export function stopMedia(): Promise<MediaState> {
|
||||
return request<MediaState>("/api/media/stop", { method: "POST" });
|
||||
}
|
||||
|
||||
export function setMediaVolume(volume: number): Promise<MediaState> {
|
||||
return request<MediaState>("/api/media/volume", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ volume }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Recordings ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function listRecordings(params?: {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
}): Promise<VoiceRecordingListResponse> {
|
||||
const sp = buildSearchParams({
|
||||
limit: params?.limit ?? 50,
|
||||
cursor: params?.cursor,
|
||||
});
|
||||
return request<VoiceRecordingListResponse>(`/api/recordings?${sp}`);
|
||||
}
|
||||
|
||||
export function deleteRecording(id: string): Promise<void> {
|
||||
return request<void>(`/api/recordings/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function login(password: string): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Dashboard ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getDashboardStats(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>("/api/dashboard/stats");
|
||||
}
|
||||
|
||||
export function listDashboardUsers(
|
||||
params: { limit?: number; cursor?: string; search?: string } = {},
|
||||
): Promise<{ data: DashboardUser[]; nextCursor: string | null }> {
|
||||
const sp = buildSearchParams({
|
||||
limit: params.limit,
|
||||
cursor: params.cursor,
|
||||
search: params.search,
|
||||
});
|
||||
return request<{ data: DashboardUser[]; nextCursor: string | null }>(
|
||||
`/api/dashboard/users?${sp}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDashboardUserDetail(
|
||||
userId: string,
|
||||
): Promise<DashboardUserDetail> {
|
||||
return request<DashboardUserDetail>(`/api/dashboard/users/${userId}`);
|
||||
}
|
||||
|
||||
// ─── Dashboard Channels ─────────────────────────────────────────────────────────
|
||||
|
||||
export function listDashboardChannels(
|
||||
params: {
|
||||
limit?: number;
|
||||
search?: string;
|
||||
guild_id?: string;
|
||||
cursor?: string;
|
||||
} = {},
|
||||
): Promise<{ data: DashboardChannel[]; nextCursor: string | null }> {
|
||||
const sp = buildSearchParams({
|
||||
limit: params.limit,
|
||||
search: params.search,
|
||||
guild_id: params.guild_id,
|
||||
cursor: params.cursor,
|
||||
});
|
||||
return request<{ data: DashboardChannel[]; nextCursor: string | null }>(
|
||||
`/api/dashboard/channels?${sp}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getDashboardChannelDetail(
|
||||
channelId: string,
|
||||
): Promise<DashboardChannelDetail> {
|
||||
return request<DashboardChannelDetail>(
|
||||
`/api/dashboard/channels/${channelId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── UI State ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getUIState(): Promise<UIState> {
|
||||
return request<UIState>("/api/ui-state");
|
||||
}
|
||||
|
||||
export function updateUIState(patch: Partial<UIState>): Promise<UIState> {
|
||||
return request<UIState>("/api/ui-state", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// ─── Generic async action state hook ──────────────────────────────────────
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface AsyncActionState {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function useAsyncAction() {
|
||||
const [state, setState] = useState<AsyncActionState>({
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const execute = useCallback(
|
||||
async <T>(fn: () => Promise<T>): Promise<T | null> => {
|
||||
setState({ loading: true, error: null });
|
||||
try {
|
||||
const result = await fn();
|
||||
setState({ loading: false, error: null });
|
||||
return result;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setState({ loading: false, error: message });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setState((prev) => ({ ...prev, error: null }));
|
||||
}, []);
|
||||
|
||||
return { ...state, execute, clearError };
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
// ─── Audio playback hook — receives PCM from WebSocket and plays through Web Audio API ──
|
||||
import {
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createLogger } from "../lib/logger.js";
|
||||
|
||||
const logger = createLogger("use-audio-playback");
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const CHANNELS = 1;
|
||||
const LEVEL_COUNT = 32;
|
||||
|
||||
// Pre-computed level distribution shape — computed once at module load, not per render
|
||||
const LEVEL_SHAPE = Array.from(
|
||||
{ length: LEVEL_COUNT },
|
||||
(_, i) => 0.3 + (Math.sin(i * 0.6) * 0.35 + 0.65) * 0.7,
|
||||
);
|
||||
|
||||
/** Reverse lookup: userIdHash → userId, populated by handleIncomingBinary */
|
||||
const userIdHashToId = new Map<number, string>();
|
||||
|
||||
export function useAudioPlayback(): {
|
||||
isListening: boolean;
|
||||
levels: number[];
|
||||
handleIncomingPcm: (data: { userId: string; pcm: string }) => void;
|
||||
handleIncomingBinary: (data: ArrayBuffer) => void;
|
||||
registerUserId: (userId: string) => void;
|
||||
toggleListening: () => Promise<void>;
|
||||
audioContextRef: RefObject<AudioContext | null>;
|
||||
} {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [levels, setLevels] = useState<number[]>(
|
||||
Array.from({ length: LEVEL_COUNT }, () => 0.04),
|
||||
);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const userTimelinesRef = useRef(new Map<string, number>());
|
||||
|
||||
// Cleanup AudioContext on unmount to prevent leak
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const ctx = audioContextRef.current;
|
||||
if (ctx) {
|
||||
ctx.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
userTimelinesRef.current.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Prune stale timeline entries (> 30s old) based on current audioContext time
|
||||
const pruneTimelines = useCallback(() => {
|
||||
const now =
|
||||
audioContextRef.current?.currentTime ?? performance.now() / 1000;
|
||||
for (const [userId, endTime] of userTimelinesRef.current) {
|
||||
if (endTime + 30 < now) userTimelinesRef.current.delete(userId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle incoming binary PCM from WS.
|
||||
* Format per chunk: 4-byte userId hash (UInt32LE) + raw PCM (Int16).
|
||||
* userId hash → userId mapping is populated by voice_active_user events.
|
||||
*/
|
||||
const handleIncomingBinary = useCallback(
|
||||
(buffer: ArrayBuffer) => {
|
||||
const view = new DataView(buffer);
|
||||
if (buffer.byteLength < 5) return; // Need at least 4-byte hash + 1 PCM byte
|
||||
const userIdHash = view.getUint32(0, true);
|
||||
const userId = userIdHashToId.get(userIdHash) ?? `user:${userIdHash}`;
|
||||
const pcmBytes = buffer.byteLength - 4;
|
||||
if (pcmBytes === 0) return;
|
||||
|
||||
const int16Array = new Int16Array(buffer, 4, pcmBytes / 2);
|
||||
if (int16Array.length === 0) return;
|
||||
|
||||
// RMS + level computation (same as before)
|
||||
let sumSquares = 0;
|
||||
const float32Array = new Float32Array(int16Array.length);
|
||||
for (let i = 0; i < int16Array.length; i++) {
|
||||
const normalized = int16Array[i] / 32768;
|
||||
float32Array[i] = normalized;
|
||||
sumSquares += normalized * normalized;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / int16Array.length);
|
||||
const dbLevel = Math.min(1, Math.max(0.04, rms * 8));
|
||||
|
||||
setLevels((prev) =>
|
||||
prev.map((_, index) =>
|
||||
Math.max(0.04, dbLevel * LEVEL_SHAPE[index] * 5),
|
||||
),
|
||||
);
|
||||
|
||||
const audioContext = audioContextRef.current;
|
||||
if (!isListening || !audioContext) return;
|
||||
|
||||
const audioBuffer = audioContext.createBuffer(
|
||||
CHANNELS,
|
||||
float32Array.length,
|
||||
SAMPLE_RATE,
|
||||
);
|
||||
audioBuffer.getChannelData(0).set(float32Array);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
|
||||
const currentTime = audioContext.currentTime;
|
||||
let nextStart = userTimelinesRef.current.get(userId) || 0;
|
||||
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
||||
source.start(nextStart);
|
||||
userTimelinesRef.current.set(userId, nextStart + audioBuffer.duration);
|
||||
pruneTimelines();
|
||||
},
|
||||
[isListening, pruneTimelines],
|
||||
);
|
||||
|
||||
/**
|
||||
* Register a userId → hash mapping from voice_active_user events.
|
||||
*/
|
||||
const registerUserId = useCallback((userId: string) => {
|
||||
const hash = fnv1a32(userId);
|
||||
userIdHashToId.set(hash, userId);
|
||||
}, []);
|
||||
|
||||
// Legacy JSON handler kept for backward compat
|
||||
const handleIncomingPcm = useCallback(
|
||||
(data: { userId: string; pcm: string }) => {
|
||||
// Decode base64 PCM data
|
||||
try {
|
||||
const bytes = Uint8Array.from(atob(data.pcm), (c) => c.charCodeAt(0));
|
||||
if (bytes.length === 0) return;
|
||||
const int16Array = new Int16Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
bytes.byteLength / 2,
|
||||
);
|
||||
|
||||
// 5b/5d: Real RMS calculation + Float32Array conversion in single pass
|
||||
let sumSquares = 0;
|
||||
const float32Array = new Float32Array(int16Array.length);
|
||||
for (let i = 0; i < int16Array.length; i++) {
|
||||
const normalized = int16Array[i] / 32768;
|
||||
float32Array[i] = normalized;
|
||||
sumSquares += normalized * normalized;
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / int16Array.length);
|
||||
// Scale RMS to a lively visualization range, clamp to [0.04, 1.0]
|
||||
const dbLevel = Math.min(1, Math.max(0.04, rms * 8));
|
||||
|
||||
// 5c: Use pre-computed LEVEL_SHAPE (no Date.now() per PCM frame)
|
||||
setLevels((prev) =>
|
||||
prev.map((_, index) =>
|
||||
Math.max(0.04, dbLevel * LEVEL_SHAPE[index] * 5),
|
||||
),
|
||||
);
|
||||
|
||||
const audioContext = audioContextRef.current;
|
||||
if (!isListening || !audioContext) return;
|
||||
|
||||
const audioBuffer = audioContext.createBuffer(
|
||||
CHANNELS,
|
||||
float32Array.length,
|
||||
SAMPLE_RATE,
|
||||
);
|
||||
audioBuffer.getChannelData(0).set(float32Array);
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
|
||||
// Schedule playback per user to avoid overlaps
|
||||
const currentTime = audioContext.currentTime;
|
||||
let nextStart = userTimelinesRef.current.get(data.userId) || 0;
|
||||
if (nextStart < currentTime) nextStart = currentTime + 0.05;
|
||||
source.start(nextStart);
|
||||
userTimelinesRef.current.set(
|
||||
data.userId,
|
||||
nextStart + audioBuffer.duration,
|
||||
);
|
||||
pruneTimelines();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
logger.error("Failed to decode PCM audio", {
|
||||
userId: data.userId,
|
||||
error: message,
|
||||
});
|
||||
}
|
||||
},
|
||||
[isListening, pruneTimelines],
|
||||
);
|
||||
|
||||
const toggleListening = useCallback(async () => {
|
||||
if (isListening) {
|
||||
await audioContextRef.current?.suspend();
|
||||
userTimelinesRef.current.clear();
|
||||
setIsListening(false);
|
||||
logger.info("Audio playback paused");
|
||||
return;
|
||||
}
|
||||
const AudioContextCtor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
audioContextRef.current ??= new AudioContextCtor({
|
||||
sampleRate: SAMPLE_RATE,
|
||||
});
|
||||
await audioContextRef.current.resume();
|
||||
setIsListening(true);
|
||||
logger.info("Audio playback started");
|
||||
}, [isListening]);
|
||||
|
||||
return {
|
||||
isListening,
|
||||
levels,
|
||||
handleIncomingPcm,
|
||||
handleIncomingBinary,
|
||||
registerUserId,
|
||||
toggleListening,
|
||||
audioContextRef,
|
||||
};
|
||||
}
|
||||
|
||||
/** 32-bit FNV-1a hash for userId → consistent 4-byte identifier */
|
||||
function fnv1a32(str: string): number {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash ^= str.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
// ─── Audio transmit hook — captures mic, encodes to PCM, sends via WebSocket ──
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { getAPIURL } from "../api/client.js";
|
||||
// note: this hook only uses API functions, not entity types
|
||||
import { createLogger } from "../lib/logger";
|
||||
|
||||
const SAMPLE_RATE = 24000;
|
||||
const LEVEL_THROTTLE_MS = 50; // 20Hz mic level updates
|
||||
const logger = createLogger("useAudioTransmit");
|
||||
|
||||
async function sendTransmitCommand(command: string): Promise<void> {
|
||||
// Send via HTTP API (kept as exported function for backward compatibility)
|
||||
const resp = await fetch(`${getAPIURL()}/api/voice/command`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
logger.warn("HTTP command response", {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
});
|
||||
const text = await resp.text().catch(() => resp.statusText);
|
||||
logger.warn("HTTP command failed", { error: text });
|
||||
throw new Error(`HTTP ${resp.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
function sendWsCommand(
|
||||
socketRef: { readonly current: WebSocket | null },
|
||||
command: string,
|
||||
): boolean {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify({
|
||||
type: "voice_command",
|
||||
command,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useAudioTransmit(socketRef: {
|
||||
readonly current: WebSocket | null;
|
||||
}): {
|
||||
isStreaming: boolean;
|
||||
micError: string | null;
|
||||
micLevel: number;
|
||||
toggle: () => Promise<void>;
|
||||
stopTransmit: () => void;
|
||||
startTransmit: () => Promise<void>;
|
||||
stop: () => void;
|
||||
start: () => Promise<void>;
|
||||
} {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [micError, setMicError] = useState<string | null>(null);
|
||||
const [micLevel, setMicLevel] = useState(0);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
const isTransmittingRef = useRef(false);
|
||||
const lastLevelUpdateRef = useRef(0);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
|
||||
if (!sendWsCommand(socketRef, "voice:transmit:stop")) {
|
||||
sendTransmitCommand("voice:transmit:stop").catch(() => {});
|
||||
}
|
||||
|
||||
setMicError(null);
|
||||
setIsStreaming(false);
|
||||
isTransmittingRef.current = false;
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect();
|
||||
processorRef.current = null;
|
||||
}
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) track.stop();
|
||||
streamRef.current = null;
|
||||
}
|
||||
setMicLevel(0);
|
||||
}, [socketRef]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
// Reset mic error on new attempt
|
||||
setMicError(null);
|
||||
|
||||
// 6c: Prefer WebSocket round-trip over HTTP for lower latency
|
||||
if (!sendWsCommand(socketRef, "voice:transmit:start")) {
|
||||
await sendTransmitCommand("voice:transmit:start");
|
||||
}
|
||||
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === "NotAllowedError") {
|
||||
setMicError(
|
||||
"Microphone access denied. Please allow microphone permissions.",
|
||||
);
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setMicError(`Microphone access failed: ${message}`);
|
||||
}
|
||||
logger.error("getUserMedia failed", { error: String(err) });
|
||||
return;
|
||||
}
|
||||
streamRef.current = stream;
|
||||
setIsStreaming(true);
|
||||
isTransmittingRef.current = true;
|
||||
const AudioContextCtor =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
const audioContext = new AudioContextCtor({ sampleRate: SAMPLE_RATE });
|
||||
audioContextRef.current = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
sourceRef.current = source;
|
||||
const processor = audioContext.createScriptProcessor(1024, 1, 1);
|
||||
processorRef.current = processor;
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
processor.onaudioprocess = (event) => {
|
||||
const inputData = event.inputBuffer.getChannelData(0);
|
||||
|
||||
// Compute RMS from input buffer for mic level metering
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < inputData.length; i++) {
|
||||
sumSquares += inputData[i] * inputData[i];
|
||||
}
|
||||
const rms = Math.sqrt(sumSquares / inputData.length);
|
||||
const now = Date.now();
|
||||
if (now - lastLevelUpdateRef.current >= LEVEL_THROTTLE_MS) {
|
||||
lastLevelUpdateRef.current = now;
|
||||
// Scale so conversational speech hits ~0.3-0.6
|
||||
setMicLevel(Math.min(1, rms * 3));
|
||||
}
|
||||
|
||||
if (!isTransmittingRef.current) return;
|
||||
if (!socketRef.current || socketRef.current.readyState !== WebSocket.OPEN)
|
||||
return;
|
||||
|
||||
const pcmData = new Int16Array(inputData.length);
|
||||
for (let i = 0; i < inputData.length; i++)
|
||||
pcmData[i] = Math.max(-1, Math.min(1, inputData[i])) * 32767;
|
||||
|
||||
// Send as binary: 4-byte magic "PCM\0" + raw PCM Int16
|
||||
const magic = new Uint8Array([0x50, 0x43, 0x4d, 0x00]); // "PCM\0"
|
||||
const pcmBytes = new Uint8Array(pcmData.buffer);
|
||||
const buf = new Uint8Array(magic.length + pcmBytes.length);
|
||||
buf.set(magic, 0);
|
||||
buf.set(pcmBytes, magic.length);
|
||||
socketRef.current.send(buf.buffer);
|
||||
};
|
||||
}, [socketRef]);
|
||||
|
||||
const stopTransmit = useCallback(() => {
|
||||
if (!isTransmittingRef.current) return;
|
||||
isTransmittingRef.current = false;
|
||||
if (!sendWsCommand(socketRef, "voice:transmit:stop")) {
|
||||
sendTransmitCommand("voice:transmit:stop").catch(() => {});
|
||||
}
|
||||
setIsStreaming(false);
|
||||
}, [socketRef]);
|
||||
|
||||
const startTransmit = useCallback(async () => {
|
||||
if (isTransmittingRef.current) return;
|
||||
isTransmittingRef.current = true;
|
||||
if (!sendWsCommand(socketRef, "voice:transmit:start")) {
|
||||
await sendTransmitCommand("voice:transmit:start");
|
||||
}
|
||||
setIsStreaming(true);
|
||||
}, [socketRef]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (isStreaming) {
|
||||
stopTransmit();
|
||||
} else if (streamRef.current) {
|
||||
// Mic already captured, resume transmission without re-acquiring
|
||||
await startTransmit();
|
||||
} else {
|
||||
await start();
|
||||
}
|
||||
}, [isStreaming, startTransmit, stopTransmit, start]);
|
||||
|
||||
return {
|
||||
isStreaming,
|
||||
micError,
|
||||
micLevel,
|
||||
toggle,
|
||||
stopTransmit,
|
||||
startTransmit,
|
||||
stop,
|
||||
start,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { type Variants } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Parent container variant for staggerChildren.
|
||||
* Use on motion.div wrapping a list of cardItem children.
|
||||
*/
|
||||
export const cardStagger: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.08, delayChildren: 0.1 },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Child item variant for fade + slide up.
|
||||
* Intended as a child of cardStagger.
|
||||
*/
|
||||
export const cardItem: Variants = {
|
||||
initial: { opacity: 0, y: 20 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.4, ease: "easeOut" },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Single element variant: fade + slide up with a cubic-bezier ease.
|
||||
* Supports exit animation (fade out + slide up).
|
||||
*/
|
||||
export const fadeSlideUp: Variants = {
|
||||
initial: { opacity: 0, y: 24 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: [0.25, 0.46, 0.45, 0.94] },
|
||||
},
|
||||
exit: { opacity: 0, y: -12, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
/**
|
||||
* Simple fade variant for generic element transitions.
|
||||
*/
|
||||
export const fadeIn: Variants = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1, transition: { duration: 0.3 } },
|
||||
exit: { opacity: 0, transition: { duration: 0.2 } },
|
||||
};
|
||||
|
||||
/**
|
||||
* Scale + fade variant for badges, pills, or small decorative elements.
|
||||
*/
|
||||
export const scaleIn: Variants = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.3, ease: "backOut" },
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Spring-based entrance for important elements.
|
||||
*/
|
||||
export const springUp: Variants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { type: "spring", stiffness: 200, damping: 20 },
|
||||
},
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Generic hook for fetching a single item by ID.
|
||||
* Automatically refetches when guildId or entityId changes.
|
||||
*/
|
||||
export function useItemDetail<T>(
|
||||
fetchFn: (guildId: string, entityId: string) => Promise<T>,
|
||||
guildId: string,
|
||||
entityId: string | null,
|
||||
entityName = "item",
|
||||
) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchFnRef = useRef(fetchFn);
|
||||
fetchFnRef.current = fetchFn;
|
||||
const guildIdRef = useRef(guildId);
|
||||
guildIdRef.current = guildId;
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
if (!entityId) return;
|
||||
const id = ++fetchIdRef.current;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await fetchFnRef.current(guildIdRef.current, entityId);
|
||||
if (id !== fetchIdRef.current) return;
|
||||
if (!result) {
|
||||
setError(`${entityName} not found`);
|
||||
return;
|
||||
}
|
||||
setData(result);
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
const msg =
|
||||
e instanceof Error ? e.message : `Failed to load ${entityName}`;
|
||||
setError(msg);
|
||||
} finally {
|
||||
if (id === fetchIdRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [entityId, entityName]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch().catch(() => undefined);
|
||||
}, [fetch]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetch().catch(() => undefined);
|
||||
}, [fetch]);
|
||||
|
||||
return { data, loading, error, refetch };
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
// ─── Validated localStorage hook with shape checking ────────────────────────
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
interface ShapeValidator<T> {
|
||||
/** Returns true if the parsed value matches the expected shape */
|
||||
validate: (value: unknown) => value is T;
|
||||
/** Default value when storage is empty or invalid */
|
||||
defaults: T;
|
||||
}
|
||||
|
||||
export function useLocalStorage<T>(key: string, validator: ShapeValidator<T>) {
|
||||
const [value, setValue] = useState<T>(() => loadStored(key, validator));
|
||||
|
||||
const update = useCallback(
|
||||
(patch: T | ((prev: T) => T)) => {
|
||||
setValue((prev) => {
|
||||
const next =
|
||||
typeof patch === "function" ? (patch as (prev: T) => T)(prev) : patch;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
return { value, setValue: update };
|
||||
}
|
||||
|
||||
function loadStored<T>(key: string, validator: ShapeValidator<T>): T {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return validator.defaults;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (validator.validate(parsed)) return parsed;
|
||||
return validator.defaults;
|
||||
} catch {
|
||||
return validator.defaults;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pre-built validators for common shapes ─────────────────────────────────
|
||||
|
||||
export function recordValidator(): ShapeValidator<Record<string, unknown>> {
|
||||
return {
|
||||
validate: (v): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v),
|
||||
defaults: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function uiStateValidator(): ShapeValidator<Record<string, unknown>> {
|
||||
return {
|
||||
validate: (v): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v),
|
||||
defaults: { activeTab: "messages" },
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ChatResponse } from "../../entities/dashboard/types.js";
|
||||
import { request } from "../api/client";
|
||||
import { createLogger } from "../lib/logger";
|
||||
|
||||
const logger = createLogger("useMascotChat");
|
||||
|
||||
export interface ChatContext {
|
||||
messageCount: number;
|
||||
activeParticipants: number;
|
||||
lastActivity: string;
|
||||
topicsDiscussed: string[];
|
||||
guildId?: string;
|
||||
channelId?: string;
|
||||
}
|
||||
|
||||
export function useMascotChat(context?: ChatContext) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (message: string): Promise<string> => {
|
||||
try {
|
||||
const data = await request<ChatResponse>("/api/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ message, context }),
|
||||
});
|
||||
return data.response || fallbackResponse(message, context);
|
||||
} catch (error) {
|
||||
logger.warn("Mascot backend unavailable, using fallback", { error });
|
||||
return fallbackResponse(message, context);
|
||||
}
|
||||
},
|
||||
[context],
|
||||
);
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
handleSendMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function fallbackResponse(input: string, context?: ChatContext): string {
|
||||
const lower = input.toLowerCase();
|
||||
|
||||
if (lower.includes("ringkasan") || lower.includes("summary")) {
|
||||
return `Aku rangkum cepat ya ✨ Ada ${context?.messageCount || 0} pesan dari ${context?.activeParticipants || 0} user aktif. Backend belum bisa dihubungi, jadi ini ringkasan lokal sementara.`;
|
||||
}
|
||||
|
||||
if (lower.includes("berapa") && lower.includes("pesan")) {
|
||||
return `Ada ${context?.messageCount || 0} pesan di konteks dashboard saat ini 📊`;
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("berapa") &&
|
||||
(lower.includes("orang") || lower.includes("user"))
|
||||
) {
|
||||
return `Ada ${context?.activeParticipants || 0} user aktif yang terdeteksi 👥`;
|
||||
}
|
||||
|
||||
return `Aku belum bisa menghubungi backend, tapi dari konteks lokal ada ${context?.messageCount || 0} pesan dan ${context?.activeParticipants || 0} user aktif. Coba tanya "ringkasan obrolan" atau "berapa pesan" ya.`;
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
data: T[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface UsePaginatedListParams {
|
||||
limit: number;
|
||||
search?: string;
|
||||
cursor?: string;
|
||||
guildId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic hook for fetching a paginated list with cursor-based pagination,
|
||||
* search support, and automatic refetch when search/guildId changes.
|
||||
*/
|
||||
export function usePaginatedList<T>(
|
||||
fetchFn: (params: UsePaginatedListParams) => Promise<PaginatedResult<T>>,
|
||||
guildId = "",
|
||||
initialSearch = "",
|
||||
) {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState(initialSearch);
|
||||
|
||||
// Stable refs so fetch() can be a stable callback that reads latest values
|
||||
const fetchFnRef = useRef(fetchFn);
|
||||
fetchFnRef.current = fetchFn;
|
||||
const guildIdRef = useRef(guildId);
|
||||
guildIdRef.current = guildId;
|
||||
const searchRef = useRef(search);
|
||||
searchRef.current = search;
|
||||
const fetchIdRef = useRef(0);
|
||||
|
||||
const fetch = useCallback(async (cursor?: string) => {
|
||||
const id = ++fetchIdRef.current;
|
||||
|
||||
if (cursor) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setData([]);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await fetchFnRef.current({
|
||||
limit: 20,
|
||||
search: searchRef.current || undefined,
|
||||
cursor,
|
||||
guildId: guildIdRef.current || undefined,
|
||||
});
|
||||
if (id !== fetchIdRef.current) return;
|
||||
|
||||
if (cursor) {
|
||||
setData((prev) => [...prev, ...result.data]);
|
||||
} else {
|
||||
setData(result.data);
|
||||
}
|
||||
setNextCursor(result.nextCursor);
|
||||
} catch (e) {
|
||||
if (id !== fetchIdRef.current) return;
|
||||
const msg = e instanceof Error ? e.message : "Failed to load data";
|
||||
setError(msg);
|
||||
} finally {
|
||||
if (id === fetchIdRef.current) {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Re-fetch on mount, search change, or guildId change
|
||||
useEffect(() => {
|
||||
fetch().catch(() => undefined);
|
||||
}, [search, guildId, fetch]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (nextCursor && !loading && !loadingMore) {
|
||||
fetch(nextCursor).catch(() => undefined);
|
||||
}
|
||||
}, [nextCursor, loading, loadingMore, fetch]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetch().catch(() => undefined);
|
||||
}, [fetch]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
nextCursor,
|
||||
search,
|
||||
setSearch,
|
||||
hasMore: !!nextCursor,
|
||||
loadMore,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
const STORAGE_KEY = 'imphnen-theme';
|
||||
|
||||
function getSystemTheme(): 'light' | 'dark' {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function applyTheme(resolved: 'light' | 'dark') {
|
||||
const root = document.documentElement;
|
||||
const transitioning = root.classList.contains('theme-transitioning');
|
||||
if (!transitioning) root.classList.add('theme-transitioning');
|
||||
root.dataset.theme = resolved;
|
||||
if (!transitioning) {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('theme-transitioning')));
|
||||
}
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'system') return stored;
|
||||
return 'system';
|
||||
});
|
||||
const resolvedTheme = theme === 'system' ? getSystemTheme() : theme;
|
||||
const setTheme = useCallback((t: Theme) => { setThemeState(t); localStorage.setItem(STORAGE_KEY, t); }, []);
|
||||
const toggle = useCallback(() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'), [resolvedTheme, setTheme]);
|
||||
useEffect(() => { applyTheme(resolvedTheme); }, [resolvedTheme]);
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => applyTheme(getSystemTheme());
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, [theme]);
|
||||
return { theme, setTheme, resolvedTheme, toggle };
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
import type { UIState } from "../../entities/ui/types.js";
|
||||
import { uiStateValidator, useLocalStorage } from "./useLocalStorage";
|
||||
|
||||
export function useUIState() {
|
||||
const { value: uiState, setValue: setUIState } = useLocalStorage<UIState>(
|
||||
"bete-dashboard-ui-state",
|
||||
uiStateValidator(),
|
||||
);
|
||||
|
||||
const patchUIState = useCallback(
|
||||
(patch: Partial<UIState>) => {
|
||||
setUIState((prev) => ({ ...prev, ...patch }));
|
||||
},
|
||||
[setUIState],
|
||||
);
|
||||
|
||||
return { uiState, setUIState, patchUIState, loading: false, error: null };
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
// ─── Client-side structured logger ────────────────────────────────────────
|
||||
|
||||
const LOG_PREFIX = "[Bete]";
|
||||
|
||||
export function createLogger(context: string) {
|
||||
const prefix = `${LOG_PREFIX} [${context}]`;
|
||||
|
||||
return {
|
||||
debug: (msg: string, data?: Record<string, unknown>) => {
|
||||
if (import.meta.env.DEV) console.debug(prefix, msg, data ?? "");
|
||||
},
|
||||
info: (msg: string, data?: Record<string, unknown>) => {
|
||||
console.info(prefix, msg, data ?? "");
|
||||
},
|
||||
warn: (msg: string, data?: Record<string, unknown>) => {
|
||||
console.warn(prefix, msg, data ?? "");
|
||||
},
|
||||
error: (msg: string, data?: Record<string, unknown>) => {
|
||||
console.error(prefix, msg, data ?? "");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${Math.round((bytes / Math.pow(k, i)) * 100) / 100} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function formatDate(value: number): string {
|
||||
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;
|
||||
};
|
||||
reference?: {
|
||||
messageId: string | null;
|
||||
channelId: string | null;
|
||||
guildId: string | null;
|
||||
type: string | null;
|
||||
content: string | null;
|
||||
repliedUsername: string | null;
|
||||
repliedUserId: string | null;
|
||||
} | null;
|
||||
isCrosspost?: boolean;
|
||||
}
|
||||
|
||||
export function parseMetadata(value: string | null): MessageMetadata {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value) as MessageMetadata;
|
||||
return parsed;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { LayoutDashboard, MessageSquare, Radio } from "lucide-react";
|
||||
import type { DashboardTab } from "../../entities/ui/types.js";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [
|
||||
{ id: "messages", label: "Messages", Icon: MessageSquare },
|
||||
{ id: "live", label: "Voice & Media", Icon: Radio },
|
||||
{ id: "dashboard", label: "Dashboard", Icon: LayoutDashboard },
|
||||
];
|
||||
|
||||
interface MobileTabBarProps {
|
||||
activeTab: DashboardTab;
|
||||
onTabChange: (tab: DashboardTab) => void;
|
||||
}
|
||||
|
||||
export function MobileTabBar({ activeTab, onTabChange }: MobileTabBarProps) {
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
role="tablist"
|
||||
className="fixed bottom-0 left-0 right-0 z-50 flex border-t border-border bg-white/80 backdrop-blur-lg pb-4 shadow-lg md:hidden"
|
||||
>
|
||||
{tabs.map(({ id, label, Icon }) => (
|
||||
<button
|
||||
key={id}
|
||||
role="tab"
|
||||
aria-selected={activeTab === id}
|
||||
aria-controls={`tabpanel-${id}`}
|
||||
type="button"
|
||||
onClick={() => onTabChange(id)}
|
||||
className={cn(
|
||||
"relative flex flex-1 flex-col items-center gap-0.5 py-2 pt-3 text-xs font-medium transition-colors",
|
||||
activeTab === id ? "text-[#23a1eb]" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{activeTab === id && (
|
||||
<motion.div
|
||||
layoutId="mobile-tab-dot"
|
||||
className="absolute top-0 h-1 w-6 rounded-full bg-[#23a1eb]"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="text-[10px]">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════════
|
||||
* IMPHNEN Badge — Pill untuk status, kategori, dan label micro-interaction
|
||||
* rounded-full (9999px), padding 4px 12px, font label-sm (12px, 500 weight)
|
||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
import type * as React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type BadgeVariant =
|
||||
| "default" /* Primary soft — #e1f0fd bg, #0d4a7a text */
|
||||
| "primary" /* Same as default, explicit alias */
|
||||
| "secondary" /* #e7f1ff bg, #003d99 text */
|
||||
| "tertiary" /* #eef0ff bg, #1a2466 text */
|
||||
| "destructive" /* #ffebee bg, #e4405f text */
|
||||
| "outline" /* Border only, no fill */
|
||||
| "success" /* #dcfce7 bg, green text */
|
||||
| "warning" /* #fef3c7 bg, amber text */
|
||||
| "info"; /* #dbeafe bg, blue text */
|
||||
|
||||
const variants: Record<BadgeVariant, string> = {
|
||||
default: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
|
||||
primary: "bg-[#e1f0fd] text-[#0d4a7a] border-transparent",
|
||||
secondary: "bg-[#e7f1ff] text-[#003d99] border-transparent",
|
||||
tertiary: "bg-[#eef0ff] text-[#1a2466] border-transparent",
|
||||
destructive: "bg-[#ffebee] text-[#e4405f] border-transparent",
|
||||
outline: "bg-transparent text-[#666666] border-[#e0e0e0]",
|
||||
success: "bg-success-soft text-success border-transparent",
|
||||
warning: "bg-warning-soft text-warning border-transparent",
|
||||
info: "bg-[#dbeafe] text-[#1e40af] border-transparent",
|
||||
};
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: BadgeVariant;
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: BadgeProps) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1",
|
||||
"font-sans text-xs font-medium leading-4 tracking-[0.03em]",
|
||||
"transition-colors duration-[150ms] ease-[cubic-bezier(0.4,0,0.2,1)]",
|
||||
variants[variant],
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user